508 lines
14 KiB
TypeScript
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 = '';
|
|
}
|
|
}
|
|
}
|