Files
catalog/ts_web/views/eco-view-scan/eco-view-scan.ts

840 lines
23 KiB
TypeScript

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