diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index bbde2d9..e11fc8b 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -4,6 +4,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getInitials } from "@/lib/utils"; import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm"; import { HetznerConfigForm } from "@/components/settings/HetznerConfigForm"; +import { SketchTemplatesSection } from "@/components/settings/SketchTemplatesSection"; import { getHetznerConfig } from "@/actions/settings"; export const metadata = { title: "Settings" }; @@ -13,6 +14,7 @@ export default async function SettingsPage() { if (!session?.user) return null; const isAdmin = session.user.role === "ADMIN"; + const canManageTemplates = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role ?? ""); const hetznerConfig = isAdmin ? await getHetznerConfig() : null; return ( @@ -48,6 +50,14 @@ export default async function SettingsPage() { )} + + {canManageTemplates && ( + + + + + + )} ); } diff --git a/app/api/sketch-templates/[id]/route.ts b/app/api/sketch-templates/[id]/route.ts new file mode 100644 index 0000000..f0def19 --- /dev/null +++ b/app/api/sketch-templates/[id]/route.ts @@ -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 }, + }); +} diff --git a/app/api/sketch-templates/route.ts b/app/api/sketch-templates/route.ts new file mode 100644 index 0000000..a94c2b1 --- /dev/null +++ b/app/api/sketch-templates/route.ts @@ -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) }, + }); +} diff --git a/components/settings/SketchTemplatesSection.tsx b/components/settings/SketchTemplatesSection.tsx new file mode 100644 index 0000000..af1be68 --- /dev/null +++ b/components/settings/SketchTemplatesSection.tsx @@ -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(null); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [pendingName, setPendingName] = useState(""); + const [editingId, setEditingId] = useState(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) => { + 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 ( +
+
+ + Sketch Templates +
+ +

+ Upload background images (body diagrams, scene layouts, storyboard frames, etc.) that artists can load as a canvas background when sketching on a take. +

+ + {loading ? ( +
+ Loading… +
+ ) : ( + <> + {/* Template grid */} + {templates.length > 0 && ( +
+ {templates.map((t) => ( +
+
+ {t.name} +
+
+ {editingId === t.id ? ( + <> + 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 + /> + + + ) : ( + <> + {t.name} + + + + )} +
+
+ ))} +
+ )} + + {/* Upload */} +
+ setPendingName(e.target.value)} + className="h-8 text-sm max-w-[200px]" + /> + + +
+ + {templates.length === 0 && ( +

No templates yet.

+ )} + + )} +
+ ); +} diff --git a/components/shoot-log/SketchPad.tsx b/components/shoot-log/SketchPad.tsx index cfd1428..d05a02c 100644 --- a/components/shoot-log/SketchPad.tsx +++ b/components/shoot-log/SketchPad.tsx @@ -1,7 +1,8 @@ "use client"; 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 type { TakeAttachmentData } from "./TakeEditorPanel"; @@ -11,6 +12,13 @@ interface Stroke { color: string; width: number; points: { x: number; y: number }[]; + isEraser: boolean; +} + +interface SketchTemplate { + id: string; + name: string; + fileUrl: string; } interface Props { @@ -31,25 +39,537 @@ const COLORS = [ 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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/sketch-templates") + .then((r) => r.json()) + .then((d) => setTemplates(d.templates ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
e.stopPropagation()} + > +
+

Choose a sketch template

+ +
+
+ {loading ? ( +
+ Loading templates… +
+ ) : ( + <> +
+ + {templates.map((t) => ( + + ))} +
+ {templates.length === 0 && ( +

+ No templates yet — upload some in Settings → Sketch Templates. +

+ )} + + )} +
+
+
+ ); +} + +// ─── Main SketchPad ─────────────────────────────────────────────────────────── export function SketchPad({ takeId, onSaved, onClose }: Props) { - const canvasRef = useRef(null); + // Two-canvas stack: + // bgCanvas — BG_COLOR + optional template image (no pointer events) + // drawCanvas — transparent, user strokes, pointer events on top + const bgCanvasRef = useRef(null); + const drawCanvasRef = useRef(null); + const containerRef = useRef(null); + const strokesRef = useRef([]); const currentStrokeRef = useRef(null); const isDrawingRef = useRef(false); + const templateImageRef = useRef(null); - // strokeCount is only used to drive button enabled/disabled state reactively const [strokeCount, setStrokeCount] = useState(0); const [color, setColor] = useState("#ffffff"); const [brushSize, setBrushSize] = useState(4); const [isEraser, setIsEraser] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [showTemplatePicker, setShowTemplatePicker] = useState(false); + const [activeTemplate, setActiveTemplate] = useState(null); - // ── Redraw all committed strokes ─────────────────────────────────────────── - const redraw = useCallback(() => { - const canvas = canvasRef.current; + // ── Background canvas: BG_COLOR + scaled template ──────────────────────── + const redrawBg = useCallback(() => { + 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) => { + 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) => { + 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) => { + 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((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 ( +
+ + {/* ── Toolbar ── */} +
+ + {/* Pen / Eraser */} +
+ + +
+ +
+ + {/* Colours */} +
+ {COLORS.map((c) => ( +
+ +
+ + {/* Brush sizes */} +
+ {BRUSH_SIZES.map((s) => ( + + ))} +
+ +
+ + {/* Template selector */} + + {activeTemplate && ( + + )} + +
+ + {error && {error}} + + {/* Actions */} +
+ + + + +
+
+ + {/* ── Canvas area ── */} +
+ {/* Background layer: BG_COLOR + optional template */} + + + {/* Drawing layer: transparent, receives pointer events */} + + + {strokeCount === 0 && !activeTemplate && ( +
+

Draw your sketch…

+
+ )} + + {/* Template picker modal */} + {showTemplatePicker && ( + { setActiveTemplate(t); setShowTemplatePicker(false); }} + onClear={() => { setActiveTemplate(null); setShowTemplatePicker(false); }} + onClose={() => setShowTemplatePicker(false)} + /> + )} +
+
+ ); +} if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; diff --git a/prisma/migrations/20260712000001_add_sketch_templates/migration.sql b/prisma/migrations/20260712000001_add_sketch_templates/migration.sql new file mode 100644 index 0000000..dedc9fa --- /dev/null +++ b/prisma/migrations/20260712000001_add_sketch_templates/migration.sql @@ -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") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 838dec8..9bcbf7d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -751,3 +751,16 @@ model TakeAttachment { @@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") +}