6f6bf31dd2
* Refactor project name from "Open Claude Design" to "Open Design" - Updated project name in package.json, package-lock.json, and README files. - Changed CLI commands and references from "ocd" to "od". - Adjusted file structure references in documentation and code to reflect new naming conventions. - Enhanced .gitignore to include new runtime data files. - Updated metadata in LICENSE file to match new project name. * Add contributing guidelines in English and Chinese - Introduced CONTRIBUTING.md and CONTRIBUTING.zh-CN.md to provide clear instructions for contributors. - Outlined contribution types, local setup instructions, and merging criteria for skills and design systems. - Enhanced README files to reference the new contributing guidelines.
366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useT } from '../i18n';
|
|
import type { Dict } from '../i18n/types';
|
|
import { projectFileUrl } from '../providers/registry';
|
|
import type { ProjectFile, ProjectFileKind } from '../types';
|
|
import { Icon } from './Icon';
|
|
|
|
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
files: ProjectFile[];
|
|
onRefreshFiles: () => Promise<void> | void;
|
|
onOpenFile: (name: string) => void;
|
|
onDeleteFile: (name: string) => void;
|
|
onUpload: () => void;
|
|
onPaste: () => void;
|
|
onNewSketch: () => void;
|
|
}
|
|
|
|
type Section = 'pages' | 'scripts' | 'images' | 'sketches' | 'other';
|
|
|
|
const SECTION_LABEL_KEY: Record<Section, keyof Dict> = {
|
|
pages: 'designFiles.sectionPages',
|
|
scripts: 'designFiles.sectionScripts',
|
|
images: 'designFiles.sectionImages',
|
|
sketches: 'designFiles.sectionSketches',
|
|
other: 'designFiles.sectionOther',
|
|
};
|
|
|
|
const SECTION_ORDER: Section[] = ['pages', 'sketches', 'scripts', 'images', 'other'];
|
|
|
|
/**
|
|
* Full-panel browser for a project's `.od/projects/<id>/` folder. Mirrors
|
|
* Claude Design's "Design Files" surface: grouped sections, hover-revealed
|
|
* row menu, drop-files footer, and (when a row is selected) a right-side
|
|
* preview pane. Triggered as a sticky first tab in FileWorkspace.
|
|
*/
|
|
export function DesignFilesPanel({
|
|
projectId,
|
|
files,
|
|
onRefreshFiles,
|
|
onOpenFile,
|
|
onDeleteFile,
|
|
onUpload,
|
|
onPaste,
|
|
onNewSketch,
|
|
}: Props) {
|
|
const t = useT();
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [hover, setHover] = useState<string | null>(null);
|
|
const [menuPos, setMenuPos] = useState<{ name: string; top: number; left: number } | null>(null);
|
|
const [preview, setPreview] = useState<string | null>(null);
|
|
|
|
const grouped = useMemo(() => {
|
|
const groups: Record<Section, ProjectFile[]> = {
|
|
pages: [],
|
|
sketches: [],
|
|
scripts: [],
|
|
images: [],
|
|
other: [],
|
|
};
|
|
const sorted = [...files].sort((a, b) => b.mtime - a.mtime);
|
|
for (const f of sorted) {
|
|
groups[sectionFor(f)].push(f);
|
|
}
|
|
return groups;
|
|
}, [files]);
|
|
|
|
const previewFile = useMemo(
|
|
() => files.find((f) => f.name === preview) ?? null,
|
|
[preview, files],
|
|
);
|
|
|
|
// Close the row menu on outside click / escape.
|
|
useEffect(() => {
|
|
if (!menuPos) return;
|
|
const close = () => setMenuPos(null);
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') close();
|
|
};
|
|
window.addEventListener('mousedown', close);
|
|
window.addEventListener('keydown', onKey);
|
|
return () => {
|
|
window.removeEventListener('mousedown', close);
|
|
window.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [menuPos]);
|
|
|
|
async function handleRefresh() {
|
|
setRefreshing(true);
|
|
try {
|
|
await onRefreshFiles();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={`df-panel ${preview ? '' : 'no-preview'}`}>
|
|
<div className="df-main">
|
|
<div className="df-head">
|
|
<button
|
|
type="button"
|
|
className="icon-only"
|
|
onClick={() => setPreview(null)}
|
|
title={t('designFiles.up')}
|
|
aria-label={t('designFiles.back')}
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="icon-only"
|
|
onClick={() => void handleRefresh()}
|
|
disabled={refreshing}
|
|
title={t('designFiles.refresh')}
|
|
aria-label={t('designFiles.refresh')}
|
|
>
|
|
<Icon name={refreshing ? 'spinner' : 'reload'} size={14} />
|
|
</button>
|
|
<span className="crumbs">{t('designFiles.crumbs')}</span>
|
|
<div className="df-actions">
|
|
<button type="button" onClick={onNewSketch} title={t('designFiles.newSketch')}>
|
|
<Icon name="pencil" size={13} />
|
|
<span>{t('designFiles.newSketch')}</span>
|
|
</button>
|
|
<button type="button" onClick={onPaste} title={t('designFiles.paste.title')}>
|
|
<Icon name="copy" size={13} />
|
|
<span>{t('designFiles.paste.label')}</span>
|
|
</button>
|
|
<button type="button" onClick={onUpload} title={t('designFiles.upload.title')}>
|
|
<Icon name="upload" size={13} />
|
|
<span>{t('designFiles.upload.label')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="df-body">
|
|
{files.length === 0 ? (
|
|
<div className="df-empty">{t('designFiles.empty')}</div>
|
|
) : (
|
|
SECTION_ORDER.filter((s) => grouped[s].length > 0).map((section) => (
|
|
<div className="df-section" key={section}>
|
|
<div className="df-section-label">
|
|
{t(SECTION_LABEL_KEY[section])}
|
|
</div>
|
|
{grouped[section].map((f) => {
|
|
const active = preview === f.name;
|
|
const isHovered = hover === f.name;
|
|
return (
|
|
<button
|
|
key={f.name}
|
|
type="button"
|
|
className={`df-row ${active ? 'active' : ''}`}
|
|
onMouseEnter={() => setHover(f.name)}
|
|
onMouseLeave={() => setHover((c) => (c === f.name ? null : c))}
|
|
onClick={() => setPreview(f.name)}
|
|
onDoubleClick={() => onOpenFile(f.name)}
|
|
>
|
|
<span className="df-row-icon" data-kind={f.kind} aria-hidden>
|
|
{kindGlyph(f.kind)}
|
|
</span>
|
|
<span className="df-row-name-wrap">
|
|
<span className="df-row-name">{f.name}</span>
|
|
<span className="df-row-sub">{kindLabel(f.kind, t)}</span>
|
|
</span>
|
|
<span className="df-row-time">{relativeTime(f.mtime, t)}</span>
|
|
<span
|
|
className="df-row-menu"
|
|
style={isHovered || active ? { opacity: 1 } : undefined}
|
|
role="button"
|
|
aria-label={t('designFiles.rowMenu')}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const rect = (e.target as HTMLElement)
|
|
.closest('.df-row-menu')
|
|
?.getBoundingClientRect();
|
|
setMenuPos({
|
|
name: f.name,
|
|
top: (rect?.bottom ?? 0) + 4,
|
|
left: (rect?.right ?? 0) - 160,
|
|
});
|
|
}}
|
|
>
|
|
⋯
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
<div className="df-drop">
|
|
<span className="label">{t('designFiles.dropTitle')}</span>
|
|
<span className="desc">{t('designFiles.dropDesc')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{preview && previewFile ? (
|
|
<DfPreview
|
|
projectId={projectId}
|
|
file={previewFile}
|
|
onOpen={() => onOpenFile(previewFile.name)}
|
|
onClose={() => setPreview(null)}
|
|
/>
|
|
) : null}
|
|
{menuPos ? (
|
|
<div
|
|
className="df-row-popover"
|
|
style={{ top: menuPos.top, left: menuPos.left }}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const name = menuPos.name;
|
|
setMenuPos(null);
|
|
onOpenFile(name);
|
|
}}
|
|
>
|
|
{t('designFiles.openInTab')}
|
|
</button>
|
|
<a
|
|
href={projectFileUrl(projectId, menuPos.name)}
|
|
download={menuPos.name}
|
|
style={{ textDecoration: 'none' }}
|
|
>
|
|
<button type="button" onClick={() => setMenuPos(null)}>
|
|
{t('designFiles.download')}
|
|
</button>
|
|
</a>
|
|
<button
|
|
type="button"
|
|
className="danger"
|
|
onClick={() => {
|
|
const name = menuPos.name;
|
|
setMenuPos(null);
|
|
onDeleteFile(name);
|
|
}}
|
|
>
|
|
{t('designFiles.delete')}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DfPreview({
|
|
projectId,
|
|
file,
|
|
onOpen,
|
|
onClose,
|
|
}: {
|
|
projectId: string;
|
|
file: ProjectFile;
|
|
onOpen: () => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const t = useT();
|
|
const url = projectFileUrl(projectId, file.name);
|
|
return (
|
|
<aside className="df-preview">
|
|
<div className="df-preview-thumb">
|
|
{file.kind === 'image' || file.kind === 'sketch' ? (
|
|
<img src={`${url}?v=${Math.round(file.mtime)}`} alt={file.name} />
|
|
) : file.kind === 'html' ? (
|
|
<iframe title={file.name} src={url} sandbox="allow-scripts" />
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(--text-faint)',
|
|
fontSize: 38,
|
|
}}
|
|
>
|
|
{kindGlyph(file.kind)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="df-preview-meta">
|
|
<button
|
|
type="button"
|
|
className="ghost"
|
|
onClick={onOpen}
|
|
style={{ alignSelf: 'flex-start' }}
|
|
>
|
|
<Icon name="eye" size={13} />
|
|
<span>{t('designFiles.previewOpen')}</span>
|
|
</button>
|
|
<div className="df-preview-name">{file.name}</div>
|
|
<div className="df-preview-kind">{kindLabel(file.kind, t)}</div>
|
|
<div className="df-preview-stats">
|
|
{t('designFiles.modified', {
|
|
time: relativeTime(file.mtime, t),
|
|
size: humanBytes(file.size),
|
|
})}
|
|
</div>
|
|
<div className="df-preview-actions">
|
|
<a
|
|
className="ghost-link"
|
|
href={url}
|
|
download={file.name}
|
|
style={{ textDecoration: 'none' }}
|
|
>
|
|
{t('designFiles.download')}
|
|
</a>
|
|
<button type="button" onClick={onClose}>
|
|
{t('designFiles.previewClose')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function sectionFor(file: ProjectFile): Section {
|
|
if (file.kind === 'html' || file.kind === 'text') return 'pages';
|
|
if (file.kind === 'sketch') return 'sketches';
|
|
if (file.kind === 'code') return 'scripts';
|
|
if (file.kind === 'image') return 'images';
|
|
return 'other';
|
|
}
|
|
|
|
function kindGlyph(kind: ProjectFileKind): string {
|
|
if (kind === 'html') return '⟨⟩';
|
|
if (kind === 'image') return '▣';
|
|
if (kind === 'sketch') return '✎';
|
|
if (kind === 'text') return '¶';
|
|
if (kind === 'code') return '{}';
|
|
return '·';
|
|
}
|
|
|
|
function kindLabel(kind: ProjectFileKind, t: TranslateFn): string {
|
|
if (kind === 'html') return t('designFiles.kindHtml');
|
|
if (kind === 'image') return t('designFiles.kindImage');
|
|
if (kind === 'sketch') return t('designFiles.kindSketch');
|
|
if (kind === 'text') return t('designFiles.kindText');
|
|
if (kind === 'code') return t('designFiles.kindCode');
|
|
return t('designFiles.kindBinary');
|
|
}
|
|
|
|
function relativeTime(ts: number, t: TranslateFn): string {
|
|
const diff = Date.now() - ts;
|
|
const min = 60_000;
|
|
const hr = 60 * min;
|
|
const day = 24 * hr;
|
|
if (diff < min) return t('common.justNow');
|
|
if (diff < hr) return t('common.minutesAgo', { n: Math.floor(diff / min) });
|
|
if (diff < day) return t('common.hoursAgo', { n: Math.floor(diff / hr) });
|
|
if (diff < 7 * day) return t('common.daysAgo', { n: Math.floor(diff / day) });
|
|
if (diff < 30 * day)
|
|
return t('designFiles.weeksAgo', { n: Math.floor(diff / (7 * day)) });
|
|
return new Date(ts).toLocaleDateString();
|
|
}
|
|
|
|
function humanBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|