added custom grouping on project pages
Deploy / deploy (push) Successful in 2m26s

This commit is contained in:
twotalesanimation
2026-05-20 22:43:29 +02:00
parent de59bbc0aa
commit d39b968dcb
7 changed files with 139 additions and 5 deletions
+9
View File
@@ -18,6 +18,7 @@ const createShotSchema = z.object({
frameEnd: z.number().int().optional(), frameEnd: z.number().int().optional(),
dueDate: z.string().optional(), dueDate: z.string().optional(),
thumbnailUrl: z.string().optional(), thumbnailUrl: z.string().optional(),
shotGroupName: z.string().max(100).optional(),
}); });
export async function createShot(data: z.infer<typeof createShotSchema>) { export async function createShot(data: z.infer<typeof createShotSchema>) {
@@ -83,6 +84,13 @@ export async function createShot(data: z.infer<typeof createShotSchema>) {
frameEnd: parsed.frameEnd, frameEnd: parsed.frameEnd,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined, dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
thumbnailUrl: parsed.thumbnailUrl, thumbnailUrl: parsed.thumbnailUrl,
shotGroupId: parsed.shotGroupName?.trim()
? (await db.shotGroup.upsert({
where: { projectId_name: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() } },
create: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() },
update: {},
})).id
: undefined,
}, },
}); });
@@ -162,6 +170,7 @@ export async function duplicateShot(sourceShotId: string) {
frameEnd: source.frameEnd, frameEnd: source.frameEnd,
dueDate: source.dueDate, dueDate: source.dueDate,
thumbnailUrl: source.thumbnailUrl, thumbnailUrl: source.thumbnailUrl,
shotGroupId: source.shotGroupId ?? undefined,
// Duplicate tasks — reset status and schedule fields // Duplicate tasks — reset status and schedule fields
tasks: { tasks: {
create: source.tasks.map((t) => ({ create: source.tasks.map((t) => ({
@@ -60,6 +60,7 @@ interface ProjectTabsClientProps {
assets: any[]; assets: any[];
tasks: any[]; tasks: any[];
artists: Artist[]; artists: Artist[];
shotGroups: { id: string; name: string }[];
canManage: boolean; canManage: boolean;
} }
@@ -73,6 +74,7 @@ export function ProjectTabsClient({
assets, assets,
tasks, tasks,
artists, artists,
shotGroups,
canManage, canManage,
}: ProjectTabsClientProps) { }: ProjectTabsClientProps) {
const [activeTab, setActiveTab] = useState<Tab>("shots"); const [activeTab, setActiveTab] = useState<Tab>("shots");
@@ -109,6 +111,32 @@ export function ProjectTabsClient({
})() })()
: []; : [];
// For standard projects: group by custom shot group when any exist
const standardGroups: [string, ShotWithDetails[]][] = projectType === "STANDARD" && shots.some((s) => s.shotGroup)
? (() => {
const sorted = [...shots].sort((a, b) => {
const ga = a.shotGroup?.name ?? "\uFFFF";
const gb = b.shotGroup?.name ?? "\uFFFF";
if (ga !== gb) return ga.localeCompare(gb, undefined, { numeric: true });
if (a.scene !== b.scene) return a.scene.localeCompare(b.scene, undefined, { numeric: true });
return a.shotNumber - b.shotNumber;
});
const map = new Map<string, ShotWithDetails[]>();
for (const shot of sorted) {
const key = shot.shotGroup?.name ?? "(Ungrouped)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
}
// Move ungrouped to the end
const ungrouped = map.get("(Ungrouped)");
if (ungrouped) {
map.delete("(Ungrouped)");
map.set("(Ungrouped)", ungrouped);
}
return Array.from(map.entries());
})()
: [];
const tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [ const tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [
{ id: "shots", label: "Shots", icon: Film, count: shots.length }, { id: "shots", label: "Shots", icon: Film, count: shots.length },
{ id: "assets", label: "Assets", icon: Package, count: assets.length }, { id: "assets", label: "Assets", icon: Package, count: assets.length },
@@ -211,6 +239,36 @@ export function ProjectTabsClient({
); );
})} })}
</div> </div>
) : standardGroups.length > 0 ? (
<div className="space-y-4">
{standardGroups.map(([groupName, groupShots]) => {
const collapsed = collapsedEpisodes.has(groupName);
return (
<div key={groupName}>
<button
onClick={() => toggleEpisode(groupName)}
className="flex items-center gap-2 w-full mb-3 group text-left"
>
{collapsed
? <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
: <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm">{groupName}</span>
<span className="text-xs text-muted-foreground font-normal">
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
</span>
<div className="flex-1 h-px bg-border ml-1" />
</button>
{!collapsed && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{groupShots.map((shot) => (
<ShotCard key={shot.id} shot={shot} projectId={projectId} canManage={canManage} />
))}
</div>
)}
</div>
);
})}
</div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{shots.map((shot) => ( {shots.map((shot) => (
@@ -267,6 +325,7 @@ export function ProjectTabsClient({
<NewShotDialog <NewShotDialog
projectId={projectId} projectId={projectId}
projectType={projectType} projectType={projectType}
shotGroups={shotGroups}
open={showNewShot} open={showNewShot}
onClose={() => setShowNewShot(false)} onClose={() => setShowNewShot(false)}
onSuccess={() => setShowNewShot(false)} onSuccess={() => setShowNewShot(false)}
+6
View File
@@ -28,6 +28,7 @@ async function getProject(id: string) {
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
include: { include: {
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 } },
versions: { versions: {
take: 1, take: 1,
orderBy: { versionNumber: "desc" }, orderBy: { versionNumber: "desc" },
@@ -70,6 +71,10 @@ async function getProject(id: string) {
}, },
}, },
}, },
shotGroups: {
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
select: { id: true, name: true },
},
}, },
}); });
} }
@@ -202,6 +207,7 @@ export default async function ProjectPage({ params }: { params: Promise<{ id: st
assets={project.assets as any} assets={project.assets as any}
tasks={project.tasks as any} tasks={project.tasks as any}
artists={artists} artists={artists}
shotGroups={project.shotGroups}
canManage={!!canManage} canManage={!!canManage}
/> />
</div> </div>
+20 -1
View File
@@ -29,6 +29,7 @@ import { useToast } from "@/components/ui/use-toast";
const shotSchema = z.object({ const shotSchema = z.object({
scene: z.string().min(1, "Scene is required").max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"), scene: z.string().min(1, "Scene is required").max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
episode: z.string().max(50).optional(), episode: z.string().max(50).optional(),
shotGroupName: z.string().max(100).optional(),
description: z.string().optional(), description: z.string().optional(),
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]), priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
fps: z.coerce.number().min(1).max(120), fps: z.coerce.number().min(1).max(120),
@@ -39,12 +40,13 @@ type ShotFormValues = z.infer<typeof shotSchema>;
interface NewShotDialogProps { interface NewShotDialogProps {
projectId: string; projectId: string;
projectType?: "STANDARD" | "EPISODIC"; projectType?: "STANDARD" | "EPISODIC";
shotGroups?: { id: string; name: string }[];
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void; onSuccess?: () => void;
} }
export function NewShotDialog({ projectId, projectType = "STANDARD", open, onClose, onSuccess }: NewShotDialogProps) { export function NewShotDialog({ projectId, projectType = "STANDARD", shotGroups = [], open, onClose, onSuccess }: NewShotDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null); const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null); const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
@@ -160,6 +162,23 @@ export function NewShotDialog({ projectId, projectType = "STANDARD", open, onClo
{isEpisodic ? "SHOW_EP01_SC010_0010" : "SHOW_SC010_0010"}) {isEpisodic ? "SHOW_EP01_SC010_0010" : "SHOW_SC010_0010"})
</p> </p>
<div className="space-y-1.5">
<Label htmlFor="shotGroupName">Group <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input
id="shotGroupName"
list="shot-group-suggestions"
placeholder={shotGroups.length > 0 ? "Choose or type a new group" : "e.g. Action Sequences"}
{...register("shotGroupName")}
/>
{shotGroups.length > 0 && (
<datalist id="shot-group-suggestions">
{shotGroups.map((g) => (
<option key={g.id} value={g.name} />
))}
</datalist>
)}
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea <Textarea
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "shot_groups" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "shot_groups_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "shots" ADD COLUMN "shotGroupId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "shot_groups_projectId_name_key" ON "shot_groups"("projectId", "name");
-- AddForeignKey
ALTER TABLE "shot_groups" ADD CONSTRAINT "shot_groups_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "shots" ADD CONSTRAINT "shots_shotGroupId_fkey" FOREIGN KEY ("shotGroupId") REFERENCES "shot_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+21 -4
View File
@@ -245,6 +245,7 @@ model Project {
shots Shot[] shots Shot[]
assets Asset[] assets Asset[]
tasks Task[] tasks Task[]
shotGroups ShotGroup[]
reviewSessions ReviewSession[] reviewSessions ReviewSession[]
@@map("projects") @@map("projects")
@@ -269,18 +270,34 @@ model Shot {
thumbnailUrl String? thumbnailUrl String?
originalFootageUrl String? originalFootageUrl String?
originalFootageKey String? originalFootageKey String?
shotGroupId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
artist User? @relation("ArtistShots", fields: [artistId], references: [id]) artist User? @relation("ArtistShots", fields: [artistId], references: [id])
versions Version[] shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
tasks Task[] versions Version[]
tasks Task[]
@@unique([projectId, shotCode]) @@unique([projectId, shotCode])
@@map("shots") @@map("shots")
} }
model ShotGroup {
id String @id @default(cuid())
name String
projectId String
sortOrder Int @default(0)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
shots Shot[]
@@unique([projectId, name])
@@map("shot_groups")
}
model Version { model Version {
id String @id @default(cuid()) id String @id @default(cuid())
versionNumber Int versionNumber Int
+2
View File
@@ -164,6 +164,8 @@ export interface ShotWithDetails {
thumbnailUrl: string | null; thumbnailUrl: string | null;
originalFootageUrl: string | null; originalFootageUrl: string | null;
originalFootageKey: string | null; originalFootageKey: string | null;
shotGroupId: string | null;
shotGroup: { id: string; name: string } | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
artist: { artist: {