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(() => {}); + } + } +}