Files
nexu-io--open-design/apps/web/src/components/LibraryPreviewModal.tsx
T
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

423 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OD Library — full-size, kind-aware asset preview.
//
// The grid only shows a thumbnail; this modal renders the asset for real:
// image → contained <img>
// video → <video controls autoplay loop>
// html /
// design-system → sandboxed <iframe> (scripts run so captured pages / CSS+JS
// animations actually move, but it sits in an opaque origin and
// cannot reach the daemon — matches the rest of the app's
// untrusted-HTML rendering)
// font → live specimen (alphabet + sizes) via an injected @font-face
// color → large swatch + the resolved value
// text → the raw text in a <pre>
// url → the captured link with an open-in-new-tab affordance
//
// Plus a metadata bar (kind / source / dimensions / size / date / tags) and
// prev/next navigation so the user can flip through the grid without closing.
//
// Copy is intentionally inline (not yet i18n-keyed), matching LibrarySection.
import { type CSSProperties, useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'motion/react';
import type { LibraryAsset } from '@open-design/contracts';
import { Button } from '@open-design/components';
import { libraryAssetElementUrl, libraryAssetFigmaUrl, libraryAssetRawUrl } from '../providers/registry';
import { modalOverlay, modalContent } from '../motion';
import {
KindIcon,
SOURCE_LABELS,
assetTitle,
badgeKind,
colorOf,
elementMetaOf,
fontFamilyFor,
formatBytes,
formatDate,
kindLabel,
kindTint,
originProjectId,
primarySource,
} from './LibraryAssetMeta';
import styles from './LibraryPreviewModal.module.css';
interface Props {
asset: LibraryAsset;
hasPrev: boolean;
hasNext: boolean;
onPrev: () => void;
onNext: () => void;
onClose: () => void;
onDelete: (id: string) => void;
onOpenProject?: (projectId: string, fileName?: string) => void;
/** Turn a captured `html` asset into a new editable OD project (html only). */
onEditAsPage?: (assetId: string) => Promise<void>;
}
/** Fetch an asset's raw bytes as text (for text / color / url kinds). */
function useRawText(rawUrl: string, enabled: boolean) {
const [state, setState] = useState<{ text: string | null; loading: boolean; error: boolean }>({
text: null,
loading: enabled,
error: false,
});
useEffect(() => {
if (!enabled) return;
let cancelled = false;
setState({ text: null, loading: true, error: false });
fetch(rawUrl)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((text) => {
if (!cancelled) setState({ text, loading: false, error: false });
})
.catch(() => {
if (!cancelled) setState({ text: null, loading: false, error: true });
});
return () => {
cancelled = true;
};
}, [rawUrl, enabled]);
return state;
}
function FontSpecimen({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) {
const family = fontFamilyFor(asset.id);
return (
<div className={styles.fontStage}>
{/* Self-contained @font-face so the specimen works even outside the grid. */}
<style>{`@font-face{font-family:"${family}";src:url("${rawUrl}");font-display:swap;}`}</style>
<div className={styles.fontSpecimen} style={{ fontFamily: `"${family}", sans-serif` }}>
<p className={styles.fontHuge}>Ag</p>
<p className={styles.fontRow}>ABCDEFGHIJKLMNOPQRSTUVWXYZ</p>
<p className={styles.fontRow}>abcdefghijklmnopqrstuvwxyz</p>
<p className={styles.fontRow}>0123456789 &amp; ! ? @ # $ %</p>
<p className={styles.fontPangram}>The quick brown fox jumps over the lazy dog.</p>
<p className={styles.fontPangramSm}>The quick brown fox jumps over the lazy dog.</p>
</div>
</div>
);
}
function ColorStage({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) {
const needsText = !asset.palette?.length;
const { text, loading } = useRawText(rawUrl, needsText);
const value = colorOf(asset, text);
if (loading && !value) return <div className={styles.stageNote}>Loading</div>;
if (!value) return <div className={styles.stageNote}>No color value available.</div>;
const swatches = asset.palette?.length ? asset.palette : [value];
return (
<div className={styles.colorStage}>
<div className={styles.colorHero} style={{ background: value }} />
<div className={styles.colorInfo}>
<code className={styles.colorValue}>{value}</code>
{swatches.length > 1 ? (
<div className={styles.palette}>
{swatches.map((c, i) => (
<span key={`${c}-${i}`} className={styles.paletteChip} style={{ background: c }} title={c} />
))}
</div>
) : null}
</div>
</div>
);
}
function TextStage({ rawUrl }: { rawUrl: string }) {
const { text, loading, error } = useRawText(rawUrl, true);
if (loading) return <div className={styles.stageNote}>Loading</div>;
if (error) return <div className={styles.stageNote}>Could not load text.</div>;
return <pre className={styles.textStage}>{text}</pre>;
}
function UrlStage({ asset, rawUrl }: { asset: LibraryAsset; rawUrl: string }) {
const needsText = !asset.sourceUrl;
const { text } = useRawText(rawUrl, needsText);
const href = asset.sourceUrl || (text ?? '').trim();
if (!href) return <div className={styles.stageNote}>No link available.</div>;
return (
<div className={styles.urlStage}>
<KindIcon kind="url" size={40} className={styles.urlGlyph} />
<a className={styles.urlValue} href={href} target="_blank" rel="noreferrer">
{href}
</a>
<a className={styles.urlOpen} href={href} target="_blank" rel="noreferrer">
Open in new tab
</a>
</div>
);
}
function Stage({ asset }: { asset: LibraryAsset }) {
const rawUrl = libraryAssetRawUrl(asset.id);
const title = assetTitle(asset);
switch (asset.kind) {
case 'image':
return <img className={styles.stageImage} src={rawUrl} alt={title} />;
case 'video':
return <video className={styles.stageVideo} src={rawUrl} controls autoPlay loop playsInline />;
case 'design-system':
case 'html':
// Opaque-origin sandbox: scripts/animations run, daemon stays unreachable.
return <iframe className={styles.stageFrame} src={rawUrl} sandbox="allow-scripts" title={title} />;
case 'font':
return <FontSpecimen asset={asset} rawUrl={rawUrl} />;
case 'color':
return <ColorStage asset={asset} rawUrl={rawUrl} />;
case 'url':
return <UrlStage asset={asset} rawUrl={rawUrl} />;
case 'text':
default:
return <TextStage rawUrl={rawUrl} />;
}
}
/**
* Element-pick captures carry `metadata.element`; this surfaces the captured
* element's selector/size beneath the rendered preview. Current clips are
* self-contained `html` assets (the stage iframe already renders the element),
* so there is no separate markup to load — the "Show HTML" affordance only
* appears for legacy `image` screenshots that still have an `.element.html`
* sidecar (`element.hasHtml`). Inline-styled so it stays self-contained against
* the modal's stylesheet.
*/
function ElementPanel({ asset }: { asset: LibraryAsset }) {
const element = elementMetaOf(asset);
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const { text, loading, error } = useRawText(libraryAssetElementUrl(asset.id), open && Boolean(element?.hasHtml));
if (!element) return null;
const dims = element.width && element.height ? `${element.width}×${element.height}` : null;
const copy = () => {
if (!text || !navigator.clipboard) return;
void navigator.clipboard.writeText(text).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
() => {},
);
};
const chipBtn: CSSProperties = {
font: 'inherit',
fontSize: 12,
fontWeight: 600,
color: 'var(--accent, #2563eb)',
background: 'transparent',
border: '1px solid var(--border, #e5e7eb)',
borderRadius: 6,
padding: '3px 9px',
cursor: 'pointer',
};
return (
<div style={{ borderTop: '1px solid var(--border, #e5e7eb)', padding: '10px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<KindIcon kind="html" size={13} />
<code style={{ fontSize: 12, color: 'var(--text-strong, #111827)', background: 'var(--subtle, #f3f4f6)', padding: '2px 6px', borderRadius: 6 }}>
{element.selector || element.tag}
</code>
{dims ? <span style={{ fontSize: 12, color: 'var(--text-muted, #6b7280)' }}>{dims}</span> : null}
{element.hasHtml ? (
<button type="button" style={{ ...chipBtn, marginLeft: 'auto' }} onClick={() => setOpen((o) => !o)}>
{open ? 'Hide HTML' : 'Show HTML'}
</button>
) : null}
</div>
{open && element.hasHtml ? (
loading ? (
<div className={styles.stageNote}>Loading</div>
) : error ? (
<div className={styles.stageNote}>Could not load element HTML.</div>
) : (
<div style={{ position: 'relative' }}>
<button type="button" style={{ ...chipBtn, position: 'absolute', top: 6, right: 6, background: 'var(--panel, #fff)' }} onClick={copy}>
{copied ? 'Copied' : 'Copy'}
</button>
<pre
style={{
maxHeight: 240,
overflow: 'auto',
margin: 0,
padding: '10px 12px',
background: 'var(--subtle, #f6f7f9)',
borderRadius: 8,
fontSize: 11,
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{text}
</pre>
</div>
)
) : null}
</div>
);
}
export function LibraryPreviewModal({
asset,
hasPrev,
hasNext,
onPrev,
onNext,
onClose,
onDelete,
onOpenProject,
onEditAsPage,
}: Props) {
const [editing, setEditing] = useState(false);
const editAsPage = useCallback(async () => {
if (!onEditAsPage) return;
setEditing(true);
try {
await onEditAsPage(asset.id);
} finally {
setEditing(false);
}
}, [onEditAsPage, asset.id]);
const src = primarySource(asset);
const projectId = originProjectId(asset);
const rawUrl = libraryAssetRawUrl(asset.id);
const title = assetTitle(asset);
const dims = asset.width && asset.height ? `${asset.width}×${asset.height}` : null;
const size = formatBytes(asset.size);
const date = formatDate(asset.capturedAt);
// Clipper-captured pages carry an OD Figma capture; only then can we export
// it as Figma import JSON.
const hasFigmaCapture =
asset.kind === 'html' && Boolean((asset.metadata as { figmaCapture?: unknown } | undefined)?.figmaCapture);
const handleKey = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'ArrowLeft' && hasPrev) {
onPrev();
} else if (e.key === 'ArrowRight' && hasNext) {
onNext();
}
},
[onClose, onPrev, onNext, hasPrev, hasNext],
);
useEffect(() => {
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [handleKey]);
const modal = (
<motion.div
className={styles.backdrop}
onClick={onClose}
variants={modalOverlay}
initial="hidden"
animate="visible"
exit="exit"
role="presentation"
>
<motion.div
className={styles.modal}
onClick={(e) => e.stopPropagation()}
variants={modalContent}
initial="hidden"
animate="visible"
exit="exit"
role="dialog"
aria-modal="true"
aria-label={title}
>
<header className={styles.head}>
<span className={styles.kindBadge} style={{ ['--kind-tint' as string]: kindTint(badgeKind(asset)) }}>
<KindIcon kind={badgeKind(asset)} size={13} />
{kindLabel(badgeKind(asset))}
</span>
<span className={styles.headTitle} title={title}>
{title}
</span>
{src ? <span className={styles.headSource}>{SOURCE_LABELS[src]}</span> : null}
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close preview">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</header>
<div className={styles.stage}>
{hasPrev ? (
<button type="button" className={`${styles.nav} ${styles.navPrev}`} onClick={onPrev} aria-label="Previous asset">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
) : null}
<div className={styles.stageInner} key={asset.id}>
<Stage asset={asset} />
</div>
{hasNext ? (
<button type="button" className={`${styles.nav} ${styles.navNext}`} onClick={onNext} aria-label="Next asset">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
) : null}
</div>
<ElementPanel asset={asset} />
<footer className={styles.foot}>
<div className={styles.facts}>
{dims ? <span>{dims}</span> : null}
{size ? <span>{size}</span> : null}
{asset.mime ? <span>{asset.mime}</span> : null}
{date ? <span>{date}</span> : null}
{asset.tags?.slice(0, 4).map((tag) => (
<span key={tag} className={styles.tag}>
#{tag}
</span>
))}
</div>
<div className={styles.actions}>
{asset.kind === 'html' && onEditAsPage ? (
<Button onClick={() => void editAsPage()} disabled={editing}>
{editing ? 'Opening…' : 'Edit as page'}
</Button>
) : null}
{projectId && onOpenProject ? (
<Button variant="ghost" onClick={() => onOpenProject(projectId)}>
Open project
</Button>
) : null}
{asset.sourceUrl ? (
<a className={styles.linkAction} href={asset.sourceUrl} target="_blank" rel="noreferrer">
Source
</a>
) : null}
{hasFigmaCapture ? (
<a
className={styles.linkAction}
href={libraryAssetFigmaUrl(asset.id)}
download
title="Download Figma import JSON — rebuild it with the OD Figma Import plugin"
>
Download Figma JSON
</a>
) : null}
<a className={styles.linkAction} href={rawUrl} target="_blank" rel="noreferrer" download>
Download
</a>
<button type="button" className={styles.removeAction} onClick={() => onDelete(asset.id)}>
Remove
</button>
</div>
</footer>
</motion.div>
</motion.div>
);
if (typeof document === 'undefined') return modal;
return createPortal(modal, document.body);
}