Shot status grouping
Deploy / deploy (push) Successful in 2m44s

This commit is contained in:
twotalesanimation
2026-08-06 10:07:59 +02:00
parent 852081b4d6
commit d2c91e7b65
2 changed files with 100 additions and 19 deletions
@@ -55,6 +55,7 @@ type ShotRow = {
description: string | null; description: string | null;
isKeyShot: boolean; isKeyShot: boolean;
artist: { id: string; name: string | null; image: string | null; email: string } | null; artist: { id: string; name: string | null; image: string | null; email: string } | null;
shotGroup: { id: string; name: string } | null;
}; };
interface Project { interface Project {
@@ -491,7 +492,8 @@ export function ShotStatusClient({
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set()); const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set()); const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
const [groupByScene, setGroupByScene] = useState(false); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Load saved episode expand state when project changes // Load saved episode expand state when project changes
@@ -504,10 +506,10 @@ export function ShotStatusClient({
setExpandedEpisodes(new Set()); setExpandedEpisodes(new Set());
} }
try { try {
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupByScene`); const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
setGroupByScene(savedGroup === "1"); setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
} catch { } catch {
setGroupByScene(false); setGroupMode("flat");
} }
}, [selectedProjectId]); }, [selectedProjectId]);
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false); const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
@@ -531,11 +533,19 @@ export function ShotStatusClient({
return next; return next;
}); });
const handleGroupByScene = (checked: boolean) => { const toggleGroup = (g: string) =>
setGroupByScene(checked); setExpandedGroups((prev) => {
if (checked) setExpandedScenes(new Set(shots.map((s) => s.scene))); const next = new Set(prev);
next.has(g) ? next.delete(g) : next.add(g);
return next;
});
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
setGroupMode(mode);
if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene)));
if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? "")));
if (selectedProjectId) { if (selectedProjectId) {
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupByScene`, checked ? "1" : "0"); } catch {} try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
} }
}; };
@@ -590,7 +600,7 @@ export function ShotStatusClient({
})() })()
: []; : [];
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupByScene const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
? (() => { ? (() => {
const map = new Map<string, ShotRow[]>(); const map = new Map<string, ShotRow[]>();
for (const shot of shots) { for (const shot of shots) {
@@ -602,6 +612,19 @@ export function ShotStatusClient({
})() })()
: []; : [];
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
? (() => {
const map = new Map<string, [string, ShotRow[]]>();
for (const shot of shots) {
const id = shot.shotGroup?.id ?? "";
const name = shot.shotGroup?.name ?? "(No Group)";
if (!map.has(id)) map.set(id, [name, []]);
map.get(id)![1].push(shot);
}
return Array.from(map.entries()).map(([id, [name, s]]) => [id, name, s] as [string, string, ShotRow[]]);
})()
: [];
const episodeDueDateMap = new Map( const episodeDueDateMap = new Map(
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)]) episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
); );
@@ -641,15 +664,23 @@ export function ShotStatusClient({
</SelectContent> </SelectContent>
</Select> </Select>
{selectedProject && !isEpisodic && ( {selectedProject && !isEpisodic && (
<label className="flex items-center gap-2 cursor-pointer select-none"> <>
<input <span className="text-sm text-zinc-500">Group by:</span>
type="checkbox" {(["flat", "scene", "group"] as const).map((mode) => (
checked={groupByScene} <button
onChange={(e) => handleGroupByScene(e.target.checked)} key={mode}
className="cursor-pointer accent-blue-500 w-3.5 h-3.5" onClick={() => handleGroupMode(mode)}
/> className={cn(
<span className="text-sm text-zinc-400">Group by scene</span> "px-3 py-1 rounded text-xs font-medium transition-colors",
</label> groupMode === mode
? "bg-zinc-700 text-zinc-100"
: "text-zinc-500 hover:text-zinc-300"
)}
>
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
</button>
))}
</>
)} )}
</div> </div>
@@ -773,7 +804,7 @@ export function ShotStatusClient({
); );
})} })}
</div> </div>
) : groupByScene ? ( ) : groupMode === "scene" ? (
<div className="divide-y divide-zinc-800"> <div className="divide-y divide-zinc-800">
{sceneGroups.map(([scene, sceneShots]) => { {sceneGroups.map(([scene, sceneShots]) => {
const collapsed = !expandedScenes.has(scene); const collapsed = !expandedScenes.has(scene);
@@ -822,6 +853,55 @@ export function ShotStatusClient({
); );
})} })}
</div> </div>
) : groupMode === "group" ? (
<div className="divide-y divide-zinc-800">
{shotGroupGroups.map(([groupId, groupName, groupShots]) => {
const collapsed = !expandedGroups.has(groupId);
const groupIds = groupShots.map((s) => s.id);
const allGrSelected = groupIds.every((id) => selectedIds.has(id));
const someGrSelected = groupIds.some((id) => selectedIds.has(id));
return (
<div key={groupId}>
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
{canManage && (
<IndeterminateCheckbox
checked={allGrSelected}
indeterminate={someGrSelected && !allGrSelected}
onChange={(e) => toggleMany(groupIds, e.target.checked)}
className="shrink-0"
/>
)}
<button
onClick={() => toggleGroup(groupId)}
className="flex items-center gap-2 flex-1 text-left"
>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
) : (
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
)}
<span className="font-semibold text-sm text-white">{groupName}</span>
<span className="text-xs text-zinc-500 font-normal">
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{!collapsed && (
<div className="overflow-x-auto">
<ShotTable
shots={groupShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}
onToggle={toggleShot}
onToggleAll={toggleMany}
/>
</div>
)}
</div>
);
})}
</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<ShotTable <ShotTable
+1
View File
@@ -41,6 +41,7 @@ async function getShotsForProject(projectId: string) {
description: true, description: true,
isKeyShot: true, isKeyShot: true,
artist: { select: { id: true, name: true, image: true, email: true } }, artist: { select: { id: true, name: true, image: true, email: true } },
shotGroup: { select: { id: true, name: true } },
}, },
}); });
} }