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:
2026-01-12 15:16:01 +00:00
parent bf4fcfac71
commit ee45fb01a2
21 changed files with 4262 additions and 271 deletions

View 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>
`;

View 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,
};
}
}

View File

@@ -0,0 +1 @@
export * from './eco-view-browser.js';

View 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>
`;

View 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);
}
}

View File

@@ -0,0 +1 @@
export * from './eco-view-scan.js';

View File

@@ -149,27 +149,83 @@ export class EcoViewSystem extends DeesElement {
@property({ type: String })
accessor activePanel: TSystemPanel = 'overview';
// Mock system data
@state()
accessor cpuUsage = 42;
// System data (can be set externally)
@property({ type: Number })
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()
accessor memoryUsage = 67;
accessor networkIn = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
@state()
accessor diskUsage = 54;
accessor networkOut = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
@state()
accessor cpuTemp = 58;
// Public method to update metrics from backend
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()
accessor uptime = '14d 7h 32m';
@state()
accessor networkIn = [45, 52, 38, 65, 72, 68, 75, 82, 79, 85, 88, 72];
@state()
accessor networkOut = [32, 28, 35, 42, 38, 45, 52, 48, 55, 62, 58, 65];
// Helper to format bytes
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
private getMenuGroups(): ISecondaryMenuGroup[] {
return [
@@ -299,6 +355,7 @@ export class EcoViewSystem extends DeesElement {
value: this.memoryUsage,
type: 'gauge' as const,
icon: 'lucide:memoryStick',
description: this.memoryTotal ? `${this.formatBytes(this.memoryUsed)} of ${this.formatBytes(this.memoryTotal)}` : undefined,
gaugeOptions: {
min: 0,
max: 100,
@@ -373,12 +430,12 @@ export class EcoViewSystem extends DeesElement {
description: 'Since last reboot',
},
{
id: 'processes',
title: 'Processes',
value: 247,
type: 'number' as const,
icon: 'lucide:layers',
description: '12 running, 235 sleeping',
id: 'hostname',
title: 'Hostname',
value: this.hostname,
type: 'text' as const,
icon: 'lucide:server',
description: `${this.platform} - ${this.cpuCores} cores`,
},
];

View File

@@ -4,3 +4,5 @@ export * from './eco-view-saasshare/index.js';
export * from './eco-view-system/index.js';
export * from './eco-view-home/index.js';
export * from './eco-view-login/index.js';
export * from './eco-view-scan/index.js';
export * from './eco-view-browser/index.js';