This commit is contained in:
@@ -18,6 +18,7 @@ const createShotSchema = z.object({
|
||||
frameEnd: z.number().int().optional(),
|
||||
dueDate: z.string().optional(),
|
||||
thumbnailUrl: z.string().optional(),
|
||||
shotGroupName: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
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,
|
||||
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
|
||||
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,
|
||||
dueDate: source.dueDate,
|
||||
thumbnailUrl: source.thumbnailUrl,
|
||||
shotGroupId: source.shotGroupId ?? undefined,
|
||||
// Duplicate tasks — reset status and schedule fields
|
||||
tasks: {
|
||||
create: source.tasks.map((t) => ({
|
||||
|
||||
@@ -60,6 +60,7 @@ interface ProjectTabsClientProps {
|
||||
assets: any[];
|
||||
tasks: any[];
|
||||
artists: Artist[];
|
||||
shotGroups: { id: string; name: string }[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ export function ProjectTabsClient({
|
||||
assets,
|
||||
tasks,
|
||||
artists,
|
||||
shotGroups,
|
||||
canManage,
|
||||
}: ProjectTabsClientProps) {
|
||||
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 }[] = [
|
||||
{ id: "shots", label: "Shots", icon: Film, count: shots.length },
|
||||
{ id: "assets", label: "Assets", icon: Package, count: assets.length },
|
||||
@@ -211,6 +239,36 @@ export function ProjectTabsClient({
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
{shots.map((shot) => (
|
||||
@@ -267,6 +325,7 @@ export function ProjectTabsClient({
|
||||
<NewShotDialog
|
||||
projectId={projectId}
|
||||
projectType={projectType}
|
||||
shotGroups={shotGroups}
|
||||
open={showNewShot}
|
||||
onClose={() => setShowNewShot(false)}
|
||||
onSuccess={() => setShowNewShot(false)}
|
||||
|
||||
@@ -28,6 +28,7 @@ async function getProject(id: string) {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
shotGroup: { select: { id: true, name: true } },
|
||||
versions: {
|
||||
take: 1,
|
||||
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}
|
||||
tasks={project.tasks as any}
|
||||
artists={artists}
|
||||
shotGroups={project.shotGroups}
|
||||
canManage={!!canManage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useToast } from "@/components/ui/use-toast";
|
||||
const shotSchema = z.object({
|
||||
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(),
|
||||
shotGroupName: z.string().max(100).optional(),
|
||||
description: z.string().optional(),
|
||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
|
||||
fps: z.coerce.number().min(1).max(120),
|
||||
@@ -39,12 +40,13 @@ type ShotFormValues = z.infer<typeof shotSchema>;
|
||||
interface NewShotDialogProps {
|
||||
projectId: string;
|
||||
projectType?: "STANDARD" | "EPISODIC";
|
||||
shotGroups?: { id: string; name: string }[];
|
||||
open: boolean;
|
||||
onClose: () => 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 [thumbnailFile, setThumbnailFile] = useState<File | 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"})
|
||||
</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">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<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
@@ -245,6 +245,7 @@ model Project {
|
||||
shots Shot[]
|
||||
assets Asset[]
|
||||
tasks Task[]
|
||||
shotGroups ShotGroup[]
|
||||
reviewSessions ReviewSession[]
|
||||
|
||||
@@map("projects")
|
||||
@@ -269,18 +270,34 @@ model Shot {
|
||||
thumbnailUrl String?
|
||||
originalFootageUrl String?
|
||||
originalFootageKey String?
|
||||
shotGroupId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
artist User? @relation("ArtistShots", fields: [artistId], references: [id])
|
||||
versions Version[]
|
||||
tasks Task[]
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
artist User? @relation("ArtistShots", fields: [artistId], references: [id])
|
||||
shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
|
||||
versions Version[]
|
||||
tasks Task[]
|
||||
|
||||
@@unique([projectId, shotCode])
|
||||
@@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 {
|
||||
id String @id @default(cuid())
|
||||
versionNumber Int
|
||||
|
||||
@@ -164,6 +164,8 @@ export interface ShotWithDetails {
|
||||
thumbnailUrl: string | null;
|
||||
originalFootageUrl: string | null;
|
||||
originalFootageKey: string | null;
|
||||
shotGroupId: string | null;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
artist: {
|
||||
|
||||
Reference in New Issue
Block a user