Files
vfxreview/app/(dashboard)/projects/[id]/import-edl/EdlImportClient.tsx
T
twotalesanimation 5f3c89119a
Deploy / deploy (push) Successful in 2m42s
Further CSV Import method added
2026-08-06 08:36:26 +02:00

872 lines
41 KiB
TypeScript

"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,
FileSpreadsheet,
} from "lucide-react";
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv } from "@/lib/edl-utils";
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow } from "@/lib/edl-utils";
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv } 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);
// ── Simple CSV state ────────────────────────────────────────────────────────
const scFileInputRef = useRef<HTMLInputElement>(null);
const [scStep, setScStep] = useState<"input" | "preview" | "result">("input");
const [scCsvText, setScCsvText] = useState("");
const [scRows, setScRows] = useState<SimpleCsvRow[]>([]);
const [scErrors, setScErrors] = useState<string[]>([]);
const [scImporting, setScImporting] = useState(false);
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
// ── Picture Tracker state ─────────────────────────────────────────────────
const ptFileInputRef = useRef<HTMLInputElement>(null);
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
const [ptCsvText, setPtCsvText] = useState("");
const [ptRows, setPtRows] = useState<PictureTrackerRow[]>([]);
const [ptErrors, setPtErrors] = useState<string[]>([]);
const [ptImporting, setPtImporting] = useState(false);
const [ptResult, setPtResult] = useState<{ 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);
};
// ── Simple CSV handlers ─────────────────────────────────────────────────────
const handleScFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setScCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handleScParse = useCallback(() => {
const { rows: parsed, errors } = parseSimpleCsv(scCsvText, existingShotCodes);
setScErrors(errors);
setScRows(parsed);
if (parsed.length > 0) setScStep("preview");
}, [scCsvText, existingShotCodes]);
const toggleScAction = (idx: number) => {
setScRows((prev) =>
prev.map((r, i) => {
if (i !== idx) return r;
const cycle: SimpleCsvRow["action"][] = ["create", "update", "skip"];
const next = cycle[(cycle.indexOf(r.action) + 1) % cycle.length];
return { ...r, action: next };
})
);
};
const handleScImport = async () => {
setScImporting(true);
try {
const res = await importShotsFromSimpleCsv(projectId, scRows);
setScResult(res);
setScStep("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 {
setScImporting(false);
}
};
const handleScReset = () => {
setScStep("input");
setScCsvText("");
setScRows([]);
setScErrors([]);
setScResult(null);
};
// ── Picture Tracker handlers ──────────────────────────────────────────────
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setPtCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
e.target.value = "";
};
const handlePtParse = useCallback(() => {
const { rows: parsed, errors } = parsePictureTrackerCsv(ptCsvText, existingShotCodes);
setPtErrors(errors);
setPtRows(parsed);
if (parsed.length > 0) setPtStep("preview");
}, [ptCsvText, existingShotCodes]);
const handlePtImport = async () => {
setPtImporting(true);
try {
const toUpdate = ptRows.filter((r) => r.exists);
const res = await updateShotsSeqTimecodes(projectId, toUpdate);
// Rows that parsed but didn't exist are also skipped
const notFound = ptRows.filter((r) => !r.exists).map((r) => r.shotCode);
const combined = { ...res, skipped: [...notFound, ...res.skipped] };
setPtResult(combined);
setPtStep("result");
if (res.updated.length > 0) {
toast({
title: "Timecodes updated",
description: `${res.updated.length} shot${res.updated.length !== 1 ? "s" : ""} updated`,
});
}
} catch (e) {
toast({ title: "Update failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setPtImporting(false);
}
};
const handlePtReset = () => {
setPtStep("input");
setPtCsvText("");
setPtRows([]);
setPtErrors([]);
setPtResult(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 &amp; 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>
)}
{/* ── SIMPLE CSV PANEL ──────────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Import Shots from Simple CSV</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Create or update shots from a CSV with <span className="font-mono text-zinc-300">Shot Name</span>, <span className="font-mono text-zinc-300">Time Code In</span>, <span className="font-mono text-zinc-300">Time Code Out</span>, <span className="font-mono text-zinc-300">Description</span>.
</p>
</div>
{scStep === "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={scFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleScFileUpload} />
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => scFileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={scCsvText}
onChange={(e) => setScCsvText(e.target.value)}
placeholder={`Shot Name,Time Code In,Time Code Out,Description\nUNG_108_001_010,01:00:10:00,01:00:20:00,Hero wide shot`}
className="font-mono text-xs min-h-[200px] 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>Required: <span className="font-mono text-amber-400">Shot Name</span></p>
<p>Optional: <span className="font-mono text-zinc-400">Time Code In, Time Code Out, Description</span></p>
<p>Existing shots will be set to <span className="font-mono text-blue-400">update</span>; new shots to <span className="font-mono text-emerald-400">create</span>. Toggle per row in the preview.</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleScParse} disabled={!scCsvText.trim()} className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{scStep === "preview" && (() => {
const scCreate = scRows.filter((r) => r.action === "create").length;
const scUpdate = scRows.filter((r) => r.action === "update").length;
const scSkip = scRows.filter((r) => r.action === "skip").length;
return (
<div className="space-y-4">
<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" />
{scCreate} 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" />
{scUpdate} to update
</div>
{scSkip > 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" />
{scSkip} to skip
</div>
)}
</div>
{scErrors.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" /> {scErrors.length} parse warning{scErrors.length !== 1 ? "s" : ""}
</p>
{scErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
</div>
)}
<div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[auto_1fr_1fr_1fr_2fr] 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>Seq TC In</span>
<span>Seq TC Out</span>
<span>Description</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{scRows.map((row, i) => {
const ActionIcon = ACTION_ICONS[row.action];
return (
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_2fr] items-center gap-4 px-4 py-3 text-sm">
<button
onClick={() => toggleScAction(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">{row.seqTimecodeStart || "—"}</span>
<span className="font-mono text-xs text-zinc-400">{row.seqTimecodeEnd || "—"}</span>
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleScReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handleScImport}
disabled={scImporting || (scCreate + scUpdate === 0)}
className="gap-2"
>
{scImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{scImporting ? "Importing…" : `Import ${scCreate + scUpdate} shot${scCreate + scUpdate !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
);
})()}
{scStep === "result" && scResult && (
<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>
{scResult.created.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-emerald-400 uppercase tracking-wide">Created ({scResult.created.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.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>
)}
{scResult.updated.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Updated ({scResult.updated.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.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>
)}
{scResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped ({scResult.skipped.length})</p>
<div className="flex flex-wrap gap-1.5">
{scResult.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>
)}
{scResult.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 ({scResult.errors.length})
</p>
{scResult.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={handleScReset}>Import Another</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>Back to Project</Button>
</div>
</div>
)}
</div>
{/* ── PICTURE TRACKER PANEL ─────────────────────────────────────── */}
<div className="pt-6 border-t border-zinc-800 space-y-4">
<div>
<h2 className="text-base font-semibold text-white">Import Picture Tracker Timecodes</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Paste a picture tracker CSV to update <span className="font-mono text-zinc-300">seqTimecodeStart</span> / <span className="font-mono text-zinc-300">seqTimecodeEnd</span> on existing shots only.
</p>
</div>
{/* PT STEP 1: Input */}
{ptStep === "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={ptFileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={handlePtFileUpload}
/>
<Button
variant="outline"
size="sm"
className="gap-1.5 h-7"
onClick={() => ptFileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5" />
Upload .csv
</Button>
</div>
</div>
<Textarea
value={ptCsvText}
onChange={(e) => setPtCsvText(e.target.value)}
placeholder={`#,Thumbnail,Shot Name,TC In,TC Out,Duration,...\n1,,UNG_106_004_010,01:00:37:16,01:00:53:01,00:00:15:09,...`}
className="font-mono text-xs min-h-[200px] 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">Shot Name</span>, <span className="font-mono text-amber-400">TC In</span>, <span className="font-mono text-amber-400">TC Out</span></p>
<p>Only shots that already exist in this project will be updated. Duplicate shot rows are ignored (first TC In/Out wins).</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handlePtParse} disabled={!ptCsvText.trim()} className="gap-2">
<Film className="h-4 w-4" />
Parse &amp; Preview
</Button>
</div>
</div>
)}
{/* PT STEP 2: Preview */}
{ptStep === "preview" && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<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" />
{ptRows.filter((r) => r.exists).length} to update
</div>
{ptRows.filter((r) => !r.exists).length > 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" />
{ptRows.filter((r) => !r.exists).length} not found (will skip)
</div>
)}
</div>
{ptErrors.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" /> {ptErrors.length} parse warning{ptErrors.length !== 1 ? "s" : ""}
</p>
{ptErrors.map((e, i) => <p key={i} className="text-xs text-red-300 pl-5">{e}</p>)}
</div>
)}
<div className="rounded-xl border border-zinc-800 overflow-hidden">
<div className="grid grid-cols-[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>Shot Code</span>
<span>Seq TC In</span>
<span>Seq TC Out</span>
<span>Status</span>
</div>
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
{ptRows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_1fr_auto] items-center gap-4 px-4 py-3 text-sm">
<span className="font-mono text-xs text-zinc-200">{row.shotCode}</span>
<span className="font-mono text-xs text-zinc-300">{row.seqTimecodeStart}</span>
<span className="font-mono text-xs text-zinc-300">{row.seqTimecodeEnd}</span>
{row.exists ? (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-blue-500/10 text-blue-400 border-blue-500/20">
<Pencil className="h-3 w-3" /> update
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border bg-zinc-500/10 text-zinc-400 border-zinc-500/20">
<SkipForward className="h-3 w-3" /> skip
</span>
)}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handlePtReset}>
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
</Button>
<Button
onClick={handlePtImport}
disabled={ptImporting || ptRows.filter((r) => r.exists).length === 0}
className="gap-2"
>
{ptImporting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{ptImporting
? "Updating…"
: `Update ${ptRows.filter((r) => r.exists).length} shot${ptRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
)}
{/* PT STEP 3: Result */}
{ptStep === "result" && ptResult && (
<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" />
Timecode update complete
</h2>
{ptResult.updated.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">
Updated ({ptResult.updated.length})
</p>
<div className="flex flex-wrap gap-1.5">
{ptResult.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>
)}
{ptResult.skipped.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">
Skipped / not found ({ptResult.skipped.length})
</p>
<div className="flex flex-wrap gap-1.5">
{ptResult.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>
)}
{ptResult.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 ({ptResult.errors.length})
</p>
{ptResult.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={handlePtReset}>
Import Another
</Button>
<Button onClick={() => router.push(`/projects/${projectId}`)}>
Back to Project
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
}