Files
vfxreview/components/shoot-log/SketchPad.tsx
T
twotalesanimation 2f91c71307
Deploy / deploy (push) Failing after 44s
Shoot sketches templates
2026-07-12 12:27:21 +02:00

894 lines
32 KiB
TypeScript

"use client";
import { useRef, useState, useEffect, useCallback } from "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";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Stroke {
color: string;
width: number;
points: { x: number; y: number }[];
isEraser: boolean;
}
interface SketchTemplate {
id: string;
name: string;
fileUrl: string;
}
interface Props {
takeId: string;
onSaved: (attachment: TakeAttachmentData) => void;
onClose: () => void;
}
// ─── Config ───────────────────────────────────────────────────────────────────
const BG_COLOR = "#1c1c1e";
const COLORS = [
"#ffffff", "#d4d4d8",
"#f87171", "#fb923c", "#facc15",
"#4ade80", "#60a5fa", "#c084fc",
];
const BRUSH_SIZES = [2, 4, 8, 14, 24];
// ─── 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) {
// 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 currentStrokeRef = useRef<Stroke | null>(null);
const isDrawingRef = useRef(false);
const templateImageRef = useRef<HTMLImageElement | null>(null);
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<string | null>(null);
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<SketchTemplate | null>(null);
// ── 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<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;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (const stroke of strokesRef.current) {
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (stroke.points.length === 1) {
// Single tap → dot
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();
}
}
}, []);
// ── Size canvas to fill container on mount + window resize ────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const fit = () => {
const parent = canvas.parentElement;
if (!parent) return;
const { width, height } = parent.getBoundingClientRect();
canvas.width = Math.floor(width);
canvas.height = Math.floor(height);
redraw();
};
fit();
window.addEventListener("resize", fit);
return () => window.removeEventListener("resize", fit);
}, [redraw]);
// ── Keyboard shortcuts ────────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") 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);
redraw();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, redraw]);
// ── Coordinate helper — CSS px → canvas buffer px ────────────────────────
const getPos = (e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.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 ? BG_COLOR : color,
width: isEraser ? brushSize * 4 : brushSize,
points: [pos],
};
currentStrokeRef.current = stroke;
// Immediate dot feedback
const ctx = canvasRef.current!.getContext("2d")!;
ctx.beginPath();
ctx.fillStyle = stroke.color;
ctx.arc(pos.x, pos.y, stroke.width / 2, 0, Math.PI * 2);
ctx.fill();
};
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);
// Incremental draw — no full redraw needed during stroke
const ctx = canvasRef.current!.getContext("2d")!;
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
};
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);
redraw();
};
const clear = () => {
strokesRef.current = [];
setStrokeCount(0);
redraw();
};
// ── Save ──────────────────────────────────────────────────────────────────
const save = async () => {
const canvas = canvasRef.current;
if (!canvas) return;
setSaving(true);
setError(null);
try {
const blob = await new Promise<Blob | null>((resolve) =>
canvas.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: "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 activeColor = isEraser ? BG_COLOR : 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 toggle */}
<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" />
{/* Colour swatches */}
<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 transition-all bg-zinc-800",
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: activeColor === BG_COLOR ? "#ef4444" : activeColor }}
/>
</button>
))}
</div>
<div className="flex-1" />
{error && <span className="text-xs text-red-400">{error}</span>}
{/* Action buttons */}
<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 ── */}
<div className="flex-1 relative overflow-hidden">
{strokeCount === 0 && (
<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>
)}
<canvas
ref={canvasRef}
className="absolute inset-0 cursor-crosshair"
style={{ touchAction: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
/>
</div>
</div>
);
}