This commit is contained in:
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,11 +270,13 @@ 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])
|
||||||
|
shotGroup ShotGroup? @relation(fields: [shotGroupId], references: [id])
|
||||||
versions Version[]
|
versions Version[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
|
||||||
@@ -281,6 +284,20 @@ model Shot {
|
|||||||
@@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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user