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) <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-03-14 14:00:53 -05:00 committed by Leivur Djurhuus
parent 0ca56a5201
commit 1fa8803bfc
14 changed files with 1724 additions and 108 deletions

2
.gitignore vendored
View file

@ -46,4 +46,4 @@ next-env.d.ts
# uploaded assets (runtime-generated, not needed in repo)
/public/uploads/
/assets/review-images/
/assets/review-images/

View file

@ -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 (0100%)
- **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 |

4
package-lock.json generated
View file

@ -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"
},

View file

@ -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",

View file

@ -7,6 +7,7 @@ import { format } from "date-fns";
import {
ArrowLeft,
ChevronRight,
Eye,
FileText,
Lock,
RotateCcw,
@ -376,17 +377,31 @@ export default function DeliverableDetailPage() {
</div>
)}
{/* Details button */}
{/* Action buttons */}
{stage.status !== "BLOCKED" && (
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px] text-[var(--muted-foreground)]"
onClick={() => setSelectedStage(stage)}
>
<FileText className="mr-1 h-3 w-3" />
Revisions & Comments
</Button>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px] text-[var(--muted-foreground)]"
onClick={() => setSelectedStage(stage)}
>
<FileText className="mr-1 h-3 w-3" />
Revisions & Comments
</Button>
<Link
href={`/projects/${projectId}/deliverables/${deliverableId}/review`}
>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px] text-[var(--primary)]"
>
<Eye className="mr-1 h-3 w-3" />
Review
</Button>
</Link>
</div>
)}
</CardContent>
</Card>

View file

@ -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<string | null>(null);
const [uploadPanelOpen, setUploadPanelOpen] = useState(false);
const [activeImageUrl, setActiveImageUrl] = useState<string | null>(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 (
<div className="flex h-full flex-col">
<Skeleton className="h-12 w-full" />
<Skeleton className="mt-2 flex-1" />
</div>
);
}
if (!deliverable) {
return (
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
Deliverable not found.
</div>
);
}
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* ── Top bar ──────────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-3">
<Link
href={`/projects/${projectId}/deliverables/${deliverableId}`}
className="flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<h1 className="font-heading text-sm font-semibold">
{deliverable.name}
</h1>
</div>
{/* Stage navigator */}
{selectedStage && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={stageIdx <= 0}
onClick={() => navigateStage(-1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs text-[var(--muted-foreground)]">
{selectedStage.template.order}.
</span>
<span className="text-xs font-medium">
{selectedStage.template.name}
</span>
<StageStatusBadge status={selectedStage.status} />
</div>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
disabled={stageIdx >= stages.length - 1}
onClick={() => navigateStage(1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-5" />
{/* Upload panel trigger */}
<Sheet open={uploadPanelOpen} onOpenChange={setUploadPanelOpen}>
<SheetTrigger asChild>
<Button size="sm" variant="outline" className="h-7 text-xs">
<Upload className="mr-1 h-3 w-3" />
Upload
</Button>
</SheetTrigger>
<SheetContent className="w-full overflow-y-auto sm:max-w-sm">
<SheetHeader>
<SheetTitle className="text-sm">Upload Images</SheetTitle>
</SheetHeader>
<Separator className="my-3" />
{latestRevision ? (
<div className="space-y-4">
<p className="text-xs text-[var(--muted-foreground)]">
Uploading to <strong>Round {latestRevision.roundNumber}</strong>
</p>
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Reference Image
</p>
<ImageUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
imageType="reference"
existingImage={latestAttachments.referenceImage}
onUploadComplete={handleUploadComplete}
/>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Current Render
</p>
<ImageUploadZone
stageId={selectedStageId!}
revisionId={latestRevision.id}
imageType="current"
existingImage={latestAttachments.currentImage}
onUploadComplete={handleUploadComplete}
/>
</div>
</div>
) : (
<p className="py-8 text-center text-sm text-[var(--muted-foreground)]">
Submit a revision first before uploading images.
</p>
)}
</SheetContent>
</Sheet>
</div>
)}
</div>
{/* ── Image viewer ─────────────────────────────────────────── */}
<ImageViewer src={activeImageUrl} className="flex-1" />
{/* ── Gallery strip ────────────────────────────────────────── */}
{galleryImages.length > 0 && (
<div className="border-t px-3 py-1.5">
<ImageGallery
images={galleryImages}
activeUrl={activeImageUrl}
onSelect={(img) => setActiveImageUrl(img.url)}
/>
</div>
)}
</div>
);
}

View file

@ -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);
}
}

View file

@ -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 (
<div className="flex items-center gap-1 overflow-x-auto rounded-lg border bg-[var(--card)] p-1.5">
<span className="shrink-0 px-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Gallery
</span>
<div className="mx-1 h-8 w-px shrink-0 bg-[var(--border)]" />
{images.map((img) => (
<button
key={`${img.revisionId}-${img.type}`}
className={cn(
"group relative shrink-0 overflow-hidden rounded border transition-all",
activeUrl === img.url
? "border-[var(--primary)] ring-1 ring-[var(--primary)]"
: "border-transparent hover:border-[var(--muted-foreground)]"
)}
onClick={() => onSelect(img)}
title={`Round ${img.roundNumber}${img.type === "reference" ? "Reference" : "Current"}`}
>
<img
src={img.thumbnailUrl}
alt={`Round ${img.roundNumber} ${img.type}`}
className="h-10 w-10 object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-0.5 text-center">
<span className="text-[8px] font-medium text-white">
R{img.roundNumber}
{img.type === "reference" ? " ref" : ""}
</span>
</div>
</button>
))}
</div>
);
}

