Key Shots
Deploy / deploy (push) Successful in 3m0s

This commit is contained in:
twotalesanimation
2026-07-09 13:43:16 +02:00
parent 637b141c87
commit 4624a4f67d
6 changed files with 138 additions and 10 deletions
+27
View File
@@ -20,6 +20,7 @@ const createShotSchema = z.object({
dueDate: z.string().optional(), dueDate: z.string().optional(),
thumbnailUrl: z.string().optional(), thumbnailUrl: z.string().optional(),
shotGroupName: z.string().max(100).optional(), shotGroupName: z.string().max(100).optional(),
isKeyShot: z.boolean().default(false),
}); });
export async function createShot(data: z.infer<typeof createShotSchema>) { export async function createShot(data: z.infer<typeof createShotSchema>) {
@@ -85,6 +86,7 @@ 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,
isKeyShot: parsed.isKeyShot ?? false,
shotGroupId: parsed.shotGroupName?.trim() shotGroupId: parsed.shotGroupName?.trim()
? (await db.shotGroup.upsert({ ? (await db.shotGroup.upsert({
where: { projectId_name: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() } }, where: { projectId_name: { projectId: parsed.projectId, name: parsed.shotGroupName.trim() } },
@@ -487,6 +489,31 @@ export async function updateShotNotes(shotId: string, notes: string) {
return { success: true }; return { success: true };
} }
// ── Toggle Key Shot ───────────────────────────────────────────────────────────
export async function toggleKeyShot(shotId: string, isKeyShot: boolean) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const shot = await db.shot.findUnique({
where: { id: shotId },
select: { projectId: true },
});
if (!shot) throw new Error("Shot not found");
await db.shot.update({
where: { id: shotId },
data: { isKeyShot },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/shot-status`);
return { success: true };
}
export async function deleteShot(shotId: string) { export async function deleteShot(shotId: string) {
const session = await auth(); const session = await auth();
if (!session?.user) throw new Error("Unauthorized"); if (!session?.user) throw new Error("Unauthorized");
@@ -22,7 +22,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { updateShotNotes, bulkUpdateDueDates } from "@/actions/shots"; import { updateShotNotes, bulkUpdateDueDates, toggleKeyShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { import {
Film, Film,
@@ -37,6 +37,7 @@ import {
X, X,
ShieldCheck, ShieldCheck,
Eye, Eye,
Star,
} from "lucide-react"; } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
@@ -52,6 +53,7 @@ type ShotRow = {
thumbnailUrl: string | null; thumbnailUrl: string | null;
notes: string | null; notes: string | null;
description: string | null; description: string | null;
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;
}; };
@@ -261,6 +263,49 @@ function NotesCell({
); );
} }
// ── KeyShotToggle ─────────────────────────────────────────────────────────────
function KeyShotToggle({ shot }: { shot: ShotRow }) {
const [optimistic, setOptimistic] = useState(shot.isKeyShot);
const [isPending, startTransition] = useTransition();
const { toast } = useToast();
const handle = () => {
const next = !optimistic;
setOptimistic(next);
startTransition(async () => {
try {
await toggleKeyShot(shot.id, next);
} catch {
setOptimistic(!next);
toast({ title: "Failed to update key shot", variant: "destructive" });
}
});
};
return (
<button
type="button"
role="switch"
aria-checked={optimistic}
aria-label={optimistic ? "Remove key shot" : "Mark as key shot"}
disabled={isPending}
onClick={handle}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 disabled:opacity-50",
optimistic ? "bg-amber-500" : "bg-zinc-600 hover:bg-zinc-500"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg transition-transform",
optimistic ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
}
function ShotTable({ function ShotTable({
shots, shots,
canManage, canManage,
@@ -297,6 +342,12 @@ function ShotTable({
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th> <th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th> <th className="text-left py-2 px-3 text-xs font-medium text-zinc-500 w-32">Due Date</th>
<th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</th> <th className="text-left py-2 px-3 text-xs font-medium text-zinc-500">Notes</th>
<th className="py-2 px-3 text-xs font-medium text-zinc-500 w-20 text-center">
<span className="flex items-center gap-1 justify-center">
<Star className="h-3 w-3" />
Key
</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-800/60"> <tbody className="divide-y divide-zinc-800/60">
@@ -308,7 +359,13 @@ function ShotTable({
const isSelected = selectedIds.has(shot.id); const isSelected = selectedIds.has(shot.id);
return ( return (
<tr key={shot.id} className={cn("hover:bg-zinc-800/30 transition-colors", isSelected && "bg-blue-500/5 hover:bg-blue-500/10")}> <tr key={shot.id} className={cn(
"transition-colors",
shot.isKeyShot
? "bg-amber-500/5 hover:bg-amber-500/10 border-l-2 border-l-amber-500"
: "hover:bg-zinc-800/30",
isSelected && "bg-blue-500/5 hover:bg-blue-500/10"
)}>
{/* Checkbox */} {/* Checkbox */}
{canManage && ( {canManage && (
<td className="py-2 px-3"> <td className="py-2 px-3">
@@ -340,6 +397,10 @@ function ShotTable({
{/* Shot name */} {/* Shot name */}
<td className="py-2 px-3"> <td className="py-2 px-3">
<div className="flex items-center gap-1.5">
{shot.isKeyShot && (
<Star className="h-3 w-3 text-amber-400 fill-amber-400 shrink-0" />
)}
<Link <Link
href={`/projects/${projectId}/shots/${shot.id}`} href={`/projects/${projectId}/shots/${shot.id}`}
className="font-mono text-sm text-white hover:text-blue-400 transition-colors" className="font-mono text-sm text-white hover:text-blue-400 transition-colors"
@@ -347,6 +408,7 @@ function ShotTable({
> >
{shot.shotCode} {shot.shotCode}
</Link> </Link>
</div>
{shot.description && ( {shot.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]"> <p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">
{shot.description} {shot.description}
@@ -383,6 +445,11 @@ function ShotTable({
<td className="py-2 px-3 min-w-[220px]"> <td className="py-2 px-3 min-w-[220px]">
<NotesCell shot={shot} canManage={canManage} /> <NotesCell shot={shot} canManage={canManage} />
</td> </td>
{/* Key shot toggle */}
<td className="py-2 px-3 text-center">
<KeyShotToggle shot={shot} />
</td>
</tr> </tr>
); );
})} })}
+1
View File
@@ -39,6 +39,7 @@ async function getShotsForProject(projectId: string) {
thumbnailUrl: true, thumbnailUrl: true,
notes: true, notes: true,
description: true, description: true,
isKeyShot: true,
artist: { select: { id: true, name: true, image: true, email: true } }, artist: { select: { id: true, name: true, image: true, email: true } },
}, },
}); });
+30 -1
View File
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { createShot } from "@/actions/shots"; import { createShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Star } from "lucide-react";
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"),
@@ -50,6 +51,7 @@ export function NewShotDialog({ projectId, projectType = "STANDARD", shotGroups
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);
const [isKeyShot, setIsKeyShot] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const isEpisodic = projectType === "EPISODIC"; const isEpisodic = projectType === "EPISODIC";
@@ -101,11 +103,12 @@ export function NewShotDialog({ projectId, projectType = "STANDARD", shotGroups
thumbnailUrl = uploadData.url; thumbnailUrl = uploadData.url;
} }
await createShot({ projectId, ...data, thumbnailUrl }); await createShot({ projectId, ...data, thumbnailUrl, isKeyShot });
toast({ title: "Shot created" }); toast({ title: "Shot created" });
reset(); reset();
setThumbnailFile(null); setThumbnailFile(null);
setThumbnailPreview(null); setThumbnailPreview(null);
setIsKeyShot(false);
router.refresh(); router.refresh();
onSuccess?.(); onSuccess?.();
onClose(); onClose();
@@ -240,6 +243,32 @@ export function NewShotDialog({ projectId, projectType = "STANDARD", shotGroups
</div> </div>
</div> </div>
{/* Key shot toggle */}
<div className="flex items-center justify-between rounded-lg border border-zinc-700 px-4 py-3">
<div className="flex items-center gap-2.5">
<Star className={isKeyShot ? "h-4 w-4 text-amber-400 fill-amber-400" : "h-4 w-4 text-zinc-500"} />
<div>
<p className="text-sm font-medium text-zinc-200">Key Shot</p>
<p className="text-xs text-zinc-500">Prioritised and highlighted across the project</p>
</div>
</div>
<button
type="button"
role="switch"
aria-checked={isKeyShot}
onClick={() => setIsKeyShot((v) => !v)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 ${
isKeyShot ? "bg-amber-500" : "bg-zinc-600"
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg transition-transform ${
isKeyShot ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
</div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
@@ -0,0 +1,2 @@
-- AlterTable: add isKeyShot flag to shots
ALTER TABLE "shots" ADD COLUMN "isKeyShot" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -311,6 +311,8 @@ model Shot {
highResFilename String? highResFilename String?
// Shot-level version tracking (e.g. v001, v002) // Shot-level version tracking (e.g. v001, v002)
shotVersion String @default("v001") shotVersion String @default("v001")
// Key shot flag — highlighted for prioritisation
isKeyShot Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt