Add initial project structure with essential files
- Created .gitignore to exclude build artifacts and dependencies. - Added index.html as the main entry point for the application. - Included LICENSE file with Apache 2.0 terms. - Initialized package.json and package-lock.json for project dependencies. - Added pnpm-lock.yaml for package management. - Created QUICKSTART.md for setup instructions. - Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
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 `.ocd/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`;
|
||||
}
|
||||
Reference in New Issue
Block a user