diff --git a/components/shoot-log/SketchPad.tsx b/components/shoot-log/SketchPad.tsx new file mode 100644 index 0000000..cfd1428 --- /dev/null +++ b/components/shoot-log/SketchPad.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from "react"; +import { X, Undo2, Trash2, Save, Loader2, Pen, Eraser } 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 }[]; +} + +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]; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function SketchPad({ takeId, onSaved, onClose }: Props) { + const canvasRef = useRef(null); + const strokesRef = useRef([]); + const currentStrokeRef = useRef(null); + const isDrawingRef = useRef(false); + + // 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); + + // ── Redraw all committed strokes ─────────────────────────────────────────── + const redraw = useCallback(() => { + const canvas = canvasRef.current; + 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) => { + 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) => { + 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) => { + 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((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 ( +
+ + {/* ── Toolbar ── */} +
+ + {/* Pen / Eraser toggle */} +
+ + +
+ +
+ + {/* Colour swatches */} +
+ {COLORS.map((c) => ( +
+ +
+ + {/* Brush sizes */} +
+ {BRUSH_SIZES.map((s) => ( + + ))} +
+ +
+ + {error && {error}} + + {/* Action buttons */} +
+ + + + +
+
+ + {/* ── Canvas ── */} +
+ {strokeCount === 0 && ( +
+

Draw your sketch…

+
+ )} + +
+
+ ); +} diff --git a/components/shoot-log/TakeImageGallery.tsx b/components/shoot-log/TakeImageGallery.tsx index 77864e9..07673e2 100644 --- a/components/shoot-log/TakeImageGallery.tsx +++ b/components/shoot-log/TakeImageGallery.tsx @@ -1,9 +1,10 @@ "use client"; import { useRef, useState, useCallback } from "react"; -import { Upload, X, Loader2, ImageIcon, ZoomIn } from "lucide-react"; +import { Upload, X, Loader2, ImageIcon, ZoomIn, Pencil } from "lucide-react"; import { cn } from "@/lib/utils"; import type { TakeAttachmentData } from "./TakeEditorPanel"; +import { SketchPad } from "./SketchPad"; // ─── Lightbox ───────────────────────────────────────────────────────────────── @@ -89,6 +90,7 @@ export function TakeImageGallery({ takeId, attachments, onChange }: Props) { const [uploading, setUploading] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); const [dragOver, setDragOver] = useState(false); + const [showSketch, setShowSketch] = useState(false); const imageAttachments = attachments.filter((a) => a.fileType === "IMAGE"); @@ -238,6 +240,16 @@ export function TakeImageGallery({ takeId, attachments, onChange }: Props) {
)} + {/* Sketch button */} + + {imageAttachments.length === 0 && !uploading && (
@@ -245,6 +257,15 @@ export function TakeImageGallery({ takeId, attachments, onChange }: Props) {
)} + {/* Sketch pad */} + {showSketch && ( + onChange([...attachments, attachment])} + onClose={() => setShowSketch(false)} + /> + )} + {/* Lightbox */} {lightboxIndex !== null && (