Files
dees-catalog/ts_web/elements/00group-media/dees-preview/dees-preview.ts

508 lines
14 KiB
TypeScript

import {
DeesElement,
html,
customElement,
type TemplateResult,
property,
state,
cssManager,
} from '@design.estate/dees-element';
import '../dees-image-viewer/component.js';
import '../dees-audio-viewer/component.js';
import '../dees-video-viewer/component.js';
import '../../00group-dataview/dees-dataview-codebox/dees-dataview-codebox.js';
import '../dees-pdf-viewer/component.js';
import '../../00group-utility/dees-icon/dees-icon.js';
import { demoFunc } from './dees-preview.demo.js';
export type TPreviewContentType = 'image' | 'pdf' | 'audio' | 'video' | 'code' | 'text' | 'unknown';
declare global {
interface HTMLElementTagNameMap {
'dees-preview': DeesPreview;
}
}
const EXTENSION_MAP: Record<string, TPreviewContentType> = {
// Image
jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', webp: 'image',
svg: 'image', bmp: 'image', avif: 'image', ico: 'image',
// PDF
pdf: 'pdf',
// Audio
mp3: 'audio', wav: 'audio', ogg: 'audio', flac: 'audio', aac: 'audio',
m4a: 'audio', opus: 'audio', weba: 'audio',
// Video
mp4: 'video', webm: 'video', mov: 'video', avi: 'video', mkv: 'video', ogv: 'video',
// Code
ts: 'code', js: 'code', jsx: 'code', tsx: 'code', json: 'code',
html: 'code', css: 'code', scss: 'code', less: 'code',
py: 'code', java: 'code', go: 'code', rs: 'code',
yaml: 'code', yml: 'code', xml: 'code', sql: 'code',
sh: 'code', bash: 'code', zsh: 'code', md: 'code',
c: 'code', cpp: 'code', h: 'code', hpp: 'code',
rb: 'code', php: 'code', swift: 'code', kt: 'code',
// Text
txt: 'text', log: 'text', csv: 'text', env: 'text',
};
const MIME_PREFIX_MAP: Record<string, TPreviewContentType> = {
'image/': 'image',
'audio/': 'audio',
'video/': 'video',
'application/pdf': 'pdf',
};
const EXTENSION_LANG_MAP: Record<string, string> = {
ts: 'typescript', tsx: 'typescript',
js: 'javascript', jsx: 'javascript',
json: 'json', html: 'xml', xml: 'xml',
css: 'css', scss: 'scss', less: 'less',
py: 'python', java: 'java', go: 'go', rs: 'rust',
yaml: 'yaml', yml: 'yaml', sql: 'sql',
sh: 'bash', bash: 'bash', zsh: 'bash',
c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
rb: 'ruby', php: 'php', swift: 'swift', kt: 'kotlin',
md: 'markdown',
};
const TYPE_ICONS: Record<TPreviewContentType, string> = {
image: 'lucide:Image',
pdf: 'lucide:FileText',
audio: 'lucide:Music',
video: 'lucide:Video',
code: 'lucide:Code',
text: 'lucide:FileText',
unknown: 'lucide:File',
};
@customElement('dees-preview')
export class DeesPreview extends DeesElement {
public static demo = demoFunc;
public static demoGroups = ['Media', 'Data View'];
// Content sources (use one)
@property()
accessor url: string = '';
@property({ attribute: false })
accessor file: File | undefined = undefined;
@property()
accessor base64: string = '';
@property()
accessor textContent: string = '';
// Hints & overrides
@property()
accessor contentType: TPreviewContentType | undefined = undefined;
@property()
accessor language: string = '';
@property()
accessor mimeType: string = '';
@property()
accessor filename: string = '';
// UI
@property({ type: Boolean })
accessor showToolbar: boolean = true;
@property({ type: Boolean })
accessor showFilename: boolean = true;
// Internal
@state()
accessor resolvedType: TPreviewContentType = 'unknown';
@state()
accessor resolvedSrc: string = '';
@state()
accessor resolvedText: string = '';
@state()
accessor resolvedLang: string = 'text';
@state()
accessor loading: boolean = false;
@state()
accessor error: string = '';
private objectUrl: string = '';
public render(): TemplateResult {
const displayName = this.filename || this.file?.name || this.getFilenameFromUrl() || '';
return html`
<style>
:host {
display: block;
position: relative;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.preview-container {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
overflow: hidden;
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.header-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
height: 40px;
background: ${cssManager.bdTheme('#f9fafb', 'hsl(215 20% 15%)')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', 'hsl(217 25% 22%)')};
flex-shrink: 0;
}
.header-icon {
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
flex-shrink: 0;
font-size: 16px;
}
.header-filename {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.header-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.15)')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
text-transform: uppercase;
flex-shrink: 0;
}
.content-area {
flex: 1;
overflow: hidden;
position: relative;
min-height: 200px;
}
.content-area > * {
width: 100%;
height: 100%;
}
dees-image-viewer {
display: block;
height: 100%;
}
dees-pdf-viewer {
display: block;
height: 100%;
}
dees-video-viewer {
display: block;
}
dees-audio-viewer {
display: block;
height: 100%;
}
dees-dataview-codebox {
display: block;
}
.text-viewer {
margin: 0;
padding: 16px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
height: 100%;
box-sizing: border-box;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
min-height: 200px;
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
}
.placeholder dees-icon {
opacity: 0.5;
font-size: 32px;
}
.placeholder-text {
font-size: 14px;
}
.loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
min-height: 200px;
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.error-container dees-icon {
font-size: 32px;
}
.error-text {
font-size: 13px;
}
</style>
<div class="preview-container">
${this.showFilename && displayName ? html`
<div class="header-bar">
<dees-icon class="header-icon" icon="${TYPE_ICONS[this.resolvedType]}"></dees-icon>
<span class="header-filename">${displayName}</span>
<span class="header-badge">${this.resolvedType}</span>
</div>
` : ''}
<div class="content-area">
${this.error ? html`
<div class="error-container">
<dees-icon icon="lucide:AlertTriangle"></dees-icon>
<span class="error-text">${this.error}</span>
</div>
` : this.loading ? html`
<div class="loading-container">
<div class="loading-spinner"></div>
</div>
` : this.renderContent()}
</div>
</div>
`;
}
private renderContent(): TemplateResult {
switch (this.resolvedType) {
case 'image':
return html`
<dees-image-viewer
.src=${this.resolvedSrc}
.showToolbar=${this.showToolbar}
alt="${this.filename || ''}"
></dees-image-viewer>
`;
case 'pdf':
return html`
<dees-pdf-viewer
.pdfUrl=${this.resolvedSrc}
.showToolbar=${this.showToolbar}
initialZoom="page-fit"
></dees-pdf-viewer>
`;
case 'audio':
return html`
<dees-audio-viewer
.src=${this.resolvedSrc}
.title=${this.filename || this.file?.name || ''}
></dees-audio-viewer>
`;
case 'video':
return html`
<dees-video-viewer
.src=${this.resolvedSrc}
></dees-video-viewer>
`;
case 'code':
return html`
<dees-dataview-codebox
.progLang=${this.resolvedLang}
.codeToDisplay=${this.resolvedText}
></dees-dataview-codebox>
`;
case 'text':
return html`<pre class="text-viewer">${this.resolvedText}</pre>`;
default:
return html`
<div class="placeholder">
<dees-icon icon="lucide:FileQuestion"></dees-icon>
<span class="placeholder-text">Preview not available</span>
</div>
`;
}
}
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
super.updated(changedProperties);
const relevant = ['url', 'file', 'base64', 'textContent', 'contentType', 'language', 'mimeType', 'filename'];
const needsResolve = relevant.some((key) => changedProperties.has(key));
if (needsResolve) {
await this.resolveContent();
}
}
public async disconnectedCallback(): Promise<void> {
await super.disconnectedCallback();
this.revokeObjectUrl();
}
private async resolveContent(): Promise<void> {
this.error = '';
this.revokeObjectUrl();
// Detect type
this.resolvedType = this.detectType();
// Resolve source
try {
if (this.url) {
this.resolvedSrc = this.url;
if (this.resolvedType === 'code' || this.resolvedType === 'text') {
if (!this.textContent) {
this.loading = true;
const response = await fetch(this.url);
this.resolvedText = await response.text();
this.loading = false;
} else {
this.resolvedText = this.textContent;
}
}
} else if (this.file) {
this.objectUrl = URL.createObjectURL(this.file);
this.resolvedSrc = this.objectUrl;
if (this.resolvedType === 'code' || this.resolvedType === 'text') {
this.loading = true;
this.resolvedText = await this.file.text();
this.loading = false;
}
} else if (this.base64) {
const mime = this.mimeType || 'application/octet-stream';
this.resolvedSrc = `data:${mime};base64,${this.base64}`;
} else if (this.textContent) {
this.resolvedText = this.textContent;
}
} catch {
this.error = 'Failed to load content';
this.loading = false;
}
// Resolve language for code
this.resolvedLang = this.resolveLanguage();
}
private detectType(): TPreviewContentType {
// 1. Explicit override
if (this.contentType) return this.contentType;
// 2. MIME type
const mime = this.mimeType || this.file?.type || '';
if (mime) {
if (mime === 'application/pdf') return 'pdf';
for (const [prefix, type] of Object.entries(MIME_PREFIX_MAP)) {
if (mime.startsWith(prefix)) return type;
}
if (mime.startsWith('text/')) return 'text';
}
// 3. File extension
const ext = this.getExtension();
if (ext && EXTENSION_MAP[ext]) return EXTENSION_MAP[ext];
// 4. If textContent is provided, assume code or text
if (this.textContent) {
return this.language ? 'code' : 'text';
}
return 'unknown';
}
private getExtension(): string {
const name = this.filename || this.file?.name || '';
if (name) {
const parts = name.split('.');
if (parts.length > 1) return parts.pop()!.toLowerCase();
}
if (this.url) {
try {
const pathname = new URL(this.url, 'https://placeholder.com').pathname;
const parts = pathname.split('.');
if (parts.length > 1) return parts.pop()!.toLowerCase();
} catch {
// Invalid URL
}
}
return '';
}
private getFilenameFromUrl(): string {
if (!this.url) return '';
try {
const pathname = new URL(this.url, 'https://placeholder.com').pathname;
return pathname.split('/').pop() || '';
} catch {
return '';
}
}
private resolveLanguage(): string {
if (this.language) return this.language;
const ext = this.getExtension();
return EXTENSION_LANG_MAP[ext] || 'text';
}
private revokeObjectUrl(): void {
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl);
this.objectUrl = '';
}
}
}