View file

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={cn("group relative", compact ? "h-16 w-16" : "")}>
<img
src={existingImage.url}
alt={existingImage.filename}
className={cn(
"rounded border object-cover",
compact ? "h-16 w-16" : "h-24 w-full"
)}
/>
<div className="absolute inset-0 flex items-center justify-center gap-1 rounded bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-[10px] text-white hover:bg-white/20 hover:text-white"
onClick={() => fileInputRef.current?.click()}
>
Replace
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-[10px] text-white hover:bg-red-500/50 hover:text-white"
onClick={handleDelete}
>
<X className="h-3 w-3" />
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/tiff"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}
// Empty state: drop zone
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors",
isDragging
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--muted-foreground)]",
compact ? "h-16 w-16 p-1" : "p-6",
isUploading && "pointer-events-none opacity-60"
)}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !isUploading && fileInputRef.current?.click()}
role="button"
tabIndex={0}
>
{isUploading ? (
<Loader2
className={cn(
"animate-spin text-[var(--muted-foreground)]",
compact ? "h-4 w-4" : "h-6 w-6"
)}
/>
) : compact ? (
<ImageIcon className="h-4 w-4 text-[var(--muted-foreground)]" />
) : (
<>
<Upload className="mb-2 h-6 w-6 text-[var(--muted-foreground)]" />
<p className="text-xs font-medium">
{imageType === "reference" ? "Reference Image" : "Current Render"}
</p>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
Drop file or click to browse
</p>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
PNG, JPEG, WebP, TIFF up to 50MB
</p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/tiff"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}

View file

@ -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 (
<div className={`relative flex flex-col ${className ?? ""}`}>
{/* Toolbar */}
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
<div className="flex items-center gap-3">
<ZoomControls
zoom={viewer.state.zoom}
onZoomIn={viewer.zoomIn}
onZoomOut={viewer.zoomOut}
onFitToView={viewer.fitToView}
onZoomToPreset={viewer.zoomToPreset}
/>
{dims && (
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
{dims.width} × {dims.height}
</span>
)}
</div>
{/* Pixel info */}
{viewer.pixelInfo && (
<div className="flex items-center gap-2 font-mono text-[10px] text-[var(--muted-foreground)]">
<span>
{viewer.pixelInfo.x}, {viewer.pixelInfo.y}
</span>
<div className="flex items-center gap-1">
<div
className="h-3 w-3 rounded-sm border border-white/20"
style={{ backgroundColor: viewer.pixelInfo.color }}
/>
<span>{viewer.pixelInfo.color}</span>
</div>
</div>
)}
</div>
{/* Canvas viewport */}
<div
ref={viewer.containerRef}
className="relative flex-1 overflow-hidden bg-[#1a1a1a]"
>
{!src && !viewer.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-[var(--muted-foreground)]">
<ImageIcon className="h-12 w-12 opacity-30" />
<p className="text-sm">No image loaded</p>
<p className="text-xs opacity-60">
Upload images to a revision to start reviewing
</p>
</div>
)}
{viewer.isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-[var(--muted-foreground)]" />
</div>
)}
<canvas
ref={viewer.canvasRef}
className="block h-full w-full"
/>
{/* Minimap */}
{src && dims && (
<Minimap
imageSrc={src}
imageWidth={dims.width}
imageHeight={dims.height}
zoom={viewer.state.zoom}
panX={viewer.state.panX}
panY={viewer.state.panY}
containerWidth={containerW}
containerHeight={containerH}
onNavigate={(panX, panY) => {
viewer.setPan(panX, panY);
}}
/>
)}
</div>
</div>
);
}

View file

@ -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<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(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<HTMLCanvasElement>) => {
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 (
<div className="absolute bottom-3 right-3 overflow-hidden rounded border border-white/20 bg-black/60 shadow-lg backdrop-blur-sm">
<canvas
ref={canvasRef}
width={mapW}
height={mapH}
className="block cursor-crosshair"
onClick={handleClick}
/>
</div>
);
}

View file

@ -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 (
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--card)] p-0.5">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onZoomOut}
title="Zoom out ()"
>
<Minus className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 min-w-[52px] px-1.5 font-mono text-xs"
>
{percent}%
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
{PRESETS.map((p) => (
<DropdownMenuItem
key={p.value}
onClick={() => onZoomToPreset(p.value)}
className="font-mono text-xs"
>
{p.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onZoomIn}
title="Zoom in (+)"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<div className="mx-0.5 h-4 w-px bg-[var(--border)]" />
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onFitToView}
title="Fit to view (0)"
>
<Maximize className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => onZoomToPreset("100")}
title="Actual pixels (1)"
>
<Square className="h-3 w-3" />
</Button>
</div>
);
}

View file

@ -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<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
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<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const rafRef = useRef<number>(0);
const [state, setState] = useState<ViewerState>({
zoom: 1,
panX: 0,
panY: 0,
});
const [imageDimensions, setImageDimensions] =
useState<ImageDimensions | null>(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,
};
}

View file

@ -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<UploadedImage> {
// 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<void> {
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(() => {});
}
}
}