added duplicate shot
Deploy / deploy (push) Successful in 2m28s

This commit is contained in:
twotalesanimation
2026-05-20 18:20:45 +02:00
parent c801d929af
commit 05475a6c19
4 changed files with 164 additions and 3 deletions
+94
View File
@@ -90,6 +90,100 @@ export async function createShot(data: z.infer<typeof createShotSchema>) {
return { success: true, shot }; return { success: true, shot };
} }
// ── Duplicate Shot ────────────────────────────────────────────────────────────
export async function duplicateShot(sourceShotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
// Load source shot with tasks
const source = await db.shot.findUnique({
where: { id: sourceShotId },
include: {
tasks: {
select: {
title: true,
description: true,
type: true,
priority: true,
estimatedHours: true,
sortOrder: true,
dueDate: true,
assignedArtistId: true,
},
},
},
});
if (!source) throw new Error("Shot not found");
const project = await db.project.findUnique({
where: { id: source.projectId },
select: { showId: true, projectType: true },
});
if (!project?.showId) throw new Error("Project has no Show ID");
// Determine next shot number in same scene/episode scope
const scopeWhere = {
projectId: source.projectId,
scene: source.scene,
...(project.projectType === "EPISODIC" ? { episode: source.episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
const shotCode =
project.projectType === "EPISODIC" && source.episode
? `${project.showId}_${source.episode}_${source.scene}_${paddedNumber}`
: `${project.showId}_${source.scene}_${paddedNumber}`;
const newShot = await db.shot.create({
data: {
shotCode,
scene: source.scene,
episode: source.episode,
shotNumber,
sequence: source.sequence,
description: source.description,
projectId: source.projectId,
artistId: source.artistId,
priority: source.priority,
fps: source.fps,
frameStart: source.frameStart,
frameEnd: source.frameEnd,
dueDate: source.dueDate,
thumbnailUrl: source.thumbnailUrl,
// Duplicate tasks — reset status and schedule fields
tasks: {
create: source.tasks.map((t) => ({
title: t.title,
description: t.description,
type: t.type,
priority: t.priority,
estimatedHours: t.estimatedHours,
sortOrder: t.sortOrder,
dueDate: t.dueDate,
assignedArtistId: t.assignedArtistId,
projectId: source.projectId,
status: "TODO" as const,
})),
},
},
});
revalidatePath(`/projects/${source.projectId}`);
return { success: true, shot: newShot };
}
// ── Update Shot ─────────────────────────────────────────────────────────────── // ── Update Shot ───────────────────────────────────────────────────────────────
const updateShotSchema = z.object({ const updateShotSchema = z.object({
@@ -154,7 +154,7 @@ export function ProjectTabsClient({
) : ( ) : (
<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) => (
<ShotCard key={shot.id} shot={shot} projectId={projectId} /> <ShotCard key={shot.id} shot={shot} projectId={projectId} canManage={canManage} />
))} ))}
</div> </div>
)} )}
@@ -20,10 +20,12 @@ import {
Settings, Settings,
ListTodo, ListTodo,
Video, Video,
Copy,
} from "lucide-react"; } from "lucide-react";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
import { FootageViewer } from "@/components/shots/FootageViewer"; import { FootageViewer } from "@/components/shots/FootageViewer";
import { duplicateShot } from "@/actions/shots";
const STATUS_CONFIG: Record< const STATUS_CONFIG: Record<
string, string,
@@ -43,9 +45,12 @@ const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
CRITICAL: { label: "Critical", dot: "bg-red-500" }, CRITICAL: { label: "Critical", dot: "bg-red-500" },
}; };
import { useToast } from "@/components/ui/use-toast";
export default function ShotDetailPage() { export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>(); const params = useParams<{ id: string; shotId: string }>();
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
const [shot, setShot] = useState<ShotWithDetails | null>(null); const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>(""); const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -53,6 +58,7 @@ export default function ShotDetailPage() {
const [tasks, setTasks] = useState<any[]>([]); const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]); const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false); const [canManage, setCanManage] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks"); const [activeTab, setActiveTab] = useState<"tasks" | "footage" | "settings">("tasks");
const fetchShot = async () => { const fetchShot = async () => {
@@ -80,6 +86,20 @@ export default function ShotDetailPage() {
fetchShot(); fetchShot();
}, [params.shotId]); }, [params.shotId]);
const handleDuplicate = async () => {
if (!shot) return;
setIsDuplicating(true);
try {
const { shot: newShot } = await duplicateShot(shot.id);
toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` });
router.push(`/projects/${params.id}/shots/${newShot.id}`);
} catch (e) {
toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsDuplicating(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -172,7 +192,20 @@ export default function ShotDetailPage() {
</div> </div>
</div> </div>
{canManage && (
<div className="ml-auto shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleDuplicate}
disabled={isDuplicating}
className="gap-2"
>
<Copy className="h-3.5 w-3.5" />
{isDuplicating ? "Duplicating…" : "Duplicate Shot"}
</Button>
</div>
)}
</div> </div>
<Separator /> <Separator />
+35 -1
View File
@@ -3,6 +3,7 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
@@ -11,6 +12,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -23,13 +25,17 @@ import {
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
ArrowUpRight, ArrowUpRight,
Copy,
} from "lucide-react"; } from "lucide-react";
import type { ShotWithDetails } from "@/types"; import type { ShotWithDetails } from "@/types";
import { duplicateShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
interface ShotCardProps { interface ShotCardProps {
shot: ShotWithDetails; shot: ShotWithDetails;
projectId: string; projectId: string;
compact?: boolean; compact?: boolean;
canManage?: boolean;
} }
const STATUS_CONFIG: Record< const STATUS_CONFIG: Record<
@@ -50,14 +56,29 @@ const PRIORITY_DOT: Record<string, string> = {
URGENT: "bg-red-500", URGENT: "bg-red-500",
}; };
export function ShotCard({ shot, projectId, compact = false }: ShotCardProps) { export function ShotCard({ shot, projectId, compact = false, canManage = false }: ShotCardProps) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast();
const [isDuplicating, setIsDuplicating] = useState(false);
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING; const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const StatusIcon = statusCfg.icon; const StatusIcon = statusCfg.icon;
const latestVersion = shot.versions?.[0]; const latestVersion = shot.versions?.[0];
const openComments = shot.versions const openComments = shot.versions
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0; ?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
const handleDuplicate = async () => {
setIsDuplicating(true);
try {
const { shot: newShot } = await duplicateShot(shot.id);
toast({ title: "Shot duplicated", description: `Created ${newShot.shotCode}` });
router.push(`/projects/${projectId}/shots/${newShot.id}`);
} catch (e) {
toast({ title: "Duplicate failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsDuplicating(false);
}
};
if (compact) { if (compact) {
return ( return (
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border hover:border-border/80 transition-colors group"> <div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border hover:border-border/80 transition-colors group">
@@ -145,6 +166,19 @@ export function ShotCard({ shot, projectId, compact = false }: ShotCardProps) {
View shot View shot
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{canManage && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDuplicate}
disabled={isDuplicating}
className="gap-2"
>
<Copy className="h-3.5 w-3.5" />
{isDuplicating ? "Duplicating…" : "Duplicate shot"}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>