@@ -19,9 +19,9 @@ import {
|
||||
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";
|
||||
import { parseEdlCsv, parsePictureTrackerCsv, parseSimpleCsv, parseGroupAssignCsv } from "@/lib/edl-utils";
|
||||
import type { EdlImportRow, PictureTrackerRow, SimpleCsvRow, GroupAssignRow } from "@/lib/edl-utils";
|
||||
import { importShotsFromEdl, updateShotsSeqTimecodes, importShotsFromSimpleCsv, assignShotGroups } from "@/actions/shots";
|
||||
|
||||
interface EdlImportClientProps {
|
||||
projectId: string;
|
||||
@@ -64,6 +64,15 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
const [scImporting, setScImporting] = useState(false);
|
||||
const [scResult, setScResult] = useState<{ created: string[]; updated: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||
|
||||
// ── Group Assignment state ───────────────────────────────────────────────
|
||||
const gaFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [gaStep, setGaStep] = useState<"input" | "preview" | "result">("input");
|
||||
const [gaCsvText, setGaCsvText] = useState("");
|
||||
const [gaRows, setGaRows] = useState<GroupAssignRow[]>([]);
|
||||
const [gaErrors, setGaErrors] = useState<string[]>([]);
|
||||
const [gaAssigning, setGaAssigning] = useState(false);
|
||||
const [gaResult, setGaResult] = useState<{ assigned: string[]; skipped: string[]; errors: string[] } | null>(null);
|
||||
|
||||
// ── Picture Tracker state ─────────────────────────────────────────────────
|
||||
const ptFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [ptStep, setPtStep] = useState<"input" | "preview" | "result">("input");
|
||||
@@ -193,6 +202,47 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
setScResult(null);
|
||||
};
|
||||
|
||||
// ── Group Assignment handlers ────────────────────────────────────────────
|
||||
const handleGaFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setGaCsvText(ev.target?.result as string ?? "");
|
||||
reader.readAsText(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleGaParse = useCallback(() => {
|
||||
const { rows: parsed, errors } = parseGroupAssignCsv(gaCsvText, existingShotCodes);
|
||||
setGaErrors(errors);
|
||||
setGaRows(parsed);
|
||||
if (parsed.length > 0) setGaStep("preview");
|
||||
}, [gaCsvText, existingShotCodes]);
|
||||
|
||||
const handleGaAssign = async () => {
|
||||
setGaAssigning(true);
|
||||
try {
|
||||
const res = await assignShotGroups(projectId, gaRows);
|
||||
setGaResult(res);
|
||||
setGaStep("result");
|
||||
if (res.assigned.length > 0) {
|
||||
toast({ title: "Groups assigned", description: `${res.assigned.length} shot${res.assigned.length !== 1 ? "s" : ""} updated` });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: "Assignment failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setGaAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGaReset = () => {
|
||||
setGaStep("input");
|
||||
setGaCsvText("");
|
||||
setGaRows([]);
|
||||
setGaErrors([]);
|
||||
setGaResult(null);
|
||||
};
|
||||
|
||||
// ── Picture Tracker handlers ──────────────────────────────────────────────
|
||||
const handlePtFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -572,18 +622,19 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
)}
|
||||
|
||||
<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">
|
||||
<div className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] 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>Group</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">
|
||||
<div key={i} className="grid grid-cols-[auto_1fr_1fr_1fr_1fr_1fr] items-center gap-4 px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => toggleScAction(i)}
|
||||
title="Click to cycle: create → update → skip"
|
||||
@@ -598,6 +649,7 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
<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-300 truncate">{row.group || <span className="text-zinc-600">none</span>}</span>
|
||||
<span className="text-xs text-zinc-500 truncate">{row.description || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -678,6 +730,166 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── GROUP ASSIGNMENT PANEL ─────────────────────────────────────── */}
|
||||
<div className="pt-6 border-t border-zinc-800 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Assign Shots to Groups</h2>
|
||||
<p className="text-sm text-zinc-500 mt-0.5">
|
||||
Bulk-assign existing shots to groups from a CSV with <span className="font-mono text-zinc-300">Shot Name</span> and <span className="font-mono text-zinc-300">Group</span> columns. Groups are created automatically if they don’t exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{gaStep === "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={gaFileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleGaFileUpload} />
|
||||
<Button variant="outline" size="sm" className="gap-1.5 h-7" onClick={() => gaFileInputRef.current?.click()}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Upload .csv
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={gaCsvText}
|
||||
onChange={(e) => setGaCsvText(e.target.value)}
|
||||
placeholder={`Shot Name,Group\nUNG_108_001_010,Action Sequences\nUNG_108_002_020,Compositing`}
|
||||
className="font-mono text-xs min-h-[180px] 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>, <span className="font-mono text-amber-400">Group</span></p>
|
||||
<p>Only shots that already exist in this project are updated. Unknown shots are skipped.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleGaParse} disabled={!gaCsvText.trim()} className="gap-2">
|
||||
<Film className="h-4 w-4" />
|
||||
Parse & Preview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gaStep === "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" />
|
||||
{gaRows.filter((r) => r.exists).length} to assign
|
||||
</div>
|
||||
{gaRows.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" />
|
||||
{gaRows.filter((r) => !r.exists).length} not found (will skip)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gaErrors.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" /> {gaErrors.length} parse warning{gaErrors.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
{gaErrors.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_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>Group</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 bg-zinc-950/40 max-h-[400px] overflow-y-auto">
|
||||
{gaRows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[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="text-xs text-zinc-300">{row.groupName}</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" /> assign
|
||||
</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={handleGaReset}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1.5" /> Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGaAssign}
|
||||
disabled={gaAssigning || gaRows.filter((r) => r.exists).length === 0}
|
||||
className="gap-2"
|
||||
>
|
||||
{gaAssigning ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{gaAssigning
|
||||
? "Assigning…"
|
||||
: `Assign ${gaRows.filter((r) => r.exists).length} shot${gaRows.filter((r) => r.exists).length !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gaStep === "result" && gaResult && (
|
||||
<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" />
|
||||
Group assignment complete
|
||||
</h2>
|
||||
{gaResult.assigned.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-blue-400 uppercase tracking-wide">Assigned ({gaResult.assigned.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gaResult.assigned.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>
|
||||
)}
|
||||
{gaResult.skipped.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">Skipped / not found ({gaResult.skipped.length})</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gaResult.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>
|
||||
)}
|
||||
{gaResult.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 ({gaResult.errors.length})
|
||||
</p>
|
||||
{gaResult.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={handleGaReset}>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>
|
||||
|
||||
Reference in New Issue
Block a user