+165
-4
@@ -781,12 +781,173 @@ export async function unapproveShot(shotId: string) {
|
|||||||
return { success: true };
|
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.
|
* Parse a VFX pull CSV into import rows.
|
||||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
* Output Name format: {showId}_{episode}_{scene}_{shot}_BG01_TT_v001
|
||||||
* Recalculates shot status (will become REVISIONS if tasks set to CHANGES).
|
* 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();
|
const session = await auth();
|
||||||
if (!session?.user) throw new Error("Unauthorized");
|
if (!session?.user) throw new Error("Unauthorized");
|
||||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(session.user.role)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition, useEffect } from "react";
|
import { useState, useTransition, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ShotCard } from "@/components/shots/ShotCard";
|
import { ShotCard } from "@/components/shots/ShotCard";
|
||||||
@@ -227,6 +228,11 @@ export function ProjectTabsClient({
|
|||||||
<Button variant="outline" size="sm" className="gap-2 h-8" onClick={() => setShowImportShots(true)}>
|
<Button variant="outline" size="sm" className="gap-2 h-8" onClick={() => setShowImportShots(true)}>
|
||||||
<FileUp className="h-3.5 w-3.5" /> Import CSV
|
<FileUp className="h-3.5 w-3.5" /> Import CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2 h-8" asChild>
|
||||||
|
<Link href={`/projects/${projectId}/import-edl`}>
|
||||||
|
<FileUp className="h-3.5 w-3.5" /> VFX Pull
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewShot(true)}>
|
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewShot(true)}>
|
||||||
<Plus className="h-3.5 w-3.5" /> New Shot
|
<Plus className="h-3.5 w-3.5" /> New Shot
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<Step>("input");
|
||||||
|
const [csvText, setCsvText] = useState("");
|
||||||
|
const [rows, setRows] = useState<EdlImportRow[]>([]);
|
||||||
|
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500">
|
||||||
|
<Link href="/projects" className="hover:text-white transition-colors">Projects</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link href={`/projects/${projectId}`} className="hover:text-white transition-colors">{projectName}</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-zinc-300">Import VFX Pull</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 -ml-2" asChild>
|
||||||
|
<Link href={`/projects/${projectId}`}><ArrowLeft className="h-4 w-4" /></Link>
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">Import VFX Pull CSV</h1>
|
||||||
|
<p className="text-sm text-zinc-500 mt-0.5">Paste or upload a Colorfront VFX pull CSV to create or update shots</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── STEP 1: Input ─────────────────────────────────────────────────── */}
|
||||||
|
{step === "input" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-zinc-300">Paste CSV or upload file</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 h-7"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
Upload .csv
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
value={csvText}
|
||||||
|
onChange={(e) => setCsvText(e.target.value)}
|
||||||
|
placeholder={`Events,"Deliverable Name","Output Name","Source Clip","Timecode Start","Timecode End",Duration,CDL,Status,Trimmed\n000001,VFX_Pull_Default_EXR,"UNG_108_001_010_BG01_TT_v001",A315C001_260313H1,...`}
|
||||||
|
className="font-mono text-xs min-h-[280px] bg-zinc-950 border-zinc-700 resize-y"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-zinc-950 border border-zinc-800 p-3 text-xs text-zinc-400 space-y-1">
|
||||||
|
<p className="font-medium text-zinc-300">Expected format</p>
|
||||||
|
<p>Columns required: <span className="font-mono text-amber-400">Output Name</span>, <span className="font-mono text-amber-400">Source Clip</span></p>
|
||||||
|
<p>Optional: <span className="font-mono text-zinc-400">Timecode Start, Timecode End, Duration</span></p>
|
||||||
|
<p className="mt-1.5">Shot code is derived from the first 4 segments of Output Name, e.g. <span className="font-mono text-zinc-300">UNG_108_030_060_BG01_TT_v001</span> → <span className="font-mono text-emerald-400">UNG_108_030_060</span></p>
|
||||||
|
<p>EXR Output = <span className="font-mono text-zinc-300">{"{shot_code}_{source_clip}"}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleParse} disabled={!csvText.trim()} className="gap-2">
|
||||||
|
<Film className="h-4 w-4" />
|
||||||
|
Parse & Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── STEP 2: Preview ───────────────────────────────────────────────── */}
|
||||||
|
{step === "preview" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm">
|
||||||
|
<FilePlus2 className="h-3.5 w-3.5" />
|
||||||
|
{counts.create} to create
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm">
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
{counts.update} to update
|
||||||
|
</div>
|
||||||
|
{counts.skip > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-500/10 border border-zinc-500/20 text-zinc-400 text-sm">
|
||||||
|
<SkipForward className="h-3.5 w-3.5" />
|
||||||
|
{counts.skip} to skip
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<span className="text-xs text-zinc-500">Set all:</span>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={() => setAllAction("create")}>Create</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={() => setAllAction("update")}>Update</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs gap-1" onClick={() => setAllAction("skip")}>Skip</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{parseErrors.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-red-500/10 border border-red-500/20 p-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium text-red-400 flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> {parseErrors.length} parse warning{parseErrors.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
{parseErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_auto] text-xs font-medium text-zinc-500 uppercase tracking-wider bg-zinc-900 px-4 py-2.5 gap-4 border-b border-zinc-800">
|
||||||
|
<span>Action</span>
|
||||||
|
<span>Shot Code</span>
|
||||||
|
<span>EXR Output</span>
|
||||||
|
<span>TC In → Out</span>
|
||||||
|
<span>Duration</span>
|
||||||
|
<span>Source Clip</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[480px] overflow-y-auto">
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const ActionIcon = ACTION_ICONS[row.action];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
{/* Action badge — click to cycle */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleAction(i)}
|
||||||
|
title="Click to cycle: create → update → skip"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-medium transition-colors shrink-0",
|
||||||
|
ACTION_STYLES[row.action]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ActionIcon className="h-3 w-3" />
|
||||||
|
{row.action}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="font-mono text-xs text-zinc-200 truncate">{row.shotCode}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400 truncate">{row.exrOutput}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-500 truncate">
|
||||||
|
{row.timecodeStart && row.timecodeEnd
|
||||||
|
? `${row.timecodeStart} → ${row.timecodeEnd}`
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-500">{row.clipDuration || "—"}</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-500 truncate">{row.sourceClip || "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={importing || (counts.create + counts.update === 0)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{importing ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{importing ? "Importing…" : `Import ${counts.create + counts.update} shot${counts.create + counts.update !== 1 ? "s" : ""}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── STEP 3: Result ───────────────────────────────────────────────── */}
|
||||||
|
{step === "result" && result && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6 space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||||
|
Import complete
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{result.created.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">
|
||||||
|
Created ({result.created.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{result.created.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.updated.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">
|
||||||
|
Updated ({result.updated.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{result.updated.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.skipped.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||||
|
Skipped ({result.skipped.length})
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{result.skipped.map((c) => (
|
||||||
|
<span key={c} className="font-mono text-xs px-2 py-0.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-400">{c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.errors.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" /> Errors ({result.errors.length})
|
||||||
|
</p>
|
||||||
|
{result.errors.map((e, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-300 pl-5">{e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
Import Another
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => router.push(`/projects/${projectId}`)}>
|
||||||
|
Back to Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { EdlImportClient } from "./EdlImportClient";
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const project = await db.project.findUnique({ where: { id }, select: { name: true } });
|
||||||
|
return { title: `Import VFX Pull — ${project?.name ?? "Project"}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ImportEdlPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||||
|
redirect(`/projects/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shots: { select: { shotCode: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!project) notFound();
|
||||||
|
|
||||||
|
const existingShotCodes = new Set(project.shots.map((s) => s.shotCode));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EdlImportClient
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
existingShotCodes={existingShotCodes}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -305,6 +305,42 @@ export default function ShotDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* EDL / Pull metadata */}
|
||||||
|
{(shot.exrOutput || shot.sourceClip) && (
|
||||||
|
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/60 px-4 py-3 space-y-2">
|
||||||
|
{shot.exrOutput && (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">EXR Output</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-200 flex-1 break-all">{shot.exrOutput}</span>
|
||||||
|
<button
|
||||||
|
className="text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
|
||||||
|
title="Copy EXR output name"
|
||||||
|
onClick={() => navigator.clipboard.writeText(shot.exrOutput ?? "")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{shot.sourceClip && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">Source Clip</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400">{shot.sourceClip}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(shot.timecodeStart || shot.timecodeEnd) && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-zinc-500 uppercase tracking-wider w-24 shrink-0">Timecode</span>
|
||||||
|
<span className="font-mono text-xs text-zinc-400">
|
||||||
|
{shot.timecodeStart ?? ""}
|
||||||
|
{shot.timecodeStart && shot.timecodeEnd && " → "}
|
||||||
|
{shot.timecodeEnd ?? ""}
|
||||||
|
{shot.clipDuration && <span className="ml-2 text-zinc-600">({shot.clipDuration})</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="ml-auto shrink-0 flex items-center gap-2">
|
<div className="ml-auto shrink-0 flex items-center gap-2">
|
||||||
{/* Internal approval action */}
|
{/* Internal approval action */}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "shots" ADD COLUMN "sourceClip" TEXT;
|
||||||
|
ALTER TABLE "shots" ADD COLUMN "timecodeStart" TEXT;
|
||||||
|
ALTER TABLE "shots" ADD COLUMN "timecodeEnd" TEXT;
|
||||||
|
ALTER TABLE "shots" ADD COLUMN "clipDuration" TEXT;
|
||||||
|
ALTER TABLE "shots" ADD COLUMN "exrOutput" TEXT;
|
||||||
@@ -297,6 +297,12 @@ model Shot {
|
|||||||
shotGroupId String?
|
shotGroupId String?
|
||||||
shotApprovalStatus ShotApprovalStatus @default(PENDING)
|
shotApprovalStatus ShotApprovalStatus @default(PENDING)
|
||||||
sharedWithClient Boolean @default(false)
|
sharedWithClient Boolean @default(false)
|
||||||
|
// EDL / pull CSV fields
|
||||||
|
sourceClip String?
|
||||||
|
timecodeStart String?
|
||||||
|
timecodeEnd String?
|
||||||
|
clipDuration String?
|
||||||
|
exrOutput String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ export interface ShotWithDetails {
|
|||||||
shotGroupId: string | null;
|
shotGroupId: string | null;
|
||||||
shotApprovalStatus: ShotApprovalStatus;
|
shotApprovalStatus: ShotApprovalStatus;
|
||||||
sharedWithClient: boolean;
|
sharedWithClient: boolean;
|
||||||
|
// EDL / pull CSV fields
|
||||||
|
sourceClip: string | null;
|
||||||
|
timecodeStart: string | null;
|
||||||
|
timecodeEnd: string | null;
|
||||||
|
clipDuration: string | null;
|
||||||
|
exrOutput: string | null;
|
||||||
shotGroup: { id: string; name: string } | null;
|
shotGroup: { id: string; name: string } | null;
|
||||||
footagePlates: {
|
footagePlates: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user