Files
twotalesanimation 0fbe856dce Initial commit
2026-05-19 22:20:29 +02:00

288 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { importShotsFromCsv } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, AlertCircle, CheckCircle2, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface ParsedRow {
scene: string;
episode?: string;
description?: string;
priority?: string;
fps?: number;
frameStart?: number;
frameEnd?: number;
}
interface ImportShotsDialogProps {
projectId: string;
projectType?: "STANDARD" | "EPISODIC";
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const TEMPLATE_STANDARD = `scene,description,priority,fps,frameStart,frameEnd
010,Opening wide shot,NORMAL,24,1001,1100
020,Close up reaction,NORMAL,24,,
030,Action sequence,HIGH,24,1001,1250`;
const TEMPLATE_EPISODIC = `scene,episode,description,priority,fps,frameStart,frameEnd
010,EP01,Opening wide shot,NORMAL,24,1001,1100
020,EP01,Close up reaction,NORMAL,24,,
010,EP02,Act two opener,HIGH,24,1001,1200`;
function parseCsv(raw: string): { rows: ParsedRow[]; parseErrors: string[] } {
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) return { rows: [], parseErrors: ["Need at least a header row and one data row"] };
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const sceneIdx = headers.indexOf("scene");
if (sceneIdx === -1) return { rows: [], parseErrors: ['Missing required "scene" column'] };
const idx = (name: string) => {
const i = headers.indexOf(name);
return i === -1 ? null : i;
};
const rows: ParsedRow[] = [];
const parseErrors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",").map((c) => c.trim());
const get = (name: string) => {
const j = idx(name);
return j !== null ? cells[j] ?? "" : "";
};
const scene = get("scene");
if (!scene) { parseErrors.push(`Row ${i + 1}: empty scene — skipped`); continue; }
const fpsRaw = get("fps");
const fsRaw = get("framestart") || get("frameStart");
const feRaw = get("frameend") || get("frameEnd");
rows.push({
scene,
episode: get("episode") || undefined,
description: get("description") || undefined,
priority: get("priority") || undefined,
fps: fpsRaw ? Number(fpsRaw) : undefined,
frameStart: fsRaw ? Number(fsRaw) : undefined,
frameEnd: feRaw ? Number(feRaw) : undefined,
});
}
return { rows, parseErrors };
}
export function ImportShotsDialog({
projectId,
projectType = "STANDARD",
open,
onClose,
onSuccess,
}: ImportShotsDialogProps) {
const { toast } = useToast();
const [step, setStep] = useState<"input" | "preview">("input");
const [csvText, setCsvText] = useState("");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<{ created: string[]; errors: string[] } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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);
};
const handleParse = () => {
const { rows, parseErrors: errs } = parseCsv(csvText);
setParsedRows(rows);
setParseErrors(errs);
if (rows.length > 0) setStep("preview");
};
const handleImport = async () => {
setIsImporting(true);
try {
const result = await importShotsFromCsv(projectId, parsedRows);
setImportResult(result);
if (result.created.length > 0) {
toast({ title: `${result.created.length} shot${result.created.length === 1 ? "" : "s"} created` });
onSuccess?.();
}
} catch (e) {
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsImporting(false);
}
};
const handleClose = () => {
setStep("input");
setCsvText("");
setParsedRows([]);
setParseErrors([]);
setImportResult(null);
onClose();
};
const template = projectType === "EPISODIC" ? TEMPLATE_EPISODIC : TEMPLATE_STANDARD;
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Import Shots from CSV</DialogTitle>
</DialogHeader>
{importResult ? (
/* Result screen */
<div className="space-y-4 py-2">
{importResult.created.length > 0 && (
<div className="space-y-1.5">
<p className="flex items-center gap-2 text-sm font-medium text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
{importResult.created.length} shot{importResult.created.length === 1 ? "" : "s"} created
</p>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs font-mono text-zinc-300 max-h-40 overflow-y-auto">
{importResult.created.map((code) => <div key={code}>{code}</div>)}
</div>
</div>
)}
{importResult.errors.length > 0 && (
<div className="space-y-1.5">
<p className="flex items-center gap-2 text-sm font-medium text-amber-400">
<AlertCircle className="h-4 w-4" />
{importResult.errors.length} warning{importResult.errors.length === 1 ? "" : "s"}
</p>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs text-zinc-400 max-h-32 overflow-y-auto space-y-0.5">
{importResult.errors.map((e, i) => <div key={i}>{e}</div>)}
</div>
</div>
)}
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</div>
) : step === "input" ? (
/* CSV input step */
<div className="space-y-4 py-2">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Paste CSV or upload a file. Required column: <code className="text-xs bg-zinc-800 px-1 rounded">scene</code>.
{projectType === "EPISODIC" && (
<> Also required: <code className="text-xs bg-zinc-800 px-1 rounded">episode</code>.</>
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5 shrink-0"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5" />
Upload file
</Button>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileUpload} />
</div>
<Textarea
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
className="font-mono text-xs min-h-[180px]"
placeholder={template}
spellCheck={false}
/>
{parseErrors.length > 0 && (
<div className="text-xs text-amber-400 space-y-0.5">
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
</div>
)}
<details className="text-xs text-zinc-500 cursor-pointer">
<summary className="hover:text-zinc-300 transition-colors">CSV format & example</summary>
<pre className="mt-2 bg-zinc-900 border border-zinc-800 rounded p-3 text-zinc-400 whitespace-pre-wrap">{template}</pre>
</details>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button onClick={handleParse} disabled={!csvText.trim()} className="gap-1.5">
Parse
<ChevronRight className="h-4 w-4" />
</Button>
</DialogFooter>
</div>
) : (
/* Preview step */
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
{parsedRows.length} shot{parsedRows.length === 1 ? "" : "s"} ready to import. Review and confirm.
</p>
<div className="rounded-lg border border-zinc-800 overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-zinc-900 text-zinc-400">
<tr>
<th className="px-3 py-2 text-left font-medium">Scene</th>
{projectType === "EPISODIC" && <th className="px-3 py-2 text-left font-medium">Episode</th>}
<th className="px-3 py-2 text-left font-medium">Description</th>
<th className="px-3 py-2 text-left font-medium">Priority</th>
<th className="px-3 py-2 text-left font-medium">FPS</th>
<th className="px-3 py-2 text-left font-medium">Frames</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{parsedRows.map((row, i) => (
<tr key={i} className={cn("bg-zinc-950", i % 2 === 0 && "bg-zinc-900/30")}>
<td className="px-3 py-2 font-mono text-zinc-200">{row.scene}</td>
{projectType === "EPISODIC" && <td className="px-3 py-2 font-mono text-zinc-400">{row.episode ?? "—"}</td>}
<td className="px-3 py-2 text-zinc-400 truncate max-w-[160px]">{row.description ?? "—"}</td>
<td className="px-3 py-2 text-zinc-400">{row.priority ?? "NORMAL"}</td>
<td className="px-3 py-2 text-zinc-400">{row.fps ?? 24}</td>
<td className="px-3 py-2 text-zinc-400 font-mono">
{row.frameStart || row.frameEnd ? `${row.frameStart ?? "?"}${row.frameEnd ?? "?"}` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{parseErrors.length > 0 && (
<div className="text-xs text-amber-400 space-y-0.5">
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep("input")}>Back</Button>
<Button onClick={handleImport} disabled={isImporting || parsedRows.length === 0}>
{isImporting ? "Importing…" : `Import ${parsedRows.length} Shot${parsedRows.length === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}