CSV TC import
Deploy / deploy (push) Successful in 3m9s

This commit is contained in:
twotalesanimation
2026-06-14 14:12:45 +02:00
parent 1bdb147d24
commit 8caf0f9bd1
6 changed files with 395 additions and 3 deletions
@@ -18,9 +18,9 @@ import {
Pencil,
Film,
} from "lucide-react";
import { parseEdlCsv } from "@/lib/edl-utils";
import type { EdlImportRow } from "@/lib/edl-utils";
import { importShotsFromEdl } from "@/actions/shots";
import { parseEdlCsv, parsePictureTrackerCsv } from "@/lib/edl-utils";
import type { EdlImportRow, PictureTrackerRow } from "@/lib/edl-utils";
import { importShotsFromEdl, updateShotsSeqTimecodes } from "@/actions/shots";
interface EdlImportClientProps {
projectId: string;
@@ -54,6 +54,15 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
const [importing, setImporting] = useState(false);
const [result, setResult] = 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;
@@ -119,6 +128,54 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
setResult(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,
@@ -368,6 +425,194 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
</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>
);