From 1fa8803bfcc331a36ca7c333a33baa4dc764a3f8 Mon Sep 17 00:00:00 2001 From: "Leivur R. Djurhuus" Date: Sat, 14 Mar 2026 14:00:53 -0500 Subject: [PATCH 01/11] feat: add visual review tool with image viewer and upload infrastructure (A1) Canvas-based image viewer with zoom-to-cursor, pan, retina support, and minimap navigation. Image upload API with PNG alpha compositing (sharp) for CG renders with transparent backgrounds, TIFF-to-PNG conversion, and auto-generated thumbnails. Review page accessible from stage cards on the deliverable detail page. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 +- ROADMAP.md | 243 ++++++----- package-lock.json | 4 +- package.json | 1 + .../deliverables/[deliverableId]/page.tsx | 35 +- .../[deliverableId]/review/page.tsx | 306 ++++++++++++++ .../revisions/[revisionId]/upload/route.ts | 103 +++++ src/components/review/image-gallery.tsx | 60 +++ src/components/review/image-upload-zone.tsx | 196 +++++++++ src/components/review/image-viewer.tsx | 116 +++++ src/components/review/minimap.tsx | 120 ++++++ src/components/review/zoom-controls.tsx | 106 +++++ src/hooks/use-image-viewer.ts | 395 ++++++++++++++++++ src/lib/services/upload-service.ts | 145 +++++++ 14 files changed, 1724 insertions(+), 108 deletions(-) create mode 100644 src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx create mode 100644 src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts create mode 100644 src/components/review/image-gallery.tsx create mode 100644 src/components/review/image-upload-zone.tsx create mode 100644 src/components/review/image-viewer.tsx create mode 100644 src/components/review/minimap.tsx create mode 100644 src/components/review/zoom-controls.tsx create mode 100644 src/hooks/use-image-viewer.ts create mode 100644 src/lib/services/upload-service.ts diff --git a/.gitignore b/.gitignore index 3ba7d15..6da28d3 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,4 @@ next-env.d.ts # uploaded assets (runtime-generated, not needed in repo) /public/uploads/ -/assets/review-images/ \ No newline at end of file +/assets/review-images/ diff --git a/ROADMAP.md b/ROADMAP.md index 74b88c6..1b02892 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ 1. [What's Built](#whats-built) 2. [Remaining Work — Priority Order](#remaining-work--priority-order) - - [A. Image Comparison & Visual Review](#a-image-comparison--visual-review) + - [A. Visual Review Tool](#a-visual-review-tool) - [B. Collaboration Enhancements](#b-collaboration-enhancements) - [C. Reporting Completions](#c-reporting-completions) - [D. Automation Completions](#d-automation-completions) @@ -114,70 +114,98 @@ --- -### A. Image Comparison & Visual Review +### A. Visual Review Tool -The highest-impact remaining feature area. CG production review is fundamentally visual — everything else supports this core workflow. +The highest-impact remaining feature. CG production review is fundamentally visual — this is a single, unified review tool built in stages, each independently useful. No throwaway prototypes. + +The review tool lives at its own dedicated page (`/projects/[projectId]/deliverables/[deliverableId]/review`) and is also accessible from the stage detail sheet via a "Review" button. It's the primary interface for inspecting renders, comparing revisions, annotating feedback, and making approve/reject decisions. + +**Existing infrastructure to build on:** +- `Revision` model exists with `attachments Json?` field (currently unused) +- Revision CRUD API + hooks + service already built +- Stage detail sheet has a tabbed interface (Revisions + Comments) +- No image upload, storage, or display infrastructure exists yet + +**New dependency for all stages:** `sharp` (server-side PNG alpha compositing + image processing) --- -#### A1 — Reference Image Comparison (Lightweight, in Stage Detail) +#### A1 — Review Viewer & Image Upload `[x]` -**What:** A-B comparison tools embedded in the existing stage detail sheet. Producers and artists compare the reference/approved image against the current WIP render without leaving the deliverable page. +The foundation. A dedicated review page with a high-fidelity image viewer and the upload infrastructure to get images into the system. -**Why first:** Lower complexity than the full review viewer (A2). Directly improves the daily review loop for existing users and unblocks meaningful visual feedback on revision rounds. - -**Implementation:** -- New "Compare" tab in the stage detail sheet (alongside Revisions and Comments tabs) -- Upload zones for reference image + current render per revision round -- Three comparison modes: - - **A-B Slider** — draggable vertical divider (CSS clip-path + pointer events, no dependency) - - **Toggle** — click/Space to crossfade between images - - **Side-by-side** — synced zoom/pan on wide screens -- Scroll-to-zoom + click-drag pan, synced across both images in all modes -- Image gallery: thumbnails of all uploaded images across all revision rounds with round labels -- **PNG alpha compositing on upload** — flatten transparent PNGs onto white background server-side using `sharp`, so CG renders with transparent drop shadows compare correctly -- Extend `Revision.attachments` JSON schema: `{ referenceImage?: {...}, currentImage?: {...}, annotations?: [...] }` -- Lightweight markup overlay (optional): arrow, rectangle, freehand, text pin drawn on top of current image +**What gets built:** +- **Review page** at `/projects/[projectId]/deliverables/[deliverableId]/review` — full-width layout with image viewer as the centerpiece, toolbar at top, panels on the sides +- **Image viewer** — canvas-based with WebGL acceleration for large CG renders (4000×4000px+) + - Zoom: scroll wheel, pinch gesture, keyboard (+/-), toolbar presets (Fit, 50%, 100%, 150%, 200%) + - Pan: click-drag when zoomed, minimap overlay showing viewport position + - Pixel info: coordinate display (x, y) + color readout (RGB/Hex) in status bar + - High-DPI (retina) support with proper pixel ratio handling +- **Image upload** — drag-and-drop zone + click-to-browse for reference and current render per revision round + - Supports PNG, TIFF, JPEG, WebP up to 50MB + - **PNG alpha compositing on upload** — flatten transparent PNGs onto white (#FFF) background server-side using `sharp`, so CG renders with semi-transparent drop shadows display correctly in all comparison and overlay modes + - Store flattened version for viewing + optionally keep original transparent PNG for download + - Extend `Revision.attachments` JSON: `{ referenceImage?: { url, filename, size, uploadedAt, originalUrl? }, currentImage?: { ... } }` +- **Image gallery** — thumbnail strip of all uploaded images across revision rounds, with round number labels. Click any to load it in the viewer. +- **"Review" button** on stage cards in the deliverable detail page — opens the review page for that stage **New API endpoints:** -- `POST /api/stages/[stageId]/revisions/[revisionId]/upload` — multipart, reference or current image -- `DELETE /api/stages/[stageId]/revisions/[revisionId]/upload` — remove image +- `POST /api/stages/[stageId]/revisions/[revisionId]/upload` — multipart form upload (type: "reference" | "current") +- `DELETE /api/stages/[stageId]/revisions/[revisionId]/upload` — remove an uploaded image **Key files:** -- `src/components/revisions/image-compare-slider.tsx` — A-B slider with drag handle -- `src/components/revisions/image-toggle.tsx` — Toggle/crossfade -- `src/components/revisions/image-side-by-side.tsx` — Synced side-by-side with zoom/pan -- `src/components/revisions/image-upload-zone.tsx` — Drag-and-drop upload -- `src/components/revisions/revision-image-gallery.tsx` — Browse images across revision rounds -- `src/components/revisions/compare-tab.tsx` — Orchestrator: mode selector + comparison view -- `src/components/revisions/markup-overlay.tsx` — Lightweight annotation layer - -**New dependency:** `sharp` (server-side PNG alpha compositing on upload) +- `src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx` — Review page +- `src/components/review/image-viewer.tsx` — Core canvas viewer +- `src/components/review/zoom-controls.tsx` — Zoom toolbar + keyboard handler +- `src/components/review/minimap.tsx` — Navigation minimap overlay +- `src/components/review/image-upload-zone.tsx` — Drag-and-drop upload +- `src/components/review/image-gallery.tsx` — Thumbnail strip across rounds +- `src/hooks/use-image-viewer.ts` — Pan/zoom state management +- `src/lib/services/upload-service.ts` — File storage + PNG alpha compositing --- -#### A2 — Full-Screen Image Viewer +#### A2 — Version Comparison `[ ]` -**What:** Dedicated full-screen viewer with high-fidelity zoom (up to 200%+) for pixel-level inspection of CG renders. +Compare two revisions of the same deliverable stage. This is the daily workhorse — producers and artists check what changed between rounds. -**Implementation:** -- Canvas-based viewer with WebGL acceleration for large images -- Zoom: scroll wheel, pinch, keyboard (+/-), toolbar buttons (fit, 50%, 100%, 150%, 200%, free) -- Pan via click-drag when zoomed; minimap overlay showing viewport position -- Pixel coordinate + color value readout (RGB/Hex) in status bar -- High-DPI display support (retina) +**What gets built:** +- **Comparison modes** (toolbar toggle in the review page): + - **Side-by-side** — dual panes with synced zoom/pan, version labels + - **A-B Slider (wipe)** — draggable vertical/horizontal divider revealing one image on each side + - **Overlay** — second image overlaid with adjustable opacity (0–100%) + - **Toggle** — click or press Space to crossfade between images (default on narrow screens) +- **Revision selectors** — dropdowns for left/right revision (default: previous round vs. current) +- **Synced navigation** — zoom/pan one pane, both move together across all modes +- **Keyboard shortcuts** — 1/2/3/4 to switch modes, left/right arrows to cycle revisions **Key files:** -- `src/components/review/image-viewer.tsx` -- `src/components/review/zoom-controls.tsx` -- `src/components/review/minimap.tsx` -- `src/hooks/use-image-viewer.ts` +- `src/components/review/comparison-viewer.tsx` — Dual-pane orchestrator +- `src/components/review/wipe-divider.tsx` — Draggable split control +- `src/components/review/overlay-controls.tsx` — Opacity slider + mode toggles + +**Dependencies:** Requires A1 --- -#### A3 — Pixel-Accurate Annotations +#### A3 — Annotations `[ ]` -**What:** Draw annotations directly on images — circles, rectangles, arrows, freehand, text labels, pins. Anchored to image coordinates so they stay accurate at any zoom level. +Draw pixel-accurate annotations directly on images. Each annotation is anchored to image coordinates so it stays accurate at any zoom level, and is linked to a comment for context. + +**What gets built:** +- **SVG overlay layer** on top of the canvas viewer (annotations in SVG for crisp scaling, image in canvas for performance) +- **Annotation tools** — toolbar with: rectangle, ellipse, arrow, freehand path, text label, pin (point marker) +- **Screenshot paste callouts** — Cmd+V / Ctrl+V pastes a clipboard image directly onto the review image as a floating, repositionable callout. Use case: paste a screenshot of a Maya attribute editor panel, a Photoshop layer setting, or a cropped reference to show the artist exactly what you mean. Callouts can be: + - **Moved** — drag to reposition anywhere on the image + - **Resized** — corner handles to scale up/down + - **Bordered** — automatic contrast border so the callout stands out against the render + - **Linked to a comment** — same as other annotations, the pasted screenshot becomes part of the feedback thread + - Stored as a small image (uploaded to server, URL in annotation data) anchored to image coordinates like any other annotation +- **Color picker** for annotation stroke (default: accent red for visibility against CG renders) +- **Image-coordinate anchoring** — annotations stored in image space, rendered correctly at any zoom +- **Comment linking** — each annotation creates or attaches to a Comment record; clicking an annotation highlights its comment in the sidebar, and vice versa +- **Visibility controls** — show/hide all, show/hide per revision round, show resolved vs. open +- **Undo/redo** for drawing sessions **New data model:** ```prisma @@ -188,63 +216,82 @@ model Annotation { revisionId String revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade) type AnnotationType - data Json // { x, y, width, height, points[], text, color, strokeWidth } - imageX Float + data Json // { x, y, width, height, points[], text, color, strokeWidth, imageUrl? } + imageX Float // anchor point in image coordinates imageY Float createdById String createdBy User @relation(fields: [createdById], references: [id]) createdAt DateTime @default(now()) } -enum AnnotationType { RECTANGLE ELLIPSE ARROW FREEHAND TEXT PIN } +enum AnnotationType { RECTANGLE ELLIPSE ARROW FREEHAND TEXT PIN SCREENSHOT } ``` **Key files:** -- `src/components/review/annotation-layer.tsx` -- `src/components/review/annotation-tools.tsx` -- `src/components/review/annotation-renderer.tsx` -- `src/lib/services/annotation-service.ts` -- `src/lib/validators/annotation.ts` -- `src/hooks/use-annotations.ts` +- `src/components/review/annotation-layer.tsx` — SVG overlay with tool switching +- `src/components/review/annotation-tools.tsx` — Toolbar with tool selection +- `src/components/review/annotation-renderer.tsx` — Renders individual shapes +- `src/components/review/screenshot-callout.tsx` — Pasted screenshot with drag/resize handles +- `src/lib/services/annotation-service.ts` — CRUD +- `src/lib/validators/annotation.ts` — Zod schemas +- `src/hooks/use-annotations.ts` — TanStack Query hook -**Dependencies:** Requires A2 (Image Viewer) +**Dependencies:** Requires A1 --- -#### A4 — Side-by-Side Version Comparison (Full Viewer) +#### A4 — Revision History Timeline `[ ]` -**What:** Compare two revisions in the full-screen viewer using side-by-side, overlay (opacity slider), wipe (draggable divider), or onion skin modes. +A collapsible sidebar panel in the review page showing the full version history for a deliverable stage. The connective tissue between annotations, comparison, and feedback — provides the longitudinal view across all rounds. + +**What gets built:** +- **Vertical timeline** in a collapsible right-side panel, each revision round as a node: + - Thumbnail preview of that round's submitted image + - Round number + status badge (Submitted, Changes Requested, Approved) + - Submitted by (name + avatar) + timestamp + - Annotation count (clickable to filter annotation layer to that round) + - Comment thread summary — first line + total count + - Decision record — who approved/rejected, when, with what note +- **Click any round** to load that version in the viewer with its annotations +- **Keyboard navigation** — up/down arrows to move through rounds +- **Comparison integration** — select two rounds from the timeline to open them in comparison mode (A2) +- **Filtering** — show all rounds, show only rounds with feedback, show only decision points + +**No new data model** — read-only aggregation over existing `Revision`, `Comment`, and `Annotation` records. **Key files:** -- `src/components/review/comparison-viewer.tsx` -- `src/components/review/wipe-divider.tsx` -- `src/components/review/overlay-controls.tsx` -- `src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx` +- `src/components/review/revision-timeline.tsx` — Main timeline panel +- `src/components/review/revision-node.tsx` — Individual round node +- `src/components/review/revision-comments.tsx` — Per-round comment display +- `src/components/review/revision-annotations-summary.tsx` — Annotation count + filters +- `src/hooks/use-revision-history.ts` — TanStack Query hook aggregating revisions + comments + annotations -**Dependencies:** Requires A2 +**Dependencies:** Requires A1. Enhanced by A3 (annotation counts and filtering). --- -#### A5 — Revision History Timeline +#### A5 — Feedback Checklist (Artist Action Items) `[ ]` -**What:** Collapsible panel in the review viewer showing all revision rounds as a vertical timeline. Each node shows thumbnail, status badge, submitter, annotation count, comment summary, and decision record. +Every annotation and actionable comment becomes a structured to-do item on a checklist for the assigned artist. Closes the feedback-to-fix loop. -**No new data model** — aggregation over existing `Revision`, `Comment`, and `Annotation` records. +**The feedback loop:** +1. Reviewer draws annotation or posts actionable comment +2. System auto-creates a FeedbackItem linked to the annotation/comment +3. Artist sees checklist — organized by severity, with direct links to annotation on the image +4. Artist works through items — checks each off with optional resolution note +5. Artist submits new revision — unchecked items carry forward with a warning +6. Reviewer verifies — can confirm resolution or reopen -**Key files:** -- `src/components/review/revision-timeline.tsx` -- `src/components/review/revision-node.tsx` -- `src/components/review/revision-comments.tsx` -- `src/components/review/revision-annotations-summary.tsx` -- `src/hooks/use-revision-history.ts` +**Where the checklist appears (3 locations):** +1. **Review page — Feedback Panel** (primary): full checklist with severity indicators, thumbnail crops of annotated regions, resolve/reopen actions, progress bar +2. **My Work page** — feedback badge per assignment ("5 open items"), expandable inline checklist, deep-link to review page +3. **Stage card on deliverable page** — compact badge ("4/7 resolved"), color-coded by severity -**Dependencies:** Requires A2 + A3 - ---- - -#### A6 — Feedback Action Items (Artist Checklist) - -**What:** Every annotation and actionable comment auto-creates a FeedbackItem on a structured checklist. Artists work through items (Critical / Major / Minor / Suggestion), check them off with resolution notes. Unresolved critical items block resubmission. Checklist appears in three places: review viewer panel, My Work page, and stage card badge. +**Severity levels:** +- **Critical** — must fix, blocks approval +- **Major** — should fix, significant quality issue +- **Minor** — nice to fix, small quality issue +- **Suggestion** — optional improvement **New data model:** ```prisma @@ -280,21 +327,28 @@ enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED } ``` **Key files:** -- `src/components/review/feedback-checklist.tsx` -- `src/components/review/feedback-item.tsx` -- `src/components/review/feedback-progress-bar.tsx` -- `src/components/my-work/feedback-summary.tsx` -- `src/components/stages/feedback-indicator.tsx` -- `src/lib/services/feedback-service.ts` +- `src/components/review/feedback-checklist.tsx` — Main checklist panel +- `src/components/review/feedback-item.tsx` — Individual item with resolve action +- `src/components/review/feedback-progress-bar.tsx` — Progress bar +- `src/components/my-work/feedback-summary.tsx` — Inline checklist on My Work +- `src/components/stages/feedback-indicator.tsx` — Compact badge for stage cards +- `src/lib/services/feedback-service.ts` — CRUD, auto-creation, carry-forward logic - `src/hooks/use-feedback-items.ts` -**Dependencies:** Requires A3 + A5 +**Dependencies:** Requires A3 (annotations to generate items from) + A4 (timeline for round context) --- -#### A7 — Review Sessions & Playlists +#### A6 — Review Sessions & Playlists `[ ]` -**What:** Curate an ordered set of deliverables into a review session. Walk through them in presenter mode with per-item approve/request-changes/reject decisions. Shareable via link. +Curate a batch of deliverables into a structured review session. Walk through them sequentially in presenter mode with per-item decisions. The formal review workflow for HP stakeholder reviews. + +**What gets built:** +- **Session builder** — pick deliverables/stages to include, drag to reorder, auto-generate from filters ("all Catalog Images in Review for Project X") +- **Presenter mode** — full-screen, navigate with arrow keys, image viewer with annotations, comment sidebar, prominent approve/request-changes/reject buttons per item +- **Summary view** — thumbnail grid with decision status badges, overall session progress +- **Session states** — DRAFT → IN_PROGRESS → COMPLETED +- **Shareable link** with optional expiry and access control **New data model:** ```prisma @@ -328,14 +382,14 @@ enum ReviewDecision { APPROVED CHANGES_REQUESTED REJECTED } ``` **Key files:** -- `src/app/(app)/reviews/page.tsx` -- `src/app/(app)/reviews/[sessionId]/page.tsx` -- `src/components/review/session-builder.tsx` -- `src/components/review/session-presenter.tsx` -- `src/components/review/session-summary.tsx` +- `src/app/(app)/reviews/page.tsx` — Session list +- `src/app/(app)/reviews/[sessionId]/page.tsx` — Session presenter view +- `src/components/review/session-builder.tsx` — Create/edit session +- `src/components/review/session-presenter.tsx` — Full-screen walkthrough +- `src/components/review/session-summary.tsx` — Thumbnail grid with decisions - `src/lib/services/review-session-service.ts` -**Dependencies:** Requires A2 + A3 +**Dependencies:** Requires A1 + A3 --- @@ -794,8 +848,9 @@ Note: `Dockerfile` and `docker-compose.yml` already exist in the repo root — r | Model | Feature | |---|---| -| Annotation, FeedbackItem | A3, A6 | -| ReviewSession, ReviewSessionItem | A7 | +| Annotation | A3 | +| FeedbackItem | A5 | +| ReviewSession, ReviewSessionItem | A6 | | ApprovalChain, ApprovalStep, ApprovalRecord | D2 | | ProjectTemplate, ProjectTemplateDeliverable | D3 | | AssetSpec, AssetValidationResult | E1 | diff --git a/package-lock.json b/package-lock.json index bfb8bcf..35fb915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "react-markdown": "^10.1.0", "recharts": "^3.7.0", "remark-gfm": "^4.0.1", + "sharp": "^0.34.5", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", @@ -1225,7 +1226,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -13675,7 +13675,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -13719,7 +13718,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index a4c9075..42abbe7 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "react-markdown": "^10.1.0", "recharts": "^3.7.0", "remark-gfm": "^4.0.1", + "sharp": "^0.34.5", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx index 9287240..f600890 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx @@ -7,6 +7,7 @@ import { format } from "date-fns"; import { ArrowLeft, ChevronRight, + Eye, FileText, Lock, RotateCcw, @@ -376,17 +377,31 @@ export default function DeliverableDetailPage() { )} - {/* Details button */} + {/* Action buttons */} {stage.status !== "BLOCKED" && ( - +
+ + + + +
)} diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx new file mode 100644 index 0000000..e64e23a --- /dev/null +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { + ArrowLeft, + ChevronLeft, + ChevronRight, + Upload, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { StageStatusBadge } from "@/components/stages/stage-status-badge"; +import { ImageViewer } from "@/components/review/image-viewer"; +import { ImageUploadZone } from "@/components/review/image-upload-zone"; +import { ImageGallery } from "@/components/review/image-gallery"; +import { useDeliverable } from "@/hooks/use-deliverables"; +import { useRevisions } from "@/hooks/use-revisions"; +import { useQueryClient } from "@tanstack/react-query"; + +interface AttachedImage { + url: string; + filename: string; + size: number; + width: number; + height: number; + uploadedAt: string; + originalUrl?: string; +} + +interface RevisionAttachments { + referenceImage?: AttachedImage; + currentImage?: AttachedImage; +} + +interface RevisionImage { + revisionId: string; + roundNumber: number; + type: "reference" | "current"; + url: string; + thumbnailUrl: string; + filename: string; +} + +export default function ReviewPage() { + const { projectId, deliverableId } = useParams<{ + projectId: string; + deliverableId: string; + }>(); + const queryClient = useQueryClient(); + + const { data: deliverableData, isLoading: delLoading } = useDeliverable( + projectId, + deliverableId + ); + const deliverable = deliverableData as any; + + // Stage selection — default to first non-blocked stage + const [selectedStageId, setSelectedStageId] = useState(null); + const [uploadPanelOpen, setUploadPanelOpen] = useState(false); + const [activeImageUrl, setActiveImageUrl] = useState(null); + + const stages = useMemo(() => { + if (!deliverable?.stages) return []; + return [...deliverable.stages].sort( + (a: any, b: any) => a.template.order - b.template.order + ); + }, [deliverable]); + + // Auto-select first stage + useEffect(() => { + if (stages.length > 0 && !selectedStageId) { + const first = + stages.find((s: any) => s.status !== "BLOCKED") ?? stages[0]; + setSelectedStageId(first.id); + } + }, [stages, selectedStageId]); + + const selectedStage = stages.find((s: any) => s.id === selectedStageId); + const stageIdx = stages.findIndex((s: any) => s.id === selectedStageId); + + // Load revisions for selected stage + const { data: revisionsData } = useRevisions(selectedStageId ?? ""); + const revisions = (revisionsData as any[]) ?? []; + + // Build gallery from all revisions + const galleryImages: RevisionImage[] = useMemo(() => { + const images: RevisionImage[] = []; + for (const rev of revisions) { + const attachments = rev.attachments as RevisionAttachments | null; + if (!attachments) continue; + + if (attachments.referenceImage) { + const thumbUrl = attachments.referenceImage.url.replace( + /\.(png|jpg|jpeg|webp)$/i, + "_thumb.jpg" + ); + images.push({ + revisionId: rev.id, + roundNumber: rev.roundNumber, + type: "reference", + url: attachments.referenceImage.url, + thumbnailUrl: thumbUrl, + filename: attachments.referenceImage.filename, + }); + } + if (attachments.currentImage) { + const thumbUrl = attachments.currentImage.url.replace( + /\.(png|jpg|jpeg|webp)$/i, + "_thumb.jpg" + ); + images.push({ + revisionId: rev.id, + roundNumber: rev.roundNumber, + type: "current", + url: attachments.currentImage.url, + thumbnailUrl: thumbUrl, + filename: attachments.currentImage.filename, + }); + } + } + return images; + }, [revisions]); + + // Auto-select the latest current image + useEffect(() => { + if (!activeImageUrl && galleryImages.length > 0) { + const latestCurrent = galleryImages.find((i) => i.type === "current"); + setActiveImageUrl(latestCurrent?.url ?? galleryImages[0].url); + } + }, [galleryImages, activeImageUrl]); + + // Latest revision for upload panel + const latestRevision = revisions[0] as any | undefined; + const latestAttachments = + (latestRevision?.attachments as RevisionAttachments) ?? {}; + + const handleUploadComplete = useCallback(() => { + if (selectedStageId) { + queryClient.invalidateQueries({ + queryKey: ["revisions", selectedStageId], + }); + } + // Reset active image to pick up new upload + setActiveImageUrl(null); + }, [selectedStageId, queryClient]); + + const navigateStage = useCallback( + (direction: -1 | 1) => { + const newIdx = stageIdx + direction; + if (newIdx >= 0 && newIdx < stages.length) { + setSelectedStageId(stages[newIdx].id); + setActiveImageUrl(null); + } + }, + [stageIdx, stages] + ); + + if (delLoading) { + return ( +
+ + +
+ ); + } + + if (!deliverable) { + return ( +
+ Deliverable not found. +
+ ); + } + + return ( +
+ {/* ── Top bar ──────────────────────────────────────────────── */} +
+
+ + + Back + + +

+ {deliverable.name} +

+
+ + {/* Stage navigator */} + {selectedStage && ( +
+ +
+ + {selectedStage.template.order}. + + + {selectedStage.template.name} + + +
+ + + + + {/* Upload panel trigger */} + + + + + + + Upload Images + + + {latestRevision ? ( +
+

+ Uploading to Round {latestRevision.roundNumber} +

+
+

+ Reference Image +

+ +
+
+

+ Current Render +

+ +
+
+ ) : ( +

+ Submit a revision first before uploading images. +

+ )} +
+
+
+ )} +
+ + {/* ── Image viewer ─────────────────────────────────────────── */} + + + {/* ── Gallery strip ────────────────────────────────────────── */} + {galleryImages.length > 0 && ( +
+ setActiveImageUrl(img.url)} + /> +
+ )} +
+ ); +} diff --git a/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts b/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts new file mode 100644 index 0000000..0c03e51 --- /dev/null +++ b/src/app/api/stages/[stageId]/revisions/[revisionId]/upload/route.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils"; +import { prisma } from "@/lib/prisma"; +import { + processAndStoreImage, + deleteRevisionImage, +} from "@/lib/services/upload-service"; +import type { UploadedImage } from "@/lib/services/upload-service"; + +type Params = { params: Promise<{ stageId: string; revisionId: string }> }; + +interface Attachments { + referenceImage?: UploadedImage; + currentImage?: UploadedImage; +} + +// POST /api/stages/:stageId/revisions/:revisionId/upload +export async function POST(request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { stageId, revisionId } = await params; + + // Verify revision belongs to this stage + const revision = await prisma.revision.findFirst({ + where: { id: revisionId, deliverableStageId: stageId }, + }); + if (!revision) return notFound("Revision not found"); + + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const imageType = formData.get("type") as "reference" | "current" | null; + + if (!file) return badRequest("No file provided"); + if (!imageType || !["reference", "current"].includes(imageType)) { + return badRequest('Image type must be "reference" or "current"'); + } + + // Process and store the image + const uploaded = await processAndStoreImage(revisionId, file, imageType); + + // Update revision attachments JSON + const existing = (revision.attachments as Attachments) ?? {}; + const updated: Attachments = { + ...existing, + [imageType === "reference" ? "referenceImage" : "currentImage"]: uploaded, + }; + + await prisma.revision.update({ + where: { id: revisionId }, + data: { attachments: updated }, + }); + + return NextResponse.json(uploaded, { status: 201 }); + } catch (e) { + if (e instanceof Error && e.message.includes("Unsupported file type")) { + return badRequest(e.message); + } + if (e instanceof Error && e.message.includes("File too large")) { + return badRequest(e.message); + } + return serverError(e); + } +} + +// DELETE /api/stages/:stageId/revisions/:revisionId/upload?type=reference|current +export async function DELETE(request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { stageId, revisionId } = await params; + const url = new URL(request.url); + const imageType = url.searchParams.get("type") as "reference" | "current" | null; + + if (!imageType || !["reference", "current"].includes(imageType)) { + return badRequest('Query param "type" must be "reference" or "current"'); + } + + const revision = await prisma.revision.findFirst({ + where: { id: revisionId, deliverableStageId: stageId }, + }); + if (!revision) return notFound("Revision not found"); + + // Delete files from disk + await deleteRevisionImage(revisionId, imageType); + + // Remove from attachments JSON + const existing = (revision.attachments as Attachments) ?? {}; + const key = imageType === "reference" ? "referenceImage" : "currentImage"; + const { [key]: _removed, ...rest } = existing; + + await prisma.revision.update({ + where: { id: revisionId }, + data: { attachments: Object.keys(rest).length > 0 ? rest : null }, + }); + + return NextResponse.json({ ok: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/review/image-gallery.tsx b/src/components/review/image-gallery.tsx new file mode 100644 index 0000000..22b45d1 --- /dev/null +++ b/src/components/review/image-gallery.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +interface RevisionImage { + revisionId: string; + roundNumber: number; + type: "reference" | "current"; + url: string; + thumbnailUrl: string; + filename: string; +} + +interface ImageGalleryProps { + images: RevisionImage[]; + activeUrl: string | null; + onSelect: (image: RevisionImage) => void; +} + +export function ImageGallery({ + images, + activeUrl, + onSelect, +}: ImageGalleryProps) { + if (images.length === 0) return null; + + return ( +
+ + Gallery + +
+ {images.map((img) => ( + + ))} +
+ ); +} diff --git a/src/components/review/image-upload-zone.tsx b/src/components/review/image-upload-zone.tsx new file mode 100644 index 0000000..9b72128 --- /dev/null +++ b/src/components/review/image-upload-zone.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { Upload, ImageIcon, X, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +interface ImageUploadZoneProps { + stageId: string; + revisionId: string; + imageType: "reference" | "current"; + existingImage?: { + url: string; + filename: string; + width: number; + height: number; + } | null; + onUploadComplete: () => void; + compact?: boolean; +} + +export function ImageUploadZone({ + stageId, + revisionId, + imageType, + existingImage, + onUploadComplete, + compact = false, +}: ImageUploadZoneProps) { + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const uploadFile = useCallback( + async (file: File) => { + setIsUploading(true); + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", imageType); + + const res = await fetch( + `/api/stages/${stageId}/revisions/${revisionId}/upload`, + { method: "POST", body: formData } + ); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Upload failed"); + } + + toast.success( + `${imageType === "reference" ? "Reference" : "Current"} image uploaded` + ); + onUploadComplete(); + } catch (e) { + toast.error("Upload failed", { + description: e instanceof Error ? e.message : "Unknown error", + }); + } finally { + setIsUploading(false); + } + }, + [stageId, revisionId, imageType, onUploadComplete] + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) uploadFile(file); + }, + [uploadFile] + ); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) uploadFile(file); + // Reset input so the same file can be selected again + e.target.value = ""; + }, + [uploadFile] + ); + + const handleDelete = useCallback(async () => { + try { + const res = await fetch( + `/api/stages/${stageId}/revisions/${revisionId}/upload?type=${imageType}`, + { method: "DELETE" } + ); + if (!res.ok) throw new Error("Delete failed"); + toast.success("Image removed"); + onUploadComplete(); + } catch (e) { + toast.error("Failed to remove image"); + } + }, [stageId, revisionId, imageType, onUploadComplete]); + + // Existing image: show thumbnail with replace/delete actions + if (existingImage) { + return ( +
+ {existingImage.filename} +
+ + +
+ +
+ ); + } + + // Empty state: drop zone + return ( +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => !isUploading && fileInputRef.current?.click()} + role="button" + tabIndex={0} + > + {isUploading ? ( + + ) : compact ? ( + + ) : ( + <> + +

