Shoot sketches templates
Deploy / deploy (push) Failing after 44s

This commit is contained in:
twotalesanimation
2026-07-12 12:27:21 +02:00
parent 4ae14cf8b2
commit 2f91c71307
7 changed files with 876 additions and 7 deletions
+10
View File
@@ -4,6 +4,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils"; import { getInitials } from "@/lib/utils";
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm"; import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm"; import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm";
import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection";
import { getHetznerConfig } from "@/actions/settings"; import { getHetznerConfig } from "@/actions/settings";
export const metadata = { title: "Settings" }; export const metadata = { title: "Settings" };
@@ -13,6 +14,7 @@ export default async function SettingsPage() {
if (!session?.user) return null; if (!session?.user) return null;
const isAdmin = session.user.role === "ADMIN"; const isAdmin = session.user.role === "ADMIN";
const canManageTemplates = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role ?? "");
const hetznerConfig = isAdmin ? await getHetznerConfig() : null; const hetznerConfig = isAdmin ? await getHetznerConfig() : null;
return ( return (
@@ -48,6 +50,14 @@ export default async function SettingsPage() {
<HetznerConfigForm initialConfig={hetznerConfig} /> <HetznerConfigForm initialConfig={hetznerConfig} />
</div> </div>
)} )}
{canManageTemplates && (
<Card>
<CardContent className="pt-6">
<SketchTemplatesSection />
</CardContent>
</Card>
)}
</div> </div>
); );
} }
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
function canManage(role: string) {
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
}
// DELETE /api/sketch-templates/[id]
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canManage(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const template = await db.sketchTemplate.findUnique({ where: { id } });
if (!template) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.sketchTemplate.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
// PATCH /api/sketch-templates/[id] — rename
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canManage(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const { name } = await req.json();
if (!name?.trim()) return NextResponse.json({ error: "Name required" }, { status: 400 });
const template = await db.sketchTemplate.update({
where: { id },
data: { name: name.trim() },
});
return NextResponse.json({
template: { ...template, fileSize: template.fileSize != null ? Number(template.fileSize) : null },
});
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadFile } from "@/lib/storage";
function canManage(role: string) {
return ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role);
}
// GET /api/sketch-templates — list all templates (any authenticated user)
export async function GET() {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const templates = await db.sketchTemplate.findMany({
orderBy: { sortOrder: "asc" },
});
return NextResponse.json({
templates: templates.map((t) => ({
...t,
fileSize: t.fileSize != null ? Number(t.fileSize) : null,
})),
});
}
// POST /api/sketch-templates — upload + create a new template
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canManage(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const formData = await req.formData();
const file = formData.get("file") as File | null;
const name = ((formData.get("name") as string) || "").trim();
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
if (!file.type.startsWith("image/"))
return NextResponse.json({ error: "Only image files accepted" }, { status: 400 });
const maxSize = 20 * 1024 * 1024; // 20 MB
if (file.size > maxSize)
return NextResponse.json({ error: "File too large (max 20 MB)" }, { status: 413 });
const buffer = Buffer.from(await file.arrayBuffer());
const uploaded = await uploadFile(buffer, file.name, file.type, "image");
const maxOrder = await db.sketchTemplate.aggregate({ _max: { sortOrder: true } });
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
const template = await db.sketchTemplate.create({
data: {
name: name || file.name.replace(/\.[^.]+$/, ""),
fileUrl: uploaded.url,
fileKey: uploaded.key,
fileName: file.name,
fileSize: BigInt(file.size),
sortOrder,
},
});
return NextResponse.json({
template: { ...template, fileSize: Number(template.fileSize) },
});
}
@@ -0,0 +1,198 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import Image from "next/image";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Loader2, Pencil, Check, LayoutTemplate } from "lucide-react";
interface SketchTemplate {
id: string;
name: string;
fileUrl: string;
fileName: string;
sortOrder: number;
}
export function SketchTemplatesSection() {
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const [templates, setTemplates] = useState<SketchTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [pendingName, setPendingName] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const fetchTemplates = useCallback(async () => {
try {
const res = await fetch("/api/sketch-templates");
if (res.ok) {
const data = await res.json();
setTemplates(data.templates);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
if (pendingName.trim()) fd.append("name", pendingName.trim());
const res = await fetch("/api/sketch-templates", { method: "POST", body: fd });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error ?? "Upload failed");
}
const { template } = await res.json();
setTemplates((prev) => [...prev, template]);
setPendingName("");
} catch (err) {
toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/sketch-templates/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Delete failed");
setTemplates((prev) => prev.filter((t) => t.id !== id));
} catch {
toast({ title: "Failed to delete template", variant: "destructive" });
}
};
const startEdit = (t: SketchTemplate) => {
setEditingId(t.id);
setEditName(t.name);
};
const saveEdit = async (id: string) => {
if (!editName.trim()) return;
try {
const res = await fetch(`/api/sketch-templates/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName }),
});
if (!res.ok) throw new Error("Rename failed");
setTemplates((prev) => prev.map((t) => t.id === id ? { ...t, name: editName.trim() } : t));
} catch {
toast({ title: "Failed to rename template", variant: "destructive" });
} finally {
setEditingId(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<LayoutTemplate className="h-4 w-4 text-amber-500" />
Sketch Templates
</div>
<Separator />
<p className="text-xs text-muted-foreground">
Upload background images (body diagrams, scene layouts, storyboard frames, etc.) that artists can load as a canvas background when sketching on a take.
</p>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading
</div>
) : (
<>
{/* Template grid */}
{templates.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{templates.map((t) => (
<div key={t.id} className="group relative rounded-lg border border-border overflow-hidden bg-zinc-900">
<div className="relative aspect-[4/3]">
<Image
src={t.fileUrl}
alt={t.name}
fill
className="object-cover"
sizes="180px"
/>
</div>
<div className="px-2 py-1.5 flex items-center gap-1">
{editingId === t.id ? (
<>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit(t.id);
if (e.key === "Escape") setEditingId(null);
}}
className="h-6 text-xs px-1 bg-zinc-800 border-zinc-600"
autoFocus
/>
<button onClick={() => saveEdit(t.id)} className="shrink-0 text-green-400 hover:text-green-300">
<Check className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<span className="flex-1 text-[11px] text-zinc-300 truncate">{t.name}</span>
<button onClick={() => startEdit(t)} className="shrink-0 text-zinc-600 hover:text-zinc-300 opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="h-3 w-3" />
</button>
<button onClick={() => handleDelete(t.id)} className="shrink-0 text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity">
<X className="h-3 w-3" />
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{/* Upload */}
<div className="flex items-center gap-2 flex-wrap">
<Input
placeholder="Template name (optional)"
value={pendingName}
onChange={(e) => setPendingName(e.target.value)}
className="h-8 text-sm max-w-[200px]"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1.5" />}
{uploading ? "Uploading…" : "Upload Template"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleUpload}
/>
</div>
{templates.length === 0 && (
<p className="text-xs text-zinc-600">No templates yet.</p>
)}
</>
)}
</div>
);
}
+527 -7
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useRef, useState, useEffect, useCallback } from "react"; import { useRef, useState, useEffect, useCallback } from "react";
import { X, Undo2, Trash2, Save, Loader2, Pen, Eraser } from "lucide-react"; import Image from "next/image";
import { X, Undo2, Trash2, Save, Loader2, Pen, Eraser, LayoutTemplate, XCircle } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TakeAttachmentData } from "./TakeEditorPanel"; import type { TakeAttachmentData } from "./TakeEditorPanel";
@@ -11,6 +12,13 @@ interface Stroke {
color: string; color: string;
width: number; width: number;
points: { x: number; y: number }[]; points: { x: number; y: number }[];
isEraser: boolean;
}
interface SketchTemplate {
id: string;
name: string;
fileUrl: string;
} }
interface Props { interface Props {
@@ -31,25 +39,537 @@ const COLORS = [
const BRUSH_SIZES = [2, 4, 8, 14, 24]; const BRUSH_SIZES = [2, 4, 8, 14, 24];
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Template picker overlay ──────────────────────────────────────────────────
function TemplatePicker({
current,
onSelect,
onClear,
onClose,
}: {
current: SketchTemplate | null;
onSelect: (t: SketchTemplate) => void;
onClear: () => void;
onClose: () => void;
}) {
const [templates, setTemplates] = useState<SketchTemplate[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/sketch-templates")
.then((r) => r.json())
.then((d) => setTemplates(d.templates ?? []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<div
className="absolute inset-0 z-10 bg-black/80 backdrop-blur-sm flex flex-col"
onClick={onClose}
>
<div
className="m-auto w-full max-w-2xl bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-white">Choose a sketch template</h3>
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
<X className="h-4 w-4" />
</button>
</div>
<div className="p-4 max-h-[60vh] overflow-y-auto">
{loading ? (
<div className="flex items-center gap-2 text-zinc-500 text-sm py-8 justify-center">
<Loader2 className="h-4 w-4 animate-spin" /> Loading templates
</div>
) : (
<>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
<button
onClick={onClear}
className={cn(
"rounded-lg border-2 aspect-[4/3] flex items-center justify-center text-xs font-medium transition-colors",
!current
? "border-amber-500 bg-amber-500/10 text-amber-400"
: "border-zinc-700 hover:border-zinc-500 text-zinc-500 hover:text-zinc-300"
)}
>
No template
</button>
{templates.map((t) => (
<button
key={t.id}
onClick={() => onSelect(t)}
className={cn(
"relative rounded-lg border-2 aspect-[4/3] overflow-hidden transition-all",
current?.id === t.id
? "border-amber-500 ring-2 ring-amber-500/30"
: "border-zinc-700 hover:border-zinc-400"
)}
>
<Image src={t.fileUrl} alt={t.name} fill className="object-cover" sizes="160px" />
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[10px] text-zinc-200 truncate px-1.5 py-0.5">
{t.name}
</div>
</button>
))}
</div>
{templates.length === 0 && (
<p className="text-center text-sm text-zinc-600 py-8">
No templates yet upload some in Settings Sketch Templates.
</p>
)}
</>
)}
</div>
</div>
</div>
);
}
// ─── Main SketchPad ───────────────────────────────────────────────────────────
export function SketchPad({ takeId, onSaved, onClose }: Props) { export function SketchPad({ takeId, onSaved, onClose }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null); // Two-canvas stack:
// bgCanvas — BG_COLOR + optional template image (no pointer events)
// drawCanvas — transparent, user strokes, pointer events on top
const bgCanvasRef = useRef<HTMLCanvasElement>(null);
const drawCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const strokesRef = useRef<Stroke[]>([]); const strokesRef = useRef<Stroke[]>([]);
const currentStrokeRef = useRef<Stroke | null>(null); const currentStrokeRef = useRef<Stroke | null>(null);
const isDrawingRef = useRef(false); const isDrawingRef = useRef(false);
const templateImageRef = useRef<HTMLImageElement | null>(null);
// strokeCount is only used to drive button enabled/disabled state reactively
const [strokeCount, setStrokeCount] = useState(0); const [strokeCount, setStrokeCount] = useState(0);
const [color, setColor] = useState("#ffffff"); const [color, setColor] = useState("#ffffff");
const [brushSize, setBrushSize] = useState(4); const [brushSize, setBrushSize] = useState(4);
const [isEraser, setIsEraser] = useState(false); const [isEraser, setIsEraser] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<SketchTemplate | null>(null);
// ── Redraw all committed strokes ─────────────────────────────────────────── // ── Background canvas: BG_COLOR + scaled template ────────────────────────
const redraw = useCallback(() => { const redrawBg = useCallback(() => {
const canvas = canvasRef.current; const canvas = bgCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const img = templateImageRef.current;
if (img) {
const scale = Math.min(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight);
const w = img.naturalWidth * scale;
const h = img.naturalHeight * scale;
ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
}
}, []);
// ── Drawing canvas: transparent bg, strokes with proper eraser ───────────
const redrawDraw = useCallback(() => {
const canvas = drawCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const stroke of strokesRef.current) {
ctx.save();
ctx.lineWidth = stroke.width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (stroke.isEraser) {
ctx.globalCompositeOperation = "destination-out";
ctx.strokeStyle = "rgba(0,0,0,1)";
ctx.fillStyle = "rgba(0,0,0,1)";
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
}
ctx.beginPath();
if (stroke.points.length === 1) {
ctx.arc(stroke.points[0].x, stroke.points[0].y, stroke.width / 2, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
}
ctx.restore();
}
}, []);
// ── Resize both canvases together ─────────────────────────────────────────
const fit = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const { width, height } = container.getBoundingClientRect();
const w = Math.floor(width);
const h = Math.floor(height);
for (const ref of [bgCanvasRef, drawCanvasRef]) {
const c = ref.current;
if (c) { c.width = w; c.height = h; }
}
redrawBg();
redrawDraw();
}, [redrawBg, redrawDraw]);
useEffect(() => {
fit();
window.addEventListener("resize", fit);
return () => window.removeEventListener("resize", fit);
}, [fit]);
// ── Load template image whenever activeTemplate changes ───────────────────
useEffect(() => {
if (!activeTemplate) {
templateImageRef.current = null;
redrawBg();
return;
}
const img = new window.Image();
img.crossOrigin = "anonymous";
img.onload = () => { templateImageRef.current = img; redrawBg(); };
img.src = activeTemplate.fileUrl;
}, [activeTemplate, redrawBg]);
// ── Keyboard shortcuts ────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (showTemplatePicker) { setShowTemplatePicker(false); return; }
onClose();
}
if (e.key === "p" && !e.metaKey && !e.ctrlKey) setIsEraser(false);
if (e.key === "e" && !e.metaKey && !e.ctrlKey) setIsEraser(true);
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
strokesRef.current = strokesRef.current.slice(0, -1);
setStrokeCount(strokesRef.current.length);
redrawDraw();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, redrawDraw, showTemplatePicker]);
// ── Pointer coordinate helper ─────────────────────────────────────────────
const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = drawCanvasRef.current!;
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (canvas.width / rect.width),
y: (e.clientY - rect.top) * (canvas.height / rect.height),
};
};
// ── Pointer handlers ──────────────────────────────────────────────────────
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
e.preventDefault();
(e.target as HTMLCanvasElement).setPointerCapture(e.pointerId);
isDrawingRef.current = true;
const pos = getPos(e);
const stroke: Stroke = {
color: isEraser ? "" : color,
width: isEraser ? brushSize * 4 : brushSize,
points: [pos],
isEraser,
};
currentStrokeRef.current = stroke;
const ctx = drawCanvasRef.current!.getContext("2d")!;
ctx.save();
if (isEraser) {
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0,1)";
} else {
ctx.fillStyle = color;
}
ctx.beginPath();
ctx.arc(pos.x, pos.y, stroke.width / 2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawingRef.current || !currentStrokeRef.current) return;
e.preventDefault();
const pos = getPos(e);
const stroke = currentStrokeRef.current;
const prev = stroke.points[stroke.points.length - 1];
stroke.points.push(pos);
const ctx = drawCanvasRef.current!.getContext("2d")!;
ctx.save();
ctx.lineWidth = stroke.width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (isEraser) {
ctx.globalCompositeOperation = "destination-out";
ctx.strokeStyle = "rgba(0,0,0,1)";
} else {
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = stroke.color;
}
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
ctx.restore();
};
const onPointerUp = () => {
if (!isDrawingRef.current) return;
isDrawingRef.current = false;
if (currentStrokeRef.current) {
strokesRef.current = [...strokesRef.current, currentStrokeRef.current];
setStrokeCount(strokesRef.current.length);
currentStrokeRef.current = null;
}
};
const undo = () => {
strokesRef.current = strokesRef.current.slice(0, -1);
setStrokeCount(strokesRef.current.length);
redrawDraw();
};
const clear = () => {
strokesRef.current = [];
setStrokeCount(0);
redrawDraw();
};
// ── Save: composite bg + draw layers into one PNG ─────────────────────────
const save = async () => {
const bgCanvas = bgCanvasRef.current;
const drawCanvas = drawCanvasRef.current;
if (!bgCanvas || !drawCanvas) return;
setSaving(true);
setError(null);
try {
const composite = document.createElement("canvas");
composite.width = bgCanvas.width;
composite.height = bgCanvas.height;
const ctx = composite.getContext("2d")!;
ctx.drawImage(bgCanvas, 0, 0);
ctx.drawImage(drawCanvas, 0, 0);
const blob = await new Promise<Blob | null>((resolve) =>
composite.toBlob(resolve, "image/png")
);
if (!blob) throw new Error("Canvas export failed");
const fileName = `sketch_${Date.now()}.png`;
const fd = new FormData();
fd.append("file", new File([blob], fileName, { type: "image/png" }));
fd.append("type", "image");
const uploadRes = await fetch("/api/upload", { method: "POST", body: fd });
if (!uploadRes.ok) throw new Error("Upload failed");
const { url, key } = await uploadRes.json();
const attRes = await fetch(`/api/shoot-log/takes/${takeId}/attachments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileUrl: url,
fileKey: key ?? "",
fileName,
fileSize: blob.size,
fileType: "IMAGE",
category: "MISCELLANEOUS",
caption: activeTemplate ? `Sketch — ${activeTemplate.name}` : "Sketch",
}),
});
if (!attRes.ok) throw new Error("Failed to save attachment");
const { attachment } = await attRes.json();
onSaved(attachment as TakeAttachmentData);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
// ─── Render ───────────────────────────────────────────────────────────────
const previewColor = isEraser ? "#ef4444" : color;
return (
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: BG_COLOR }}>
{/* ── Toolbar ── */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/80 backdrop-blur shrink-0 flex-wrap">
{/* Pen / Eraser */}
<div className="flex items-center gap-0.5 bg-zinc-800 rounded-lg p-0.5">
<button
onClick={() => setIsEraser(false)}
className={cn(
"p-1.5 rounded-md transition-colors",
!isEraser ? "bg-zinc-700 text-white shadow" : "text-zinc-500 hover:text-zinc-300"
)}
title="Pen (P)"
>
<Pen className="h-4 w-4" />
</button>
<button
onClick={() => setIsEraser(true)}
className={cn(
"p-1.5 rounded-md transition-colors",
isEraser ? "bg-zinc-700 text-red-400 shadow" : "text-zinc-500 hover:text-zinc-300"
)}
title="Eraser (E)"
>
<Eraser className="h-4 w-4" />
</button>
</div>
<div className="w-px h-5 bg-zinc-700" />
{/* Colours */}
<div className="flex items-center gap-1.5">
{COLORS.map((c) => (
<button
key={c}
onClick={() => { setColor(c); setIsEraser(false); }}
className={cn(
"rounded-full border-2 transition-all",
color === c && !isEraser ? "border-amber-400 scale-125" : "border-zinc-700 hover:border-zinc-400"
)}
style={{ width: 20, height: 20, backgroundColor: c }}
/>
))}
</div>
<div className="w-px h-5 bg-zinc-700" />
{/* Brush sizes */}
<div className="flex items-center gap-2">
{BRUSH_SIZES.map((s) => (
<button
key={s}
onClick={() => setBrushSize(s)}
className={cn(
"flex items-center justify-center rounded-full bg-zinc-800 transition-all",
brushSize === s ? "ring-2 ring-amber-400" : "opacity-50 hover:opacity-100"
)}
style={{ width: s + 10, height: s + 10 }}
title={`${s}px`}
>
<div className="rounded-full" style={{ width: s, height: s, backgroundColor: previewColor }} />
</button>
))}
</div>
<div className="w-px h-5 bg-zinc-700" />
{/* Template selector */}
<button
onClick={() => setShowTemplatePicker(true)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors",
activeTemplate
? "bg-amber-500/15 border border-amber-600/40 text-amber-400"
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
)}
title="Background template"
>
<LayoutTemplate className="h-3.5 w-3.5" />
{activeTemplate ? activeTemplate.name : "Template"}
</button>
{activeTemplate && (
<button
onClick={() => setActiveTemplate(null)}
className="p-1 -ml-2 text-zinc-600 hover:text-red-400 transition-colors"
title="Remove template"
>
<XCircle className="h-3.5 w-3.5" />
</button>
)}
<div className="flex-1" />
{error && <span className="text-xs text-red-400">{error}</span>}
{/* Actions */}
<div className="flex items-center gap-1.5">
<button
onClick={undo}
disabled={strokeCount === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 transition-colors"
title="Undo (Ctrl+Z)"
>
<Undo2 className="h-3.5 w-3.5" /> Undo
</button>
<button
onClick={clear}
disabled={strokeCount === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 disabled:opacity-30 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" /> Clear
</button>
<button
onClick={save}
disabled={saving || strokeCount === 0}
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white disabled:opacity-50 transition-colors"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{saving ? "Saving…" : "Save Sketch"}
</button>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-zinc-800 transition-colors ml-1"
title="Discard (Esc)"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* ── Canvas area ── */}
<div ref={containerRef} className="flex-1 relative overflow-hidden">
{/* Background layer: BG_COLOR + optional template */}
<canvas ref={bgCanvasRef} className="absolute inset-0" />
{/* Drawing layer: transparent, receives pointer events */}
<canvas
ref={drawCanvasRef}
className="absolute inset-0 cursor-crosshair"
style={{ touchAction: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
{strokeCount === 0 && !activeTemplate && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
<p className="text-zinc-700 text-base">Draw your sketch</p>
</div>
)}
{/* Template picker modal */}
{showTemplatePicker && (
<TemplatePicker
current={activeTemplate}
onSelect={(t) => { setActiveTemplate(t); setShowTemplatePicker(false); }}
onClear={() => { setActiveTemplate(null); setShowTemplatePicker(false); }}
onClose={() => setShowTemplatePicker(false)}
/>
)}
</div>
</div>
);
}
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "sketch_templates" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"fileUrl" TEXT NOT NULL,
"fileKey" TEXT NOT NULL DEFAULT '',
"fileName" TEXT NOT NULL DEFAULT '',
"fileSize" BIGINT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sketch_templates_pkey" PRIMARY KEY ("id")
);
+13
View File
@@ -751,3 +751,16 @@ model TakeAttachment {
@@map("take_attachments") @@map("take_attachments")
} }
model SketchTemplate {
id String @id @default(cuid())
name String
fileUrl String
fileKey String @default("")
fileName String @default("")
fileSize BigInt?
sortOrder Int @default(0)
createdAt DateTime @default(now())
@@map("sketch_templates")
}