diff --git a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx index 2a4a068..0cb8de4 100644 --- a/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx +++ b/webapp/pivot/app/project/[id]/components/tabs/PanoramasTab/PanoramaGrid.tsx @@ -1,3 +1,4 @@ +import { useState, useRef, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { @@ -6,6 +7,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; import { Upload, MoreVertical, @@ -15,7 +23,9 @@ import { List, Box, Loader2, + Wand2, } from "lucide-react"; +import { createClient } from "@/utils/supabase/client"; import { Panorama } from "../../../../../../hooks/usePanoramas"; interface PanoramaGridProps { @@ -53,18 +63,204 @@ export default function PanoramaGrid({ setGenerate360DialogOpen, getProjectPanoramas, }: PanoramaGridProps) { - // Determine which panoramas to show based on currentFolder + // -------- local state for redaction -------- + const supabase = createClient(); + const [redactDialogOpen, setRedactDialogOpen] = useState(false); + const [redactTarget, setRedactTarget] = useState(null); + const canvasRef = useRef(null); + const [imgEl, setImgEl] = useState(null); + const [rects, setRects] = useState< + { x: number; y: number; w: number; h: number }[] + >([]); + const [drawing, setDrawing] = useState(false); + const startPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const [saving, setSaving] = useState(false); + const [blurredUrls, setBlurredUrls] = useState>({}); + const panoramasToShow = getProjectPanoramas(); + // -------- helper to draw (or re-draw) canvas -------- + const redraw = useCallback( + (previewRect?: { x: number; y: number; w: number; h: number } | null) => { + if (!canvasRef.current || !imgEl) return; + const ctx = canvasRef.current.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); + ctx.drawImage( + imgEl, + 0, + 0, + canvasRef.current.width, + canvasRef.current.height + ); + + const allRects = previewRect ? [...rects, previewRect] : rects; + + allRects.forEach((r) => { + ctx.save(); + ctx.filter = "blur(10px)"; + // blur only the sub-rectangle + ctx.drawImage( + imgEl, + r.x / scale, + r.y / scale, + r.w / scale, + r.h / scale, + r.x, + r.y, + r.w, + r.h + ); + ctx.restore(); + // draw dashed outline so user sees selection + ctx.save(); + ctx.strokeStyle = "rgba(255,255,255,0.8)"; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.strokeRect(r.x, r.y, r.w, r.h); + ctx.restore(); + }); + }, + [imgEl, rects, scale] + ); + + // -------- open redaction dialog -------- + const openRedactor = (p: Panorama) => { + setRedactTarget(p); + setRects([]); + setRedactDialogOpen(true); + }; + + // -------- load image when dialog opens -------- + useEffect(() => { + if (!redactDialogOpen || !redactTarget) return; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = redactTarget.url ?? ""; + img.onload = () => { + // fit image into at most 800px width for the dialog + const maxW = 800; + const sc = img.width > maxW ? maxW / img.width : 1; + setScale(sc); + if (canvasRef.current) { + canvasRef.current.width = img.width * sc; + canvasRef.current.height = img.height * sc; + } + setImgEl(img); + redraw(); + }; + }, [redactDialogOpen, redactTarget, redraw]); + + // -------- mouse handlers -------- + const handleMouseDown = (e: React.MouseEvent) => { + if (!canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + startPos.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + setDrawing(true); + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!drawing || !canvasRef.current) return; + const rectCanvas = canvasRef.current.getBoundingClientRect(); + const curr = { + x: e.clientX - rectCanvas.left, + y: e.clientY - rectCanvas.top, + }; + const preview = { + x: Math.min(startPos.current.x, curr.x), + y: Math.min(startPos.current.y, curr.y), + w: Math.abs(curr.x - startPos.current.x), + h: Math.abs(curr.y - startPos.current.y), + }; + redraw(preview); + }; + + const handleMouseUp = (e: React.MouseEvent) => { + if (!drawing || !canvasRef.current) return; + const rectCanvas = canvasRef.current.getBoundingClientRect(); + const end = { + x: e.clientX - rectCanvas.left, + y: e.clientY - rectCanvas.top, + }; + const newRect = { + x: Math.min(startPos.current.x, end.x), + y: Math.min(startPos.current.y, end.y), + w: Math.abs(end.x - startPos.current.x), + h: Math.abs(end.y - startPos.current.y), + }; + setRects((prev) => [...prev, newRect]); + setDrawing(false); + }; + + // re-draw whenever rects change + useEffect(() => { + if (rects.length > 0) redraw(); + }, [rects, redraw]); + + // -------- save blurred image -------- + const handleSave = async () => { + if (!canvasRef.current || !redactTarget) return; + setSaving(true); + canvasRef.current.toBlob( + async (blob) => { + if (!blob) { + setSaving(false); + alert("Failed to generate image blob"); + return; + } + try { + const bucket = redactTarget.is_public + ? "panoramas-public" + : "panoramas-private"; + await supabase.storage + .from(bucket) + .upload(redactTarget.storage_path, blob, { + upsert: true, + contentType: "image/jpeg", + }); + + // refresh URL (signed or public) + let newUrl: string | null = null; + if (redactTarget.is_public) { + const { data } = supabase.storage + .from(bucket) + .getPublicUrl(redactTarget.storage_path); + newUrl = data.publicUrl + `?t=${Date.now()}`; // cache-buster + } else { + const { data } = await supabase.storage + .from(bucket) + .createSignedUrl(redactTarget.storage_path, 3600); + newUrl = (data?.signedUrl ?? "") + `&t=${Date.now()}`; + } + if (newUrl) { + setBlurredUrls((prev) => ({ ...prev, [redactTarget.id]: newUrl })); + } + setRedactDialogOpen(false); + } catch (err) { + console.error("Save blurred image error:", err); + alert("Failed to save blurred image"); + } finally { + setSaving(false); + } + }, + "image/jpeg", + 0.95 + ); + }; + + // -------------------------------------------------- + // ----------------------- UI ----------------------- + // -------------------------------------------------- return (
+ {/* ---------- main card ---------- */}
- - {"All 360° Images"} - + {"All 360° Images"}
+ {/* view toggle */} - {/* Panorama upload button */} + {/* upload */}
+ + {/* ---------- main content ---------- */} {uploading || processing ? (
@@ -152,7 +350,12 @@ export default function PanoramaGrid({
{panorama.name} @@ -166,6 +369,7 @@ export default function PanoramaGrid({ {panorama.name} {panorama.is_processing && " (Processing)"}
+ {/* ---- dropdown ---- */}
Rename + + {/* NEW OPTION */} + { + e.stopPropagation(); + openRedactor(panorama); + }} + disabled={panorama.is_processing} + > + + Blur & Redact (beta) + + { @@ -212,6 +429,7 @@ export default function PanoramaGrid({ ))}
) : ( + // ---------- list view ----------
{panoramasToShow.map((panorama) => (
{panorama.name} @@ -262,6 +485,19 @@ export default function PanoramaGrid({ Rename + + {/* NEW OPTION (list view) */} + { + e.stopPropagation(); + openRedactor(panorama); + }} + disabled={panorama.is_processing} + > + + Blur & Redact (beta) + + { @@ -281,6 +517,7 @@ export default function PanoramaGrid({
)} + {/* empty-state */} {panoramasToShow.length === 0 && (
@@ -315,6 +552,46 @@ export default function PanoramaGrid({ )} + + {/* ---------- REDACTION DIALOG ---------- */} + + + + Blur & Redact (beta) + +
+ +
+ + + + +
+
); }