+ {imageType === "reference" ? "Reference Image" : "Current Render"} +

+

+ Drop file or click to browse +

+

+ PNG, JPEG, WebP, TIFF — up to 50MB +

+ + )} + +
+ ); +} diff --git a/src/components/review/image-viewer.tsx b/src/components/review/image-viewer.tsx new file mode 100644 index 0000000..f62a7a9 --- /dev/null +++ b/src/components/review/image-viewer.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useEffect } from "react"; +import { useImageViewer } from "@/hooks/use-image-viewer"; +import { ZoomControls } from "@/components/review/zoom-controls"; +import { Minimap } from "@/components/review/minimap"; +import { ImageIcon, Loader2 } from "lucide-react"; + +interface ImageViewerProps { + src: string | null; + imageDimensionsOverride?: { width: number; height: number } | null; + className?: string; +} + +export function ImageViewer({ + src, + imageDimensionsOverride, + className, +}: ImageViewerProps) { + const viewer = useImageViewer(); + + // Load image when src changes + useEffect(() => { + if (src) { + viewer.loadImage(src); + } + }, [src]); // eslint-disable-line react-hooks/exhaustive-deps + + const containerRect = + viewer.containerRef.current?.getBoundingClientRect(); + const containerW = containerRect?.width ?? 0; + const containerH = containerRect?.height ?? 0; + const dims = imageDimensionsOverride ?? viewer.imageDimensions; + + return ( +
+ {/* Toolbar */} +
+
+ + {dims && ( + + {dims.width} × {dims.height} + + )} +
+ + {/* Pixel info */} + {viewer.pixelInfo && ( +
+ + {viewer.pixelInfo.x}, {viewer.pixelInfo.y} + +
+
+ {viewer.pixelInfo.color} +
+
+ )} +
+ + {/* Canvas viewport */} +
+ {!src && !viewer.isLoading && ( +
+ +

No image loaded

+

+ Upload images to a revision to start reviewing +

+
+ )} + + {viewer.isLoading && ( +
+ +
+ )} + + + + {/* Minimap */} + {src && dims && ( + { + viewer.setPan(panX, panY); + }} + /> + )} +
+
+ ); +} diff --git a/src/components/review/minimap.tsx b/src/components/review/minimap.tsx new file mode 100644 index 0000000..374b705 --- /dev/null +++ b/src/components/review/minimap.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; + +interface MinimapProps { + imageSrc: string | null; + imageWidth: number; + imageHeight: number; + zoom: number; + panX: number; + panY: number; + containerWidth: number; + containerHeight: number; + onNavigate: (panX: number, panY: number) => void; +} + +const MINIMAP_SIZE = 150; + +export function Minimap({ + imageSrc, + imageWidth, + imageHeight, + zoom, + panX, + panY, + containerWidth, + containerHeight, + onNavigate, +}: MinimapProps) { + const canvasRef = useRef(null); + const imageRef = useRef(null); + + // Only show minimap when zoomed in past fit + const imageDisplayW = imageWidth * zoom; + const imageDisplayH = imageHeight * zoom; + const isZoomedIn = + imageDisplayW > containerWidth || imageDisplayH > containerHeight; + + const scale = Math.min( + MINIMAP_SIZE / imageWidth, + MINIMAP_SIZE / imageHeight + ); + const mapW = Math.ceil(imageWidth * scale); + const mapH = Math.ceil(imageHeight * scale); + + const render = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + const img = imageRef.current; + if (!canvas || !ctx || !img) return; + + ctx.clearRect(0, 0, mapW, mapH); + + // Draw scaled image + ctx.drawImage(img, 0, 0, mapW, mapH); + + // Draw viewport rectangle + const vpX = (-panX / zoom) * scale; + const vpY = (-panY / zoom) * scale; + const vpW = (containerWidth / zoom) * scale; + const vpH = (containerHeight / zoom) * scale; + + ctx.strokeStyle = "rgba(255, 255, 255, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(vpX, vpY, vpW, vpH); + + ctx.fillStyle = "rgba(255, 255, 255, 0.08)"; + ctx.fillRect(vpX, vpY, vpW, vpH); + }, [mapW, mapH, panX, panY, zoom, containerWidth, containerHeight, scale]); + + // Load minimap image + useEffect(() => { + if (!imageSrc) return; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + imageRef.current = img; + render(); + }; + img.src = imageSrc; + }, [imageSrc, render]); + + // Re-render on viewport changes + useEffect(() => { + render(); + }, [render]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + // Convert minimap coordinates to image coordinates + const imgX = clickX / scale; + const imgY = clickY / scale; + + // Center the viewport on this point + const newPanX = containerWidth / 2 - imgX * zoom; + const newPanY = containerHeight / 2 - imgY * zoom; + + onNavigate(newPanX, newPanY); + }, + [scale, zoom, containerWidth, containerHeight, onNavigate] + ); + + if (!isZoomedIn || !imageSrc) return null; + + return ( +
+ +
+ ); +} diff --git a/src/components/review/zoom-controls.tsx b/src/components/review/zoom-controls.tsx new file mode 100644 index 0000000..1e012d2 --- /dev/null +++ b/src/components/review/zoom-controls.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Minus, Plus, Maximize, Square } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { ZoomPreset } from "@/hooks/use-image-viewer"; + +const PRESETS: { label: string; value: ZoomPreset }[] = [ + { label: "Fit", value: "fit" }, + { label: "50%", value: "50" }, + { label: "100%", value: "100" }, + { label: "150%", value: "150" }, + { label: "200%", value: "200" }, +]; + +interface ZoomControlsProps { + zoom: number; + onZoomIn: () => void; + onZoomOut: () => void; + onFitToView: () => void; + onZoomToPreset: (preset: ZoomPreset) => void; +} + +export function ZoomControls({ + zoom, + onZoomIn, + onZoomOut, + onFitToView, + onZoomToPreset, +}: ZoomControlsProps) { + const percent = Math.round(zoom * 100); + + return ( +
+ + + + + + + + {PRESETS.map((p) => ( + onZoomToPreset(p.value)} + className="font-mono text-xs" + > + {p.label} + + ))} + + + + + +
+ + + + +
+ ); +} diff --git a/src/hooks/use-image-viewer.ts b/src/hooks/use-image-viewer.ts new file mode 100644 index 0000000..41c54e4 --- /dev/null +++ b/src/hooks/use-image-viewer.ts @@ -0,0 +1,395 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export type ZoomPreset = "fit" | "50" | "100" | "150" | "200"; + +interface ViewerState { + zoom: number; + panX: number; + panY: number; +} + +interface ImageDimensions { + width: number; + height: number; +} + +export interface UseImageViewerReturn { + canvasRef: React.RefObject; + containerRef: React.RefObject; + state: ViewerState; + imageDimensions: ImageDimensions | null; + fitZoom: number; + // Actions + setZoom: (zoom: number, centerX?: number, centerY?: number) => void; + setPan: (panX: number, panY: number) => void; + zoomToPreset: (preset: ZoomPreset) => void; + zoomIn: () => void; + zoomOut: () => void; + fitToView: () => void; + // Pixel info + pixelInfo: { x: number; y: number; color: string } | null; + // Image loading + loadImage: (src: string) => void; + isLoading: boolean; +} + +const MIN_ZOOM = 0.05; +const MAX_ZOOM = 5; +const ZOOM_STEP = 1.15; + +export function useImageViewer(): UseImageViewerReturn { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const imageRef = useRef(null); + const rafRef = useRef(0); + + const [state, setState] = useState({ + zoom: 1, + panX: 0, + panY: 0, + }); + const [imageDimensions, setImageDimensions] = + useState(null); + const [fitZoom, setFitZoom] = useState(1); + const [pixelInfo, setPixelInfo] = useState<{ + x: number; + y: number; + color: string; + } | null>(null); + const [isLoading, setIsLoading] = useState(false); + + // Use refs for state in event handlers to avoid stale closures + const stateRef = useRef(state); + stateRef.current = state; + + const isPanning = useRef(false); + const lastMouse = useRef({ x: 0, y: 0 }); + + // ── Render ────────────────────────────────────────────────────────── + const render = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + const img = imageRef.current; + if (!canvas || !ctx || !img) return; + + const container = containerRef.current; + if (!container) return; + + // Handle retina + const dpr = window.devicePixelRatio || 1; + const rect = container.getBoundingClientRect(); + + if ( + canvas.width !== rect.width * dpr || + canvas.height !== rect.height * dpr + ) { + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + } + + const { zoom, panX, panY } = stateRef.current; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw checkerboard background for transparency indication + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.translate(panX, panY); + ctx.scale(zoom, zoom); + + // Draw white background behind image area + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, img.width, img.height); + + ctx.imageSmoothingEnabled = zoom < 1; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img, 0, 0); + }, []); + + const requestRender = useCallback(() => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(render); + }, [render]); + + // ── Fit calculation ───────────────────────────────────────────────── + const calculateFitZoom = useCallback(() => { + const container = containerRef.current; + const img = imageRef.current; + if (!container || !img) return 1; + + const rect = container.getBoundingClientRect(); + const padding = 20; + const availW = rect.width - padding * 2; + const availH = rect.height - padding * 2; + + return Math.min(availW / img.width, availH / img.height, 1); + }, []); + + // ── Actions ───────────────────────────────────────────────────────── + const fitToView = useCallback(() => { + const container = containerRef.current; + const img = imageRef.current; + if (!container || !img) return; + + const zoom = calculateFitZoom(); + const rect = container.getBoundingClientRect(); + const panX = (rect.width - img.width * zoom) / 2; + const panY = (rect.height - img.height * zoom) / 2; + + setFitZoom(zoom); + setState({ zoom, panX, panY }); + requestRender(); + }, [calculateFitZoom, requestRender]); + + const setZoom = useCallback( + (newZoom: number, centerX?: number, centerY?: number) => { + const container = containerRef.current; + if (!container) return; + + const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom)); + const rect = container.getBoundingClientRect(); + + // Default center is the middle of the container + const cx = centerX ?? rect.width / 2; + const cy = centerY ?? rect.height / 2; + + const { zoom: oldZoom, panX, panY } = stateRef.current; + const scale = clamped / oldZoom; + + // Zoom toward the cursor/center point + const newPanX = cx - (cx - panX) * scale; + const newPanY = cy - (cy - panY) * scale; + + setState({ zoom: clamped, panX: newPanX, panY: newPanY }); + requestRender(); + }, + [requestRender] + ); + + const setPan = useCallback( + (panX: number, panY: number) => { + setState((prev) => ({ ...prev, panX, panY })); + requestRender(); + }, + [requestRender] + ); + + const zoomIn = useCallback(() => { + setZoom(stateRef.current.zoom * ZOOM_STEP); + }, [setZoom]); + + const zoomOut = useCallback(() => { + setZoom(stateRef.current.zoom / ZOOM_STEP); + }, [setZoom]); + + const zoomToPreset = useCallback( + (preset: ZoomPreset) => { + if (preset === "fit") { + fitToView(); + return; + } + const level = parseInt(preset) / 100; + const container = containerRef.current; + const img = imageRef.current; + if (!container || !img) return; + + const rect = container.getBoundingClientRect(); + const panX = (rect.width - img.width * level) / 2; + const panY = (rect.height - img.height * level) / 2; + + setState({ zoom: level, panX, panY }); + requestRender(); + }, + [fitToView, requestRender] + ); + + // ── Load image ────────────────────────────────────────────────────── + const loadImage = useCallback( + (src: string) => { + setIsLoading(true); + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + imageRef.current = img; + setImageDimensions({ width: img.width, height: img.height }); + setIsLoading(false); + // Delay fit to allow container to settle + requestAnimationFrame(() => fitToView()); + }; + img.onerror = () => { + setIsLoading(false); + }; + img.src = src; + }, + [fitToView] + ); + + // ── Event handlers ────────────────────────────────────────────────── + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + // Wheel → zoom + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + setZoom(stateRef.current.zoom * factor, mouseX, mouseY); + }; + + // Mouse → pan + const handleMouseDown = (e: MouseEvent) => { + if (e.button !== 0) return; // left click only + isPanning.current = true; + lastMouse.current = { x: e.clientX, y: e.clientY }; + canvas.style.cursor = "grabbing"; + }; + + const handleMouseMove = (e: MouseEvent) => { + // Update pixel info + const img = imageRef.current; + if (img) { + const rect = canvas.getBoundingClientRect(); + const { zoom, panX, panY } = stateRef.current; + const imgX = Math.floor((e.clientX - rect.left - panX) / zoom); + const imgY = Math.floor((e.clientY - rect.top - panY) / zoom); + + if (imgX >= 0 && imgX < img.width && imgY >= 0 && imgY < img.height) { + // Read pixel color from the canvas + const dpr = window.devicePixelRatio || 1; + const ctx = canvas.getContext("2d"); + if (ctx) { + const pixel = ctx.getImageData( + (e.clientX - rect.left) * dpr, + (e.clientY - rect.top) * dpr, + 1, + 1 + ).data; + const hex = `#${pixel[0].toString(16).padStart(2, "0")}${pixel[1].toString(16).padStart(2, "0")}${pixel[2].toString(16).padStart(2, "0")}`; + setPixelInfo({ x: imgX, y: imgY, color: hex.toUpperCase() }); + } + } else { + setPixelInfo(null); + } + } + + if (!isPanning.current) return; + const dx = e.clientX - lastMouse.current.x; + const dy = e.clientY - lastMouse.current.y; + lastMouse.current = { x: e.clientX, y: e.clientY }; + + setState((prev) => ({ + ...prev, + panX: prev.panX + dx, + panY: prev.panY + dy, + })); + requestRender(); + }; + + const handleMouseUp = () => { + isPanning.current = false; + canvas.style.cursor = "grab"; + }; + + const handleMouseLeave = () => { + isPanning.current = false; + canvas.style.cursor = "grab"; + setPixelInfo(null); + }; + + canvas.addEventListener("wheel", handleWheel, { passive: false }); + canvas.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("mouseleave", handleMouseLeave); + + canvas.style.cursor = "grab"; + + return () => { + canvas.removeEventListener("wheel", handleWheel); + canvas.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [setZoom, requestRender]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Don't capture if typing in an input + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + switch (e.key) { + case "=": + case "+": + e.preventDefault(); + zoomIn(); + break; + case "-": + e.preventDefault(); + zoomOut(); + break; + case "0": + e.preventDefault(); + fitToView(); + break; + case "1": + e.preventDefault(); + zoomToPreset("100"); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [zoomIn, zoomOut, fitToView, zoomToPreset]); + + // Resize observer + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(() => { + requestRender(); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [requestRender]); + + // Re-render when state changes + useEffect(() => { + requestRender(); + }, [state, requestRender]); + + return { + canvasRef, + containerRef, + state, + imageDimensions, + fitZoom, + setZoom, + setPan, + zoomToPreset, + zoomIn, + zoomOut, + fitToView, + pixelInfo, + loadImage, + isLoading, + }; +} diff --git a/src/lib/services/upload-service.ts b/src/lib/services/upload-service.ts new file mode 100644 index 0000000..009ec8c --- /dev/null +++ b/src/lib/services/upload-service.ts @@ -0,0 +1,145 @@ +import { writeFile, mkdir, unlink } from "fs/promises"; +import { existsSync } from "fs"; +import path from "path"; +import sharp from "sharp"; + +const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "revisions"); + +const ALLOWED_TYPES = [ + "image/png", + "image/jpeg", + "image/webp", + "image/tiff", +]; + +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB + +export interface UploadedImage { + url: string; + filename: string; + size: number; + width: number; + height: number; + uploadedAt: string; + originalUrl?: string; // kept for transparent PNGs +} + +/** + * Process and store an uploaded image for a revision. + * + * - Validates file type and size + * - For PNGs with alpha: flattens onto white background (CG renders + * have semi-transparent drop shadows that break comparison modes) + * - Stores the processed image and returns metadata + */ +export async function processAndStoreImage( + revisionId: string, + file: File, + imageType: "reference" | "current" +): Promise { + // Validate type + if (!ALLOWED_TYPES.includes(file.type)) { + throw new Error( + `Unsupported file type: ${file.type}. Allowed: PNG, JPEG, WebP, TIFF.` + ); + } + + // Validate size + if (file.size > MAX_FILE_SIZE) { + throw new Error( + `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum: 50MB.` + ); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const metadata = await sharp(buffer).metadata(); + + if (!metadata.width || !metadata.height) { + throw new Error("Could not read image dimensions."); + } + + // Ensure upload directory exists + const revisionDir = path.join(UPLOADS_DIR, revisionId); + if (!existsSync(revisionDir)) { + await mkdir(revisionDir, { recursive: true }); + } + + const timestamp = Date.now(); + const ext = file.type === "image/tiff" ? "tiff" : "png"; + const filename = `${imageType}_${timestamp}.${ext}`; + const filePath = path.join(revisionDir, filename); + const url = `/uploads/revisions/${revisionId}/${filename}`; + + let originalUrl: string | undefined; + + // PNG alpha compositing: flatten transparent PNGs onto white + if (metadata.format === "png" && metadata.hasAlpha) { + // Save original for download + const originalFilename = `${imageType}_${timestamp}_original.png`; + const originalPath = path.join(revisionDir, originalFilename); + await writeFile(originalPath, buffer); + originalUrl = `/uploads/revisions/${revisionId}/${originalFilename}`; + + // Flatten onto white background + const flattened = await sharp(buffer) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .png() + .toBuffer(); + await writeFile(filePath, flattened); + } else if (metadata.format === "tiff") { + // Convert TIFF to PNG for browser display, keep original + const originalFilename = `${imageType}_${timestamp}_original.tiff`; + const originalPath = path.join(revisionDir, originalFilename); + await writeFile(originalPath, buffer); + originalUrl = `/uploads/revisions/${revisionId}/${originalFilename}`; + + const converted = await sharp(buffer).png().toBuffer(); + const pngFilename = `${imageType}_${timestamp}.png`; + const pngPath = path.join(revisionDir, pngFilename); + await writeFile(pngPath, converted); + // URL already points to png + } else { + // JPEG/WebP/PNG without alpha: store as-is + await writeFile(filePath, buffer); + } + + // Generate thumbnail for gallery + const thumbFilename = `${imageType}_${timestamp}_thumb.jpg`; + const thumbPath = path.join(revisionDir, thumbFilename); + await sharp(buffer) + .resize(200, 200, { fit: "inside", withoutEnlargement: true }) + .flatten({ background: { r: 255, g: 255, b: 255 } }) + .jpeg({ quality: 80 }) + .toBuffer() + .then((thumbBuffer) => writeFile(thumbPath, thumbBuffer)); + + return { + url, + filename: file.name, + size: file.size, + width: metadata.width, + height: metadata.height, + uploadedAt: new Date().toISOString(), + originalUrl, + }; +} + +/** + * Delete uploaded images for a revision image type. + */ +export async function deleteRevisionImage( + revisionId: string, + imageType: "reference" | "current" +): Promise { + const revisionDir = path.join(UPLOADS_DIR, revisionId); + if (!existsSync(revisionDir)) return; + + const { readdir } = await import("fs/promises"); + const files = await readdir(revisionDir); + + for (const file of files) { + if (file.startsWith(`${imageType}_`)) { + await unlink(path.join(revisionDir, file)).catch(() => {}); + } + } +} From eba5e30c987446c1c1f2e0d77694d61b053bdca4 Mon Sep 17 00:00:00 2001 From: "Leivur R. Djurhuus" Date: Sat, 14 Mar 2026 14:46:51 -0500 Subject: [PATCH 02/11] feat: add version comparison (A2) and annotation system (A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2 — Version Comparison: - 4 comparison modes: side-by-side, A-B wipe slider, overlay with opacity, toggle with crossfade - Synced zoom/pan across all modes - Revision selectors for left/right image - Keyboard shortcuts: 1-4 switch modes, Escape exits A3 — Annotations: - SVG overlay with 7 annotation types: rectangle, ellipse, arrow, freehand, text, pin, screenshot paste (Cmd+V) - All annotations anchored to image coordinates (accurate at any zoom) - Annotation model added to Prisma schema (requires db push) - CRUD API routes at /api/revisions/[id]/annotations - Annotations linked to comments (transactional create) - Screenshot callouts: draggable, resizable with corner handles - Undo/redo stack, color picker, visibility toggle - Floating toolbar with backdrop blur Co-Authored-By: Claude Opus 4.6 (1M context) --- ROADMAP.md | 21 +- prisma/schema.prisma | 2 +- .../[deliverableId]/review/page.tsx | 174 +++++- .../annotations/[annotationId]/route.ts | 58 ++ .../[revisionId]/annotations/route.ts | 58 ++ src/components/review/annotation-layer.tsx | 191 +++++++ src/components/review/annotation-renderer.tsx | 280 ++++++++++ src/components/review/annotation-tools.tsx | 241 +++++++++ src/components/review/comparison-toolbar.tsx | 160 ++++++ src/components/review/comparison-viewer.tsx | 403 ++++++++++++++ src/components/review/image-viewer.tsx | 30 + src/components/review/overlay-controls.tsx | 42 ++ src/components/review/screenshot-callout.tsx | 160 ++++++ src/components/review/wipe-divider.tsx | 167 ++++++ src/hooks/use-annotation-state.ts | 512 ++++++++++++++++++ src/hooks/use-annotations.ts | 73 +++ src/lib/services/annotation-service.ts | 115 ++++ src/lib/validators/annotation.ts | 50 ++ 18 files changed, 2725 insertions(+), 12 deletions(-) create mode 100644 src/app/api/revisions/[revisionId]/annotations/[annotationId]/route.ts create mode 100644 src/app/api/revisions/[revisionId]/annotations/route.ts create mode 100644 src/components/review/annotation-layer.tsx create mode 100644 src/components/review/annotation-renderer.tsx create mode 100644 src/components/review/annotation-tools.tsx create mode 100644 src/components/review/comparison-toolbar.tsx create mode 100644 src/components/review/comparison-viewer.tsx create mode 100644 src/components/review/overlay-controls.tsx create mode 100644 src/components/review/screenshot-callout.tsx create mode 100644 src/components/review/wipe-divider.tsx create mode 100644 src/hooks/use-annotation-state.ts create mode 100644 src/hooks/use-annotations.ts create mode 100644 src/lib/services/annotation-service.ts create mode 100644 src/lib/validators/annotation.ts diff --git a/ROADMAP.md b/ROADMAP.md index 1b02892..7333957 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -120,11 +120,18 @@ The highest-impact remaining feature. CG production review is fundamentally visu The review tool lives at its own dedicated page (`/projects/[projectId]/deliverables/[deliverableId]/review`) and is also accessible from the stage detail sheet via a "Review" button. It's the primary interface for inspecting renders, comparing revisions, annotating feedback, and making approve/reject decisions. -**Existing infrastructure to build on:** -- `Revision` model exists with `attachments Json?` field (currently unused) -- Revision CRUD API + hooks + service already built -- Stage detail sheet has a tabbed interface (Revisions + Comments) -- No image upload, storage, or display infrastructure exists yet +**Infrastructure built so far:** +- Review page at `/projects/[projectId]/deliverables/[deliverableId]/review` with image viewer, upload, gallery +- Canvas-based image viewer with zoom/pan/minimap, retina support +- Image upload API with PNG alpha compositing + TIFF conversion (sharp) +- `Revision.attachments` JSON stores `{ referenceImage, currentImage }` with metadata +- Comparison viewer with 4 modes: side-by-side, wipe, overlay, toggle +- SVG annotation layer with 7 tools (rect, ellipse, arrow, freehand, text, pin, screenshot paste) +- Annotation model in Prisma schema (requires `db push` to sync) +- Annotation API: GET/POST `/api/revisions/[id]/annotations`, PATCH/DELETE `/api/revisions/[id]/annotations/[id]` +- Annotations linked to comments (transactional create), undo/redo stack +- Screenshot paste: Cmd+V pastes clipboard image as draggable/resizable callout +- "Review" button on stage cards in deliverable detail page **New dependency for all stages:** `sharp` (server-side PNG alpha compositing + image processing) @@ -165,7 +172,7 @@ The foundation. A dedicated review page with a high-fidelity image viewer and th --- -#### A2 — Version Comparison `[ ]` +#### A2 — Version Comparison `[x]` Compare two revisions of the same deliverable stage. This is the daily workhorse — producers and artists check what changed between rounds. @@ -188,7 +195,7 @@ Compare two revisions of the same deliverable stage. This is the daily workhorse --- -#### A3 — Annotations `[ ]` +#### A3 — Annotations `[x]` Draw pixel-accurate annotations directly on images. Each annotation is anchored to image coordinates so it stays accurate at any zoom level, and is linked to a comment for context. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a662f5c..9f40426 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -697,7 +697,7 @@ model SearchLog { @@map("search_logs") } -// ─── Enums (from feature branch) ──────────────────────── +// ─── Annotations (Visual Review) ──────────────────────── enum AnnotationType { RECTANGLE diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx index e64e23a..38294bb 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -8,6 +8,7 @@ import { ChevronLeft, ChevronRight, Upload, + Columns2, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -21,9 +22,15 @@ import { import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { StageStatusBadge } from "@/components/stages/stage-status-badge"; -import { ImageViewer } from "@/components/review/image-viewer"; +import { ImageViewer, type ImageViewerState } from "@/components/review/image-viewer"; +import { ComparisonViewer } from "@/components/review/comparison-viewer"; +import { + ComparisonToolbar, + type ComparisonMode, +} from "@/components/review/comparison-toolbar"; import { ImageUploadZone } from "@/components/review/image-upload-zone"; import { ImageGallery } from "@/components/review/image-gallery"; +import { AnnotationLayer } from "@/components/review/annotation-layer"; import { useDeliverable } from "@/hooks/use-deliverables"; import { useRevisions } from "@/hooks/use-revisions"; import { useQueryClient } from "@tanstack/react-query"; @@ -70,6 +77,13 @@ export default function ReviewPage() { const [uploadPanelOpen, setUploadPanelOpen] = useState(false); const [activeImageUrl, setActiveImageUrl] = useState(null); + // ── Comparison mode state ──────────────────────────────────────────── + const [comparisonActive, setComparisonActive] = useState(false); + const [comparisonMode, setComparisonMode] = + useState("side-by-side"); + const [leftRevisionKey, setLeftRevisionKey] = useState(""); + const [rightRevisionKey, setRightRevisionKey] = useState(""); + const stages = useMemo(() => { if (!deliverable?.stages) return []; return [...deliverable.stages].sort( @@ -132,6 +146,48 @@ export default function ReviewPage() { return images; }, [revisions]); + // Build revision options for comparison dropdowns + const revisionOptions = useMemo(() => { + return galleryImages.map((img) => ({ + revisionId: img.revisionId, + roundNumber: img.roundNumber, + type: img.type, + label: `R${img.roundNumber} — ${img.type === "reference" ? "Reference" : "Render"}`, + url: img.url, + })); + }, [galleryImages]); + + // Auto-select comparison revisions: previous round vs current + useEffect(() => { + if (!comparisonActive || revisionOptions.length < 2) return; + if (leftRevisionKey && rightRevisionKey) return; + + // Default: second-to-last as A, latest as B + const latest = revisionOptions[revisionOptions.length - 1]; + const previous = + revisionOptions.length >= 2 + ? revisionOptions[revisionOptions.length - 2] + : latest; + + setLeftRevisionKey(`${previous.revisionId}-${previous.type}`); + setRightRevisionKey(`${latest.revisionId}-${latest.type}`); + }, [comparisonActive, revisionOptions, leftRevisionKey, rightRevisionKey]); + + // Resolve selected revision keys to URLs + const leftSrc = useMemo(() => { + const opt = revisionOptions.find( + (o) => `${o.revisionId}-${o.type}` === leftRevisionKey + ); + return opt?.url ?? null; + }, [revisionOptions, leftRevisionKey]); + + const rightSrc = useMemo(() => { + const opt = revisionOptions.find( + (o) => `${o.revisionId}-${o.type}` === rightRevisionKey + ); + return opt?.url ?? null; + }, [revisionOptions, rightRevisionKey]); + // Auto-select the latest current image useEffect(() => { if (!activeImageUrl && galleryImages.length > 0) { @@ -140,6 +196,12 @@ export default function ReviewPage() { } }, [galleryImages, activeImageUrl]); + // Find the revision ID for the currently active image (for annotations) + const activeRevisionId = useMemo(() => { + const match = galleryImages.find((img) => img.url === activeImageUrl); + return match?.revisionId ?? null; + }, [galleryImages, activeImageUrl]); + // Latest revision for upload panel const latestRevision = revisions[0] as any | undefined; const latestAttachments = @@ -161,11 +223,64 @@ export default function ReviewPage() { if (newIdx >= 0 && newIdx < stages.length) { setSelectedStageId(stages[newIdx].id); setActiveImageUrl(null); + // Reset comparison state when changing stages + setLeftRevisionKey(""); + setRightRevisionKey(""); } }, [stageIdx, stages] ); + const handleEnterComparison = useCallback(() => { + setComparisonActive(true); + // Reset revision keys so auto-select picks up + setLeftRevisionKey(""); + setRightRevisionKey(""); + }, []); + + const handleExitComparison = useCallback(() => { + setComparisonActive(false); + }, []); + + // ── Keyboard shortcuts for comparison modes ──────────────────────────── + useEffect(() => { + if (!comparisonActive) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + switch (e.key) { + case "1": + e.preventDefault(); + setComparisonMode("side-by-side"); + break; + case "2": + e.preventDefault(); + setComparisonMode("wipe"); + break; + case "3": + e.preventDefault(); + setComparisonMode("overlay"); + break; + case "4": + e.preventDefault(); + setComparisonMode("toggle"); + break; + case "Escape": + e.preventDefault(); + handleExitComparison(); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [comparisonActive, handleExitComparison]); + if (delLoading) { return (
@@ -234,6 +349,19 @@ export default function ReviewPage() { + {/* Compare toggle */} + {!comparisonActive && galleryImages.length >= 2 && ( + + )} + {/* Upload panel trigger */} @@ -288,11 +416,49 @@ export default function ReviewPage() { )}
- {/* ── Image viewer ─────────────────────────────────────────── */} - + {/* ── Comparison toolbar (when active) ──────────────────────── */} + {comparisonActive && ( + + )} + + {/* ── Image viewer / Comparison viewer ─────────────────────── */} + {comparisonActive ? ( + + ) : ( + ( + + )} + /> + )} {/* ── Gallery strip ────────────────────────────────────────── */} - {galleryImages.length > 0 && ( + {!comparisonActive && galleryImages.length > 0 && (
}; + +// PATCH /api/revisions/:revisionId/annotations/:annotationId +export async function PATCH(request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { annotationId } = await params; + const body = await request.json(); + const parsed = updateAnnotationSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const annotation = await updateAnnotation( + annotationId, + session!.user!.id!, + parsed.data + ); + return NextResponse.json(annotation); + } catch (e) { + if (e instanceof Error && e.message === "Annotation not found") { + return notFound(e.message); + } + if (e instanceof Error && e.message === "Not authorized") { + return NextResponse.json({ error: e.message }, { status: 403 }); + } + return serverError(e); + } +} + +// DELETE /api/revisions/:revisionId/annotations/:annotationId +export async function DELETE(_request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { annotationId } = await params; + + const result = await deleteAnnotation(annotationId, session!.user!.id!); + return NextResponse.json(result); + } catch (e) { + if (e instanceof Error && e.message === "Annotation not found") { + return notFound(e.message); + } + if (e instanceof Error && e.message === "Not authorized") { + return NextResponse.json({ error: e.message }, { status: 403 }); + } + return serverError(e); + } +} diff --git a/src/app/api/revisions/[revisionId]/annotations/route.ts b/src/app/api/revisions/[revisionId]/annotations/route.ts new file mode 100644 index 0000000..a0846f2 --- /dev/null +++ b/src/app/api/revisions/[revisionId]/annotations/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils"; +import { prisma } from "@/lib/prisma"; +import { createAnnotationSchema } from "@/lib/validators/annotation"; +import { listAnnotations, createAnnotation } from "@/lib/services/annotation-service"; + +type Params = { params: Promise<{ revisionId: string }> }; + +// GET /api/revisions/:revisionId/annotations +export async function GET(_request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { revisionId } = await params; + + const revision = await prisma.revision.findUnique({ + where: { id: revisionId }, + }); + if (!revision) return notFound("Revision not found"); + + const annotations = await listAnnotations(revisionId); + return NextResponse.json(annotations); + } catch (e) { + return serverError(e); + } +} + +// POST /api/revisions/:revisionId/annotations +export async function POST(request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { revisionId } = await params; + + const revision = await prisma.revision.findUnique({ + where: { id: revisionId }, + }); + if (!revision) return notFound("Revision not found"); + + const body = await request.json(); + const parsed = createAnnotationSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const annotation = await createAnnotation( + revisionId, + session!.user!.id!, + parsed.data + ); + return NextResponse.json(annotation, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/review/annotation-layer.tsx b/src/components/review/annotation-layer.tsx new file mode 100644 index 0000000..54a67e1 --- /dev/null +++ b/src/components/review/annotation-layer.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { + AnnotationRenderer, + type AnnotationShape, +} from "@/components/review/annotation-renderer"; +import { ScreenshotCallout } from "@/components/review/screenshot-callout"; +import { AnnotationTools } from "@/components/review/annotation-tools"; +import { useAnnotationState } from "@/hooks/use-annotation-state"; + +// ── Types ────────────────────────────────────────────── + +export interface AnnotationLayerProps { + revisionId: string | null; + stageId: string | null; + zoom: number; + panX: number; + panY: number; + containerWidth: number; + containerHeight: number; + imageDimensions: { width: number; height: number } | null; +} + +// ── Component ────────────────────────────────────────── + +export function AnnotationLayer({ + revisionId, + stageId, + zoom, + panX, + panY, + containerWidth, + containerHeight, + imageDimensions, +}: AnnotationLayerProps) { + const ann = useAnnotationState(revisionId, stageId); + + // Clipboard paste handler for screenshots + useEffect(() => { + const handlePaste = async (e: ClipboardEvent) => { + if (!revisionId || !stageId) return; + + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (item.type.startsWith("image/")) { + e.preventDefault(); + const file = item.getAsFile(); + if (!file) continue; + await ann.handleScreenshotPaste( + file, + containerWidth, + containerHeight, + panX, + panY, + zoom, + imageDimensions + ); + break; + } + } + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [revisionId, stageId, panX, panY, zoom, containerWidth, containerHeight, imageDimensions, ann]); + + // Cursor style + const cursorStyle = useMemo(() => { + if (ann.activeTool === "select") return "default"; + if (ann.activeTool === "text") return "text"; + return "crosshair"; + }, [ann.activeTool]); + + // Don't render overlay when there's no revision + if (!revisionId || !imageDimensions) return null; + + return ( + <> + {/* ── Annotation toolbar (floating inside viewport, top-center) ── */} +
+
+ ann.setVisible((v: boolean) => !v)} + hasSelection={!!ann.selectedId} + onDeleteSelection={ann.handleDeleteSelection} + /> +
+
+ + {/* ── SVG overlay ────────────────────────────────── */} + ann.handleMouseDown(e, panX, panY, zoom)} + onMouseMove={(e) => ann.handleMouseMove(e, panX, panY, zoom)} + onMouseUp={ann.handleMouseUp} + > + {ann.visible && ( + + {/* Persisted annotations */} + {ann.annotationShapes + .filter((a: AnnotationShape) => a.type !== "SCREENSHOT") + .map((a: AnnotationShape) => ( + + ))} + + {/* Screenshot callouts */} + {ann.screenshotAnnotations.map((a: any) => { + const d = a.data ?? {}; + return ( + + ); + })} + + {/* Drawing preview */} + {ann.drawingPreview && ( + + + + )} + + )} + + + {/* ── Floating text input ────────────────────────── */} + {ann.textInput && ( +
+ ann.setTextValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + ann.commitTextAnnotation(); + } + if (e.key === "Escape") { + ann.setTextInput(null); + ann.setTextValue(""); + } + }} + onBlur={ann.commitTextAnnotation} + className="h-7 min-w-[120px] border-[var(--primary)] bg-[var(--card)] text-xs" + placeholder="Type label..." + autoFocus + /> +
+ )} + + ); +} diff --git a/src/components/review/annotation-renderer.tsx b/src/components/review/annotation-renderer.tsx new file mode 100644 index 0000000..f5c0fcc --- /dev/null +++ b/src/components/review/annotation-renderer.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { memo } from "react"; +import type { AnnotationTypeValue } from "@/lib/validators/annotation"; + +export interface AnnotationShape { + id: string; + type: AnnotationTypeValue; + data: { + x?: number; + y?: number; + width?: number; + height?: number; + endX?: number; + endY?: number; + points?: { x: number; y: number }[]; + text?: string; + color?: string; + strokeWidth?: number; + imageUrl?: string; + }; + imageX: number; + imageY: number; + isSelected?: boolean; + onClick?: (id: string) => void; +} + +interface AnnotationRendererProps { + annotation: AnnotationShape; +} + +function buildArrowHeadPath( + startX: number, + startY: number, + endX: number, + endY: number, + headLen: number +): string { + const angle = Math.atan2(endY - startY, endX - startX); + const a1x = endX - headLen * Math.cos(angle - Math.PI / 6); + const a1y = endY - headLen * Math.sin(angle - Math.PI / 6); + const a2x = endX - headLen * Math.cos(angle + Math.PI / 6); + const a2y = endY - headLen * Math.sin(angle + Math.PI / 6); + return `M${a1x},${a1y} L${endX},${endY} L${a2x},${a2y}`; +} + +function simplifyPoints( + points: { x: number; y: number }[], + tolerance: number +): { x: number; y: number }[] { + if (points.length <= 2) return points; + + // Douglas-Peucker simplification + let maxDist = 0; + let maxIdx = 0; + const first = points[0]; + const last = points[points.length - 1]; + + for (let i = 1; i < points.length - 1; i++) { + const dist = perpendicularDist(points[i], first, last); + if (dist > maxDist) { + maxDist = dist; + maxIdx = i; + } + } + + if (maxDist > tolerance) { + const left = simplifyPoints(points.slice(0, maxIdx + 1), tolerance); + const right = simplifyPoints(points.slice(maxIdx), tolerance); + return left.slice(0, -1).concat(right); + } + + return [first, last]; +} + +function perpendicularDist( + point: { x: number; y: number }, + lineStart: { x: number; y: number }, + lineEnd: { x: number; y: number } +): number { + const dx = lineEnd.x - lineStart.x; + const dy = lineEnd.y - lineStart.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) { + const px = point.x - lineStart.x; + const py = point.y - lineStart.y; + return Math.sqrt(px * px + py * py); + } + const num = Math.abs( + dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x + ); + return num / Math.sqrt(lenSq); +} + +export function pointsToPath(points: { x: number; y: number }[]): string { + if (points.length === 0) return ""; + const simplified = simplifyPoints(points, 1.5); + const parts = [`M${simplified[0].x},${simplified[0].y}`]; + for (let i = 1; i < simplified.length; i++) { + parts.push(`L${simplified[i].x},${simplified[i].y}`); + } + return parts.join(" "); +} + +export const AnnotationRenderer = memo(function AnnotationRenderer({ + annotation, +}: AnnotationRendererProps) { + const { type, data, isSelected, onClick, id } = annotation; + const color = data.color || "#EE5540"; + const strokeWidth = data.strokeWidth || 2; + const selectionExtra = isSelected ? 2 : 0; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(id); + }; + + const sharedProps = { + stroke: color, + strokeWidth: strokeWidth + selectionExtra, + fill: "none", + cursor: "pointer" as const, + onClick: handleClick, + style: isSelected + ? { filter: "drop-shadow(0 0 3px rgba(255,255,255,0.6))" } + : undefined, + }; + + switch (type) { + case "RECTANGLE": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const w = data.width ?? 0; + const h = data.height ?? 0; + return ( + + ); + } + + case "ELLIPSE": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const w = data.width ?? 0; + const h = data.height ?? 0; + const cx = x + w / 2; + const cy = y + h / 2; + return ( + + ); + } + + case "ARROW": { + const x1 = data.x ?? 0; + const y1 = data.y ?? 0; + const x2 = data.endX ?? x1; + const y2 = data.endY ?? y1; + const headLen = Math.max(8, strokeWidth * 4); + return ( + + + + + ); + } + + case "FREEHAND": { + const points = data.points ?? []; + if (points.length === 0) return null; + const d = pointsToPath(points); + return ( + + ); + } + + case "TEXT": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const text = data.text ?? ""; + return ( + + {/* Text background for readability */} + + {text} + + + {text} + + + ); + } + + case "PIN": { + const x = data.x ?? 0; + const y = data.y ?? 0; + const r = 6; + return ( + + {/* Pin drop shadow */} + + {/* Pin outer ring */} + + {/* Pin inner dot */} + + + ); + } + + case "SCREENSHOT": { + // Screenshots are rendered by the ScreenshotCallout component via foreignObject + return null; + } + + default: + return null; + } +}); diff --git a/src/components/review/annotation-tools.tsx b/src/components/review/annotation-tools.tsx new file mode 100644 index 0000000..99e97e4 --- /dev/null +++ b/src/components/review/annotation-tools.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { + Square, + Circle, + ArrowUpRight, + Pencil, + Type, + MapPin, + Undo2, + Redo2, + Eye, + EyeOff, + Trash2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type AnnotationTool = + | "select" + | "rectangle" + | "ellipse" + | "arrow" + | "freehand" + | "text" + | "pin"; + +const PRESET_COLORS = [ + { label: "Red", value: "#EE5540" }, + { label: "Blue", value: "#3B82F6" }, + { label: "Green", value: "#22C55E" }, + { label: "Yellow", value: "#EAB308" }, + { label: "White", value: "#FFFFFF" }, + { label: "Orange", value: "#F97316" }, + { label: "Purple", value: "#A855F7" }, + { label: "Cyan", value: "#06B6D4" }, +]; + +interface AnnotationToolsProps { + activeTool: AnnotationTool; + onToolChange: (tool: AnnotationTool) => void; + color: string; + onColorChange: (color: string) => void; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + visible: boolean; + onToggleVisibility: () => void; + hasSelection: boolean; + onDeleteSelection: () => void; +} + +const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [ + { id: "rectangle", icon: Square, label: "Rectangle", shortcut: "R" }, + { id: "ellipse", icon: Circle, label: "Ellipse", shortcut: "E" }, + { id: "arrow", icon: ArrowUpRight, label: "Arrow", shortcut: "A" }, + { id: "freehand", icon: Pencil, label: "Freehand", shortcut: "F" }, + { id: "text", icon: Type, label: "Text label", shortcut: "T" }, + { id: "pin", icon: MapPin, label: "Pin", shortcut: "P" }, +]; + +export function AnnotationTools({ + activeTool, + onToolChange, + color, + onColorChange, + canUndo, + canRedo, + onUndo, + onRedo, + visible, + onToggleVisibility, + hasSelection, + onDeleteSelection, +}: AnnotationToolsProps) { + return ( +
+ {/* Drawing tools */} + {tools.map((tool) => { + const Icon = tool.icon; + const isActive = activeTool === tool.id; + return ( + + + + + + {tool.label} + {tool.shortcut && ( + + {tool.shortcut} + + )} + + + ); + })} + + + + {/* Color picker */} + + + + + +

+ Stroke Color +

+
+ {PRESET_COLORS.map((c) => ( +
+
+
+ + + + {/* Undo / Redo */} + + + + + + Undo Cmd+Z + + + + + + + + + Redo Cmd+Shift+Z + + + + + + {/* Visibility toggle */} + + + + + + {visible ? "Hide annotations" : "Show annotations"} + + + + {/* Delete selection */} + {hasSelection && ( + + + + + + Delete selected + Del + + + )} +
+ ); +} diff --git a/src/components/review/comparison-toolbar.tsx b/src/components/review/comparison-toolbar.tsx new file mode 100644 index 0000000..99f8355 --- /dev/null +++ b/src/components/review/comparison-toolbar.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { + Columns2, + SplitSquareHorizontal, + Layers, + ToggleLeft, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export type ComparisonMode = "side-by-side" | "wipe" | "overlay" | "toggle"; + +interface RevisionOption { + revisionId: string; + roundNumber: number; + type: "reference" | "current"; + label: string; + url: string; +} + +interface ComparisonToolbarProps { + mode: ComparisonMode; + onModeChange: (mode: ComparisonMode) => void; + revisionOptions: RevisionOption[]; + leftRevisionKey: string; + rightRevisionKey: string; + onLeftChange: (key: string) => void; + onRightChange: (key: string) => void; + onExit: () => void; +} + +const MODE_CONFIG: { + value: ComparisonMode; + label: string; + icon: typeof Columns2; + shortcut: string; +}[] = [ + { value: "side-by-side", label: "Side by Side", icon: Columns2, shortcut: "1" }, + { value: "wipe", label: "A/B Wipe", icon: SplitSquareHorizontal, shortcut: "2" }, + { value: "overlay", label: "Overlay", icon: Layers, shortcut: "3" }, + { value: "toggle", label: "Toggle", icon: ToggleLeft, shortcut: "4" }, +]; + +export function ComparisonToolbar({ + mode, + onModeChange, + revisionOptions, + leftRevisionKey, + rightRevisionKey, + onLeftChange, + onRightChange, + onExit, +}: ComparisonToolbarProps) { + return ( +
+
+ {/* Mode selector */} +
+ {MODE_CONFIG.map(({ value, label, icon: Icon, shortcut }) => ( + + + + + + {label} {shortcut} + + + ))} +
+ +
+ + {/* Revision selectors */} +
+
+ + A + + +
+ +
+ + B + + +
+
+
+ + {/* Exit button */} + + + + + + Exit comparison mode Esc + + +
+ ); +} diff --git a/src/components/review/comparison-viewer.tsx b/src/components/review/comparison-viewer.tsx new file mode 100644 index 0000000..cb99a8f --- /dev/null +++ b/src/components/review/comparison-viewer.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, ImageIcon } from "lucide-react"; +import { useImageViewer } from "@/hooks/use-image-viewer"; +import { ZoomControls } from "@/components/review/zoom-controls"; +import { WipeDivider } from "@/components/review/wipe-divider"; +import { OverlayControls } from "@/components/review/overlay-controls"; +import type { ComparisonMode } from "@/components/review/comparison-toolbar"; + +interface ComparisonViewerProps { + leftSrc: string | null; + rightSrc: string | null; + mode: ComparisonMode; + className?: string; +} + +const ZOOM_STEP = 1.15; +const MIN_ZOOM = 0.05; +const MAX_ZOOM = 5; + +/** + * Comparison viewer that renders two images in the selected comparison mode. + * Manages shared zoom/pan state so both images stay synced. + */ +export function ComparisonViewer({ + leftSrc, + rightSrc, + mode, + className, +}: ComparisonViewerProps) { + // ── Shared zoom/pan state ────────────────────────────────────────────── + const [zoom, setZoomState] = useState(1); + const [panX, setPanX] = useState(0); + const [panY, setPanY] = useState(0); + const zoomRef = useRef(zoom); + const panRef = useRef({ x: panX, y: panY }); + zoomRef.current = zoom; + panRef.current = { x: panX, y: panY }; + + // ── Overlay opacity ──────────────────────────────────────────────────── + const [overlayOpacity, setOverlayOpacity] = useState(50); + + // ── Toggle state ─────────────────────────────────────────────────────── + const [toggleShowRight, setToggleShowRight] = useState(false); + + // ── Canvas-based viewers for side-by-side mode ───────────────────────── + const leftViewer = useImageViewer(); + const rightViewer = useImageViewer(); + + // Load images into canvas viewers when sources change + useEffect(() => { + if (leftSrc && mode === "side-by-side") { + leftViewer.loadImage(leftSrc); + } + }, [leftSrc, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (rightSrc && mode === "side-by-side") { + rightViewer.loadImage(rightSrc); + } + }, [rightSrc, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + // Sync right viewer to left viewer state in side-by-side mode + useEffect(() => { + if (mode !== "side-by-side") return; + const { zoom: lz, panX: lx, panY: ly } = leftViewer.state; + rightViewer.setPan(lx, ly); + // We need to set zoom without re-centering, so set pan after zoom + if (Math.abs(rightViewer.state.zoom - lz) > 0.001) { + rightViewer.setZoom(lz); + rightViewer.setPan(lx, ly); + } + }, [leftViewer.state.zoom, leftViewer.state.panX, leftViewer.state.panY, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Shared pan handler for non-side-by-side modes ────────────────────── + const handlePan = useCallback((newPanX: number, newPanY: number) => { + setPanX(newPanX); + setPanY(newPanY); + }, []); + + const handleZoom = useCallback( + (newZoom: number, centerX: number, centerY: number) => { + const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom)); + const oldZoom = zoomRef.current; + const scale = clamped / oldZoom; + const newPanX = centerX - (centerX - panRef.current.x) * scale; + const newPanY = centerY - (centerY - panRef.current.y) * scale; + setZoomState(clamped); + setPanX(newPanX); + setPanY(newPanY); + }, + [] + ); + + // ── Fit images to container on mode change or source change ──────────── + const sharedContainerRef = useRef(null); + + const fitSharedView = useCallback(() => { + const container = sharedContainerRef.current; + if (!container) return; + // We don't know image dimensions until loaded, use a reasonable default + const rect = container.getBoundingClientRect(); + setZoomState(1); + setPanX(rect.width / 2); + setPanY(rect.height / 2); + }, []); + + useEffect(() => { + if (mode !== "side-by-side") { + fitSharedView(); + } + }, [mode, leftSrc, rightSrc]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Toggle keyboard shortcut (Space) ─────────────────────────────────── + useEffect(() => { + if (mode !== "toggle") return; + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + if (e.code === "Space") { + e.preventDefault(); + setToggleShowRight((prev) => !prev); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [mode]); + + // ── Shared pan/zoom interaction for overlay, toggle, wipe containers ─── + const isPanning = useRef(false); + const lastMouse = useRef({ x: 0, y: 0 }); + + const handleContainerPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + isPanning.current = true; + lastMouse.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handleContainerPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isPanning.current) return; + const dx = e.clientX - lastMouse.current.x; + const dy = e.clientY - lastMouse.current.y; + lastMouse.current = { x: e.clientX, y: e.clientY }; + setPanX((prev) => prev + dx); + setPanY((prev) => prev + dy); + }, + [] + ); + + const handleContainerPointerUp = useCallback(() => { + isPanning.current = false; + }, []); + + const handleContainerWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const container = sharedContainerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + handleZoom(zoomRef.current * factor, mouseX, mouseY); + }, + [handleZoom] + ); + + const imgTransform = `translate(${panX}px, ${panY}px) scale(${zoom})`; + + // ── Empty state ──────────────────────────────────────────────────────── + if (!leftSrc || !rightSrc) { + return ( +
+
+
+ +

+ Select two revisions to compare +

+
+
+
+ ); + } + + // ── Side-by-side mode ────────────────────────────────────────────────── + if (mode === "side-by-side") { + return ( +
+ {/* Shared zoom controls */} +
+ +
+ +
+ {/* Left pane */} +
+
+ A +
+
+ {leftViewer.isLoading && ( +
+ +
+ )} + +
+
+ + {/* Right pane */} +
+
+ B +
+
+ {rightViewer.isLoading && ( +
+ +
+ )} + +
+
+
+
+ ); + } + + // ── Wipe mode ────────────────────────────────────────────────────────── + if (mode === "wipe") { + return ( +
+ {/* Zoom controls */} +
+ handleZoom(zoom * ZOOM_STEP, 0, 0)} + onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)} + onFitToView={fitSharedView} + onZoomToPreset={() => {}} + /> +
+ +
+ +
+
+ ); + } + + // ── Overlay mode ─────────────────────────────────────────────────────── + if (mode === "overlay") { + return ( +
+ {/* Zoom controls */} +
+ handleZoom(zoom * ZOOM_STEP, 0, 0)} + onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)} + onFitToView={fitSharedView} + onZoomToPreset={() => {}} + /> +
+ +
+ {/* Base image (A) */} + Version A + + {/* Overlay image (B) */} + Version B + + {/* Labels */} +
+ A +
+
+ B ({overlayOpacity}%) +
+ + +
+
+ ); + } + + // ── Toggle mode ──────────────────────────────────────────────────────── + return ( +
+ {/* Zoom controls */} +
+ handleZoom(zoom * ZOOM_STEP, 0, 0)} + onZoomOut={() => handleZoom(zoom / ZOOM_STEP, 0, 0)} + onFitToView={fitSharedView} + onZoomToPreset={() => {}} + /> + + Press Space to toggle + +
+ +
setToggleShowRight((prev) => !prev)} + onPointerDown={handleContainerPointerDown} + onPointerMove={handleContainerPointerMove} + onPointerUp={handleContainerPointerUp} + onPointerLeave={handleContainerPointerUp} + onWheel={handleContainerWheel} + > + {/* Image A */} + Version A + + {/* Image B */} + Version B + + {/* Active label */} +
+ {toggleShowRight ? "B" : "A"} +
+
+
+ ); +} diff --git a/src/components/review/image-viewer.tsx b/src/components/review/image-viewer.tsx index f62a7a9..83e2564 100644 --- a/src/components/review/image-viewer.tsx +++ b/src/components/review/image-viewer.tsx @@ -6,16 +6,31 @@ import { ZoomControls } from "@/components/review/zoom-controls"; import { Minimap } from "@/components/review/minimap"; import { ImageIcon, Loader2 } from "lucide-react"; +export interface ImageViewerState { + zoom: number; + panX: number; + panY: number; + containerWidth: number; + containerHeight: number; + imageDimensions: { width: number; height: number } | null; +} + interface ImageViewerProps { src: string | null; imageDimensionsOverride?: { width: number; height: number } | null; className?: string; + /** Render prop for overlays positioned inside the canvas viewport */ + renderOverlay?: (state: ImageViewerState) => React.ReactNode; + /** Render prop for extra toolbar items (placed after zoom controls) */ + renderToolbar?: (state: ImageViewerState) => React.ReactNode; } export function ImageViewer({ src, imageDimensionsOverride, className, + renderOverlay, + renderToolbar, }: ImageViewerProps) { const viewer = useImageViewer(); @@ -32,6 +47,15 @@ export function ImageViewer({ const containerH = containerRect?.height ?? 0; const dims = imageDimensionsOverride ?? viewer.imageDimensions; + const viewerState: ImageViewerState = { + zoom: viewer.state.zoom, + panX: viewer.state.panX, + panY: viewer.state.panY, + containerWidth: containerW, + containerHeight: containerH, + imageDimensions: dims, + }; + return (
{/* Toolbar */} @@ -51,6 +75,9 @@ export function ImageViewer({ )}
+ {/* Extra toolbar (annotation tools) */} + {renderToolbar?.(viewerState)} + {/* Pixel info */} {viewer.pixelInfo && (
@@ -94,6 +121,9 @@ export function ImageViewer({ className="block h-full w-full" /> + {/* Annotation overlay */} + {renderOverlay?.(viewerState)} + {/* Minimap */} {src && dims && ( void; +} + +export function OverlayControls({ + opacity, + onOpacityChange, +}: OverlayControlsProps) { + const handleChange = useCallback( + (e: React.ChangeEvent) => { + onOpacityChange(Number(e.target.value)); + }, + [onOpacityChange] + ); + + return ( +
+ + Opacity + + + + {opacity}% + +
+ ); +} diff --git a/src/components/review/screenshot-callout.tsx b/src/components/review/screenshot-callout.tsx new file mode 100644 index 0000000..3f56181 --- /dev/null +++ b/src/components/review/screenshot-callout.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +interface ScreenshotCalloutProps { + id: string; + imageUrl: string; + x: number; + y: number; + width: number; + height: number; + isSelected: boolean; + zoom: number; + onSelect: (id: string) => void; + onMove: (id: string, x: number, y: number) => void; + onResize: (id: string, width: number, height: number) => void; +} + +const MIN_SIZE = 40; + +export function ScreenshotCallout({ + id, + imageUrl, + x, + y, + width, + height, + isSelected, + zoom, + onSelect, + onMove, + onResize, +}: ScreenshotCalloutProps) { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const dragStart = useRef({ mouseX: 0, mouseY: 0, originX: x, originY: y }); + const resizeStart = useRef({ mouseX: 0, mouseY: 0, originW: width, originH: height }); + + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + onSelect(id); + setIsDragging(true); + dragStart.current = { mouseX: e.clientX, mouseY: e.clientY, originX: x, originY: y }; + + const handleMove = (me: MouseEvent) => { + const dx = (me.clientX - dragStart.current.mouseX) / zoom; + const dy = (me.clientY - dragStart.current.mouseY) / zoom; + onMove(id, dragStart.current.originX + dx, dragStart.current.originY + dy); + }; + + const handleUp = () => { + setIsDragging(false); + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [id, x, y, zoom, onSelect, onMove] + ); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + setIsResizing(true); + resizeStart.current = { + mouseX: e.clientX, + mouseY: e.clientY, + originW: width, + originH: height, + }; + + const handleMove = (me: MouseEvent) => { + const dx = (me.clientX - resizeStart.current.mouseX) / zoom; + const dy = (me.clientY - resizeStart.current.mouseY) / zoom; + const newW = Math.max(MIN_SIZE, resizeStart.current.originW + dx); + const newH = Math.max(MIN_SIZE, resizeStart.current.originH + dy); + onResize(id, newW, newH); + }; + + const handleUp = () => { + setIsResizing(false); + window.removeEventListener("mousemove", handleMove); + window.removeEventListener("mouseup", handleUp); + }; + + window.addEventListener("mousemove", handleMove); + window.addEventListener("mouseup", handleUp); + }, + [id, width, height, zoom, onResize] + ); + + const borderWidth = 2; + + return ( + +
{ + e.stopPropagation(); + onSelect(id); + }} + style={{ + position: "relative", + width: width, + height: height, + border: `${borderWidth}px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`, + boxShadow: isSelected + ? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)" + : "0 2px 8px rgba(0,0,0,0.4)", + borderRadius: "3px", + cursor: isDragging ? "grabbing" : "grab", + userSelect: "none", + overflow: "hidden", + }} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + Screenshot annotation + + {/* Resize handle — bottom-right corner */} + {isSelected && ( +
+ )} +
+ + ); +} diff --git a/src/components/review/wipe-divider.tsx b/src/components/review/wipe-divider.tsx new file mode 100644 index 0000000..602bed3 --- /dev/null +++ b/src/components/review/wipe-divider.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +interface WipeDividerProps { + /** Left image (A) source URL */ + leftSrc: string; + /** Right image (B) source URL */ + rightSrc: string; + /** Shared zoom level */ + zoom: number; + /** Shared pan X offset */ + panX: number; + /** Shared pan Y offset */ + panY: number; + /** Called when the user drags to pan */ + onPan: (panX: number, panY: number) => void; + /** Called when the user scrolls to zoom */ + onZoom: (newZoom: number, centerX: number, centerY: number) => void; +} + +const ZOOM_STEP = 1.15; + +export function WipeDivider({ + leftSrc, + rightSrc, + zoom, + panX, + panY, + onPan, + onZoom, +}: WipeDividerProps) { + const containerRef = useRef(null); + const [dividerPercent, setDividerPercent] = useState(50); + const isDraggingDivider = useRef(false); + const isPanning = useRef(false); + const lastMouse = useRef({ x: 0, y: 0 }); + + const handleDividerPointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingDivider.current = true; + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (isDraggingDivider.current) { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pct = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setDividerPercent(pct); + return; + } + + if (isPanning.current) { + const dx = e.clientX - lastMouse.current.x; + const dy = e.clientY - lastMouse.current.y; + lastMouse.current = { x: e.clientX, y: e.clientY }; + onPan(panX + dx, panY + dy); + } + }, + [panX, panY, onPan] + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + // Only start pan if not on the divider handle + if (isDraggingDivider.current) return; + if (e.button !== 0) return; + isPanning.current = true; + lastMouse.current = { x: e.clientX, y: e.clientY }; + }, + [] + ); + + const handlePointerUp = useCallback(() => { + isDraggingDivider.current = false; + isPanning.current = false; + }, []); + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + e.preventDefault(); + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP; + onZoom(zoom * factor, mouseX, mouseY); + }, + [zoom, onZoom] + ); + + const imgTransform = `translate(${panX}px, ${panY}px) scale(${zoom})`; + + return ( +
+ {/* Left image (A) — full frame, visible behind the right clipped layer */} + Version A + + {/* Right image (B) — clipped to reveal from divider rightward */} +
+ Version B +
+ + {/* Divider handle */} +
+ {/* Line */} +
+ + {/* Drag handle */} +
+
+
+
+
+
+
+ + {/* Labels */} +
+ A +
+
+ B +
+
+ ); +} diff --git a/src/hooks/use-annotation-state.ts b/src/hooks/use-annotation-state.ts new file mode 100644 index 0000000..b5ed469 --- /dev/null +++ b/src/hooks/use-annotation-state.ts @@ -0,0 +1,512 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { AnnotationShape } from "@/components/review/annotation-renderer"; +import type { AnnotationTool } from "@/components/review/annotation-tools"; +import { + useAnnotations, + useCreateAnnotation, + useDeleteAnnotation, + useUpdateAnnotation, +} from "@/hooks/use-annotations"; +import type { AnnotationTypeValue, CreateAnnotationInput } from "@/lib/validators/annotation"; + +// ── Types ────────────────────────────────────────────── + +interface DrawingState { + type: AnnotationTypeValue; + startX: number; + startY: number; + currentX: number; + currentY: number; + points: { x: number; y: number }[]; +} + +interface UndoEntry { + action: "create" | "delete"; + annotationId: string; + input?: CreateAnnotationInput; +} + +const TOOL_TO_TYPE: Record, AnnotationTypeValue> = { + rectangle: "RECTANGLE", + ellipse: "ELLIPSE", + arrow: "ARROW", + freehand: "FREEHAND", + text: "TEXT", + pin: "PIN", +}; + +function screenToImage( + screenX: number, + screenY: number, + panX: number, + panY: number, + zoom: number +): { x: number; y: number } { + return { + x: (screenX - panX) / zoom, + y: (screenY - panY) / zoom, + }; +} + +// ── Hook ─────────────────────────────────────────────── + +export function useAnnotationState( + revisionId: string | null, + stageId: string | null +) { + const [activeTool, setActiveTool] = useState("select"); + const [color, setColor] = useState("#EE5540"); + const [visible, setVisible] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [drawing, setDrawing] = useState(null); + const [textInput, setTextInput] = useState<{ + x: number; + y: number; + imgX: number; + imgY: number; + } | null>(null); + const [textValue, setTextValue] = useState(""); + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + + const svgRef = useRef(null); + const textInputRef = useRef(null); + + // Data hooks + const { data: annotationsRaw } = useAnnotations(revisionId); + const annotations = (annotationsRaw as any[]) ?? []; + const createMutation = useCreateAnnotation(revisionId); + const deleteMutation = useDeleteAnnotation(revisionId); + const updateMutation = useUpdateAnnotation(revisionId); + + // Derived shapes + const annotationShapes: AnnotationShape[] = useMemo(() => { + return annotations.map((a: any) => ({ + id: a.id, + type: a.type as AnnotationTypeValue, + data: a.data ?? {}, + imageX: a.imageX, + imageY: a.imageY, + isSelected: a.id === selectedId, + onClick: (id: string) => setSelectedId(id), + })); + }, [annotations, selectedId]); + + const screenshotAnnotations = useMemo(() => { + return annotations.filter((a: any) => a.type === "SCREENSHOT"); + }, [annotations]); + + // Save annotation + const saveAnnotation = useCallback( + ( + type: AnnotationTypeValue, + data: Record, + imgX: number, + imgY: number + ) => { + if (!revisionId || !stageId) return; + + const typeLabel = type.charAt(0) + type.slice(1).toLowerCase(); + const commentContent = `${typeLabel} annotation at (${Math.round(imgX)}, ${Math.round(imgY)})`; + + const input: CreateAnnotationInput = { + type, + data: data as any, + imageX: imgX, + imageY: imgY, + commentContent, + stageId, + }; + + createMutation.mutate(input, { + onSuccess: (result: any) => { + setUndoStack((prev) => [...prev, { action: "create", annotationId: result.id, input }]); + setRedoStack([]); + }, + onError: (err) => { + toast.error(`Failed to save annotation: ${err.message}`); + }, + }); + }, + [revisionId, stageId, createMutation] + ); + + // Get image coordinates from screen event + const getImageCoords = useCallback( + (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { + const svgEl = svgRef.current; + if (!svgEl) return { x: 0, y: 0 }; + const rect = svgEl.getBoundingClientRect(); + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + return screenToImage(screenX, screenY, panX, panY, zoom); + }, + [] + ); + + // Mouse handlers (need zoom/pan passed in) + const handleMouseDown = useCallback( + (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { + if (activeTool === "select") { + setSelectedId(null); + return; + } + + const img = getImageCoords(e, panX, panY, zoom); + + if (activeTool === "text") { + const svgEl = svgRef.current; + if (!svgEl) return; + const rect = svgEl.getBoundingClientRect(); + setTextInput({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + imgX: img.x, + imgY: img.y, + }); + setTextValue(""); + setTimeout(() => textInputRef.current?.focus(), 0); + return; + } + + if (activeTool === "pin") { + saveAnnotation("PIN", { x: img.x, y: img.y, color }, img.x, img.y); + return; + } + + const type = TOOL_TO_TYPE[activeTool]; + setDrawing({ + type, + startX: img.x, + startY: img.y, + currentX: img.x, + currentY: img.y, + points: [{ x: img.x, y: img.y }], + }); + }, + [activeTool, getImageCoords, color, saveAnnotation] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent, panX: number, panY: number, zoom: number) => { + if (!drawing) return; + const img = getImageCoords(e, panX, panY, zoom); + setDrawing((prev) => { + if (!prev) return prev; + return { + ...prev, + currentX: img.x, + currentY: img.y, + points: + prev.type === "FREEHAND" + ? [...prev.points, { x: img.x, y: img.y }] + : prev.points, + }; + }); + }, + [drawing, getImageCoords] + ); + + const handleMouseUp = useCallback(() => { + if (!drawing) return; + + const { type, startX, startY, currentX, currentY, points } = drawing; + let data: Record = { color }; + + switch (type) { + case "RECTANGLE": + case "ELLIPSE": + data = { + ...data, + x: Math.min(startX, currentX), + y: Math.min(startY, currentY), + width: Math.abs(currentX - startX), + height: Math.abs(currentY - startY), + }; + break; + case "ARROW": + data = { ...data, x: startX, y: startY, endX: currentX, endY: currentY }; + break; + case "FREEHAND": + data = { ...data, points }; + break; + } + + // Discard tiny accidental clicks + if (type === "RECTANGLE" || type === "ELLIPSE") { + if (Math.abs(currentX - startX) < 3 && Math.abs(currentY - startY) < 3) { + setDrawing(null); + return; + } + } + if (type === "ARROW") { + const dist = Math.sqrt((currentX - startX) ** 2 + (currentY - startY) ** 2); + if (dist < 3) { + setDrawing(null); + return; + } + } + if (type === "FREEHAND" && points.length < 3) { + setDrawing(null); + return; + } + + saveAnnotation(type, data, startX, startY); + setDrawing(null); + }, [drawing, color, saveAnnotation]); + + // Text commit + const commitTextAnnotation = useCallback(() => { + if (!textInput || !textValue.trim()) { + setTextInput(null); + setTextValue(""); + return; + } + saveAnnotation( + "TEXT", + { x: textInput.imgX, y: textInput.imgY, text: textValue.trim(), color }, + textInput.imgX, + textInput.imgY + ); + setTextInput(null); + setTextValue(""); + }, [textInput, textValue, color, saveAnnotation]); + + // Undo / Redo + const handleUndo = useCallback(() => { + const last = undoStack[undoStack.length - 1]; + if (!last) return; + setUndoStack((prev) => prev.slice(0, -1)); + if (last.action === "create") { + deleteMutation.mutate(last.annotationId, { + onSuccess: () => setRedoStack((prev) => [...prev, last]), + }); + } + }, [undoStack, deleteMutation]); + + const handleRedo = useCallback(() => { + const last = redoStack[redoStack.length - 1]; + if (!last || !last.input) return; + setRedoStack((prev) => prev.slice(0, -1)); + if (last.action === "create") { + createMutation.mutate(last.input, { + onSuccess: (result: any) => { + setUndoStack((prev) => [ + ...prev, + { action: "create", annotationId: result.id, input: last.input }, + ]); + }, + }); + } + }, [redoStack, createMutation]); + + // Delete selection + const handleDeleteSelection = useCallback(() => { + if (!selectedId) return; + deleteMutation.mutate(selectedId, { + onSuccess: () => { + setSelectedId(null); + toast.success("Annotation deleted"); + }, + onError: (err) => toast.error(`Failed to delete: ${err.message}`), + }); + }, [selectedId, deleteMutation]); + + // Screenshot move/resize + const handleScreenshotMove = useCallback( + (id: string, newX: number, newY: number) => { + const ann = annotations.find((a: any) => a.id === id); + if (!ann) return; + const newData = { ...(ann.data as any), x: newX, y: newY }; + updateMutation.mutate({ annotationId: id, data: { data: newData, imageX: newX, imageY: newY } }); + }, + [annotations, updateMutation] + ); + + const handleScreenshotResize = useCallback( + (id: string, newW: number, newH: number) => { + const ann = annotations.find((a: any) => a.id === id); + if (!ann) return; + const newData = { ...(ann.data as any), width: newW, height: newH }; + updateMutation.mutate({ annotationId: id, data: { data: newData } }); + }, + [annotations, updateMutation] + ); + + // Screenshot paste + const handleScreenshotPaste = useCallback( + async ( + file: File, + containerWidth: number, + containerHeight: number, + panX: number, + panY: number, + zoom: number, + imageDimensions: { width: number; height: number } | null + ) => { + if (!revisionId || !stageId) return; + + const formData = new FormData(); + formData.append("file", file); + formData.append("type", "current"); + + try { + const uploadRes = await fetch( + `/api/stages/${stageId}/revisions/${revisionId}/upload`, + { method: "POST", body: formData } + ); + + if (!uploadRes.ok) { + const errBody = await uploadRes.json().catch(() => ({})); + toast.error(errBody.error || "Failed to upload screenshot"); + return; + } + + const uploaded = await uploadRes.json(); + const centerImg = screenToImage( + containerWidth / 2, + containerHeight / 2, + panX, + panY, + zoom + ); + + const defaultWidth = Math.min(300, (imageDimensions?.width ?? 600) * 0.3); + const defaultHeight = defaultWidth * 0.75; + + saveAnnotation( + "SCREENSHOT", + { + x: centerImg.x - defaultWidth / 2, + y: centerImg.y - defaultHeight / 2, + width: defaultWidth, + height: defaultHeight, + imageUrl: uploaded.url, + color, + }, + centerImg.x, + centerImg.y + ); + + toast.success("Screenshot pasted"); + } catch { + toast.error("Failed to paste screenshot"); + } + }, + [revisionId, stageId, color, saveAnnotation] + ); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) + return; + + if (!e.metaKey && !e.ctrlKey) { + switch (e.key.toLowerCase()) { + case "v": setActiveTool("select"); return; + case "r": setActiveTool("rectangle"); return; + case "e": setActiveTool("ellipse"); return; + case "a": setActiveTool("arrow"); return; + case "f": setActiveTool("freehand"); return; + case "t": setActiveTool("text"); return; + case "p": setActiveTool("pin"); return; + case "delete": + case "backspace": + if (selectedId) { e.preventDefault(); handleDeleteSelection(); } + return; + case "escape": + setSelectedId(null); + setActiveTool("select"); + setTextInput(null); + setDrawing(null); + return; + } + } + + if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + e.preventDefault(); + handleUndo(); + return; + } + if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { + e.preventDefault(); + handleRedo(); + return; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedId, handleDeleteSelection, handleUndo, handleRedo]); + + // Drawing preview + const drawingPreview = useMemo((): AnnotationShape | null => { + if (!drawing) return null; + const { type, startX, startY, currentX, currentY, points } = drawing; + const data: Record = { color }; + + switch (type) { + case "RECTANGLE": + case "ELLIPSE": + data.x = Math.min(startX, currentX); + data.y = Math.min(startY, currentY); + data.width = Math.abs(currentX - startX); + data.height = Math.abs(currentY - startY); + break; + case "ARROW": + data.x = startX; data.y = startY; + data.endX = currentX; data.endY = currentY; + break; + case "FREEHAND": + data.points = points; + break; + } + + return { id: "__drawing__", type, data, imageX: startX, imageY: startY }; + }, [drawing, color]); + + return { + // Tool state + activeTool, + setActiveTool, + color, + setColor, + visible, + setVisible, + selectedId, + setSelectedId, + drawing, + textInput, + setTextInput, + textValue, + setTextValue, + // Refs + svgRef, + textInputRef, + // Derived + annotationShapes, + screenshotAnnotations, + drawingPreview, + // Undo/redo + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0, + handleUndo, + handleRedo, + // Actions + handleMouseDown, + handleMouseMove, + handleMouseUp, + commitTextAnnotation, + handleDeleteSelection, + handleScreenshotMove, + handleScreenshotResize, + handleScreenshotPaste, + }; +} diff --git a/src/hooks/use-annotations.ts b/src/hooks/use-annotations.ts new file mode 100644 index 0000000..5454800 --- /dev/null +++ b/src/hooks/use-annotations.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json(); +} + +export function useAnnotations(revisionId: string | null) { + return useQuery({ + queryKey: ["annotations", revisionId], + queryFn: () => fetchJson(`/api/revisions/${revisionId}/annotations`), + enabled: !!revisionId, + }); +} + +export function useCreateAnnotation(revisionId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateAnnotationInput) => + fetchJson(`/api/revisions/${revisionId}/annotations`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] }); + }, + }); +} + +export function useUpdateAnnotation(revisionId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + annotationId, + data, + }: { + annotationId: string; + data: UpdateAnnotationInput; + }) => + fetchJson(`/api/revisions/${revisionId}/annotations/${annotationId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] }); + }, + }); +} + +export function useDeleteAnnotation(revisionId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (annotationId: string) => + fetchJson(`/api/revisions/${revisionId}/annotations/${annotationId}`, { + method: "DELETE", + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] }); + }, + }); +} diff --git a/src/lib/services/annotation-service.ts b/src/lib/services/annotation-service.ts new file mode 100644 index 0000000..842dbea --- /dev/null +++ b/src/lib/services/annotation-service.ts @@ -0,0 +1,115 @@ +import { prisma } from "@/lib/prisma"; +import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation"; + +/** + * List all annotations for a revision, including the linked comment + author. + */ +export async function listAnnotations(revisionId: string) { + return prisma.annotation.findMany({ + where: { revisionId }, + include: { + comment: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + createdBy: { select: { id: true, name: true, email: true, image: true } }, + }, + orderBy: { createdAt: "asc" }, + }); +} + +/** + * Create an annotation and its linked comment in a single transaction. + * The comment is created on the stage associated with the revision. + */ +export async function createAnnotation( + revisionId: string, + userId: string, + input: CreateAnnotationInput +) { + return prisma.$transaction(async (tx) => { + // Create the linked comment on the stage + const comment = await tx.comment.create({ + data: { + deliverableStageId: input.stageId, + authorId: userId, + content: input.commentContent, + }, + }); + + // Create the annotation linked to the comment and revision + const annotation = await tx.annotation.create({ + data: { + revisionId, + commentId: comment.id, + type: input.type, + data: input.data as any, + imageX: input.imageX, + imageY: input.imageY, + createdById: userId, + }, + include: { + comment: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + createdBy: { select: { id: true, name: true, email: true, image: true } }, + }, + }); + + return annotation; + }); +} + +/** + * Update annotation data (position, shape data). + */ +export async function updateAnnotation( + annotationId: string, + userId: string, + input: UpdateAnnotationInput +) { + const annotation = await prisma.annotation.findUnique({ + where: { id: annotationId }, + }); + + if (!annotation) throw new Error("Annotation not found"); + if (annotation.createdById !== userId) throw new Error("Not authorized"); + + return prisma.annotation.update({ + where: { id: annotationId }, + data: { + ...(input.data !== undefined && { data: input.data as any }), + ...(input.imageX !== undefined && { imageX: input.imageX }), + ...(input.imageY !== undefined && { imageY: input.imageY }), + }, + include: { + comment: { + include: { + author: { select: { id: true, name: true, email: true, image: true } }, + }, + }, + createdBy: { select: { id: true, name: true, email: true, image: true } }, + }, + }); +} + +/** + * Delete an annotation and its linked comment (cascade via Comment relation). + */ +export async function deleteAnnotation(annotationId: string, userId: string) { + const annotation = await prisma.annotation.findUnique({ + where: { id: annotationId }, + select: { id: true, commentId: true, createdById: true }, + }); + + if (!annotation) throw new Error("Annotation not found"); + if (annotation.createdById !== userId) throw new Error("Not authorized"); + + // Deleting the comment cascades to the annotation + await prisma.comment.delete({ where: { id: annotation.commentId } }); + + return { ok: true }; +} diff --git a/src/lib/validators/annotation.ts b/src/lib/validators/annotation.ts new file mode 100644 index 0000000..5659e57 --- /dev/null +++ b/src/lib/validators/annotation.ts @@ -0,0 +1,50 @@ +import { z } from "zod/v4"; + +const annotationTypeEnum = z.enum([ + "RECTANGLE", + "ELLIPSE", + "ARROW", + "FREEHAND", + "TEXT", + "PIN", + "SCREENSHOT", +]); + +export type AnnotationTypeValue = z.infer; + +/** + * Shape data varies by annotation type. We use a loose Json schema + * here and validate more specifically in the service layer if needed. + */ +const annotationDataSchema = z.object({ + x: z.number().optional(), + y: z.number().optional(), + width: z.number().optional(), + height: z.number().optional(), + endX: z.number().optional(), + endY: z.number().optional(), + points: z.array(z.object({ x: z.number(), y: z.number() })).optional(), + text: z.string().optional(), + color: z.string().optional(), + strokeWidth: z.number().optional(), + imageUrl: z.string().optional(), +}); + +export const createAnnotationSchema = z.object({ + type: annotationTypeEnum, + data: annotationDataSchema, + imageX: z.number(), + imageY: z.number(), + commentContent: z.string().min(1, "Comment text is required"), + stageId: z.string().min(1, "Stage ID is required"), +}); + +export type CreateAnnotationInput = z.infer; + +export const updateAnnotationSchema = z.object({ + data: annotationDataSchema.optional(), + imageX: z.number().optional(), + imageY: z.number().optional(), +}); + +export type UpdateAnnotationInput = z.infer; From 05061baf26e39c27ef9e84c01cc18e9e41ee9a81 Mon Sep 17 00:00:00 2001 From: "Leivur R. Djurhuus" Date: Sat, 14 Mar 2026 15:09:37 -0500 Subject: [PATCH 03/11] feat: add revision history timeline (A4) and feedback checklist (A5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A4 — Revision History Timeline: - Collapsible right panel with vertical timeline of all revision rounds - Each node shows thumbnail, status badge, timestamp, annotation count, comment summary, and decision record - Keyboard navigation (up/down arrows), auto-scroll to active round - Filter by rounds with feedback, "Compare from here" action - Enriched revision data hook aggregating annotations + comments A5 — Feedback Checklist: - FeedbackItem model with severity (Critical/Major/Minor/Suggestion), status flow (Open → In Progress → Resolved → Verified), and carry-forward between revision rounds - Auto-creation from annotations (non-blocking, post-transaction) - Checklist panel in review page with progress bar, severity grouping, resolve-with-note flow, verify/reopen actions - FeedbackIndicator badge on stage cards in deliverable detail page - CRUD API routes + TanStack Query hooks - Prisma schema additions (requires db push) Co-Authored-By: Claude Opus 4.6 (1M context) --- prisma/schema.prisma | 8 + .../deliverables/[deliverableId]/page.tsx | 9 +- .../[deliverableId]/review/page.tsx | 155 ++++++-- src/app/api/feedback/[itemId]/route.ts | 91 +++++ .../api/stages/[stageId]/feedback/route.ts | 61 ++++ src/components/review/feedback-checklist.tsx | 301 ++++++++++++++++ src/components/review/feedback-item-card.tsx | 312 ++++++++++++++++ .../review/feedback-progress-bar.tsx | 54 +++ src/components/review/revision-node.tsx | 259 +++++++++++++ src/components/review/revision-timeline.tsx | 340 ++++++++++++++++++ src/components/stages/feedback-indicator.tsx | 66 ++++ src/hooks/use-feedback.ts | 195 ++++++++++ src/hooks/use-revision-history.ts | 156 ++++++++ src/lib/services/annotation-service.ts | 19 +- src/lib/services/feedback-service.ts | 270 ++++++++++++++ src/lib/validators/feedback.ts | 49 +++ 16 files changed, 2310 insertions(+), 35 deletions(-) create mode 100644 src/app/api/feedback/[itemId]/route.ts create mode 100644 src/app/api/stages/[stageId]/feedback/route.ts create mode 100644 src/components/review/feedback-checklist.tsx create mode 100644 src/components/review/feedback-item-card.tsx create mode 100644 src/components/review/feedback-progress-bar.tsx create mode 100644 src/components/review/revision-node.tsx create mode 100644 src/components/review/revision-timeline.tsx create mode 100644 src/components/stages/feedback-indicator.tsx create mode 100644 src/hooks/use-feedback.ts create mode 100644 src/hooks/use-revision-history.ts create mode 100644 src/lib/services/feedback-service.ts create mode 100644 src/lib/validators/feedback.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9f40426..295d045 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -775,6 +775,7 @@ model FeedbackItem { comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull) summary String isActionItem Boolean @default(true) + severity FeedbackSeverity @default(MAJOR) status FeedbackStatus @default(OPEN) sortOrder Int @default(0) assignedToId String? @@ -800,3 +801,10 @@ model FeedbackItem { @@index([status]) @@map("feedback_items") } + +enum FeedbackSeverity { + CRITICAL + MAJOR + MINOR + SUGGESTION +} diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx index f600890..762f031 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/page.tsx @@ -30,6 +30,8 @@ import { StageDatePopover } from "@/components/stages/stage-date-popover"; import { PipelineProgress } from "@/components/deliverables/pipeline-progress"; import { StageStatusBadge } from "@/components/stages/stage-status-badge"; import { StageDetailSheet } from "@/components/stages/stage-detail-sheet"; +import { FeedbackIndicator } from "@/components/stages/feedback-indicator"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; const PRIORITY_STYLES: Record = { @@ -114,6 +116,7 @@ export default function DeliverableDetailPage() { ); return ( +
{/* Back link + header */}
@@ -290,7 +293,10 @@ export default function DeliverableDetailPage() { )}
- +
+ + +
@@ -419,5 +425,6 @@ export default function DeliverableDetailPage() { }} />
+ ); } diff --git a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx index 38294bb..f948370 100644 --- a/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx +++ b/src/app/(app)/projects/[projectId]/deliverables/[deliverableId]/review/page.tsx @@ -9,6 +9,8 @@ import { ChevronRight, Upload, Columns2, + History, + ClipboardList, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -31,6 +33,8 @@ import { import { ImageUploadZone } from "@/components/review/image-upload-zone"; import { ImageGallery } from "@/components/review/image-gallery"; import { AnnotationLayer } from "@/components/review/annotation-layer"; +import { RevisionTimeline } from "@/components/review/revision-timeline"; +import { FeedbackChecklist } from "@/components/review/feedback-checklist"; import { useDeliverable } from "@/hooks/use-deliverables"; import { useRevisions } from "@/hooks/use-revisions"; import { useQueryClient } from "@tanstack/react-query"; @@ -77,6 +81,12 @@ export default function ReviewPage() { const [uploadPanelOpen, setUploadPanelOpen] = useState(false); const [activeImageUrl, setActiveImageUrl] = useState(null); + // ── Revision timeline panel state ──────────────────────────────────── + const [timelinePanelOpen, setTimelinePanelOpen] = useState(true); + + // ── Feedback checklist panel state ───────────────────────────────── + const [feedbackPanelOpen, setFeedbackPanelOpen] = useState(false); + // ── Comparison mode state ──────────────────────────────────────────── const [comparisonActive, setComparisonActive] = useState(false); const [comparisonMode, setComparisonMode] = @@ -242,6 +252,37 @@ export default function ReviewPage() { setComparisonActive(false); }, []); + // ── Revision timeline handlers ────────────────────────────────────── + const handleTimelineSelectRevision = useCallback( + (revisionId: string, imageUrl: string | null) => { + if (imageUrl) { + setActiveImageUrl(imageUrl); + } + }, + [] + ); + + const handleTimelineCompareRevisions = useCallback( + (leftRevId: string, rightRevId: string) => { + // Find gallery images for these revisions (prefer "current" type) + const leftImg = + galleryImages.find( + (img) => img.revisionId === leftRevId && img.type === "current" + ) ?? galleryImages.find((img) => img.revisionId === leftRevId); + const rightImg = + galleryImages.find( + (img) => img.revisionId === rightRevId && img.type === "current" + ) ?? galleryImages.find((img) => img.revisionId === rightRevId); + + if (leftImg && rightImg) { + setComparisonActive(true); + setLeftRevisionKey(`${leftImg.revisionId}-${leftImg.type}`); + setRightRevisionKey(`${rightImg.revisionId}-${rightImg.type}`); + } + }, + [galleryImages] + ); + // ── Keyboard shortcuts for comparison modes ──────────────────────────── useEffect(() => { if (!comparisonActive) return; @@ -362,6 +403,28 @@ export default function ReviewPage() { )} + {/* Revision history toggle */} + + + {/* Feedback checklist toggle */} + + {/* Upload panel trigger */} @@ -430,43 +493,69 @@ export default function ReviewPage() { /> )} - {/* ── Image viewer / Comparison viewer ─────────────────────── */} - {comparisonActive ? ( - - ) : ( - ( - + {/* ── Viewer column ──────────────────────────────────────── */} +
+ {/* Image viewer / Comparison viewer */} + {comparisonActive ? ( + + ) : ( + ( + + )} /> )} - /> - )} - {/* ── Gallery strip ────────────────────────────────────────── */} - {!comparisonActive && galleryImages.length > 0 && ( -
- setActiveImageUrl(img.url)} - /> + {/* Gallery strip */} + {!comparisonActive && galleryImages.length > 0 && ( +
+ setActiveImageUrl(img.url)} + /> +
+ )} + + {/* Feedback checklist panel */} + {feedbackPanelOpen && selectedStageId && ( + + )}
- )} + + {/* ── Revision history timeline panel ────────────────────── */} + {timelinePanelOpen && ( + + )} +
); } diff --git a/src/app/api/feedback/[itemId]/route.ts b/src/app/api/feedback/[itemId]/route.ts new file mode 100644 index 0000000..16daef4 --- /dev/null +++ b/src/app/api/feedback/[itemId]/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { + getAuthSession, + badRequest, + notFound, + serverError, +} from "@/lib/api-utils"; +import { + updateFeedbackSchema, + resolveFeedbackSchema, +} from "@/lib/validators/feedback"; +import { + getFeedbackItem, + updateFeedbackItem, + resolveFeedbackItem, + verifyFeedbackItem, + reopenFeedbackItem, + deleteFeedbackItem, +} from "@/lib/services/feedback-service"; + +type Params = { params: Promise<{ itemId: string }> }; + +// PATCH /api/feedback/:itemId +// Supports multiple actions via `action` field: "update" (default), "resolve", "verify", "reopen" +export async function PATCH(request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { itemId } = await params; + const body = await request.json(); + const action = body.action ?? "update"; + + const existing = await getFeedbackItem(itemId); + if (!existing) return notFound("Feedback item not found"); + + let result; + + switch (action) { + case "resolve": { + const parsed = resolveFeedbackSchema.safeParse(body); + if (!parsed.success) { + return badRequest( + parsed.error.issues.map((i) => i.message).join(", ") + ); + } + result = await resolveFeedbackItem(itemId, session!.user.id, parsed.data); + break; + } + case "verify": { + result = await verifyFeedbackItem(itemId, session!.user.id); + break; + } + case "reopen": { + result = await reopenFeedbackItem(itemId); + break; + } + default: { + const parsed = updateFeedbackSchema.safeParse(body); + if (!parsed.success) { + return badRequest( + parsed.error.issues.map((i) => i.message).join(", ") + ); + } + result = await updateFeedbackItem(itemId, parsed.data); + break; + } + } + + return NextResponse.json(result); + } catch (e) { + return serverError(e); + } +} + +// DELETE /api/feedback/:itemId +export async function DELETE(_request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { itemId } = await params; + const existing = await getFeedbackItem(itemId); + if (!existing) return notFound("Feedback item not found"); + + await deleteFeedbackItem(itemId); + return NextResponse.json({ ok: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/stages/[stageId]/feedback/route.ts b/src/app/api/stages/[stageId]/feedback/route.ts new file mode 100644 index 0000000..624f668 --- /dev/null +++ b/src/app/api/stages/[stageId]/feedback/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { createFeedbackSchema } from "@/lib/validators/feedback"; +import { + listFeedbackItems, + createFeedbackItem, + getFeedbackSummary, +} from "@/lib/services/feedback-service"; + +type Params = { params: Promise<{ stageId: string }> }; + +// GET /api/stages/:stageId/feedback +// Query params: ?revisionId=&status=&severity=&summary=true +export async function GET(request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { stageId } = await params; + const url = new URL(request.url); + const revisionId = url.searchParams.get("revisionId") ?? undefined; + const status = url.searchParams.get("status") ?? undefined; + const severity = url.searchParams.get("severity") ?? undefined; + const summaryOnly = url.searchParams.get("summary") === "true"; + + if (summaryOnly) { + const summary = await getFeedbackSummary(stageId); + return NextResponse.json(summary); + } + + const items = await listFeedbackItems(stageId, { + revisionId, + status, + severity, + }); + return NextResponse.json(items); + } catch (e) { + return serverError(e); + } +} + +// POST /api/stages/:stageId/feedback +export async function POST(request: Request, { params }: Params) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const { stageId } = await params; + const body = await request.json(); + const parsed = createFeedbackSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const item = await createFeedbackItem(stageId, session!.user.id, parsed.data); + return NextResponse.json(item, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/review/feedback-checklist.tsx b/src/components/review/feedback-checklist.tsx new file mode 100644 index 0000000..aecc59a --- /dev/null +++ b/src/components/review/feedback-checklist.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ChevronDown, + ChevronUp, + ClipboardList, + Filter, + Plus, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + useFeedbackItems, + useResolveFeedback, + useVerifyFeedback, + useReopenFeedback, + useDeleteFeedback, +} from "@/hooks/use-feedback"; +import { FeedbackItemCard } from "./feedback-item-card"; +import { FeedbackProgressBar } from "./feedback-progress-bar"; + +interface FeedbackChecklistProps { + stageId: string; + revisionId?: string; + className?: string; + onAnnotationClick?: (annotation: { + id: string; + imageX: number; + imageY: number; + }) => void; +} + +type SeverityFilter = "ALL" | "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION"; +type StatusFilter = "ALL" | "OPEN" | "RESOLVED"; + +const SEVERITY_ORDER = ["CRITICAL", "MAJOR", "MINOR", "SUGGESTION"] as const; + +export function FeedbackChecklist({ + stageId, + revisionId, + className, + onAnnotationClick, +}: FeedbackChecklistProps) { + const [collapsed, setCollapsed] = useState(false); + const [severityFilter, setSeverityFilter] = useState("ALL"); + const [statusFilter, setStatusFilter] = useState("ALL"); + + const { data: items = [], isLoading } = useFeedbackItems(stageId, revisionId); + const resolveMutation = useResolveFeedback(stageId); + const verifyMutation = useVerifyFeedback(stageId); + const reopenMutation = useReopenFeedback(stageId); + const deleteMutation = useDeleteFeedback(stageId); + + const isPending = + resolveMutation.isPending || + verifyMutation.isPending || + reopenMutation.isPending || + deleteMutation.isPending; + + // Filter items + const filteredItems = useMemo(() => { + let result = [...items]; + + if (severityFilter !== "ALL") { + result = result.filter((i: any) => i.severity === severityFilter); + } + + if (statusFilter === "OPEN") { + result = result.filter( + (i: any) => + i.status === "OPEN" || + i.status === "IN_PROGRESS" || + i.status === "REOPENED" + ); + } else if (statusFilter === "RESOLVED") { + result = result.filter( + (i: any) => i.status === "RESOLVED" || i.status === "VERIFIED" + ); + } + + return result; + }, [items, severityFilter, statusFilter]); + + // Group by severity + const groupedItems = useMemo(() => { + const groups: Record = {}; + for (const sev of SEVERITY_ORDER) { + const group = filteredItems.filter((i: any) => i.severity === sev); + if (group.length > 0) groups[sev] = group; + } + return groups; + }, [filteredItems]); + + // Stats + const totalCount = items.length; + const resolvedCount = items.filter( + (i: any) => i.status === "RESOLVED" || i.status === "VERIFIED" + ).length; + + const handleResolve = (itemId: string, resolutionNote?: string) => { + resolveMutation.mutate( + { itemId, data: { resolutionNote } }, + { + onError: (err) => toast.error("Failed to resolve", { description: err.message }), + } + ); + }; + + const handleVerify = (itemId: string) => { + verifyMutation.mutate(itemId, { + onError: (err) => toast.error("Failed to verify", { description: err.message }), + }); + }; + + const handleReopen = (itemId: string) => { + reopenMutation.mutate(itemId, { + onError: (err) => toast.error("Failed to reopen", { description: err.message }), + }); + }; + + const handleDelete = (itemId: string) => { + deleteMutation.mutate(itemId, { + onError: (err) => toast.error("Failed to delete", { description: err.message }), + }); + }; + + const hasActiveFilters = severityFilter !== "ALL" || statusFilter !== "ALL"; + + return ( + +
+ {/* Header */} + + + {!collapsed && ( +
+ {/* Progress bar */} + {totalCount > 0 && ( + + )} + + {/* Filters */} + {totalCount > 0 && ( +
+ {/* Status filter */} +
+ {(["ALL", "OPEN", "RESOLVED"] as StatusFilter[]).map((s) => ( + + ))} +
+ + {/* Severity filter */} + + + + + + {(["ALL", ...SEVERITY_ORDER] as SeverityFilter[]).map( + (sev) => ( + + ) + )} + + + + {hasActiveFilters && ( + + )} +
+ )} + + {/* Item list grouped by severity */} + {isLoading ? ( +
+ Loading feedback items... +
+ ) : totalCount === 0 ? ( +
+ No feedback items yet. Annotations will automatically create + checklist items. +
+ ) : filteredItems.length === 0 ? ( +
+ No items match the current filters. +
+ ) : ( +
+ {Object.entries(groupedItems).map(([severity, group]) => ( +
+

+ {severity} ({group.length}) +

+
+ {group.map((item: any) => ( + + ))} +
+
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/review/feedback-item-card.tsx b/src/components/review/feedback-item-card.tsx new file mode 100644 index 0000000..c891fae --- /dev/null +++ b/src/components/review/feedback-item-card.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useState } from "react"; +import { + Check, + CheckCheck, + ChevronDown, + ChevronUp, + MapPin, + RotateCcw, + Trash2, + ArrowRight, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface FeedbackItemData { + id: string; + summary: string; + severity: "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION"; + status: "OPEN" | "IN_PROGRESS" | "RESOLVED" | "VERIFIED" | "REOPENED"; + resolutionNote?: string | null; + annotation?: { + id: string; + type: string; + imageX: number; + imageY: number; + } | null; + carriedFrom?: { id: string; summary: string; revisionId: string } | null; + createdBy?: { id: string; name: string | null; email: string } | null; + resolvedBy?: { id: string; name: string | null } | null; + verifiedBy?: { id: string; name: string | null } | null; + assignedTo?: { id: string; name: string | null } | null; +} + +interface FeedbackItemCardProps { + item: FeedbackItemData; + onResolve: (itemId: string, resolutionNote?: string) => void; + onVerify: (itemId: string) => void; + onReopen: (itemId: string) => void; + onDelete: (itemId: string) => void; + onAnnotationClick?: (annotation: { + id: string; + imageX: number; + imageY: number; + }) => void; + isPending?: boolean; +} + +const SEVERITY_STYLES: Record = { + CRITICAL: { + badge: "bg-red-500/10 text-red-600 border-red-500/30", + border: "border-l-red-500", + }, + MAJOR: { + badge: "bg-orange-500/10 text-orange-600 border-orange-500/30", + border: "border-l-orange-500", + }, + MINOR: { + badge: "bg-yellow-500/10 text-yellow-600 border-yellow-500/30", + border: "border-l-yellow-500", + }, + SUGGESTION: { + badge: "bg-blue-500/10 text-blue-600 border-blue-500/30", + border: "border-l-blue-500", + }, +}; + +const STATUS_LABELS: Record = { + OPEN: "Open", + IN_PROGRESS: "In Progress", + RESOLVED: "Resolved", + VERIFIED: "Verified", + REOPENED: "Reopened", +}; + +export function FeedbackItemCard({ + item, + onResolve, + onVerify, + onReopen, + onDelete, + onAnnotationClick, + isPending, +}: FeedbackItemCardProps) { + const [expanded, setExpanded] = useState(false); + const [resolutionNote, setResolutionNote] = useState(""); + + const isResolved = item.status === "RESOLVED" || item.status === "VERIFIED"; + const styles = SEVERITY_STYLES[item.severity]; + + const handleResolve = () => { + onResolve(item.id, resolutionNote || undefined); + setResolutionNote(""); + setExpanded(false); + }; + + return ( +
+
+ {/* Checkbox */} + { + if (checked) { + if (item.summary.length < 50) { + onResolve(item.id); + } else { + setExpanded(true); + } + } else { + onReopen(item.id); + } + }} + className="mt-0.5" + /> + + {/* Content */} +
+
+ + {item.severity} + + + {item.status !== "OPEN" && !isResolved && ( + + {STATUS_LABELS[item.status]} + + )} + + {item.status === "VERIFIED" && ( + + + + + + Verified{item.verifiedBy?.name ? ` by ${item.verifiedBy.name}` : ""} + + + )} + + {item.carriedFrom && ( + + + + + + Carried forward from previous round + + + )} +
+ +

+ {item.summary} +

+ + {/* Resolution note display */} + {item.resolutionNote && isResolved && ( +

+ Resolution: {item.resolutionNote} +

+ )} + + {/* Expanded resolve form */} + {expanded && !isResolved && ( +
+