feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies
This commit is contained in:
507
ts_web/elements/00group-media/dees-preview/dees-preview.ts
Normal file
507
ts_web/elements/00group-media/dees-preview/dees-preview.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user