Files
open-design/src/components/DesignSystemsTab.tsx
T
pftom ac70719d4d feat(media): add image / video / audio surfaces with unified od media generate dispatcher
Extends Open Design from web-only to a multi-modal creation tool. The
unifying contract is one code-agent loop driven by skills + project
metadata + prompt constraints; for non-web surfaces the agent shells
out to a single dispatcher (`od media generate`) that the daemon
routes per (surface, model).

- Types: new Surface union, MediaAspect / AudioKind, image/video/audio
  ProjectKind + ProjectMetadata fields, video/audio ProjectFileKind.
- NewProjectPanel: top-level surface picker + Image / Video / Audio
  forms with model, aspect, length, duration, voice, audio-kind pickers.
- ExamplesTab + DesignSystemsTab: surface filter row that scopes
  before mode / scenario / category filters.
- FileViewer / FileWorkspace: native <video> and <audio> previews and
  matching tab icons.
- Daemon: parses `od.surface` and `> Surface:` blockquotes; recognises
  mp4 / webm / mov / mp3 / wav / ogg / m4a / flac extensions; spawns
  agents with OD_BIN / OD_DAEMON_URL / OD_PROJECT_ID / OD_PROJECT_DIR
  env so any code-agent CLI with shell access can call the dispatcher.
- daemon/media.js + daemon/media-models.js: surface-agnostic dispatcher
  with stub providers that emit deterministic placeholder bytes
  (1x1 PNG, valid mp4 ftyp, mp3 frame / silent WAV) so the framework
  works without API keys; real provider integrations slot in later.
- daemon/cli.js: `od media generate --surface ... --model ...`
  subcommand routes to POST /api/projects/:id/media/generate and
  prints one JSON line for the agent to parse.
- prompts/media-contract.ts: hard contract pinned LAST in the system
  prompt for image/video/audio surfaces — env vars, exact invocation,
  registered model IDs per surface, six workflow rules. system.ts
  metadata block updated to point at the contract.
- Seed skills: image-poster, video-shortform, audio-jingle each ship a
  SKILL.md with `mode/surface: image|video|audio` and a stylized
  example.html preview, and instruct the agent to dispatch via the
  contract.

Made-with: Cursor
2026-04-28 22:40:58 +08:00

194 lines
6.2 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useT } from '../i18n';
import type { Dict } from '../i18n/types';
import type { DesignSystemSummary, Surface } from '../types';
import { Icon } from './Icon';
interface Props {
systems: DesignSystemSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
onPreview: (id: string) => void;
}
type SurfaceFilter = 'all' | Surface;
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: keyof Dict; icon: 'grid' | 'image' | 'video' | 'music' | null }[] = [
{ value: 'all', labelKey: 'common.all', icon: null },
{ value: 'web', labelKey: 'ds.surfaceWeb', icon: 'grid' },
{ value: 'image', labelKey: 'ds.surfaceImage', icon: 'image' },
{ value: 'video', labelKey: 'ds.surfaceVideo', icon: 'video' },
{ value: 'audio', labelKey: 'ds.surfaceAudio', icon: 'music' },
];
function surfaceOf(system: DesignSystemSummary): Surface {
return system.surface ?? 'web';
}
const CATEGORY_ORDER = [
'Starter',
'AI & LLM',
'Developer Tools',
'Productivity & SaaS',
'Backend & Data',
'Design & Creative',
'Fintech & Crypto',
'E-Commerce & Retail',
'Media & Consumer',
'Automotive',
];
export function DesignSystemsTab({ systems, selectedId, onSelect, onPreview }: Props) {
const t = useT();
const [filter, setFilter] = useState('');
const [category, setCategory] = useState<string>('All');
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
// Pre-scope by surface so the category dropdown only lists categories
// that exist within the active surface — avoids ghost options that
// would yield zero rows.
const surfaceScoped = useMemo(
() =>
surfaceFilter === 'all'
? systems
: systems.filter((s) => surfaceOf(s) === surfaceFilter),
[systems, surfaceFilter],
);
const surfaceCounts = useMemo(() => {
const counts: Record<SurfaceFilter, number> = {
all: systems.length,
web: 0,
image: 0,
video: 0,
audio: 0,
};
for (const s of systems) counts[surfaceOf(s)]++;
return counts;
}, [systems]);
const categories = useMemo(() => {
const cats = new Set<string>();
for (const s of surfaceScoped) cats.add(s.category || 'Uncategorized');
const ordered: string[] = [];
for (const c of CATEGORY_ORDER) if (cats.has(c)) ordered.push(c);
for (const c of [...cats].sort()) if (!ordered.includes(c)) ordered.push(c);
return ['All', ...ordered];
}, [surfaceScoped]);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
return surfaceScoped.filter((s) => {
if (category !== 'All' && (s.category || 'Uncategorized') !== category) return false;
if (!q) return true;
return (
s.title.toLowerCase().includes(q) ||
s.summary.toLowerCase().includes(q)
);
});
}, [surfaceScoped, filter, category]);
// The category metadata coming from each design system is authored in
// English. We translate the well-known buckets (All / Uncategorized) but
// pass the rest through unchanged so user-facing labels stay aligned with
// the underlying tags.
const renderCategory = (c: string) => {
if (c === 'All') return t('ds.categoryAll');
if (c === 'Uncategorized') return t('ds.categoryUncategorized');
return c;
};
return (
<div className="tab-panel">
<div
className="examples-filter-row"
role="tablist"
aria-label={t('ds.surfaceLabel')}
>
<span className="examples-filter-label">{t('ds.surfaceLabel')}</span>
{SURFACE_PILLS.map((p) => (
<button
key={p.value}
type="button"
role="tab"
aria-selected={surfaceFilter === p.value}
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
onClick={() => {
setSurfaceFilter(p.value);
setCategory('All');
}}
>
{p.icon ? <Icon name={p.icon} size={12} /> : null}
{t(p.labelKey)}
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
</button>
))}
</div>
<div className="tab-panel-toolbar">
<input
placeholder={t('ds.searchPlaceholder')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{renderCategory(c)}
</option>
))}
</select>
</div>
{filtered.length === 0 ? (
<div className="tab-empty">{t('ds.emptyNoMatch')}</div>
) : (
<div className="ds-list">
{filtered.map((s) => {
const active = s.id === selectedId;
return (
<div
key={s.id}
className={`ds-row ${active ? 'active' : ''}`}
onClick={() => onSelect(s.id)}
>
<div className="ds-row-body">
<div className="ds-row-title">
{s.title}
{active ? (
<span className="ds-row-default">
{t('ds.badgeDefault')}
</span>
) : null}
</div>
<div className="ds-row-summary">{s.summary || s.category}</div>
</div>
{s.swatches && s.swatches.length > 0 ? (
<div className="ds-row-swatches" aria-hidden>
{s.swatches.map((c, i) => (
<span
key={i}
className="ds-row-swatch"
style={{ background: c }}
title={c}
/>
))}
</div>
) : null}
<button
className="ghost"
onClick={(e) => {
e.stopPropagation();
onPreview(s.id);
}}
title={t('ds.previewTitle')}
>
{t('ds.preview')}
</button>
</div>
);
})}
</div>
)}
</div>
);
}