Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9deb437c5 | |||
| a3be3489de | |||
| cc89415a29 | |||
| 6b15bae62a | |||
| 7b36329769 | |||
| d2c91e7b65 | |||
| 852081b4d6 | |||
| 98120f3ca8 | |||
| 5f3c89119a | |||
| c727795a78 |
@@ -31,3 +31,6 @@ yarn-error.log*
|
||||
.vercel
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Local tooling
|
||||
RenderWorker/
|
||||
|
||||
@@ -951,6 +951,154 @@ export async function updateShotsSeqTimecodes(
|
||||
return { updated, skipped, errors };
|
||||
}
|
||||
|
||||
// ── Simple CSV shot import ────────────────────────────────────────────────────
|
||||
|
||||
export interface SimpleCsvRow {
|
||||
shotCode: string;
|
||||
seqTimecodeStart: string;
|
||||
seqTimecodeEnd: string;
|
||||
description: string;
|
||||
group: string;
|
||||
action: "create" | "update" | "skip";
|
||||
}
|
||||
|
||||
export async function importShotsFromSimpleCsv(
|
||||
projectId: string,
|
||||
rows: SimpleCsvRow[]
|
||||
): 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 {
|
||||
const existing = await db.shot.findFirst({
|
||||
where: { projectId, shotCode: row.shotCode },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const shotGroupId = row.group?.trim()
|
||||
? (await db.shotGroup.upsert({
|
||||
where: { projectId_name: { projectId, name: row.group.trim() } },
|
||||
create: { projectId, name: row.group.trim() },
|
||||
update: {},
|
||||
})).id
|
||||
: undefined;
|
||||
await db.shot.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
description: row.description || null,
|
||||
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||
...(shotGroupId ? { shotGroupId } : {}),
|
||||
},
|
||||
});
|
||||
updated.push(row.shotCode);
|
||||
} else if (row.action === "create") {
|
||||
const parts = row.shotCode.split("_");
|
||||
const scene = parts.length >= 3 ? parts[2] : (parts[1] ?? "000");
|
||||
const episode = parts.length >= 4 ? 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;
|
||||
const shotGroupId = row.group?.trim()
|
||||
? (await db.shotGroup.upsert({
|
||||
where: { projectId_name: { projectId, name: row.group.trim() } },
|
||||
create: { projectId, name: row.group.trim() },
|
||||
update: {},
|
||||
})).id
|
||||
: undefined;
|
||||
|
||||
await db.shot.create({
|
||||
data: {
|
||||
projectId,
|
||||
shotCode: row.shotCode,
|
||||
scene,
|
||||
episode,
|
||||
shotNumber,
|
||||
description: row.description || null,
|
||||
seqTimecodeStart: row.seqTimecodeStart || null,
|
||||
seqTimecodeEnd: row.seqTimecodeEnd || null,
|
||||
shotGroupId,
|
||||
},
|
||||
});
|
||||
created.push(row.shotCode);
|
||||
} else {
|
||||
// action === "update" but shot not found
|
||||
skipped.push(row.shotCode);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
return { created, updated, skipped, errors };
|
||||
}
|
||||
|
||||
// ── Group Assignment ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GroupAssignRow {
|
||||
shotCode: string;
|
||||
groupName: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export async function assignShotGroups(
|
||||
projectId: string,
|
||||
rows: GroupAssignRow[]
|
||||
): Promise<{ assigned: 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 assigned: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.exists) { skipped.push(row.shotCode); continue; }
|
||||
try {
|
||||
const shot = await db.shot.findFirst({
|
||||
where: { projectId, shotCode: row.shotCode },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!shot) { skipped.push(row.shotCode); continue; }
|
||||
|
||||
const group = await db.shotGroup.upsert({
|
||||
where: { projectId_name: { projectId, name: row.groupName.trim() } },
|
||||
create: { projectId, name: row.groupName.trim() },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await db.shot.update({
|
||||
where: { id: shot.id },
|
||||
data: { shotGroupId: group.id },
|
||||
});
|
||||
assigned.push(row.shotCode);
|
||||
} catch (e) {
|
||||
errors.push(`${row.shotCode}: ${e instanceof Error ? e.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
return { assigned, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: handle client requesting changes on a shot.
|
||||
* Resets shotApprovalStatus = PENDING, sharedWithClient = false.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function DashboardError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// After a redeployment the old chunk URLs 404 — force a hard reload to pick up new manifest
|
||||
if (error?.name === "ChunkLoadError") {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
if (error?.name === "ChunkLoadError") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 text-zinc-400">
|
||||
<AlertTriangle className="w-10 h-10 text-amber-500" />
|
||||
<p className="text-lg font-medium text-zinc-200">Something went wrong</p>
|
||||
{error?.message && (
|
||||
<p className="text-sm text-zinc-500 max-w-md text-center">{error.message}</p>
|
||||
)}
|
||||
<Button variant="outline" onClick={reset} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
FilePlus2,
|
||||
Pencil,
|
||||
Film,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { parseEdlCsv, parsePictureTrackerCsv } from "@/lib/edl-utils";
|
||||
import type { EdlImportRow, PictureTrackerRow } from "@/lib/edl-utils";
|
||||
import { importShotsFromEdl, updateShotsSeqTimecodes } 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;
|
||||
@@ -54,6 +55,24 @@ 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);
|
||||
|
||||
// ── 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);
|
||||
|
||||
// ── 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");
|
||||
@@ -128,6 +147,102 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
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);
|
||||
};
|
||||
|
||||
// ── 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];
|
||||
@@ -426,6 +541,355 @@ export function EdlImportClient({ projectId, projectName, existingShotCodes }: E
|
||||
</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 & 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_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_1fr_1fr] 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-300 truncate">{row.group || <span className="text-zinc-600">none</span>}</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>
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
@@ -7,10 +7,16 @@ import { Input } from "@/components/ui/input";
|
||||
import { ProjectCard } from "@/components/projects/ProjectCard";
|
||||
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
||||
import { Plus, Search, Loader2 } from "lucide-react";
|
||||
import { HIDE_ARCHIVED_KEY } from "@/components/settings/HideArchivedToggle";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [hideArchived, setHideArchived] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHideArchived(localStorage.getItem(HIDE_ARCHIVED_KEY) === "1");
|
||||
}, []);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["projects", search],
|
||||
@@ -35,6 +41,8 @@ export default function ProjectsPage() {
|
||||
const projects = data?.projects ?? [];
|
||||
const clients = clientsData?.clients ?? [];
|
||||
|
||||
const visibleProjects = hideArchived ? projects.filter((p: any) => p.status !== "ARCHIVED") : projects;
|
||||
|
||||
const SCROLL_KEY = 'projects-scroll';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,6 +64,9 @@ export default function ProjectsPage() {
|
||||
<h1 className="text-3xl font-bold text-white">Projects</h1>
|
||||
<p className="text-zinc-400 mt-1">
|
||||
{projects.length} project{projects.length !== 1 ? "s" : ""}
|
||||
{hideArchived && projects.length !== visibleProjects.length && (
|
||||
<span className="text-zinc-600 ml-1">({projects.length - visibleProjects.length} archived hidden)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowNew(true)} className="gap-2">
|
||||
@@ -94,7 +105,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{projects.map((project) => (
|
||||
{visibleProjects.map((project: any) => (
|
||||
<div key={project.id} onClick={saveScroll}>
|
||||
<ProjectCard project={project} />
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getInitials } from "@/lib/utils";
|
||||
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
|
||||
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
|
||||
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
|
||||
import { HideArchivedToggle } from "@/components/settings/HideArchivedToggle";
|
||||
import { getHetznerConfig } from "@/actions/settings";
|
||||
|
||||
export const metadata = { title: "Settings" };
|
||||
@@ -42,6 +43,15 @@ export default async function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Display Preferences</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HideArchivedToggle />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
|
||||
|
||||
{isAdmin && hetznerConfig && (
|
||||
|
||||
@@ -55,6 +55,7 @@ type ShotRow = {
|
||||
description: string | null;
|
||||
isKeyShot: boolean;
|
||||
artist: { id: string; name: string | null; image: string | null; email: string } | null;
|
||||
shotGroup: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
interface Project {
|
||||
@@ -490,6 +491,9 @@ export function ShotStatusClient({
|
||||
}, []);
|
||||
|
||||
const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(new Set());
|
||||
const [expandedScenes, setExpandedScenes] = useState<Set<string>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load saved episode expand state when project changes
|
||||
@@ -501,6 +505,12 @@ export function ShotStatusClient({
|
||||
} catch {
|
||||
setExpandedEpisodes(new Set());
|
||||
}
|
||||
try {
|
||||
const savedGroup = localStorage.getItem(`shotStatus:${selectedProjectId}:groupMode`);
|
||||
setGroupMode((savedGroup as "flat" | "scene" | "group") ?? "flat");
|
||||
} catch {
|
||||
setGroupMode("flat");
|
||||
}
|
||||
}, [selectedProjectId]);
|
||||
const [dueDateDialogOpen, setDueDateDialogOpen] = useState(false);
|
||||
|
||||
@@ -516,6 +526,29 @@ export function ShotStatusClient({
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleScene = (sc: string) =>
|
||||
setExpandedScenes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(sc) ? next.delete(sc) : next.add(sc);
|
||||
return next;
|
||||
});
|
||||
|
||||
const toggleGroup = (g: string) =>
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(g) ? next.delete(g) : next.add(g);
|
||||
return next;
|
||||
});
|
||||
|
||||
const handleGroupMode = (mode: "flat" | "scene" | "group") => {
|
||||
setGroupMode(mode);
|
||||
if (mode === "scene") setExpandedScenes(new Set(shots.map((s) => s.scene)));
|
||||
if (mode === "group") setExpandedGroups(new Set(shots.map((s) => s.shotGroup?.id ?? "")));
|
||||
if (selectedProjectId) {
|
||||
try { localStorage.setItem(`shotStatus:${selectedProjectId}:groupMode`, mode); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectChange = (id: string) => {
|
||||
localStorage.setItem("shotStatus:lastProjectId", id);
|
||||
setSelectedIds(new Set());
|
||||
@@ -567,6 +600,31 @@ export function ShotStatusClient({
|
||||
})()
|
||||
: [];
|
||||
|
||||
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
|
||||
? (() => {
|
||||
const map = new Map<string, ShotRow[]>();
|
||||
for (const shot of shots) {
|
||||
const key = shot.scene || "(No Scene)";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(shot);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
})()
|
||||
: [];
|
||||
|
||||
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
|
||||
? (() => {
|
||||
const map = new Map<string, [string, ShotRow[]]>();
|
||||
for (const shot of shots) {
|
||||
const id = shot.shotGroup?.id ?? "";
|
||||
const name = shot.shotGroup?.name ?? "(No Group)";
|
||||
if (!map.has(id)) map.set(id, [name, []]);
|
||||
map.get(id)![1].push(shot);
|
||||
}
|
||||
return Array.from(map.entries()).map(([id, [name, s]]) => [id, name, s] as [string, string, ShotRow[]]);
|
||||
})()
|
||||
: [];
|
||||
|
||||
const episodeDueDateMap = new Map(
|
||||
episodeDueDates.map((e) => [e.episode, new Date(e.dueDate)])
|
||||
);
|
||||
@@ -605,6 +663,25 @@ export function ShotStatusClient({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedProject && !isEpisodic && (
|
||||
<>
|
||||
<span className="text-sm text-zinc-500">Group by:</span>
|
||||
{(["flat", "scene", "group"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => handleGroupMode(mode)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded text-xs font-medium transition-colors",
|
||||
groupMode === mode
|
||||
? "bg-zinc-700 text-zinc-100"
|
||||
: "text-zinc-500 hover:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{mode === "flat" ? "None" : mode === "scene" ? "Scene" : "Group"}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No project selected */}
|
||||
@@ -727,6 +804,104 @@ export function ShotStatusClient({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : groupMode === "scene" ? (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{sceneGroups.map(([scene, sceneShots]) => {
|
||||
const collapsed = !expandedScenes.has(scene);
|
||||
const sceneIds = sceneShots.map((s) => s.id);
|
||||
const allScSelected = sceneIds.every((id) => selectedIds.has(id));
|
||||
const someScSelected = sceneIds.some((id) => selectedIds.has(id));
|
||||
return (
|
||||
<div key={scene}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
|
||||
{canManage && (
|
||||
<IndeterminateCheckbox
|
||||
checked={allScSelected}
|
||||
indeterminate={someScSelected && !allScSelected}
|
||||
onChange={(e) => toggleMany(sceneIds, e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleScene(scene)}
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-white">Scene {scene}</span>
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{sceneShots.length} shot{sceneShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
shots={sceneShots}
|
||||
canManage={canManage}
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : groupMode === "group" ? (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{shotGroupGroups.map(([groupId, groupName, groupShots]) => {
|
||||
const collapsed = !expandedGroups.has(groupId);
|
||||
const groupIds = groupShots.map((s) => s.id);
|
||||
const allGrSelected = groupIds.every((id) => selectedIds.has(id));
|
||||
const someGrSelected = groupIds.some((id) => selectedIds.has(id));
|
||||
return (
|
||||
<div key={groupId}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-zinc-800/60 hover:bg-zinc-800/90 transition-colors">
|
||||
{canManage && (
|
||||
<IndeterminateCheckbox
|
||||
checked={allGrSelected}
|
||||
indeterminate={someGrSelected && !allGrSelected}
|
||||
onChange={(e) => toggleMany(groupIds, e.target.checked)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleGroup(groupId)}
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-sm text-white">{groupName}</span>
|
||||
<span className="text-xs text-zinc-500 font-normal">
|
||||
{groupShots.length} shot{groupShots.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
shots={groupShots}
|
||||
canManage={canManage}
|
||||
projectId={selectedProjectId!}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggleShot}
|
||||
onToggleAll={toggleMany}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<ShotTable
|
||||
|
||||
@@ -41,6 +41,7 @@ async function getShotsForProject(projectId: string) {
|
||||
description: true,
|
||||
isKeyShot: true,
|
||||
artist: { select: { id: true, name: true, image: true, email: true } },
|
||||
shotGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]);
|
||||
|
||||
export interface ThumbnailPreviewItem {
|
||||
fileName: string;
|
||||
stemName: string;
|
||||
shotCode: string | null;
|
||||
shotId: string | null;
|
||||
status: "match" | "no-match";
|
||||
currentThumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
function stem(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
return (dot > 0 ? fileName.slice(0, dot) : fileName).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/thumbnails
|
||||
* Body: { projectId: string; fileNames: string[] }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
let body: { projectId?: string; fileNames?: string[] };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectId, fileNames } = body;
|
||||
if (!projectId || !Array.isArray(fileNames)) {
|
||||
return NextResponse.json({ error: "projectId and fileNames are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Only image files
|
||||
const imageFiles = fileNames.filter((f) => {
|
||||
const ext = f.split(".").pop()?.toLowerCase() ?? "";
|
||||
return IMAGE_EXTS.has(ext);
|
||||
});
|
||||
|
||||
const shots = await db.shot.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, shotCode: true, thumbnailUrl: true },
|
||||
});
|
||||
|
||||
// Build a lowercase map for case-insensitive matching
|
||||
const shotMap = new Map(shots.map((s) => [s.shotCode.toLowerCase(), s]));
|
||||
|
||||
const items: ThumbnailPreviewItem[] = fileNames.map((fileName) => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||
if (!IMAGE_EXTS.has(ext)) {
|
||||
return { fileName, stemName: stem(fileName), shotCode: null, shotId: null, status: "no-match" as const, currentThumbnailUrl: null };
|
||||
}
|
||||
const s = stem(fileName);
|
||||
const shot = shotMap.get(s) ?? null;
|
||||
return {
|
||||
fileName,
|
||||
stemName: s,
|
||||
shotCode: shot?.shotCode ?? null,
|
||||
shotId: shot?.id ?? null,
|
||||
status: shot ? "match" : "no-match",
|
||||
currentThumbnailUrl: shot?.thumbnailUrl ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ items });
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadToHetzner } from "@/lib/storage";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* POST /api/batch-upload/thumbnails/upload
|
||||
* FormData: { projectId, shotId, file (image) }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
const shotId = formData.get("shotId") as string | null;
|
||||
const projectId = formData.get("projectId") as string | null;
|
||||
|
||||
if (!file || !shotId || !projectId) {
|
||||
return NextResponse.json({ error: "file, shotId and projectId are required" }, { status: 400 });
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "File must be an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
const shot = await db.shot.findFirst({ where: { id: shotId, projectId }, select: { id: true } });
|
||||
if (!shot) return NextResponse.json({ error: "Shot not found" }, { status: 404 });
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const { key } = await uploadToHetzner(buffer, file.name, file.type, "image");
|
||||
const thumbnailUrl = `/api/files/${key}`;
|
||||
|
||||
await db.shot.update({ where: { id: shotId }, data: { thumbnailUrl } });
|
||||
revalidatePath(`/projects/${projectId}`);
|
||||
|
||||
return NextResponse.json({ success: true, thumbnailUrl });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Root-level error boundary — must include its own <html>/<body>
|
||||
export default function GlobalError({
|
||||
error,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (error?.name === "ChunkLoadError") {
|
||||
window.location.reload();
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body className="flex items-center justify-center min-h-screen bg-zinc-950 text-zinc-200">
|
||||
{error?.name === "ChunkLoadError" ? null : (
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-lg font-medium">Application error</p>
|
||||
{error?.digest && (
|
||||
<p className="text-sm text-zinc-500">Digest: {error.digest}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,12 @@ import {
|
||||
ChevronRight,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { PreviewItem, PreviewItemStatus } from "@/app/api/batch-upload/preview/route";
|
||||
import type { ThumbnailPreviewItem } from "@/app/api/batch-upload/thumbnails/route";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -54,7 +56,25 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
const [uploadComplete, setUploadComplete] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
// ── Thumbnail upload state ─────────────────────────────────────────────────
|
||||
const thumbInputRef = useRef<HTMLInputElement>(null);
|
||||
const [thumbFiles, setThumbFiles] = useState<File[]>([]);
|
||||
const [thumbIsDragging, setThumbIsDragging] = useState(false);
|
||||
const [thumbPreview, setThumbPreview] = useState<ThumbnailPreviewItem[] | null>(null);
|
||||
const [thumbLoadingPreview, setThumbLoadingPreview] = useState(false);
|
||||
const [thumbUploadStates, setThumbUploadStates] = useState<Record<string, UploadState>>({});
|
||||
const [thumbIsUploading, setThumbIsUploading] = useState(false);
|
||||
const [thumbUploadComplete, setThumbUploadComplete] = useState(false);
|
||||
|
||||
const THUMB_EXTS = new Set(["jpg", "jpeg", "png", "webp", "tiff", "tif", "avif"]);
|
||||
const acceptThumb = (f: File) => THUMB_EXTS.has(f.name.split(".").pop()?.toLowerCase() ?? "");
|
||||
|
||||
const resetThumbs = () => {
|
||||
setThumbFiles([]);
|
||||
setThumbPreview(null);
|
||||
setThumbUploadStates({});
|
||||
setThumbUploadComplete(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setFiles([]);
|
||||
@@ -242,6 +262,68 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
setUploadComplete(true);
|
||||
};
|
||||
|
||||
// ── Thumbnail handlers ─────────────────────────────────────────────────────
|
||||
|
||||
const handleThumbDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setThumbIsDragging(false);
|
||||
const dropped = Array.from(e.dataTransfer.files).filter(acceptThumb);
|
||||
if (dropped.length > 0) { setThumbFiles(dropped); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); }
|
||||
else toast({ title: "No image files", description: "Only JPG, PNG, WebP, TIFF images are accepted." });
|
||||
}, [toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleThumbInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files ?? []).filter(acceptThumb);
|
||||
if (selected.length > 0) { setThumbFiles(selected); setThumbPreview(null); setThumbUploadStates({}); setThumbUploadComplete(false); }
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const fetchThumbPreview = async () => {
|
||||
if (!projectId || thumbFiles.length === 0) return;
|
||||
setThumbLoadingPreview(true);
|
||||
try {
|
||||
const res = await fetch("/api/batch-upload/thumbnails", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectId, fileNames: thumbFiles.map((f) => f.name) }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Preview failed");
|
||||
const data = await res.json();
|
||||
setThumbPreview(data.items);
|
||||
const states: Record<string, UploadState> = {};
|
||||
for (const item of data.items as ThumbnailPreviewItem[]) states[item.fileName] = { status: "pending" };
|
||||
setThumbUploadStates(states);
|
||||
} catch {
|
||||
toast({ title: "Preview failed", variant: "destructive" });
|
||||
} finally {
|
||||
setThumbLoadingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startThumbUpload = async () => {
|
||||
if (!thumbPreview || !projectId) return;
|
||||
setThumbIsUploading(true);
|
||||
const matched = thumbPreview.filter((i) => i.status === "match");
|
||||
for (const item of matched) {
|
||||
const file = thumbFiles.find((f) => f.name === item.fileName);
|
||||
if (!file) continue;
|
||||
setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "uploading" } }));
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("shotId", item.shotId!);
|
||||
fd.append("projectId", projectId);
|
||||
const res = await fetch("/api/batch-upload/thumbnails/upload", { method: "POST", body: fd });
|
||||
if (!res.ok) { const d = await res.json().catch(() => ({ error: "Upload failed" })); throw new Error(d.error ?? "Upload failed"); }
|
||||
setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "success" } }));
|
||||
} catch (err) {
|
||||
setThumbUploadStates((prev) => ({ ...prev, [item.fileName]: { status: "error", error: err instanceof Error ? err.message : "Upload failed" } }));
|
||||
}
|
||||
}
|
||||
setThumbIsUploading(false);
|
||||
setThumbUploadComplete(true);
|
||||
};
|
||||
|
||||
// ── Derived counts ─────────────────────────────────────────────────────────
|
||||
|
||||
const uploadable = preview?.filter(
|
||||
@@ -474,6 +556,187 @@ export function BatchUploadClient({ projects }: BatchUploadClientProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Thumbnail bulk upload ─────────────────────────────────────────── */}
|
||||
{projectId && (
|
||||
<>
|
||||
<div className="border-t border-zinc-800 pt-2">
|
||||
<h2 className="text-base font-semibold text-white">Bulk Thumbnail Upload</h2>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Drop images here to assign thumbnails to existing shots. Files are matched by filename (without extension) to shot codes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Thumb drop zone */}
|
||||
{!thumbUploadComplete && (
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setThumbIsDragging(true); }}
|
||||
onDragLeave={() => setThumbIsDragging(false)}
|
||||
onDrop={handleThumbDrop}
|
||||
onClick={() => thumbInputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-colors select-none",
|
||||
thumbIsDragging
|
||||
? "border-blue-400 bg-blue-400/5"
|
||||
: "border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800/30"
|
||||
)}
|
||||
>
|
||||
<ImageIcon className="h-9 w-9 text-zinc-500 mx-auto mb-3" />
|
||||
<p className="text-zinc-300 font-medium">Drop thumbnail images here</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">JPG, PNG, WebP, TIFF · or click to browse</p>
|
||||
{thumbFiles.length > 0 && (
|
||||
<p className="text-blue-400 text-sm mt-3 font-medium">
|
||||
{thumbFiles.length} image{thumbFiles.length !== 1 ? "s" : ""} selected
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
ref={thumbInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleThumbInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{thumbFiles.length > 0 && !thumbPreview && (
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<Button onClick={fetchThumbPreview} disabled={thumbLoadingPreview}>
|
||||
{thumbLoadingPreview ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Loading preview…</>
|
||||
) : (
|
||||
<>Preview<ChevronRight className="h-4 w-4 ml-2" /></>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={resetThumbs} disabled={thumbLoadingPreview}>Clear</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Thumb preview table */}
|
||||
{thumbPreview && !thumbUploadComplete && (() => {
|
||||
const matched = thumbPreview.filter((i) => i.status === "match");
|
||||
const unmatched = thumbPreview.filter((i) => i.status === "no-match");
|
||||
const thumbSuccess = Object.values(thumbUploadStates).filter((s) => s.status === "success").length;
|
||||
const thumbErrors = Object.values(thumbUploadStates).filter((s) => s.status === "error").length;
|
||||
const thumbDone = thumbSuccess + thumbErrors;
|
||||
const thumbProgress = matched.length > 0 ? (thumbDone / matched.length) * 100 : 0;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-base font-medium text-zinc-200">
|
||||
Thumbnail preview —{" "}
|
||||
<span className="text-zinc-400 font-normal">
|
||||
{matched.length} matched · {unmatched.length} unmatched
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">File</th>
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-6 py-2.5">Matched Shot</th>
|
||||
<th className="text-left text-xs text-zinc-500 font-normal px-4 py-2.5 w-28">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{thumbPreview.map((item) => {
|
||||
const state = thumbUploadStates[item.fileName];
|
||||
return (
|
||||
<tr key={item.fileName} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||
<span className="text-zinc-200 font-mono text-xs truncate max-w-[240px]">{item.fileName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{item.shotCode ? (
|
||||
<span className="font-mono text-xs text-zinc-300">{item.shotCode}</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">no match</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{item.status === "no-match" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-zinc-500"><XCircle className="h-3.5 w-3.5" />skip</span>
|
||||
) : !state || state.status === "pending" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-blue-400"><ArrowRight className="h-3.5 w-3.5" />set thumbnail</span>
|
||||
) : state.status === "uploading" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-400"><Loader2 className="h-3.5 w-3.5 animate-spin" />uploading</span>
|
||||
) : state.status === "success" ? (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle2 className="h-3.5 w-3.5" />done</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-400" title={state.error}><XCircle className="h-3.5 w-3.5" />error</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{thumbIsUploading && (
|
||||
<div className="px-6 py-3 border-t border-zinc-800">
|
||||
<div className="flex justify-between text-xs text-zinc-400 mb-1.5">
|
||||
<span>Uploading {Math.min(thumbDone + 1, matched.length)} of {matched.length}…</span>
|
||||
<span>{Math.round(thumbProgress)}%</span>
|
||||
</div>
|
||||
<Progress value={thumbProgress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex items-center justify-between gap-4">
|
||||
<p className="text-zinc-500 text-xs">
|
||||
{matched.length} thumbnail{matched.length !== 1 ? "s" : ""} will be assigned
|
||||
{unmatched.length > 0 && <span className="text-zinc-600"> · {unmatched.length} skipped (no matching shot)</span>}
|
||||
</p>
|
||||
<div className="flex gap-3 shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={resetThumbs} disabled={thumbIsUploading}>Change files</Button>
|
||||
<Button size="sm" onClick={startThumbUpload} disabled={thumbIsUploading || matched.length === 0}>
|
||||
{thumbIsUploading ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Uploading…</>
|
||||
) : (
|
||||
`Upload ${matched.length} thumbnail${matched.length !== 1 ? "s" : ""}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Thumb done summary */}
|
||||
{thumbUploadComplete && (() => {
|
||||
const thumbSuccess = Object.values(thumbUploadStates).filter((s) => s.status === "success").length;
|
||||
const thumbErrors = Object.values(thumbUploadStates).filter((s) => s.status === "error").length;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-8 pb-8 flex flex-col items-center gap-4 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||
<div>
|
||||
<p className="text-white font-semibold text-lg">Thumbnails uploaded</p>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
{thumbSuccess} assigned{thumbErrors > 0 && <span className="text-red-400"> · {thumbErrors} failed</span>}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={resetThumbs} className="mt-2">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />Upload more thumbnails
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const HIDE_ARCHIVED_KEY = "app:hideArchivedProjects";
|
||||
|
||||
export function HideArchivedToggle() {
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(localStorage.getItem(HIDE_ARCHIVED_KEY) === "1");
|
||||
}, []);
|
||||
|
||||
const toggle = (val: boolean) => {
|
||||
setChecked(val);
|
||||
localStorage.setItem(HIDE_ARCHIVED_KEY, val ? "1" : "0");
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => toggle(e.target.checked)}
|
||||
className="cursor-pointer accent-blue-500 w-4 h-4 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Hide archived productions</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Archived projects won't appear on the Projects page</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -184,3 +184,145 @@ export function parsePictureTrackerCsv(
|
||||
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
// ── Simple Shot CSV ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface SimpleCsvRow {
|
||||
shotCode: string;
|
||||
seqTimecodeStart: string;
|
||||
seqTimecodeEnd: string;
|
||||
description: string;
|
||||
group: string;
|
||||
action: "create" | "update" | "skip";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a simple shot CSV: Shot Name, Time Code In, Time Code Out, Description.
|
||||
* Column names are matched case-insensitively with common aliases.
|
||||
* Timecodes are validated but not required.
|
||||
*/
|
||||
export function parseSimpleCsv(
|
||||
csvText: string,
|
||||
existingCodes: Set<string>
|
||||
): { rows: SimpleCsvRow[]; 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 rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
|
||||
const col = (names: string[]) => {
|
||||
for (const name of names) {
|
||||
const idx = rawHeaders.indexOf(name);
|
||||
if (idx !== -1) return idx;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
|
||||
const tcInIdx = col(["time code in", "timecode in", "tc in", "tcin", "timecode start", "tc start"]);
|
||||
const tcOutIdx = col(["time code out", "timecode out", "tc out", "tcout", "timecode end", "tc end"]);
|
||||
const descIdx = col(["description", "desc", "notes", "note"]);
|
||||
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
|
||||
|
||||
if (shotNameIdx === -1) {
|
||||
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
|
||||
}
|
||||
|
||||
const rows: SimpleCsvRow[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cells = parseCsvLine(lines[i]);
|
||||
const get = (idx: number) =>
|
||||
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
|
||||
|
||||
const shotCode = get(shotNameIdx);
|
||||
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
|
||||
|
||||
const tcIn = get(tcInIdx);
|
||||
const tcOut = get(tcOutIdx);
|
||||
|
||||
if (tcIn && !isValidTimecode(tcIn)) {
|
||||
errors.push(`Row ${i + 1} (${shotCode}): invalid TC In "${tcIn}" — skipped`);
|
||||
continue;
|
||||
}
|
||||
if (tcOut && !isValidTimecode(tcOut)) {
|
||||
errors.push(`Row ${i + 1} (${shotCode}): invalid TC Out "${tcOut}" — skipped`);
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
shotCode,
|
||||
seqTimecodeStart: tcIn,
|
||||
seqTimecodeEnd: tcOut,
|
||||
description: get(descIdx),
|
||||
group: get(groupIdx),
|
||||
action: existingCodes.has(shotCode) ? "update" : "create",
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
// ── Group Assignment CSV ──────────────────────────────────────────────────────
|
||||
|
||||
export interface GroupAssignRow {
|
||||
shotCode: string;
|
||||
groupName: string;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a group-assignment CSV: Shot Name, Group.
|
||||
* Only shots that already exist in the project will be updated.
|
||||
*/
|
||||
export function parseGroupAssignCsv(
|
||||
csvText: string,
|
||||
existingCodes: Set<string>
|
||||
): { rows: GroupAssignRow[]; 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 rawHeaders = parseCsvLine(lines[0]).map((h) => h.toLowerCase().trim());
|
||||
const col = (names: string[]) => {
|
||||
for (const name of names) {
|
||||
const idx = rawHeaders.indexOf(name);
|
||||
if (idx !== -1) return idx;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const shotNameIdx = col(["shot name", "shot code", "shotcode", "shot", "name"]);
|
||||
const groupIdx = col(["group", "shot group", "shotgroup", "group name"]);
|
||||
|
||||
if (shotNameIdx === -1) {
|
||||
return { rows: [], errors: ['CSV must contain a "Shot Name" (or "Shot Code") column'] };
|
||||
}
|
||||
if (groupIdx === -1) {
|
||||
return { rows: [], errors: ['CSV must contain a "Group" column'] };
|
||||
}
|
||||
|
||||
const rows: GroupAssignRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cells = parseCsvLine(lines[i]);
|
||||
const get = (idx: number) =>
|
||||
idx !== -1 && idx < cells.length ? (cells[idx] ?? "").trim() : "";
|
||||
|
||||
const shotCode = get(shotNameIdx);
|
||||
const groupName = get(groupIdx);
|
||||
if (!shotCode) { errors.push(`Row ${i + 1}: empty Shot Name — skipped`); continue; }
|
||||
if (!groupName) { errors.push(`Row ${i + 1} (${shotCode}): empty Group — skipped`); continue; }
|
||||
if (seen.has(shotCode)) continue;
|
||||
seen.add(shotCode);
|
||||
|
||||
rows.push({ shotCode, groupName, exists: existingCodes.has(shotCode) });
|
||||
}
|
||||
|
||||
return { rows, errors };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user