diff --git a/actions/shots.ts b/actions/shots.ts index a317e13..55874ca 100644 --- a/actions/shots.ts +++ b/actions/shots.ts @@ -781,12 +781,173 @@ export async function unapproveShot(shotId: string) { return { success: true }; } + revalidatePath(`/projects/${shot.projectId}`); + revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`); + revalidatePath(`/shot-status`); + return { success: true }; +} + +// ── EDL / Pull CSV Import ───────────────────────────────────────────────────── + +export interface EdlImportRow { + outputName: string; + sourceClip: string; + timecodeStart: string; + timecodeEnd: string; + clipDuration: string; + // Derived + shotCode: string; + exrOutput: string; + // Action decided during preview + action: "create" | "update" | "skip"; + existingShotId?: string; +} + /** - * Internal: handle client requesting changes on a shot. - * Resets shotApprovalStatus = PENDING, sharedWithClient = false. - * Recalculates shot status (will become REVISIONS if tasks set to CHANGES). + * Parse a VFX pull CSV into import rows. + * Output Name format: {showId}_{episode}_{scene}_{shot}_BG01_TT_v001 + * shotCode = first 4 underscore-separated segments + * exrOutput = {shotCode}_{sourceClip} */ -export async function clientRequestShotChanges(shotId: string, taskId?: string) { +export function parseEdlCsv(csvText: string): { rows: EdlImportRow[]; errors: string[] } { + const lines = csvText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + if (lines.length < 2) return { rows: [], errors: ["CSV must have a header row and at least one data row"] }; + + const unquote = (s: string) => s.replace(/^"|"$/g, "").trim(); + + const rawHeaders = lines[0].split(",").map(unquote).map((h) => h.toLowerCase()); + const col = (name: string) => rawHeaders.indexOf(name); + + const outNameIdx = col("output name"); + const srcClipIdx = col("source clip"); + const tcStartIdx = col("timecode start"); + const tcEndIdx = col("timecode end"); + const durIdx = col("duration"); + + if (outNameIdx === -1 || srcClipIdx === -1) { + return { rows: [], errors: ['CSV must contain "Output Name" and "Source Clip" columns'] }; + } + + const rows: EdlImportRow[] = []; + const errors: string[] = []; + + for (let i = 1; i < lines.length; i++) { + const cells = lines[i].match(/("(?:[^"]|"")*"|[^,]*)/g)?.map(unquote) ?? []; + const get = (idx: number) => (idx !== -1 ? cells[idx] ?? "" : ""); + + const outputName = get(outNameIdx); + const sourceClip = get(srcClipIdx); + if (!outputName) { errors.push(`Row ${i + 1}: empty Output Name — skipped`); continue; } + + const parts = outputName.split("_"); + if (parts.length < 4) { + errors.push(`Row ${i + 1}: "${outputName}" — cannot parse shot code (need at least 4 segments)`); + continue; + } + const shotCode = parts.slice(0, 4).join("_"); + const exrOutput = sourceClip ? `${shotCode}_${sourceClip}` : shotCode; + + rows.push({ + outputName, + sourceClip, + timecodeStart: get(tcStartIdx), + timecodeEnd: get(tcEndIdx), + clipDuration: get(durIdx), + shotCode, + exrOutput, + action: "create", + }); + } + + return { rows, errors }; +} + +export async function importShotsFromEdl( + projectId: string, + rows: EdlImportRow[] +): Promise<{ created: string[]; updated: string[]; skipped: string[]; errors: 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"); + } + + const created: string[] = []; + const updated: string[] = []; + const skipped: string[] = []; + const errors: string[] = []; + + for (const row of rows) { + if (row.action === "skip") { skipped.push(row.shotCode); continue; } + + try { + if (row.action === "update" && row.existingShotId) { + await db.shot.update({ + where: { id: row.existingShotId }, + data: { + sourceClip: row.sourceClip || null, + timecodeStart: row.timecodeStart || null, + timecodeEnd: row.timecodeEnd || null, + clipDuration: row.clipDuration || null, + exrOutput: row.exrOutput || null, + }, + }); + updated.push(row.shotCode); + } else { + const existing = await db.shot.findFirst({ + where: { projectId, shotCode: row.shotCode }, + select: { id: true }, + }); + + if (existing) { + await db.shot.update({ + where: { id: existing.id }, + data: { + sourceClip: row.sourceClip || null, + timecodeStart: row.timecodeStart || null, + timecodeEnd: row.timecodeEnd || null, + clipDuration: row.clipDuration || null, + exrOutput: row.exrOutput || null, + }, + }); + updated.push(row.shotCode); + } else { + const parts = row.shotCode.split("_"); + const scene = parts[2] ?? parts[1] ?? "000"; + const episode = parts[1] ?? null; + + const maxNum = await db.shot.findFirst({ + where: { projectId, scene, episode }, + orderBy: { shotNumber: "desc" }, + select: { shotNumber: true }, + }); + const shotNumber = (maxNum?.shotNumber ?? 0) + 10; + + await db.shot.create({ + data: { + projectId, + shotCode: row.shotCode, + scene, + episode, + shotNumber, + sourceClip: row.sourceClip || null, + timecodeStart: row.timecodeStart || null, + timecodeEnd: row.timecodeEnd || null, + clipDuration: row.clipDuration || null, + exrOutput: row.exrOutput || null, + }, + }); + created.push(row.shotCode); + } + } + } catch (e) { + errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`); + } + } + + revalidatePath(`/projects/${projectId}`); + return { created, updated, skipped, errors }; +} const session = await auth(); if (!session?.user) throw new Error("Unauthorized"); if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) { diff --git a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx index 309928f..e1f66a3 100644 --- a/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx +++ b/app/(dashboard)/projects/[id]/ProjectTabsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useTransition, useEffect } from "react"; +import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ShotCard } from "@/components/shots/ShotCard"; @@ -227,6 +228,11 @@ export function ProjectTabsClient({ + diff --git a/app/(dashboard)/projects/[id]/import-edl/EdlImportClient.tsx b/app/(dashboard)/projects/[id]/import-edl/EdlImportClient.tsx new file mode 100644 index 0000000..0c3e3b0 --- /dev/null +++ b/app/(dashboard)/projects/[id]/import-edl/EdlImportClient.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; +import { + ArrowLeft, + Upload, + AlertCircle, + CheckCircle2, + RefreshCw, + SkipForward, + FilePlus2, + Pencil, + Film, +} from "lucide-react"; +import { parseEdlCsv, importShotsFromEdl } from "@/actions/shots"; +import type { EdlImportRow } from "@/actions/shots"; + +interface EdlImportClientProps { + projectId: string; + projectName: string; + existingShotCodes: Set; +} + +type Step = "input" | "preview" | "result"; + +const ACTION_STYLES = { + create: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", + update: "bg-blue-500/10 text-blue-400 border-blue-500/20", + skip: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", +}; + +const ACTION_ICONS = { + create: FilePlus2, + update: Pencil, + skip: SkipForward, +}; + +export function EdlImportClient({ projectId, projectName, existingShotCodes }: EdlImportClientProps) { + const router = useRouter(); + const { toast } = useToast(); + const fileInputRef = useRef(null); + + const [step, setStep] = useState("input"); + const [csvText, setCsvText] = useState(""); + const [rows, setRows] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [importing, setImporting] = useState(false); + const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null); + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => setCsvText(ev.target?.result as string ?? ""); + reader.readAsText(file); + e.target.value = ""; + }; + + const handleParse = useCallback(() => { + const { rows: parsed, errors } = parseEdlCsv(csvText); + setParseErrors(errors); + + // Determine action for each row based on existing shots + const withActions: EdlImportRow[] = parsed.map((row) => ({ + ...row, + action: existingShotCodes.has(row.shotCode) ? "update" : "create", + })); + + setRows(withActions); + if (withActions.length > 0) setStep("preview"); + }, [csvText, existingShotCodes]); + + const toggleAction = (idx: number) => { + setRows((prev) => + prev.map((r, i) => { + if (i !== idx) return r; + const cycle: EdlImportRow["action"][] = ["create", "update", "skip"]; + const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length]; + return { ...r, action: next }; + }) + ); + }; + + const setAllAction = (action: EdlImportRow["action"]) => { + setRows((prev) => prev.map((r) => ({ ...r, action }))); + }; + + const handleImport = async () => { + setImporting(true); + try { + const res = await importShotsFromEdl(projectId, rows); + setResult(res); + setStep("result"); + if (res.created.length + res.updated.length > 0) { + toast({ + title: `Import complete`, + description: `${res.created.length} created, ${res.updated.length} updated`, + }); + } + } catch (e) { + toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" }); + } finally { + setImporting(false); + } + }; + + const handleReset = () => { + setStep("input"); + setCsvText(""); + setRows([]); + setParseErrors([]); + setResult(null); + }; + + const counts = { + create: rows.filter((r) => r.action === "create").length, + update: rows.filter((r) => r.action === "update").length, + skip: rows.filter((r) => r.action === "skip").length, + }; + + return ( +
+
+ {/* Breadcrumb */} +
+ Projects + / + {projectName} + / + Import VFX Pull +
+ + {/* Header */} +
+ +
+

Import VFX Pull CSV

+

Paste or upload a Colorfront VFX pull CSV to create or update shots

+
+
+ + {/* ── STEP 1: Input ─────────────────────────────────────────────────── */} + {step === "input" && ( +
+
+
+

Paste CSV or upload file

+
+ + +
+
+ +