feat(elements): add eco-provider-frame and dataprovider interfaces; improve virtual keyboard interactions; add demos, exports and bump dev dependencies
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-01-12 - 3.37.0 - feat(elements)
|
||||||
|
add eco-provider-frame and dataprovider interfaces; improve virtual keyboard interactions; add demos, exports and bump dev dependencies
|
||||||
|
|
||||||
|
- Add new eco-provider-frame web component (iframe-based provider protocol) with messaging API (sendData, requestData, ping) and events for provider-ready, features-changed, provider-response, provider-error
|
||||||
|
- Introduce comprehensive dataprovider TypeScript interfaces (ts_web/elements/interfaces/dataprovider.ts) describing messages, payloads, provider state and store schema
|
||||||
|
- Improve virtual keyboard and launcher interactions: add tabindex, preventDefault on mousedown, stopPropagation on pointer handlers, remove key focus outline to fix touch/mouse behavior
|
||||||
|
- Add demos and new exports for provider-frame, browser and scan views/components (demo files and index exports)
|
||||||
|
- Update build/dev config: add npmextra.json tsbundle config and bump dependencies: @design.estate/dees-catalog -> ^3.34.1, @git.zone/tsbuild -> ^4.1.0, @git.zone/tsbundle -> ^2.8.1, @types/node -> ^25.0.6
|
||||||
|
|
||||||
## 2026-01-12 - 3.36.1 - fix()
|
## 2026-01-12 - 3.36.1 - fix()
|
||||||
no changes
|
no changes
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"@git.zone/tsbundle": {
|
||||||
|
"bundles": [
|
||||||
|
{
|
||||||
|
"from": "ts_web/index.ts",
|
||||||
|
"to": "dist_bundle/bundle.js",
|
||||||
|
"bundler": "esbuild",
|
||||||
|
"production": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"@git.zone/cli": {
|
"@git.zone/cli": {
|
||||||
"projectType": "wcc",
|
"projectType": "wcc",
|
||||||
"module": {
|
"module": {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"author": "Lossless GmbH",
|
"author": "Lossless GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@design.estate/dees-catalog": "^3.33.0",
|
"@design.estate/dees-catalog": "^3.34.1",
|
||||||
"@design.estate/dees-domtools": "^2.3.7",
|
"@design.estate/dees-domtools": "^2.3.7",
|
||||||
"@design.estate/dees-element": "^2.1.5",
|
"@design.estate/dees-element": "^2.1.5",
|
||||||
"@push.rocks/smartpromise": "^4.2.3",
|
"@push.rocks/smartpromise": "^4.2.3",
|
||||||
@@ -23,12 +23,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@design.estate/dees-wcctools": "^3.7.1",
|
"@design.estate/dees-wcctools": "^3.7.1",
|
||||||
"@git.zone/tsbuild": "^4.0.2",
|
"@git.zone/tsbuild": "^4.1.0",
|
||||||
"@git.zone/tsbundle": "^2.6.3",
|
"@git.zone/tsbundle": "^2.8.1",
|
||||||
"@git.zone/tstest": "^3.1.4",
|
"@git.zone/tstest": "^3.1.4",
|
||||||
"@git.zone/tswatch": "^2.3.13",
|
"@git.zone/tswatch": "^2.3.13",
|
||||||
"@push.rocks/projectinfo": "^5.0.2",
|
"@push.rocks/projectinfo": "^5.0.2",
|
||||||
"@types/node": "^25.0.3"
|
"@types/node": "^25.0.6"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
|
|||||||
1814
pnpm-lock.yaml
generated
1814
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@ecobridge.xyz/catalog',
|
name: '@ecobridge.xyz/catalog',
|
||||||
version: '3.36.1',
|
version: '3.37.0',
|
||||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,10 @@ export class EcoApplauncherKeyboard extends DeesElement {
|
|||||||
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 28%)')};
|
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 28%)')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.key.special {
|
.key.special {
|
||||||
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -375,10 +379,12 @@ export class EcoApplauncherKeyboard extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="key ${type} ${widthClass} ${isActive ? 'active' : ''}"
|
class="key ${type} ${widthClass} ${isActive ? 'active' : ''}"
|
||||||
|
tabindex="-1"
|
||||||
@pointerdown=${(e: PointerEvent) => this.handlePointerDown(e, config)}
|
@pointerdown=${(e: PointerEvent) => this.handlePointerDown(e, config)}
|
||||||
@pointerup=${(e: PointerEvent) => this.handlePointerUp(e, config)}
|
@pointerup=${(e: PointerEvent) => this.handlePointerUp(e, config)}
|
||||||
@pointerleave=${(e: PointerEvent) => this.handlePointerLeave(e, config)}
|
@pointerleave=${(e: PointerEvent) => this.handlePointerLeave(e, config)}
|
||||||
@pointermove=${(e: PointerEvent) => this.handlePointerMove(e, config)}
|
@pointermove=${(e: PointerEvent) => this.handlePointerMove(e, config)}
|
||||||
|
@mousedown=${(e: MouseEvent) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
${displayValue}
|
${displayValue}
|
||||||
</div>
|
</div>
|
||||||
@@ -418,6 +424,7 @@ export class EcoApplauncherKeyboard extends DeesElement {
|
|||||||
|
|
||||||
private handlePointerDown(e: PointerEvent, config: IKeyConfig): void {
|
private handlePointerDown(e: PointerEvent, config: IKeyConfig): void {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
const target = e.currentTarget as HTMLElement;
|
const target = e.currentTarget as HTMLElement;
|
||||||
target.setPointerCapture(e.pointerId);
|
target.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
@@ -483,6 +490,7 @@ export class EcoApplauncherKeyboard extends DeesElement {
|
|||||||
|
|
||||||
private handlePointerUp(e: PointerEvent, config: IKeyConfig): void {
|
private handlePointerUp(e: PointerEvent, config: IKeyConfig): void {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
this.clearLongPressTimer();
|
this.clearLongPressTimer();
|
||||||
this.keyPreview = null;
|
this.keyPreview = null;
|
||||||
|
|
||||||
|
|||||||
@@ -644,7 +644,11 @@ export class EcoApplauncher extends DeesElement {
|
|||||||
<div class="launcher-container">
|
<div class="launcher-container">
|
||||||
${this.mode === 'login' ? '' : this.renderTopBar()}
|
${this.mode === 'login' ? '' : this.renderTopBar()}
|
||||||
${this.renderMainContent()}
|
${this.renderMainContent()}
|
||||||
<div class="keyboard-area ${this.keyboardVisible ? 'visible' : ''}">
|
<div
|
||||||
|
class="keyboard-area ${this.keyboardVisible ? 'visible' : ''}"
|
||||||
|
tabindex="-1"
|
||||||
|
@mousedown=${(e: MouseEvent) => e.preventDefault()}
|
||||||
|
>
|
||||||
<eco-applauncher-keyboard
|
<eco-applauncher-keyboard
|
||||||
?visible=${this.keyboardVisible}
|
?visible=${this.keyboardVisible}
|
||||||
@key-press=${this.handleKeyboardKeyPress}
|
@key-press=${this.handleKeyboardKeyPress}
|
||||||
@@ -962,7 +966,9 @@ export class EcoApplauncher extends DeesElement {
|
|||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="status-item clickable ${this.keyboardVisible ? 'active' : ''}"
|
class="status-item clickable ${this.keyboardVisible ? 'active' : ''}"
|
||||||
|
tabindex="-1"
|
||||||
@click=${this.handleKeyboardToggle}
|
@click=${this.handleKeyboardToggle}
|
||||||
|
@mousedown=${(e: MouseEvent) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:keyboard'} .iconSize=${18}></dees-icon>
|
<dees-icon .icon=${'lucide:keyboard'} .iconSize=${18}></dees-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-info {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: hsl(240 6% 12%);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: hsl(0 0% 70%);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 80px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<div class="demo-info">
|
||||||
|
The provider frame loads external web apps that implement the ecobridge provider protocol.
|
||||||
|
In this demo, no provider URL is set, so it shows the loading state.
|
||||||
|
</div>
|
||||||
|
<div class="demo-frame">
|
||||||
|
<eco-provider-frame
|
||||||
|
providerId="demo-provider"
|
||||||
|
providerName="Demo Provider"
|
||||||
|
providerUrl=""
|
||||||
|
@provider-ready=${(e: CustomEvent) => console.log('Provider ready:', e.detail)}
|
||||||
|
@provider-features-changed=${(e: CustomEvent) => console.log('Features changed:', e.detail)}
|
||||||
|
@provider-response=${(e: CustomEvent) => console.log('Response:', e.detail)}
|
||||||
|
@provider-error=${(e: CustomEvent) => console.log('Error:', e.detail)}
|
||||||
|
></eco-provider-frame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
502
ts_web/elements/eco-provider-frame/eco-provider-frame.ts
Normal file
502
ts_web/elements/eco-provider-frame/eco-provider-frame.ts
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
type TemplateResult,
|
||||||
|
html,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
state,
|
||||||
|
query,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesIcon } from '@design.estate/dees-catalog';
|
||||||
|
import type {
|
||||||
|
IDataProvider,
|
||||||
|
IProviderMessage,
|
||||||
|
IEcobridgeMessage,
|
||||||
|
IProviderReadyPayload,
|
||||||
|
IProviderResponsePayload,
|
||||||
|
IProviderDataOfferPayload,
|
||||||
|
IFeatureChangeRequest,
|
||||||
|
TProviderFeature,
|
||||||
|
TProviderStatus,
|
||||||
|
ISendDataPayload,
|
||||||
|
IRequestDataPayload,
|
||||||
|
} from '../interfaces/dataprovider.js';
|
||||||
|
import { demo } from './eco-provider-frame.demo.js';
|
||||||
|
|
||||||
|
// Ensure components are registered
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-provider-frame': EcoProviderFrame;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('eco-provider-frame')
|
||||||
|
export class EcoProviderFrame extends DeesElement {
|
||||||
|
public static demo = demo;
|
||||||
|
public static demoGroup = 'Elements';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-frame-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.8)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')};
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay dees-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')};
|
||||||
|
z-index: 10;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-overlay .error-icon {
|
||||||
|
color: hsl(0 72% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(0 72% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: hsl(217 91% 60%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:hover {
|
||||||
|
background: hsl(217 91% 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor providerId = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor providerUrl = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor providerName = '';
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor confirmedFeatures: TProviderFeature[] = [];
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
accessor sandboxed = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor status: TProviderStatus = 'loading';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor error: string | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentFeatures: TProviderFeature[] = [];
|
||||||
|
|
||||||
|
@query('iframe')
|
||||||
|
accessor iframe: HTMLIFrameElement | null;
|
||||||
|
|
||||||
|
private messageHandler: ((e: MessageEvent) => void) | null = null;
|
||||||
|
private pendingRequests: Map<string, {
|
||||||
|
resolve: (value: IProviderResponsePayload) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
async connectedCallback(): Promise<void> {
|
||||||
|
await super.connectedCallback();
|
||||||
|
this.setupMessageListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectedCallback(): Promise<void> {
|
||||||
|
await super.disconnectedCallback();
|
||||||
|
this.cleanupMessageListener();
|
||||||
|
this.clearPendingRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMessageListener(): void {
|
||||||
|
this.messageHandler = (event: MessageEvent) => {
|
||||||
|
this.handleProviderMessage(event);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', this.messageHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupMessageListener(): void {
|
||||||
|
if (this.messageHandler) {
|
||||||
|
window.removeEventListener('message', this.messageHandler);
|
||||||
|
this.messageHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPendingRequests(): void {
|
||||||
|
for (const [requestId, pending] of this.pendingRequests) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pending.reject(new Error('Provider disconnected'));
|
||||||
|
}
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="provider-frame-container">
|
||||||
|
${this.providerUrl ? html`
|
||||||
|
<iframe
|
||||||
|
src=${this.providerUrl}
|
||||||
|
sandbox=${this.sandboxed ? 'allow-scripts allow-same-origin allow-forms' : ''}
|
||||||
|
@load=${this.handleIframeLoad}
|
||||||
|
@error=${this.handleIframeError}
|
||||||
|
></iframe>
|
||||||
|
` : ''}
|
||||||
|
${this.renderOverlay()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderOverlay(): TemplateResult | null {
|
||||||
|
switch (this.status) {
|
||||||
|
case 'loading':
|
||||||
|
return html`
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<dees-icon .icon=${'lucide:loader'} .iconSize=${32}></dees-icon>
|
||||||
|
<span>Loading provider...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case 'error':
|
||||||
|
return html`
|
||||||
|
<div class="error-overlay">
|
||||||
|
<dees-icon class="error-icon" .icon=${'lucide:alertCircle'} .iconSize=${48}></dees-icon>
|
||||||
|
<span class="error-title">Connection Error</span>
|
||||||
|
<span class="error-message">${this.error || 'Failed to connect to provider'}</span>
|
||||||
|
<button class="retry-button" @click=${this.retry}>Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
case 'disconnected':
|
||||||
|
return html`
|
||||||
|
<div class="disconnected-overlay">
|
||||||
|
<dees-icon .icon=${'lucide:unplug'} .iconSize=${32}></dees-icon>
|
||||||
|
<span>Provider disconnected</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIframeLoad(): void {
|
||||||
|
// Start waiting for provider-ready message
|
||||||
|
// If not received within timeout, show error
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.status === 'loading') {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = 'Provider did not respond. Make sure it implements the ecobridge provider protocol.';
|
||||||
|
}
|
||||||
|
}, 10000); // 10 second timeout for provider to declare ready
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIframeError(): void {
|
||||||
|
this.status = 'error';
|
||||||
|
this.error = 'Failed to load provider URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleProviderMessage(event: MessageEvent): void {
|
||||||
|
// Verify origin matches provider URL
|
||||||
|
if (!this.providerUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providerOrigin = new URL(this.providerUrl).origin;
|
||||||
|
if (event.origin !== providerOrigin) return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = event.data as IProviderMessage;
|
||||||
|
if (!message || typeof message.type !== 'string') return;
|
||||||
|
|
||||||
|
// Only accept messages from this provider
|
||||||
|
if (message.providerId && message.providerId !== this.providerId) return;
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'provider-ready':
|
||||||
|
this.handleProviderReady(message.payload as IProviderReadyPayload);
|
||||||
|
break;
|
||||||
|
case 'provider-features':
|
||||||
|
this.handleFeaturesUpdate(message.payload as { features: TProviderFeature[] });
|
||||||
|
break;
|
||||||
|
case 'provider-response':
|
||||||
|
this.handleProviderResponse(message.requestId!, message.payload as IProviderResponsePayload);
|
||||||
|
break;
|
||||||
|
case 'provider-error':
|
||||||
|
this.handleProviderError(message.payload as { error: string });
|
||||||
|
break;
|
||||||
|
case 'data-offer':
|
||||||
|
this.handleDataOffer(message.payload as IProviderDataOfferPayload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleProviderReady(payload: IProviderReadyPayload): void {
|
||||||
|
this.currentFeatures = payload.features || [];
|
||||||
|
|
||||||
|
// Check for feature changes
|
||||||
|
if (this.confirmedFeatures.length > 0) {
|
||||||
|
const added = this.currentFeatures.filter(f => !this.confirmedFeatures.includes(f));
|
||||||
|
const removed = this.confirmedFeatures.filter(f => !this.currentFeatures.includes(f));
|
||||||
|
|
||||||
|
if (added.length > 0 || removed.length > 0) {
|
||||||
|
const request: IFeatureChangeRequest = {
|
||||||
|
providerId: this.providerId,
|
||||||
|
providerName: payload.name || this.providerName,
|
||||||
|
addedFeatures: added,
|
||||||
|
removedFeatures: removed,
|
||||||
|
currentFeatures: this.currentFeatures,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('provider-features-changed', {
|
||||||
|
detail: { request },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = 'connected';
|
||||||
|
|
||||||
|
const provider: IDataProvider = {
|
||||||
|
id: this.providerId,
|
||||||
|
name: payload.name || this.providerName,
|
||||||
|
url: this.providerUrl,
|
||||||
|
features: this.currentFeatures,
|
||||||
|
confirmedFeatures: this.confirmedFeatures,
|
||||||
|
icon: payload.icon,
|
||||||
|
lastSeen: new Date(),
|
||||||
|
status: 'connected',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('provider-ready', {
|
||||||
|
detail: { provider },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFeaturesUpdate(payload: { features: TProviderFeature[] }): void {
|
||||||
|
const previousFeatures = [...this.currentFeatures];
|
||||||
|
this.currentFeatures = payload.features || [];
|
||||||
|
|
||||||
|
const added = this.currentFeatures.filter(f => !previousFeatures.includes(f));
|
||||||
|
const removed = previousFeatures.filter(f => !this.currentFeatures.includes(f));
|
||||||
|
|
||||||
|
if (added.length > 0 || removed.length > 0) {
|
||||||
|
const request: IFeatureChangeRequest = {
|
||||||
|
providerId: this.providerId,
|
||||||
|
providerName: this.providerName,
|
||||||
|
addedFeatures: added,
|
||||||
|
removedFeatures: removed,
|
||||||
|
currentFeatures: this.currentFeatures,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('provider-features-changed', {
|
||||||
|
detail: { request },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleProviderResponse(requestId: string, response: IProviderResponsePayload): void {
|
||||||
|
const pending = this.pendingRequests.get(requestId);
|
||||||
|
if (pending) {
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
pending.resolve(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('provider-response', {
|
||||||
|
detail: { requestId, response },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleProviderError(payload: { error: string }): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('provider-error', {
|
||||||
|
detail: { error: payload.error },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDataOffer(offer: IProviderDataOfferPayload): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('provider-data-offer', {
|
||||||
|
detail: { offer },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
public retry(): void {
|
||||||
|
this.status = 'loading';
|
||||||
|
this.error = null;
|
||||||
|
if (this.iframe) {
|
||||||
|
this.iframe.src = this.providerUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendMessage(message: IEcobridgeMessage): void {
|
||||||
|
if (!this.iframe?.contentWindow) {
|
||||||
|
console.warn('Cannot send message: iframe not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const origin = new URL(this.providerUrl).origin;
|
||||||
|
this.iframe.contentWindow.postMessage(message, origin);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send message to provider:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendData(data: ISendDataPayload, timeoutMs = 30000): Promise<IProviderResponsePayload> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'send-data',
|
||||||
|
requestId,
|
||||||
|
payload: data,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requestData(request: IRequestDataPayload, timeoutMs = 30000): Promise<IProviderResponsePayload> {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
||||||
|
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'request-data',
|
||||||
|
requestId,
|
||||||
|
payload: request,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ping(): void {
|
||||||
|
this.sendMessage({
|
||||||
|
type: 'ping',
|
||||||
|
requestId: crypto.randomUUID(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public hasFeature(feature: TProviderFeature): boolean {
|
||||||
|
return this.confirmedFeatures.includes(feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): TProviderStatus {
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts_web/elements/eco-provider-frame/index.ts
Normal file
1
ts_web/elements/eco-provider-frame/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-provider-frame.js';
|
||||||
@@ -6,3 +6,4 @@ export * from './00group-applauncher/index.js';
|
|||||||
|
|
||||||
// Standalone Components
|
// Standalone Components
|
||||||
export * from './eco-screensaver/index.js';
|
export * from './eco-screensaver/index.js';
|
||||||
|
export * from './eco-provider-frame/index.js';
|
||||||
|
|||||||
158
ts_web/elements/interfaces/dataprovider.ts
Normal file
158
ts_web/elements/interfaces/dataprovider.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Data Provider System Interfaces
|
||||||
|
*
|
||||||
|
* Data providers are external web apps that can be registered to provide
|
||||||
|
* or receive data from ecobridge applications. They communicate via
|
||||||
|
* postMessage API in sandboxed iframes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Provider feature types - what capabilities a provider can offer
|
||||||
|
export type TProviderFeature =
|
||||||
|
| 'scan-destination' // Can receive scanned documents
|
||||||
|
| 'media-source' // Can provide media URLs for playback
|
||||||
|
| 'document-storage' // Can store/retrieve documents
|
||||||
|
| 'print-destination'; // Can receive print jobs
|
||||||
|
|
||||||
|
// Provider connection status
|
||||||
|
export type TProviderStatus = 'connected' | 'disconnected' | 'loading' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data Provider configuration and state
|
||||||
|
*/
|
||||||
|
export interface IDataProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
features: TProviderFeature[]; // Features declared by provider
|
||||||
|
confirmedFeatures: TProviderFeature[]; // Features user has approved
|
||||||
|
icon?: string; // Base64 data URL or icon URL
|
||||||
|
lastSeen: Date;
|
||||||
|
status: TProviderStatus;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider registration payload (sent by provider on ready)
|
||||||
|
*/
|
||||||
|
export interface IProviderRegistration {
|
||||||
|
name: string;
|
||||||
|
features: TProviderFeature[];
|
||||||
|
icon?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message types from Provider to Ecobridge
|
||||||
|
*/
|
||||||
|
export type TProviderToEcobridgeMessageType =
|
||||||
|
| 'provider-ready' // Provider loaded and declaring features
|
||||||
|
| 'provider-features' // Provider updating its features
|
||||||
|
| 'provider-response' // Response to an ecobridge request
|
||||||
|
| 'provider-error' // Error occurred in provider
|
||||||
|
| 'data-offer'; // Provider offering data (e.g., media URL)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message from Provider to Ecobridge
|
||||||
|
*/
|
||||||
|
export interface IProviderMessage {
|
||||||
|
type: TProviderToEcobridgeMessageType;
|
||||||
|
providerId: string;
|
||||||
|
requestId?: string; // For responses to specific requests
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider ready message payload
|
||||||
|
*/
|
||||||
|
export interface IProviderReadyPayload {
|
||||||
|
name: string;
|
||||||
|
features: TProviderFeature[];
|
||||||
|
icon?: string;
|
||||||
|
version?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider response payload
|
||||||
|
*/
|
||||||
|
export interface IProviderResponsePayload {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider data offer payload (e.g., for media-source)
|
||||||
|
*/
|
||||||
|
export interface IProviderDataOfferPayload {
|
||||||
|
type: 'media-url' | 'document' | 'file-list';
|
||||||
|
data: unknown;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message types from Ecobridge to Provider
|
||||||
|
*/
|
||||||
|
export type TEcobridgeToProviderMessageType =
|
||||||
|
| 'request-features' // Ask provider to declare features
|
||||||
|
| 'send-data' // Send data to provider (e.g., scan)
|
||||||
|
| 'request-data' // Request data from provider (e.g., media URL)
|
||||||
|
| 'ping'; // Health check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message from Ecobridge to Provider
|
||||||
|
*/
|
||||||
|
export interface IEcobridgeMessage {
|
||||||
|
type: TEcobridgeToProviderMessageType;
|
||||||
|
requestId: string;
|
||||||
|
payload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send data payload (e.g., sending scan to provider)
|
||||||
|
*/
|
||||||
|
export interface ISendDataPayload {
|
||||||
|
dataType: 'scan' | 'document' | 'print-job';
|
||||||
|
data: string; // Base64 encoded data
|
||||||
|
format: string; // File format (pdf, jpeg, etc.)
|
||||||
|
filename?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request data payload (e.g., requesting media from provider)
|
||||||
|
*/
|
||||||
|
export interface IRequestDataPayload {
|
||||||
|
dataType: 'media-url' | 'document' | 'file-list';
|
||||||
|
filter?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature change event - when provider's features differ from confirmed
|
||||||
|
*/
|
||||||
|
export interface IFeatureChangeRequest {
|
||||||
|
providerId: string;
|
||||||
|
providerName: string;
|
||||||
|
addedFeatures: TProviderFeature[];
|
||||||
|
removedFeatures: TProviderFeature[];
|
||||||
|
currentFeatures: TProviderFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider store schema for persistence
|
||||||
|
*/
|
||||||
|
export interface IProviderStore {
|
||||||
|
providers: IDataProvider[];
|
||||||
|
lastUpdated: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events dispatched by eco-provider-frame
|
||||||
|
*/
|
||||||
|
export interface IProviderFrameEvents {
|
||||||
|
'provider-ready': CustomEvent<{ provider: IDataProvider }>;
|
||||||
|
'provider-features-changed': CustomEvent<{ request: IFeatureChangeRequest }>;
|
||||||
|
'provider-response': CustomEvent<{ requestId: string; response: IProviderResponsePayload }>;
|
||||||
|
'provider-data-offer': CustomEvent<{ offer: IProviderDataOfferPayload }>;
|
||||||
|
'provider-error': CustomEvent<{ error: string }>;
|
||||||
|
'provider-disconnected': CustomEvent<{ providerId: string }>;
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export * from './appbarmenuitem.js';
|
|||||||
export * from './menugroup.js';
|
export * from './menugroup.js';
|
||||||
export * from './appconfig.js';
|
export * from './appconfig.js';
|
||||||
export * from './secondarymenu.js';
|
export * from './secondarymenu.js';
|
||||||
|
export * from './dataprovider.js';
|
||||||
|
|||||||
34
ts_web/views/eco-view-browser/eco-view-browser.demo.ts
Normal file
34
ts_web/views/eco-view-browser/eco-view-browser.demo.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 600px;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<eco-view-browser
|
||||||
|
.bookmarks=${[
|
||||||
|
{ id: 'bm-1', title: 'Google', url: 'https://www.google.com', createdAt: new Date() },
|
||||||
|
{ id: 'bm-2', title: 'GitHub', url: 'https://github.com', createdAt: new Date() },
|
||||||
|
{ id: 'bm-3', title: 'Stack Overflow', url: 'https://stackoverflow.com', createdAt: new Date() },
|
||||||
|
{ id: 'bm-4', title: 'MDN Web Docs', url: 'https://developer.mozilla.org', createdAt: new Date() },
|
||||||
|
]}
|
||||||
|
.showBookmarksBar=${true}
|
||||||
|
@navigate=${(e: CustomEvent) => console.log('Navigate:', e.detail)}
|
||||||
|
@bookmark-add=${(e: CustomEvent) => console.log('Bookmark add:', e.detail)}
|
||||||
|
@bookmark-remove=${(e: CustomEvent) => console.log('Bookmark remove:', e.detail)}
|
||||||
|
@browser-back=${() => console.log('Browser back')}
|
||||||
|
@browser-forward=${() => console.log('Browser forward')}
|
||||||
|
@browser-refresh=${() => console.log('Browser refresh')}
|
||||||
|
@browser-stop=${() => console.log('Browser stop')}
|
||||||
|
@new-tab=${() => console.log('New tab')}
|
||||||
|
@open-devtools=${() => console.log('Open devtools')}
|
||||||
|
></eco-view-browser>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
959
ts_web/views/eco-view-browser/eco-view-browser.ts
Normal file
959
ts_web/views/eco-view-browser/eco-view-browser.ts
Normal file
@@ -0,0 +1,959 @@
|
|||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
type TemplateResult,
|
||||||
|
html,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
state,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesIcon } from '@design.estate/dees-catalog';
|
||||||
|
import { demo } from './eco-view-browser.demo.js';
|
||||||
|
|
||||||
|
// Ensure components are registered
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-view-browser': EcoViewBrowser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface IBookmark {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
favicon?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBrowserState {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
canGoBack: boolean;
|
||||||
|
canGoForward: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TBrowserPanel = 'browser' | 'bookmarks';
|
||||||
|
|
||||||
|
@customElement('eco-view-browser')
|
||||||
|
export class EcoViewBrowser extends DeesElement {
|
||||||
|
public static demo = demo;
|
||||||
|
public static demoGroup = 'Views';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: ${cssManager.bdTheme('#f5f5f7', 'hsl(240 6% 10%)')};
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover:not(:disabled) {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button.loading dees-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 36px;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-container:focus-within {
|
||||||
|
border-color: hsl(217 91% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input::placeholder {
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-button:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-button.bookmarked {
|
||||||
|
color: hsl(45 93% 47%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-button.bookmarked:hover {
|
||||||
|
color: hsl(45 93% 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 5%)')};
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webview-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
webview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder dees-icon {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: ${cssManager.bdTheme('#fafafa', 'hsl(240 6% 9%)')};
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 13%)')};
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-bar::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-bar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-bar::-webkit-scrollbar-thumb {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-chip:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 18%)')};
|
||||||
|
border-color: ${cssManager.bdTheme('hsl(0 0% 80%)', 'hsl(240 5% 25%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-chip .favicon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
|
||||||
|
min-width: 180px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 20%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.destructive {
|
||||||
|
color: hsl(0 72% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.destructive:hover {
|
||||||
|
background: hsl(0 72% 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmarks panel styles */
|
||||||
|
.bookmarks-panel {
|
||||||
|
padding: 32px 48px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmarks-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 12%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item:hover {
|
||||||
|
border-color: hsl(217 91% 60%);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item .favicon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-delete {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item:hover .bookmark-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-delete-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-delete-btn:hover {
|
||||||
|
background: hsl(0 72% 95%);
|
||||||
|
color: hsl(0 72% 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state dees-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
hsl(217 91% 60%) 0%,
|
||||||
|
hsl(217 91% 70%) 50%,
|
||||||
|
hsl(217 91% 60%) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor bookmarks: IBookmark[] = [];
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
accessor showBookmarksBar = true;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor defaultUrl = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor activePanel: TBrowserPanel = 'browser';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentUrl = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor inputUrl = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor pageTitle = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor canGoBack = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor canGoForward = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isLoading = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showMenu = false;
|
||||||
|
|
||||||
|
private isElectron = false;
|
||||||
|
private webviewEventsBound = false;
|
||||||
|
|
||||||
|
async connectedCallback(): Promise<void> {
|
||||||
|
await super.connectedCallback();
|
||||||
|
// Check if we're in Electron - webview tag only works in Electron
|
||||||
|
// Since nodeIntegration is disabled, we check for the existence of electronAPI
|
||||||
|
// or if we're in a file:// protocol (Electron loads from file://)
|
||||||
|
this.isElectron = typeof window !== 'undefined' && (
|
||||||
|
typeof (window as any).electronAPI !== 'undefined' ||
|
||||||
|
window.location.protocol === 'file:' ||
|
||||||
|
navigator.userAgent.includes('Electron')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.defaultUrl) {
|
||||||
|
this.inputUrl = this.defaultUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected updated(changedProperties: Map<string, any>): void {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
// Bind webview events after it's rendered
|
||||||
|
this.bindWebviewEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindWebviewEvents(): void {
|
||||||
|
if (!this.isElectron || this.webviewEventsBound) return;
|
||||||
|
|
||||||
|
const webview = this.shadowRoot?.querySelector('webview') as any;
|
||||||
|
if (!webview) return;
|
||||||
|
|
||||||
|
this.webviewEventsBound = true;
|
||||||
|
|
||||||
|
webview.addEventListener('did-start-loading', () => {
|
||||||
|
this.isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.addEventListener('did-stop-loading', () => {
|
||||||
|
this.isLoading = false;
|
||||||
|
this.updateNavigationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.addEventListener('did-navigate', (e: any) => {
|
||||||
|
this.currentUrl = e.url || this.currentUrl;
|
||||||
|
this.inputUrl = this.currentUrl;
|
||||||
|
this.updateNavigationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.addEventListener('did-navigate-in-page', (e: any) => {
|
||||||
|
if (e.isMainFrame) {
|
||||||
|
this.currentUrl = e.url || this.currentUrl;
|
||||||
|
this.inputUrl = this.currentUrl;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.addEventListener('page-title-updated', (e: any) => {
|
||||||
|
this.pageTitle = e.title || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.addEventListener('page-favicon-updated', (e: any) => {
|
||||||
|
// Could store favicon for bookmarks
|
||||||
|
});
|
||||||
|
|
||||||
|
webview.addEventListener('dom-ready', () => {
|
||||||
|
this.updateNavigationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Webview events bound');
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="browser-container">
|
||||||
|
${this.renderToolbar()}
|
||||||
|
${this.showBookmarksBar && this.bookmarks.length > 0 ? this.renderBookmarksBar() : ''}
|
||||||
|
${this.activePanel === 'browser' ? this.renderBrowserContent() : this.renderBookmarksPanel()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderToolbar(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="nav-buttons">
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
title="Back"
|
||||||
|
?disabled=${!this.canGoBack}
|
||||||
|
@click=${this.handleBack}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:arrowLeft'} .iconSize=${18}></dees-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
title="Forward"
|
||||||
|
?disabled=${!this.canGoForward}
|
||||||
|
@click=${this.handleForward}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:arrowRight'} .iconSize=${18}></dees-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="nav-button ${this.isLoading ? 'loading' : ''}"
|
||||||
|
title=${this.isLoading ? 'Stop' : 'Refresh'}
|
||||||
|
@click=${this.isLoading ? this.handleStop : this.handleRefresh}
|
||||||
|
>
|
||||||
|
<dees-icon
|
||||||
|
.icon=${this.isLoading ? 'lucide:loader' : 'lucide:refreshCw'}
|
||||||
|
.iconSize=${18}
|
||||||
|
></dees-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="url-container">
|
||||||
|
<dees-icon class="url-icon" .icon=${'lucide:globe'} .iconSize=${16}></dees-icon>
|
||||||
|
<input
|
||||||
|
class="url-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter URL or search..."
|
||||||
|
.value=${this.inputUrl}
|
||||||
|
@input=${(e: InputEvent) => this.inputUrl = (e.target as HTMLInputElement).value}
|
||||||
|
@keydown=${this.handleUrlKeydown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="bookmark-button ${this.isCurrentPageBookmarked() ? 'bookmarked' : ''}"
|
||||||
|
title=${this.isCurrentPageBookmarked() ? 'Remove bookmark' : 'Add bookmark'}
|
||||||
|
@click=${this.toggleBookmark}
|
||||||
|
?disabled=${!this.currentUrl}
|
||||||
|
>
|
||||||
|
<dees-icon
|
||||||
|
.icon=${this.isCurrentPageBookmarked() ? 'lucide:star' : 'lucide:star'}
|
||||||
|
.iconSize=${18}
|
||||||
|
></dees-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="menu-container">
|
||||||
|
<button
|
||||||
|
class="menu-button"
|
||||||
|
title="Menu"
|
||||||
|
@click=${() => this.showMenu = !this.showMenu}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:moreVertical'} .iconSize=${18}></dees-icon>
|
||||||
|
</button>
|
||||||
|
${this.showMenu ? this.renderMenu() : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderMenu(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="dropdown-menu" @mouseleave=${() => this.showMenu = false}>
|
||||||
|
<div class="dropdown-item" @click=${() => { this.activePanel = 'bookmarks'; this.showMenu = false; }}>
|
||||||
|
<dees-icon .icon=${'lucide:bookmark'} .iconSize=${18}></dees-icon>
|
||||||
|
Bookmarks
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-item" @click=${this.handleNewTab}>
|
||||||
|
<dees-icon .icon=${'lucide:plus'} .iconSize=${18}></dees-icon>
|
||||||
|
New Tab
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item" @click=${this.handleOpenDevTools}>
|
||||||
|
<dees-icon .icon=${'lucide:code'} .iconSize=${18}></dees-icon>
|
||||||
|
Developer Tools
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<div class="dropdown-item" @click=${this.handleCopyUrl}>
|
||||||
|
<dees-icon .icon=${'lucide:copy'} .iconSize=${18}></dees-icon>
|
||||||
|
Copy URL
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBookmarksBar(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="bookmarks-bar">
|
||||||
|
${this.bookmarks.slice(0, 10).map(bookmark => html`
|
||||||
|
<div
|
||||||
|
class="bookmark-chip"
|
||||||
|
title=${bookmark.url}
|
||||||
|
@click=${() => this.navigate(bookmark.url)}
|
||||||
|
>
|
||||||
|
${bookmark.favicon
|
||||||
|
? html`<img class="favicon" src=${bookmark.favicon} alt="" />`
|
||||||
|
: html`<dees-icon .icon=${'lucide:globe'} .iconSize=${14}></dees-icon>`
|
||||||
|
}
|
||||||
|
${bookmark.title || new URL(bookmark.url).hostname}
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBrowserContent(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="webview-container">
|
||||||
|
${this.isLoading ? html`<div class="loading-bar"></div>` : ''}
|
||||||
|
<div class="webview-wrapper">
|
||||||
|
${this.currentUrl ? this.renderWebview() : this.renderPlaceholder()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderWebview(): TemplateResult {
|
||||||
|
// In Electron, we use webview tag. In browser, we show a notice.
|
||||||
|
if (this.isElectron) {
|
||||||
|
// Reset event binding flag when URL changes so events get rebound
|
||||||
|
// Note: webview events are bound in updated() lifecycle
|
||||||
|
return html`
|
||||||
|
<webview
|
||||||
|
src=${this.currentUrl}
|
||||||
|
style="width: 100%; height: 100%;"
|
||||||
|
allowpopups
|
||||||
|
></webview>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Fallback for non-Electron environment (demo/testing)
|
||||||
|
return html`
|
||||||
|
<div class="placeholder">
|
||||||
|
<dees-icon .icon=${'lucide:monitor'} .iconSize=${48}></dees-icon>
|
||||||
|
<span class="placeholder-text">Webview requires Electron environment</span>
|
||||||
|
<span class="placeholder-hint">URL: ${this.currentUrl}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPlaceholder(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="placeholder">
|
||||||
|
<dees-icon .icon=${'lucide:globe'} .iconSize=${64}></dees-icon>
|
||||||
|
<span class="placeholder-text">Enter a URL to start browsing</span>
|
||||||
|
<span class="placeholder-hint">or select a bookmark</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBookmarksPanel(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="bookmarks-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2 class="panel-title">Bookmarks</h2>
|
||||||
|
<p class="panel-description">Your saved websites</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.bookmarks.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="bookmarks-list">
|
||||||
|
${this.bookmarks.map(bookmark => html`
|
||||||
|
<div class="bookmark-item" @click=${() => this.handleBookmarkClick(bookmark)}>
|
||||||
|
${bookmark.favicon
|
||||||
|
? html`<img class="favicon" src=${bookmark.favicon} alt="" />`
|
||||||
|
: html`<dees-icon .icon=${'lucide:globe'} .iconSize=${20}></dees-icon>`
|
||||||
|
}
|
||||||
|
<div class="bookmark-info">
|
||||||
|
<div class="bookmark-title">${bookmark.title || bookmark.url}</div>
|
||||||
|
<div class="bookmark-url">${bookmark.url}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bookmark-delete">
|
||||||
|
<button
|
||||||
|
class="bookmark-delete-btn"
|
||||||
|
title="Delete bookmark"
|
||||||
|
@click=${(e: Event) => { e.stopPropagation(); this.removeBookmark(bookmark.id); }}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:trash2'} .iconSize=${16}></dees-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon .icon=${'lucide:bookmark'} .iconSize=${48}></dees-icon>
|
||||||
|
<p>No bookmarks yet. Star a page to save it here.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation methods
|
||||||
|
private navigate(url: string): void {
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
// Add protocol if missing
|
||||||
|
if (!/^https?:\/\//i.test(url)) {
|
||||||
|
// Check if it looks like a domain
|
||||||
|
if (/^[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}/.test(url)) {
|
||||||
|
url = 'https://' + url;
|
||||||
|
} else {
|
||||||
|
// Treat as search query
|
||||||
|
url = `https://www.google.com/search?q=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentUrl = url;
|
||||||
|
this.inputUrl = url;
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('navigate', {
|
||||||
|
detail: { url },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleUrlKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.navigate(this.inputUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get webview(): any {
|
||||||
|
return this.shadowRoot?.querySelector('webview');
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBack(): void {
|
||||||
|
const wv = this.webview;
|
||||||
|
if (wv && this.isElectron) {
|
||||||
|
wv.goBack();
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('browser-back', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleForward(): void {
|
||||||
|
const wv = this.webview;
|
||||||
|
if (wv && this.isElectron) {
|
||||||
|
wv.goForward();
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('browser-forward', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRefresh(): void {
|
||||||
|
const wv = this.webview;
|
||||||
|
if (wv && this.isElectron) {
|
||||||
|
wv.reload();
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('browser-refresh', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleStop(): void {
|
||||||
|
const wv = this.webview;
|
||||||
|
if (wv && this.isElectron) {
|
||||||
|
wv.stop();
|
||||||
|
}
|
||||||
|
this.isLoading = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('browser-stop', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFaviconUpdate(e: any): void {
|
||||||
|
// Could update current page favicon for bookmarks
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateNavigationState(): void {
|
||||||
|
if (this.webview && this.isElectron) {
|
||||||
|
this.canGoBack = (this.webview as any).canGoBack?.() || false;
|
||||||
|
this.canGoForward = (this.webview as any).canGoForward?.() || false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark methods
|
||||||
|
private isCurrentPageBookmarked(): boolean {
|
||||||
|
return this.bookmarks.some(b => b.url === this.currentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleBookmark(): void {
|
||||||
|
if (!this.currentUrl) return;
|
||||||
|
|
||||||
|
if (this.isCurrentPageBookmarked()) {
|
||||||
|
const bookmark = this.bookmarks.find(b => b.url === this.currentUrl);
|
||||||
|
if (bookmark) {
|
||||||
|
this.removeBookmark(bookmark.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.addBookmark();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addBookmark(): void {
|
||||||
|
const bookmark: IBookmark = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: this.pageTitle || this.currentUrl,
|
||||||
|
url: this.currentUrl,
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('bookmark-add', {
|
||||||
|
detail: { bookmark },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeBookmark(id: string): void {
|
||||||
|
this.dispatchEvent(new CustomEvent('bookmark-remove', {
|
||||||
|
detail: { id },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleBookmarkClick(bookmark: IBookmark): void {
|
||||||
|
this.navigate(bookmark.url);
|
||||||
|
this.activePanel = 'browser';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu actions
|
||||||
|
private handleNewTab(): void {
|
||||||
|
this.showMenu = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('new-tab', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOpenDevTools(): void {
|
||||||
|
this.showMenu = false;
|
||||||
|
if (this.webview && this.isElectron) {
|
||||||
|
(this.webview as any).openDevTools();
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('open-devtools', { bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCopyUrl(): void {
|
||||||
|
this.showMenu = false;
|
||||||
|
if (this.currentUrl) {
|
||||||
|
navigator.clipboard.writeText(this.currentUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
public goTo(url: string): void {
|
||||||
|
this.navigate(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setBookmarks(bookmarks: IBookmark[]): void {
|
||||||
|
this.bookmarks = bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): IBrowserState {
|
||||||
|
return {
|
||||||
|
url: this.currentUrl,
|
||||||
|
title: this.pageTitle,
|
||||||
|
canGoBack: this.canGoBack,
|
||||||
|
canGoForward: this.canGoForward,
|
||||||
|
isLoading: this.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts_web/views/eco-view-browser/index.ts
Normal file
1
ts_web/views/eco-view-browser/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-view-browser.js';
|
||||||
30
ts_web/views/eco-view-scan/eco-view-scan.demo.ts
Normal file
30
ts_web/views/eco-view-scan/eco-view-scan.demo.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { html } from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
export const demo = () => html`
|
||||||
|
<style>
|
||||||
|
.demo-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: hsl(240 10% 4%);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="demo-container">
|
||||||
|
<eco-view-scan
|
||||||
|
.scanners=${[
|
||||||
|
{ id: 'scanner-1', name: 'HP ScanJet Pro', address: '192.168.1.100', status: 'online', capabilities: { resolutions: [150, 300, 600], formats: ['pdf', 'jpeg', 'png'], colorModes: ['color', 'grayscale'], sources: ['flatbed', 'adf'] } },
|
||||||
|
{ id: 'scanner-2', name: 'Canon imageFORMULA', address: '192.168.1.101', status: 'online', capabilities: { resolutions: [200, 300, 400, 600], formats: ['pdf', 'jpeg', 'tiff'], colorModes: ['color', 'grayscale', 'blackwhite'], sources: ['flatbed'] } },
|
||||||
|
{ id: 'scanner-3', name: 'Epson WorkForce', address: '192.168.1.102', status: 'offline' },
|
||||||
|
]}
|
||||||
|
.providers=${[
|
||||||
|
{ id: 'provider-1', name: 'Cloud Storage', icon: 'lucide:cloud' },
|
||||||
|
{ id: 'provider-2', name: 'Google Drive', icon: 'lucide:hardDrive' },
|
||||||
|
]}
|
||||||
|
@scanner-select=${(e: CustomEvent) => console.log('Scanner selected:', e.detail)}
|
||||||
|
@scan-request=${(e: CustomEvent) => console.log('Scan requested:', e.detail)}
|
||||||
|
@save-local=${(e: CustomEvent) => console.log('Save local:', e.detail)}
|
||||||
|
@send-to-provider=${(e: CustomEvent) => console.log('Send to provider:', e.detail)}
|
||||||
|
></eco-view-scan>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
839
ts_web/views/eco-view-scan/eco-view-scan.ts
Normal file
839
ts_web/views/eco-view-scan/eco-view-scan.ts
Normal file
@@ -0,0 +1,839 @@
|
|||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
type TemplateResult,
|
||||||
|
html,
|
||||||
|
property,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
state,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
import { DeesAppuiSecondarymenu, DeesIcon } from '@design.estate/dees-catalog';
|
||||||
|
import type { ISecondaryMenuGroup } from '../../elements/interfaces/secondarymenu.js';
|
||||||
|
import { demo } from './eco-view-scan.demo.js';
|
||||||
|
|
||||||
|
// Ensure components are registered
|
||||||
|
DeesAppuiSecondarymenu;
|
||||||
|
DeesIcon;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'eco-view-scan': EcoViewScan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type TScanFormat = 'pdf' | 'jpeg' | 'png' | 'tiff';
|
||||||
|
export type TScanColorMode = 'color' | 'grayscale' | 'blackwhite';
|
||||||
|
export type TScanSource = 'flatbed' | 'adf' | 'adf-duplex';
|
||||||
|
export type TScanPanel = 'scan' | 'history' | 'settings';
|
||||||
|
|
||||||
|
export interface IScanSettings {
|
||||||
|
format: TScanFormat;
|
||||||
|
resolution: number;
|
||||||
|
colorMode: TScanColorMode;
|
||||||
|
source: TScanSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IScannedDocument {
|
||||||
|
id: string;
|
||||||
|
timestamp: Date;
|
||||||
|
format: TScanFormat;
|
||||||
|
data: string; // base64
|
||||||
|
thumbnail?: string;
|
||||||
|
size: number;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IScannerInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
status: 'online' | 'offline' | 'busy' | 'error';
|
||||||
|
capabilities?: {
|
||||||
|
resolutions: number[];
|
||||||
|
formats: TScanFormat[];
|
||||||
|
colorModes: TScanColorMode[];
|
||||||
|
sources: TScanSource[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDataProviderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('eco-view-scan')
|
||||||
|
export class EcoViewScan extends DeesElement {
|
||||||
|
public static demo = demo;
|
||||||
|
public static demoGroup = 'Views';
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: ${cssManager.bdTheme('#f5f5f7', 'hsl(240 6% 10%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
dees-appui-secondarymenu {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')};
|
||||||
|
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 15%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-selector label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-selector select {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-selector select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: hsl(217 91% 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 12%)')};
|
||||||
|
border: 2px dashed ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area.has-image {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 60%)', 'hsl(0 0% 50%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder dees-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary {
|
||||||
|
background: hsl(217 91% 60%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.primary:hover:not(:disabled) {
|
||||||
|
background: hsl(217 91% 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.secondary {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 20%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 90%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.secondary:hover:not(:disabled) {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 25%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button.scanning dees-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 20%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item dees-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 12%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 18%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 85%)', 'hsl(240 5% 25%)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 15%)')};
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 12%)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 18%)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
border-color: hsl(217 91% 60%);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(0,0,0,0.3)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(240 5% 18%)')};
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-info {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state dees-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor scanners: IScannerInfo[] = [];
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor providers: IDataProviderInfo[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor activePanel: TScanPanel = 'scan';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor selectedScannerId: string | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isScanning = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentPreview: string | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentDocument: IScannedDocument | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor scanHistory: IScannedDocument[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor settings: IScanSettings = {
|
||||||
|
format: 'pdf',
|
||||||
|
resolution: 300,
|
||||||
|
colorMode: 'color',
|
||||||
|
source: 'flatbed',
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showSendToMenu = false;
|
||||||
|
|
||||||
|
private get selectedScanner(): IScannerInfo | null {
|
||||||
|
return this.scanners.find(s => s.id === this.selectedScannerId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get availableResolutions(): number[] {
|
||||||
|
return this.selectedScanner?.capabilities?.resolutions || [150, 300, 600];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get availableFormats(): TScanFormat[] {
|
||||||
|
return this.selectedScanner?.capabilities?.formats || ['pdf', 'jpeg', 'png'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get availableColorModes(): TScanColorMode[] {
|
||||||
|
return this.selectedScanner?.capabilities?.colorModes || ['color', 'grayscale'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private get availableSources(): TScanSource[] {
|
||||||
|
return this.selectedScanner?.capabilities?.sources || ['flatbed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="scan-container">
|
||||||
|
<dees-appui-secondarymenu
|
||||||
|
.menuGroups=${this.getMenuGroups()}
|
||||||
|
.selectedKey=${this.activePanel}
|
||||||
|
></dees-appui-secondarymenu>
|
||||||
|
<div class="content">
|
||||||
|
${this.renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMenuGroups(): ISecondaryMenuGroup[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'SCAN',
|
||||||
|
iconName: 'lucide:scan',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'scan',
|
||||||
|
label: 'New Scan',
|
||||||
|
iconName: 'lucide:plus',
|
||||||
|
action: () => this.activePanel = 'scan',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HISTORY',
|
||||||
|
iconName: 'lucide:history',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'history',
|
||||||
|
label: 'Recent Scans',
|
||||||
|
iconName: 'lucide:clock',
|
||||||
|
action: () => this.activePanel = 'history',
|
||||||
|
badge: this.scanHistory.length > 0 ? this.scanHistory.length : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'OPTIONS',
|
||||||
|
iconName: 'lucide:settings',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
label: 'Scan Settings',
|
||||||
|
iconName: 'lucide:sliders',
|
||||||
|
action: () => this.activePanel = 'settings',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderContent(): TemplateResult {
|
||||||
|
switch (this.activePanel) {
|
||||||
|
case 'scan':
|
||||||
|
return this.renderScanPanel();
|
||||||
|
case 'history':
|
||||||
|
return this.renderHistoryPanel();
|
||||||
|
case 'settings':
|
||||||
|
return this.renderSettingsPanel();
|
||||||
|
default:
|
||||||
|
return this.renderScanPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderScanPanel(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-header-left">
|
||||||
|
<h2 class="panel-title">Scan Document</h2>
|
||||||
|
<p class="panel-description">Select a scanner and start scanning</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scanner-selector">
|
||||||
|
<label>Scanner:</label>
|
||||||
|
<select
|
||||||
|
.value=${this.selectedScannerId || ''}
|
||||||
|
@change=${(e: Event) => this.handleScannerChange(e)}
|
||||||
|
>
|
||||||
|
<option value="">Select a scanner...</option>
|
||||||
|
${this.scanners.map(scanner => html`
|
||||||
|
<option value=${scanner.id} ?selected=${scanner.id === this.selectedScannerId}>
|
||||||
|
${scanner.name} ${scanner.status !== 'online' ? `(${scanner.status})` : ''}
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-area ${this.currentPreview ? 'has-image' : ''}">
|
||||||
|
${this.currentPreview
|
||||||
|
? html`<img class="preview-image" src=${this.currentPreview} alt="Scan preview" />`
|
||||||
|
: html`
|
||||||
|
<div class="preview-placeholder">
|
||||||
|
<dees-icon .icon=${'lucide:scan'} .iconSize=${48}></dees-icon>
|
||||||
|
<span>Scan preview will appear here</span>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<button
|
||||||
|
class="action-button primary ${this.isScanning ? 'scanning' : ''}"
|
||||||
|
?disabled=${!this.selectedScannerId || this.isScanning}
|
||||||
|
@click=${this.handleScan}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${this.isScanning ? 'lucide:loader' : 'lucide:scan'} .iconSize=${18}></dees-icon>
|
||||||
|
${this.isScanning ? 'Scanning...' : 'Scan'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dropdown-container">
|
||||||
|
<button
|
||||||
|
class="action-button secondary"
|
||||||
|
?disabled=${!this.currentDocument}
|
||||||
|
@click=${() => this.showSendToMenu = !this.showSendToMenu}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:send'} .iconSize=${18}></dees-icon>
|
||||||
|
Send To
|
||||||
|
<dees-icon .icon=${'lucide:chevronUp'} .iconSize=${14}></dees-icon>
|
||||||
|
</button>
|
||||||
|
${this.showSendToMenu ? this.renderSendToMenu() : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="action-button secondary"
|
||||||
|
?disabled=${!this.currentDocument}
|
||||||
|
@click=${this.handleSaveLocal}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:download'} .iconSize=${18}></dees-icon>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSendToMenu(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="dropdown-menu" @mouseleave=${() => this.showSendToMenu = false}>
|
||||||
|
${this.providers.length > 0
|
||||||
|
? this.providers.map(provider => html`
|
||||||
|
<div class="dropdown-item" @click=${() => this.handleSendToProvider(provider.id)}>
|
||||||
|
<dees-icon .icon=${provider.icon || 'lucide:cloud'} .iconSize=${18}></dees-icon>
|
||||||
|
${provider.name}
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`
|
||||||
|
<div class="dropdown-item" style="opacity: 0.5; cursor: default;">
|
||||||
|
<dees-icon .icon=${'lucide:info'} .iconSize=${18}></dees-icon>
|
||||||
|
No providers configured
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHistoryPanel(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-header-left">
|
||||||
|
<h2 class="panel-title">Recent Scans</h2>
|
||||||
|
<p class="panel-description">View and manage your scanned documents</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.scanHistory.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="history-grid">
|
||||||
|
${this.scanHistory.map(doc => html`
|
||||||
|
<div class="history-item" @click=${() => this.handleHistoryItemClick(doc)}>
|
||||||
|
<div class="history-thumbnail">
|
||||||
|
${doc.thumbnail
|
||||||
|
? html`<img src=${doc.thumbnail} alt=${doc.name || 'Scan'} />`
|
||||||
|
: html`<dees-icon .icon=${'lucide:file'} .iconSize=${32}></dees-icon>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="history-info">
|
||||||
|
<div class="history-name">${doc.name || `Scan ${doc.id.slice(0, 8)}`}</div>
|
||||||
|
<div class="history-date">${this.formatDate(doc.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon .icon=${'lucide:inbox'} .iconSize=${48}></dees-icon>
|
||||||
|
<p>No scans yet. Start scanning to see your documents here.</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSettingsPanel(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="panel-header">
|
||||||
|
<div class="panel-header-left">
|
||||||
|
<h2 class="panel-title">Scan Settings</h2>
|
||||||
|
<p class="panel-description">Configure default scan options</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="settings-section-title">Output Settings</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Format</span>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select
|
||||||
|
.value=${this.settings.format}
|
||||||
|
@change=${(e: Event) => this.updateSetting('format', (e.target as HTMLSelectElement).value as TScanFormat)}
|
||||||
|
>
|
||||||
|
${this.availableFormats.map(fmt => html`
|
||||||
|
<option value=${fmt} ?selected=${fmt === this.settings.format}>
|
||||||
|
${fmt.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Resolution (DPI)</span>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select
|
||||||
|
.value=${String(this.settings.resolution)}
|
||||||
|
@change=${(e: Event) => this.updateSetting('resolution', parseInt((e.target as HTMLSelectElement).value))}
|
||||||
|
>
|
||||||
|
${this.availableResolutions.map(res => html`
|
||||||
|
<option value=${res} ?selected=${res === this.settings.resolution}>
|
||||||
|
${res} DPI
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Color Mode</span>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select
|
||||||
|
.value=${this.settings.colorMode}
|
||||||
|
@change=${(e: Event) => this.updateSetting('colorMode', (e.target as HTMLSelectElement).value as TScanColorMode)}
|
||||||
|
>
|
||||||
|
${this.availableColorModes.map(mode => html`
|
||||||
|
<option value=${mode} ?selected=${mode === this.settings.colorMode}>
|
||||||
|
${this.getColorModeLabel(mode)}
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Source</span>
|
||||||
|
<div class="setting-control">
|
||||||
|
<select
|
||||||
|
.value=${this.settings.source}
|
||||||
|
@change=${(e: Event) => this.updateSetting('source', (e.target as HTMLSelectElement).value as TScanSource)}
|
||||||
|
>
|
||||||
|
${this.availableSources.map(src => html`
|
||||||
|
<option value=${src} ?selected=${src === this.settings.source}>
|
||||||
|
${this.getSourceLabel(src)}
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleScannerChange(e: Event): void {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
this.selectedScannerId = select.value || null;
|
||||||
|
this.dispatchEvent(new CustomEvent('scanner-select', {
|
||||||
|
detail: { scannerId: this.selectedScannerId },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleScan(): Promise<void> {
|
||||||
|
if (!this.selectedScannerId || this.isScanning) return;
|
||||||
|
|
||||||
|
this.isScanning = true;
|
||||||
|
this.dispatchEvent(new CustomEvent('scan-request', {
|
||||||
|
detail: {
|
||||||
|
scannerId: this.selectedScannerId,
|
||||||
|
settings: this.settings,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public setScanResult(result: { data: string; format: TScanFormat; thumbnail?: string }): void {
|
||||||
|
const doc: IScannedDocument = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
format: result.format,
|
||||||
|
data: result.data,
|
||||||
|
thumbnail: result.thumbnail,
|
||||||
|
size: result.data.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentDocument = doc;
|
||||||
|
this.currentPreview = result.thumbnail || `data:image/${result.format};base64,${result.data}`;
|
||||||
|
this.scanHistory = [doc, ...this.scanHistory.slice(0, 19)]; // Keep last 20
|
||||||
|
this.isScanning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setScanError(error: string): void {
|
||||||
|
this.isScanning = false;
|
||||||
|
console.error('Scan error:', error);
|
||||||
|
// Could dispatch error event or show toast
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSaveLocal(): void {
|
||||||
|
if (!this.currentDocument) return;
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('save-local', {
|
||||||
|
detail: { document: this.currentDocument },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSendToProvider(providerId: string): void {
|
||||||
|
if (!this.currentDocument) return;
|
||||||
|
|
||||||
|
this.showSendToMenu = false;
|
||||||
|
this.dispatchEvent(new CustomEvent('send-to-provider', {
|
||||||
|
detail: {
|
||||||
|
providerId,
|
||||||
|
document: this.currentDocument,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleHistoryItemClick(doc: IScannedDocument): void {
|
||||||
|
this.currentDocument = doc;
|
||||||
|
this.currentPreview = doc.thumbnail || `data:image/${doc.format};base64,${doc.data}`;
|
||||||
|
this.activePanel = 'scan';
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSetting<K extends keyof IScanSettings>(key: K, value: IScanSettings[K]): void {
|
||||||
|
this.settings = { ...this.settings, [key]: value };
|
||||||
|
this.dispatchEvent(new CustomEvent('settings-change', {
|
||||||
|
detail: { settings: this.settings },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getColorModeLabel(mode: TScanColorMode): string {
|
||||||
|
const labels: Record<TScanColorMode, string> = {
|
||||||
|
color: 'Color',
|
||||||
|
grayscale: 'Grayscale',
|
||||||
|
blackwhite: 'Black & White',
|
||||||
|
};
|
||||||
|
return labels[mode];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSourceLabel(source: TScanSource): string {
|
||||||
|
const labels: Record<TScanSource, string> = {
|
||||||
|
flatbed: 'Flatbed',
|
||||||
|
adf: 'Document Feeder',
|
||||||
|
'adf-duplex': 'Document Feeder (Duplex)',
|
||||||
|
};
|
||||||
|
return labels[source];
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(date: Date): string {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ts_web/views/eco-view-scan/index.ts
Normal file
1
ts_web/views/eco-view-scan/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './eco-view-scan.js';
|
||||||
@@ -149,27 +149,83 @@ export class EcoViewSystem extends DeesElement {
|
|||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
accessor activePanel: TSystemPanel = 'overview';
|
accessor activePanel: TSystemPanel = 'overview';
|
||||||
|
|
||||||
// Mock system data
|
// System data (can be set externally)
|
||||||
@state()
|
@property({ type: Number })
|
||||||
accessor cpuUsage = 42;
|
accessor cpuUsage = 0;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor memoryUsage = 0;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor diskUsage = 0;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor cpuTemp = 0;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor uptime = '--';
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor cpuCores = 0;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor cpuModel = 'Unknown';
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor cpuSpeed = 0;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor memoryTotal = 0;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor memoryUsed = 0;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
accessor memoryFree = 0;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor hostname = 'Unknown';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
accessor platform = 'Unknown';
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
accessor loadAvg: number[] = [0, 0, 0];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor memoryUsage = 67;
|
accessor networkIn = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor diskUsage = 54;
|
accessor networkOut = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
@state()
|
// Public method to update metrics from backend
|
||||||
accessor cpuTemp = 58;
|
public setMetrics(metrics: {
|
||||||
|
cpu: { usage: number; cores: number; model: string; speed: number; loadAvg: number[] };
|
||||||
|
memory: { total: number; used: number; free: number; usagePercent: number };
|
||||||
|
system: { platform: string; hostname: string; uptimeFormatted: string };
|
||||||
|
}): void {
|
||||||
|
this.cpuUsage = metrics.cpu.usage;
|
||||||
|
this.cpuCores = metrics.cpu.cores;
|
||||||
|
this.cpuModel = metrics.cpu.model;
|
||||||
|
this.cpuSpeed = metrics.cpu.speed;
|
||||||
|
this.loadAvg = metrics.cpu.loadAvg;
|
||||||
|
this.memoryUsage = metrics.memory.usagePercent;
|
||||||
|
this.memoryTotal = metrics.memory.total;
|
||||||
|
this.memoryUsed = metrics.memory.used;
|
||||||
|
this.memoryFree = metrics.memory.free;
|
||||||
|
this.hostname = metrics.system.hostname;
|
||||||
|
this.platform = metrics.system.platform;
|
||||||
|
this.uptime = metrics.system.uptimeFormatted;
|
||||||
|
}
|
||||||
|
|
||||||
@state()
|
// Helper to format bytes
|
||||||
accessor uptime = '14d 7h 32m';
|
private formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
@state()
|
const k = 1024;
|
||||||
accessor networkIn = [45, 52, 38, 65, 72, 68, 75, 82, 79, 85, 88, 72];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
@state()
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
accessor networkOut = [32, 28, 35, 42, 38, 45, 52, 48, 55, 62, 58, 65];
|
}
|
||||||
|
|
||||||
private getMenuGroups(): ISecondaryMenuGroup[] {
|
private getMenuGroups(): ISecondaryMenuGroup[] {
|
||||||
return [
|
return [
|
||||||
@@ -299,6 +355,7 @@ export class EcoViewSystem extends DeesElement {
|
|||||||
value: this.memoryUsage,
|
value: this.memoryUsage,
|
||||||
type: 'gauge' as const,
|
type: 'gauge' as const,
|
||||||
icon: 'lucide:memoryStick',
|
icon: 'lucide:memoryStick',
|
||||||
|
description: this.memoryTotal ? `${this.formatBytes(this.memoryUsed)} of ${this.formatBytes(this.memoryTotal)}` : undefined,
|
||||||
gaugeOptions: {
|
gaugeOptions: {
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 100,
|
max: 100,
|
||||||
@@ -373,12 +430,12 @@ export class EcoViewSystem extends DeesElement {
|
|||||||
description: 'Since last reboot',
|
description: 'Since last reboot',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'processes',
|
id: 'hostname',
|
||||||
title: 'Processes',
|
title: 'Hostname',
|
||||||
value: 247,
|
value: this.hostname,
|
||||||
type: 'number' as const,
|
type: 'text' as const,
|
||||||
icon: 'lucide:layers',
|
icon: 'lucide:server',
|
||||||
description: '12 running, 235 sleeping',
|
description: `${this.platform} - ${this.cpuCores} cores`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ export * from './eco-view-saasshare/index.js';
|
|||||||
export * from './eco-view-system/index.js';
|
export * from './eco-view-system/index.js';
|
||||||
export * from './eco-view-home/index.js';
|
export * from './eco-view-home/index.js';
|
||||||
export * from './eco-view-login/index.js';
|
export * from './eco-view-login/index.js';
|
||||||
|
export * from './eco-view-scan/index.js';
|
||||||
|
export * from './eco-view-browser/index.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user