Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions client/src/components/creative-director/ProjectLibrary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useMemo } from 'react';
import useUrlParams from '../../hooks/useUrlParams.js';
import { matchHaystack, tokenizeQuery } from '../../lib/mediaSearch.js';

const SORTS = { unfinished: 'Unfinished first', newest: 'Newest created', updated: 'Recently updated', name: 'Name A–Z' };

export default function ProjectLibrary({ projects, children }) {
const [params, updateParams] = useUrlParams();
const query = params.get('q') || '';
const statuses = useMemo(() => [...new Set(projects.map(p => p.status).filter(Boolean))].sort(), [projects]);
const requestedStatus = params.get('status');
const status = requestedStatus === 'unfinished' || statuses.includes(requestedStatus) ? requestedStatus : 'all';
const sort = Object.hasOwn(SORTS, params.get('sort')) ? params.get('sort') : 'unfinished';
const compact = params.get('layout') === 'list';
const visible = useMemo(() => {
const tokens = tokenizeQuery(query);
return projects.filter(p => (
(status === 'all' || (status === 'unfinished' ? p.status !== 'complete' : p.status === status)) &&
matchHaystack([p.name, p.id, p.status, p.modelId, p.directive?.goal, p.userStory, p.styleSpec].filter(Boolean).join(' ').toLowerCase(), tokens)
)).sort((a, b) => {
if (sort === 'unfinished') {
const completed = Number(a.status === 'complete') - Number(b.status === 'complete');
if (completed) return completed;
}
if (sort !== 'name') {
const aTime = (sort === 'updated' && Date.parse(a.updatedAt)) || Date.parse(a.createdAt) || 0;
const bTime = (sort === 'updated' && Date.parse(b.updatedAt)) || Date.parse(b.createdAt) || 0;
if (aTime !== bTime) return bTime - aTime;
}
return (a.name || '').localeCompare(b.name || '') || a.id.localeCompare(b.id);
});
}, [projects, query, status, sort]);
const controlClass = 'w-full bg-port-bg border border-port-border rounded px-3 py-2 text-sm';

return (
<section aria-label="Project library" className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label htmlFor="project-search" className="block text-xs text-port-text-muted mb-1">Search projects</label>
<input id="project-search" type="search" value={query} onChange={e => updateParams({ q: e.target.value }, { replace: true })} placeholder="Name, brief, model, or ID…" className={controlClass} />
</div>
<div>
<label htmlFor="project-status" className="block text-xs text-port-text-muted mb-1">Status</label>
<select id="project-status" value={status} onChange={e => updateParams({ status: e.target.value === 'all' ? null : e.target.value })} className={controlClass}>
<option value="all">All projects ({projects.length})</option>
<option value="unfinished">Unfinished ({projects.filter(p => p.status !== 'complete').length})</option>
{statuses.map(value => <option key={value} value={value}>{value} ({projects.filter(p => p.status === value).length})</option>)}
</select>
</div>
<div>
<label htmlFor="project-sort" className="block text-xs text-port-text-muted mb-1">Sort projects</label>
<select id="project-sort" value={sort} onChange={e => updateParams({ sort: e.target.value === 'unfinished' ? null : e.target.value })} className={controlClass}>
{Object.entries(SORTS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</div>
<div>
<label htmlFor="project-layout" className="block text-xs text-port-text-muted mb-1">View</label>
<select id="project-layout" value={compact ? 'list' : 'gallery'} onChange={e => updateParams({ layout: e.target.value === 'gallery' ? null : e.target.value })} className={controlClass}>
<option value="gallery">Preview gallery</option>
<option value="list">Compact list</option>
</select>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-port-text-muted">
<span role="status">Showing {visible.length} of {projects.length} projects</span>
{(query || status !== 'all') && <button onClick={() => updateParams({ q: null, status: null })} className="text-port-accent">Clear filters</button>}
</div>
{projects.length > 0 && visible.length === 0 && <p className="py-8 text-center text-port-text-muted">No projects match your search and filters.</p>}
<div className={compact ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'}>
{visible.map(p => children(p, compact))}
</div>
</section>
);
}
23 changes: 16 additions & 7 deletions client/src/pages/CreativeDirector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import Drawer from '../components/Drawer';
import DirectiveComposer from '../components/creative-director/DirectiveComposer.jsx';
import CreativeDirectorModelsDrawer from '../components/creative-director/CreativeDirectorModelsDrawer.jsx';
import VideoDraftDrawer from '../components/creative-director/VideoDraftDrawer.jsx';
import ProjectLibrary from '../components/creative-director/ProjectLibrary.jsx';
import ProjectPreview from '../components/creative-director/ProjectPreview.jsx';

const ASPECT_RATIOS = ['16:9', '9:16', '1:1'];
Expand Down Expand Up @@ -139,6 +140,12 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
// a later, unrelated project created from this list page (#1808 review).
const clearRemix = () => { setRemixIds([]); setRemixIngredients([]); };

const clearProjectFilters = () => setSearchParams(prev => {
const next = new URLSearchParams(prev);
['q', 'status', 'sort'].forEach(key => next.delete(key));
return next;
}, { replace: true });

const handleCreate = async (e) => {
e.preventDefault();
if (!form.name.trim() || !form.modelId) {
Expand All @@ -161,7 +168,8 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
};
try {
const created = await createCreativeDirectorProject(payload, { silent: true });
setProjects((prev) => [...prev, created]);
setProjects((prev) => [created, ...prev]);
clearProjectFilters();
setShowForm(false);
setForm((f) => ({ ...f, name: '', styleSpec: '', userStory: '', startingImageFile: '' }));
toast.success(`Created "${created.name}"`);
Expand Down Expand Up @@ -274,6 +282,7 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
if (!created) return;
toast.success('Test clip render started');
setProjects((prev) => [created, ...prev]);
clearProjectFilters();
};

if (loading) {
Expand Down Expand Up @@ -493,12 +502,12 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
No projects yet. Open <Link to="/creative-director?new=video" className="text-port-accent">Creative Director</Link> to create a video draft.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{projects.map((p) => (
<ProjectLibrary projects={projects}>
{(p, compact) => (
<div key={p.id} className="bg-port-card border border-port-border rounded p-3 flex flex-col gap-2">
<ProjectPreview project={p} to={`${basePath}/${p.id}/overview`} />
{!compact && <ProjectPreview project={p} to={`${basePath}/${p.id}/overview`} />}
<div className="flex items-start justify-between gap-2">
<Link to={`${basePath}/${p.id}/overview`} className="flex-1 min-w-0">
<Link to={`${basePath}/${p.id}/overview`} aria-label={compact ? `Open ${p.name}` : undefined} className="flex-1 min-w-0">
<div className="font-medium truncate">{p.name}</div>
<div className="text-xs text-port-text-muted truncate">{p.id}</div>
</Link>
Expand Down Expand Up @@ -530,8 +539,8 @@ export default function CreativeDirector({ basePath = '/creative-director', brow
</button>}
</div>
</div>
))}
</div>
)}
</ProjectLibrary>
</div>

<Drawer
Expand Down
67 changes: 67 additions & 0 deletions client/src/pages/CreativeDirector.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,70 @@ it('keeps Video as a browsing surface and sends production actions to Creative D
expect(cdApi.createCreativeDirectorProject).not.toHaveBeenCalled();
expect(cdApi.startCreativeDirectorProject).not.toHaveBeenCalled();
});

describe('Project library discovery', () => {
const projects = [
{ id: 'finished', name: 'Alpha finished', status: 'complete', createdAt: '2026-09-08', updatedAt: '2026-09-09' },
{ id: 'older', name: 'Beta paused', status: 'paused', createdAt: '2026-09-01', updatedAt: '2026-09-08', userStory: 'Mountain traveler' },
{ id: 'new', name: 'Zeta draft', status: 'draft', createdAt: '2026-09-07', updatedAt: '2026-09-07' },
{ id: 'legacy', name: 'Legacy project', status: 'failed' },
];
const names = () => screen.getAllByRole('link', { name: /^Open (Alpha|Beta|Zeta|Legacy)/ }).map(link => link.getAttribute('aria-label'));
beforeEach(() => { vi.clearAllMocks(); cdApi.listCreativeDirectorProjects.mockResolvedValue(projects); });

it('prioritizes new unfinished work and supports alternate ordering and a compact view', async () => {
const user = userEvent.setup();
await renderPage();
expect(names()).toEqual(['Open Zeta draft', 'Open Beta paused', 'Open Legacy project', 'Open Alpha finished']);
await user.selectOptions(screen.getByLabelText('Sort projects'), 'newest');
expect(names()[0]).toBe('Open Alpha finished');
await user.selectOptions(screen.getByLabelText('Sort projects'), 'updated');
expect(names().slice(0, 3)).toEqual(['Open Alpha finished', 'Open Beta paused', 'Open Zeta draft']);
await user.selectOptions(screen.getByLabelText('Sort projects'), 'name');
await user.selectOptions(screen.getByLabelText('View'), 'list');
expect(names()).toEqual(['Open Alpha finished', 'Open Beta paused', 'Open Legacy project', 'Open Zeta draft']);
expect(screen.queryByText('no render yet')).toBeNull();
expect(screen.getByRole('link', { name: 'Open Zeta draft' })).toHaveAttribute('href', '/creative-director/new/overview');
});

it('combines bookmarkable text and status filters and recovers from no matches', async () => {
const user = userEvent.setup();
render(<MemoryRouter initialEntries={['/creative-director?q=TRAVELER%20mountain&status=unfinished&layout=list']}><CreativeDirector /></MemoryRouter>);
await screen.findByText('Showing 1 of 4 projects');
expect(names()).toEqual(['Open Beta paused']);
await user.selectOptions(screen.getByLabelText('Status'), 'draft');
expect(screen.getByText('No projects match your search and filters.')).toBeTruthy();
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
expect(names()).toHaveLength(4);
expect(screen.getByLabelText('View')).toHaveValue('list');
expect(cdApi.listCreativeDirectorProjects).toHaveBeenCalledTimes(1);
});

it('shows newly created work even when the previous filters hid drafts', async () => {
const user = userEvent.setup();
cdApi.createCreativeDirectorProject.mockResolvedValue({ id: 'created', name: 'Newly created', status: 'draft', createdAt: '2026-09-09' });
render(<MemoryRouter initialEntries={['/creative-director?status=complete&sort=name&q=Alpha']}><CreativeDirector /></MemoryRouter>);
await user.click(await screen.findByRole('button', { name: 'New project' }));
await user.type(screen.getByLabelText('Name'), 'Newly created');
await user.click(screen.getByRole('button', { name: 'Create' }));
await screen.findByRole('link', { name: 'Open Newly created' });
expect(screen.getAllByRole('link', { name: /^Open / })[0]).toHaveAttribute('aria-label', 'Open Newly created');
expect(screen.getByLabelText('Search projects')).toHaveValue('');
expect(screen.getByLabelText('Status')).toHaveValue('all');
});
});

it('reveals a smoke-test project when started from a filtered library', async () => {
vi.clearAllMocks();
cdApi.listCreativeDirectorProjects.mockResolvedValue([{ id: 'finished', name: 'Finished example', status: 'complete' }]);
cdApi.createSmokeTestCreativeDirectorProject.mockResolvedValue({ id: 'smoke-new', name: 'New test clip', status: 'planning', createdAt: '2026-09-09' });
const user = userEvent.setup();
render(<MemoryRouter initialEntries={['/creative-director?status=complete&q=Finished']}><CreativeDirector /></MemoryRouter>);
await screen.findByRole('button', { name: 'New project' });
await openMenu(user);
await user.click(screen.getByRole('menuitem', { name: ITEM_LABEL }));
await user.click(screen.getByRole('button', { name: CONFIRM_LABEL }));
await screen.findByRole('link', { name: 'Open New test clip' });
expect(screen.getByLabelText('Status')).toHaveValue('all');
expect(screen.getByLabelText('Search projects')).toHaveValue('');
});