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:
parent
0ca56a5201
commit
1fa8803bfc
14 changed files with 1724 additions and 108 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -46,4 +46,4 @@ next-env.d.ts
|
|||
|
||||
# uploaded assets (runtime-generated, not needed in repo)
|
||||
/public/uploads/
|
||||
/assets/review-images/
|
||||
/assets/review-images/
|
||||
|
|
|
|||
243
ROADMAP.md
243
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 |
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
60
src/components/review/image-gallery.tsx
Normal file
60
src/components/review/image-gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
196
src/components/review/image-upload-zone.tsx
Normal file
196
src/components/review/image-upload-zone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/review/image-viewer.tsx
Normal file
116
src/components/review/image-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/review/minimap.tsx
Normal file
120
src/components/review/minimap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/review/zoom-controls.tsx
Normal file
106
src/components/review/zoom-controls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
395
src/hooks/use-image-viewer.ts
Normal file
395
src/hooks/use-image-viewer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
145
src/lib/services/upload-service.ts
Normal file
145
src/lib/services/upload-service.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue