Merge pull request #2 from packman86/feature/visual-review-tool
Feature/visual review tool
This commit is contained in:
commit
082b91b09e
66 changed files with 12506 additions and 144 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/
|
||||
|
|
|
|||
529
ROADMAP.md
529
ROADMAP.md
|
|
@ -3,7 +3,7 @@
|
|||
> Single source of truth for project status and remaining work.
|
||||
> Previous planning documents (IMPLEMENTATION_PLAN.md, UPGRADE_PLAN.md) are archived in `docs/archive/`.
|
||||
|
||||
*Last updated: 2026-03-14 (Phase 2 complete)*
|
||||
*Last updated: 2026-03-16*
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -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,107 @@
|
|||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
**Infrastructure built so far:**
|
||||
- Review page at `/projects/[projectId]/deliverables/[deliverableId]/review` with image viewer, upload, gallery
|
||||
- Canvas-based image viewer with zoom/pan/minimap, retina support
|
||||
- Image upload API with PNG alpha compositing + TIFF conversion (sharp)
|
||||
- `Revision.attachments` JSON stores `{ referenceImage, currentImage }` with metadata
|
||||
- Comparison viewer with 4 modes: side-by-side, wipe, overlay, toggle
|
||||
- SVG annotation layer with 7 tools (rect, ellipse, arrow, freehand, text, pin, screenshot paste)
|
||||
- Annotation model in Prisma schema (requires `db push` to sync)
|
||||
- Annotation API: GET/POST `/api/revisions/[id]/annotations`, PATCH/DELETE `/api/revisions/[id]/annotations/[id]`
|
||||
- Annotations linked to comments (transactional create), undo/redo stack
|
||||
- Screenshot paste: Cmd+V pastes clipboard image as draggable/resizable callout
|
||||
- "Review" button on stage cards in deliverable detail page
|
||||
- Review sessions: session builder, presenter mode, summary grid, decision recording
|
||||
- `ReviewSession` + `ReviewSessionItem` models in schema
|
||||
|
||||
**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 `[x]`
|
||||
|
||||
**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 `[x]`
|
||||
|
||||
**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 +225,80 @@ 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 `[x]`
|
||||
|
||||
**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) `[x]`
|
||||
|
||||
**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 — action items with direct links to annotations on the image
|
||||
4. Artist works through items — checks each off with optional resolution note
|
||||
5. Artist submits new revision — unchecked action 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`
|
||||
**Two item types (simplified from 4-level severity):**
|
||||
- **Action Item** (default) — something the artist needs to fix. Has checkbox, can be resolved/verified.
|
||||
- **Info Callout** — context or reference that doesn't require action (e.g., "FYI the client prefers warmer tones"). No checkbox. Can be toggled from action item and vice versa.
|
||||
|
||||
**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.
|
||||
**Where the checklist appears (3 locations):**
|
||||
1. **Review page — Feedback Panel** (primary): full checklist with action items first, then info callouts. Progress bar counts only action items. Filter by type and status.
|
||||
2. **My Work page** — feedback badge per assignment ("5 open items")
|
||||
3. **Stage card on deliverable page** — compact badge ("4/7 resolved") for action items
|
||||
|
||||
**New data model:**
|
||||
```prisma
|
||||
|
|
@ -259,7 +313,7 @@ model FeedbackItem {
|
|||
commentId String?
|
||||
comment Comment? @relation(...)
|
||||
summary String
|
||||
severity FeedbackSeverity @default(MAJOR)
|
||||
isActionItem Boolean @default(true)
|
||||
status FeedbackStatus @default(OPEN)
|
||||
sortOrder Int @default(0)
|
||||
assignedToId String?
|
||||
|
|
@ -275,26 +329,32 @@ model FeedbackItem {
|
|||
@@map("feedback_items")
|
||||
}
|
||||
|
||||
enum FeedbackSeverity { CRITICAL MAJOR MINOR SUGGESTION }
|
||||
enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED }
|
||||
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 `[x]`
|
||||
|
||||
**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
|
||||
|
|
@ -327,15 +387,277 @@ enum ReviewSessionStatus { DRAFT IN_PROGRESS COMPLETED }
|
|||
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/lib/services/review-session-service.ts`
|
||||
**Infrastructure built so far:**
|
||||
- Review sessions list page at `/reviews` with create/delete, status filtering
|
||||
- Session detail page at `/reviews/[sessionId]` with builder (list) and summary (grid) views
|
||||
- Session builder with auto-fill from project (filter by stage status), reorder, remove items
|
||||
- Presenter mode with full-screen walkthrough, image viewer, annotation overlay (read-only), keyboard shortcuts (A=approve, C=changes, arrows=navigate, Esc=exit), fullscreen toggle
|
||||
- Session summary with thumbnail grid, decision badges, stats strip
|
||||
- `ReviewSession` + `ReviewSessionItem` models in Prisma schema (decision stored as string, no enum coupling)
|
||||
- Full service layer, API routes (CRUD + add-items/remove/reorder/decide/generate), TanStack Query hooks
|
||||
- "Reviews" added to sidebar navigation
|
||||
- `AnnotationLayer` extended with `readOnly` prop for presenter mode
|
||||
- No shareable links (deferred to B3)
|
||||
- No pipeline advancement on approve (manual "client approved" comes later)
|
||||
|
||||
**Dependencies:** Requires A2 + A3
|
||||
**Key files:**
|
||||
- `src/app/(app)/reviews/page.tsx` — Session list
|
||||
- `src/app/(app)/reviews/[sessionId]/page.tsx` — Session detail (builder/summary/presenter)
|
||||
- `src/components/review/create-session-dialog.tsx` — Create session dialog
|
||||
- `src/components/review/session-builder.tsx` — Item list with auto-fill
|
||||
- `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` — All business logic
|
||||
- `src/lib/validators/review-session.ts` — Zod schemas
|
||||
- `src/hooks/use-review-sessions.ts` — TanStack Query hooks
|
||||
- `src/app/api/reviews/route.ts` — List/create API
|
||||
- `src/app/api/reviews/[sessionId]/route.ts` — CRUD + actions API
|
||||
|
||||
**Dependencies:** Requires A1 + A3
|
||||
|
||||
---
|
||||
|
||||
#### A7 — Video Review with Timestamped Annotations
|
||||
|
||||
Extend the review tool to support video. Artists submit MP4 renders; reviewers scrub to a frame, pause, and draw spatial annotations (reusing the existing annotation tools) tied to that timestamp. Optional reference video for side-by-side comparison. Self-hosted transcoding via FFmpeg — no external services.
|
||||
|
||||
**Reference implementation:** [lawn-video-reviewer](https://github.com/) — React video player with HLS streaming, timestamped comments, and timeline markers. Key patterns to adapt: custom video player with frame-accurate seeking, comment markers on the scrub timeline, and keyboard-driven playback controls.
|
||||
|
||||
**New dependency:** `ffmpeg` (added to Docker image for server-side frame extraction + optional transcoding)
|
||||
|
||||
The video feature is broken into sub-stages, each independently useful:
|
||||
|
||||
---
|
||||
|
||||
##### A7.1 — FFmpeg in Docker + Video Upload Infrastructure
|
||||
|
||||
Add FFmpeg to the Docker environment and extend the upload system to accept video files.
|
||||
|
||||
**What gets built:**
|
||||
- **Dockerfile update** — install `ffmpeg` in both builder and runner stages (`apk add --no-cache ffmpeg` on Alpine)
|
||||
- **docker-compose.yml** — no changes needed (ffmpeg is in the app container)
|
||||
- **Upload service extension** — accept MP4 files up to 500MB alongside existing image types
|
||||
- New `type` value: `"video"` (in addition to "reference", "current", "screenshot")
|
||||
- Validate MIME type (`video/mp4`) and file size
|
||||
- Store in `/public/uploads/revisions/[revisionId]/video_[timestamp].mp4`
|
||||
- **Thumbnail extraction** — on upload, run `ffmpeg` to extract a poster frame (first frame or frame at 1s) as JPEG
|
||||
- **Video metadata extraction** — use `ffmpeg -i` (or `ffprobe`) to read duration, resolution, codec, frame rate
|
||||
- **Extend `Revision.attachments` JSON:**
|
||||
```json
|
||||
{
|
||||
"referenceImage": { ... },
|
||||
"currentImage": { ... },
|
||||
"video": {
|
||||
"url": "/uploads/revisions/.../video_xxx.mp4",
|
||||
"filename": "video_xxx.mp4",
|
||||
"size": 52428800,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"duration": 12.5,
|
||||
"fps": 24,
|
||||
"codec": "h264",
|
||||
"thumbnailUrl": "/uploads/revisions/.../video_xxx_thumb.jpg",
|
||||
"uploadedAt": "2026-03-16T..."
|
||||
},
|
||||
"referenceVideo": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**New API endpoints:**
|
||||
- `POST /api/stages/[stageId]/revisions/[revisionId]/upload` — extend existing endpoint to handle `type: "video" | "referenceVideo"`
|
||||
- `DELETE /api/stages/[stageId]/revisions/[revisionId]/upload?type=video` — remove uploaded video
|
||||
|
||||
**Key files (new/modified):**
|
||||
- `Dockerfile` — add `ffmpeg` installation
|
||||
- `src/lib/services/upload-service.ts` — extend with video handling, ffprobe metadata, thumbnail extraction
|
||||
- `src/lib/services/video-service.ts` — FFmpeg wrapper (thumbnail extraction, metadata parsing, frame extraction)
|
||||
- `src/components/review/video-upload-zone.tsx` — Drag-and-drop for video files (reuse `image-upload-zone.tsx` pattern)
|
||||
|
||||
**Dependencies:** Requires A1 (upload infrastructure)
|
||||
|
||||
---
|
||||
|
||||
##### A7.2 — Video Player Component
|
||||
|
||||
A custom video player built for frame-accurate review, embedded in the existing review page alongside the image viewer.
|
||||
|
||||
**What gets built:**
|
||||
- **Video player component** — HTML5 `<video>` element with custom controls (no native browser chrome)
|
||||
- Play/pause (Space or K)
|
||||
- Seek bar / scrub timeline with hover preview
|
||||
- Frame-by-frame stepping (← / → arrow keys, step by 1/fps seconds)
|
||||
- Playback speed control (0.25x, 0.5x, 1x, 1.5x, 2x)
|
||||
- Volume control + mute (M)
|
||||
- Fullscreen toggle (F)
|
||||
- Current time / duration display (timecode format: `HH:MM:SS:FF`)
|
||||
- Loop toggle
|
||||
- **Review page integration** — detect whether the revision has a video or image attachment and render the appropriate viewer
|
||||
- Tab or toggle to switch between image viewer and video player when both exist
|
||||
- Gallery strip shows video thumbnails alongside image thumbnails
|
||||
- **Keyboard shortcuts** — consistent with lawn's patterns:
|
||||
- Space/K = play/pause
|
||||
- J/L = skip back/forward 5s
|
||||
- ← / → = frame step (when paused)
|
||||
- , / . = frame step alternative (matching common NLEs)
|
||||
- F = fullscreen
|
||||
- M = mute
|
||||
- [ / ] = playback speed down/up
|
||||
|
||||
**Key files:**
|
||||
- `src/components/review/video-player.tsx` — Core player component with custom controls
|
||||
- `src/components/review/video-controls.tsx` — Play, seek, speed, volume, fullscreen controls
|
||||
- `src/components/review/video-timeline.tsx` — Scrub bar with hover time preview
|
||||
- `src/components/review/video-frame-display.tsx` — Timecode display (HH:MM:SS:FF)
|
||||
- `src/hooks/use-video-player.ts` — Player state management (current time, playing, speed, volume)
|
||||
|
||||
**Dependencies:** Requires A7.1 (video upload)
|
||||
|
||||
---
|
||||
|
||||
##### A7.3 — Timestamped Video Annotations
|
||||
|
||||
The core differentiator: pause a video at any frame, draw spatial annotations on that frame using the existing annotation tools, and have those annotations tied to both a timestamp and image coordinates.
|
||||
|
||||
**What gets built:**
|
||||
- **Frame capture** — when the user activates an annotation tool while the video is playing:
|
||||
1. Video auto-pauses
|
||||
2. Current frame is captured to a canvas (via `drawImage` from `<video>`)
|
||||
3. SVG annotation layer overlays the paused frame (reuse existing `annotation-layer.tsx`)
|
||||
4. User draws annotation using existing tools (rectangle, ellipse, arrow, freehand, text, pin)
|
||||
5. On submit, annotation is saved with `timestampSeconds` + spatial coordinates
|
||||
- **Server-side frame extraction fallback** — API endpoint to extract a specific frame via FFmpeg for cases where client-side capture fails (e.g., CORS, codec issues):
|
||||
- `GET /api/stages/[stageId]/revisions/[revisionId]/frame?t=5.25` → returns JPEG of frame at 5.25s
|
||||
- **Timeline annotation markers** — colored dots/ticks on the video scrub bar showing where annotations exist
|
||||
- Orange = unresolved, green = resolved (matches lawn's pattern)
|
||||
- Click a marker to seek to that timestamp and show the annotation
|
||||
- Cluster nearby markers when zoomed out
|
||||
- **Annotation visibility by time** — annotations fade in/out based on current playback position
|
||||
- When playing: briefly flash annotations as playhead crosses their timestamp
|
||||
- When paused: show annotations for the current frame (±0.5s window configurable)
|
||||
- Toggle: "show all annotations" mode shows all regardless of timestamp
|
||||
- **Comment linking** — each video annotation creates a Comment (same as image annotations) with the timestamp embedded
|
||||
- Comment sidebar shows timestamp badge (clickable to seek)
|
||||
- Comments sorted by timestamp (not creation time)
|
||||
|
||||
**Data model extension:**
|
||||
```prisma
|
||||
// Extend existing Annotation model — add optional timestamp field
|
||||
model Annotation {
|
||||
// ... existing fields ...
|
||||
timestampSeconds Float? // null for image annotations, set for video annotations
|
||||
frameThumbnailUrl String? // cached frame thumbnail for the annotation's moment
|
||||
}
|
||||
```
|
||||
|
||||
**New API endpoints:**
|
||||
- `GET /api/stages/[stageId]/revisions/[revisionId]/frame?t=5.25` — extract frame at timestamp via FFmpeg
|
||||
- Existing annotation CRUD endpoints gain `timestampSeconds` field
|
||||
|
||||
**Key files (new/modified):**
|
||||
- `src/components/review/video-annotation-layer.tsx` — Orchestrates frame capture + annotation overlay on paused video
|
||||
- `src/components/review/video-timeline-markers.tsx` — Annotation markers on scrub bar
|
||||
- `src/components/review/annotation-layer.tsx` — extend to accept optional timestamp context
|
||||
- `src/components/review/comment-list.tsx` — extend to show timestamp badges, sort by time
|
||||
- `src/lib/services/video-service.ts` — add frame extraction endpoint
|
||||
- `src/lib/services/annotation-service.ts` — extend to handle `timestampSeconds`
|
||||
- `prisma/schema.prisma` — add `timestampSeconds` and `frameThumbnailUrl` to Annotation
|
||||
|
||||
**Dependencies:** Requires A7.2 (video player) + A3 (annotation system)
|
||||
|
||||
---
|
||||
|
||||
##### A7.4 — Video Comparison (Reference vs. Current)
|
||||
|
||||
Side-by-side video comparison for reviewing changes between a reference render and the current submission.
|
||||
|
||||
**What gets built:**
|
||||
- **Dual video player** — two synced `<video>` elements playing in lockstep
|
||||
- Synced playback: play/pause/seek one, both follow
|
||||
- Synced frame stepping
|
||||
- Independent volume controls
|
||||
- Labels: "Reference" / "Current"
|
||||
- **Comparison modes** (reuse concepts from image comparison):
|
||||
- **Side-by-side** — two players next to each other (default)
|
||||
- **Toggle** — press Space to swap which video is visible (single player area)
|
||||
- **Wipe mode (stretch goal)** — CSS clip-path or canvas compositing to do A/B wipe on video
|
||||
- More complex due to dual video decoding — defer if performance is an issue
|
||||
- **Revision selectors** — dropdowns to pick which revision's video goes on each side
|
||||
- **Annotations shared across comparison** — annotations from either video visible, with source label
|
||||
|
||||
**Key files:**
|
||||
- `src/components/review/video-comparison.tsx` — Dual synced player orchestrator
|
||||
- `src/components/review/synced-video-player.tsx` — Player that accepts external time/play state
|
||||
- `src/components/review/video-comparison-toolbar.tsx` — Mode switcher
|
||||
- `src/hooks/use-synced-video.ts` — Shared playback state management
|
||||
|
||||
**Dependencies:** Requires A7.2 (video player). Reference video upload from A7.1.
|
||||
|
||||
---
|
||||
|
||||
##### A7.5 — Video in Review Sessions
|
||||
|
||||
Extend the existing review session presenter (A6) to handle video deliverables alongside images.
|
||||
|
||||
**What gets built:**
|
||||
- **Presenter mode video support** — when a ReviewSessionItem's revision has a video attachment, render the video player instead of the image viewer
|
||||
- All video controls available (play, pause, seek, frame-step, speed)
|
||||
- Annotations visible on the timeline and as overlays when paused
|
||||
- Decision buttons (Approve / Changes Requested) work the same as for images
|
||||
- **Mixed sessions** — sessions can contain both image and video items; presenter mode switches viewer type per item
|
||||
- **Session summary** — video items show poster frame thumbnail (extracted in A7.1) in the summary grid
|
||||
|
||||
**Key files (modified):**
|
||||
- `src/components/review/session-presenter.tsx` — add video player branch
|
||||
- `src/components/review/session-summary.tsx` — video thumbnail support
|
||||
- `src/components/review/session-builder.tsx` — show video icon for video items
|
||||
|
||||
**Dependencies:** Requires A6 (review sessions) + A7.2 (video player)
|
||||
|
||||
---
|
||||
|
||||
##### A7.6 — Feedback Checklist for Video Annotations
|
||||
|
||||
Extend the feedback checklist (A5) to work with timestamped video annotations.
|
||||
|
||||
**What gets built:**
|
||||
- **Timestamp in feedback items** — each feedback item created from a video annotation shows the timestamp
|
||||
- **Click-to-seek** — clicking a video feedback item in the checklist seeks the player to that timestamp and highlights the annotation
|
||||
- **Frame thumbnail in checklist** — small thumbnail of the annotated frame next to each feedback item for quick visual context
|
||||
- **Carry-forward for video** — when a new revision is submitted, unresolved video feedback items carry forward with their timestamps (timestamps may shift if the video changes length — flag these for manual review)
|
||||
|
||||
**Key files (modified):**
|
||||
- `src/components/review/feedback-checklist.tsx` — add timestamp display + seek action
|
||||
- `src/components/review/feedback-item-card.tsx` — frame thumbnail + timestamp badge
|
||||
- `src/lib/services/feedback-service.ts` — carry-forward logic for video annotations
|
||||
|
||||
**Dependencies:** Requires A5 (feedback checklist) + A7.3 (timestamped annotations)
|
||||
|
||||
---
|
||||
|
||||
##### Implementation Order & Dependency Graph
|
||||
|
||||
```
|
||||
A7.1 FFmpeg + Video Upload
|
||||
│
|
||||
▼
|
||||
A7.2 Video Player Component
|
||||
│
|
||||
├──────────────┐
|
||||
▼ ▼
|
||||
A7.3 Timestamped A7.4 Video
|
||||
Annotations Comparison
|
||||
│ │
|
||||
▼ ▼
|
||||
A7.6 Feedback A7.5 Review
|
||||
Checklist Sessions
|
||||
```
|
||||
|
||||
**Estimated build order:**
|
||||
1. **A7.1** — Foundation. Small scope, mostly backend. Do this first.
|
||||
2. **A7.2** — Core player. Can start immediately after A7.1.
|
||||
3. **A7.3** — The main feature. Requires the player + existing annotation system.
|
||||
4. **A7.4** — Comparison. Can be built in parallel with A7.3 if desired.
|
||||
5. **A7.5 + A7.6** — Integration with existing features. Do last.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -735,7 +1057,7 @@ enum ViewType { TABLE BOARD TIMELINE }
|
|||
|
||||
**What:** `docker-compose.yml` with three services — Next.js app, PostgreSQL + pgvector, Ollama with pre-configured models. One `docker compose up` starts everything.
|
||||
|
||||
Note: `Dockerfile` and `docker-compose.yml` already exist in the repo root — review and complete as needed.
|
||||
Note: `Dockerfile` and `docker-compose.yml` already exist in the repo root — review and complete as needed. The Dockerfile must include `ffmpeg` (required by A7 — Video Review).
|
||||
|
||||
**Services:**
|
||||
|
||||
|
|
@ -794,8 +1116,9 @@ Note: `Dockerfile` and `docker-compose.yml` already exist in the repo root — r
|
|||
|
||||
| Model | Feature |
|
||||
|---|---|
|
||||
| Annotation, FeedbackItem | A3, A6 |
|
||||
| ReviewSession, ReviewSessionItem | A7 |
|
||||
| Annotation (+ `timestampSeconds`, `frameThumbnailUrl` fields) | A3, A7.3 |
|
||||
| FeedbackItem | A5 |
|
||||
| ~~ReviewSession, ReviewSessionItem~~ | ~~A6~~ ✅ |
|
||||
| ApprovalChain, ApprovalStep, ApprovalRecord | D2 |
|
||||
| ProjectTemplate, ProjectTemplateDeliverable | D3 |
|
||||
| AssetSpec, AssetValidationResult | E1 |
|
||||
|
|
@ -804,6 +1127,7 @@ Note: `Dockerfile` and `docker-compose.yml` already exist in the repo root — r
|
|||
| SLATarget | C4 |
|
||||
| ActivityEntry | B2 |
|
||||
| SavedView | F1 |
|
||||
| Revision.attachments extended with `video` + `referenceVideo` | A7.1 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -843,7 +1167,7 @@ src/
|
|||
│ ├── reports/weekly/ # ✅ Weekly report components
|
||||
│ ├── pipeline-builder/ # ✅ Phase 2 Pipeline graph + stage list
|
||||
│ ├── revisions/ # A1 Image comparison (to build)
|
||||
│ ├── review/ # A2-A7 Full review viewer (to build)
|
||||
│ ├── review/ # A2-A7 Full review viewer (image + video)
|
||||
│ ├── automations/ # D1 Automation UI (to build)
|
||||
│ └── search/ # ✅ Smart search panel
|
||||
├── lib/
|
||||
|
|
@ -880,6 +1204,7 @@ export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
|||
| `sharp` | PNG alpha compositing + image validation + thumbnail generation | A1, E1, E2 |
|
||||
| `bcryptjs` | Password hashing for portal links | C3 |
|
||||
| `@react-pdf/renderer` | PDF export for weekly report download button | C — weekly report enhancement |
|
||||
| `ffmpeg` (system) | Video frame extraction, metadata parsing, thumbnail generation | A7 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -148,7 +148,9 @@ model User {
|
|||
feedbackAssigned FeedbackItem[] @relation("FeedbackAssignee")
|
||||
feedbackResolved FeedbackItem[] @relation("FeedbackResolver")
|
||||
feedbackVerified FeedbackItem[] @relation("FeedbackVerifier")
|
||||
colorProbes ColorProbe[]
|
||||
colorProbes ColorProbe[] @relation("ColorProbeCreator")
|
||||
reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator")
|
||||
reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -399,6 +401,7 @@ model DeliverableStage {
|
|||
revisions Revision[]
|
||||
comments Comment[]
|
||||
feedbackItems FeedbackItem[]
|
||||
reviewSessionItems ReviewSessionItem[]
|
||||
|
||||
@@unique([deliverableId, templateId])
|
||||
@@index([deliverableId])
|
||||
|
|
@ -446,6 +449,7 @@ model Revision {
|
|||
annotations Annotation[]
|
||||
feedbackItems FeedbackItem[]
|
||||
colorProbes ColorProbe[]
|
||||
reviewSessionItems ReviewSessionItem[]
|
||||
|
||||
@@index([deliverableStageId])
|
||||
@@map("revisions")
|
||||
|
|
@ -697,7 +701,7 @@ model SearchLog {
|
|||
@@map("search_logs")
|
||||
}
|
||||
|
||||
// ─── Enums (from feature branch) ────────────────────────
|
||||
// ─── Annotations (Visual Review) ────────────────────────
|
||||
|
||||
enum AnnotationType {
|
||||
RECTANGLE
|
||||
|
|
@ -740,27 +744,6 @@ model Annotation {
|
|||
@@map("annotations")
|
||||
}
|
||||
|
||||
// ─── Color Probe ────────────────────────────────────────
|
||||
|
||||
model ColorProbe {
|
||||
id String @id @default(cuid())
|
||||
revisionId String
|
||||
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
|
||||
index Int
|
||||
workingX Float
|
||||
workingY Float
|
||||
referenceX Float
|
||||
referenceY Float
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([revisionId, index])
|
||||
@@index([revisionId])
|
||||
@@map("color_probes")
|
||||
}
|
||||
|
||||
// ─── Feedback Item ──────────────────────────────────────
|
||||
|
||||
model FeedbackItem {
|
||||
|
|
@ -774,7 +757,7 @@ model FeedbackItem {
|
|||
commentId String?
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull)
|
||||
summary String
|
||||
isActionItem Boolean @default(true)
|
||||
isActionItem Boolean @default(true) // true = action item (must fix), false = info callout
|
||||
status FeedbackStatus @default(OPEN)
|
||||
sortOrder Int @default(0)
|
||||
assignedToId String?
|
||||
|
|
@ -800,3 +783,75 @@ model FeedbackItem {
|
|||
@@index([status])
|
||||
@@map("feedback_items")
|
||||
}
|
||||
|
||||
// FeedbackSeverity removed — replaced by isActionItem boolean
|
||||
// Action items = things the artist must fix (default for annotations)
|
||||
// Info callouts = context/reference that doesn't need action
|
||||
|
||||
// ─── Review Sessions (A6) ────────────────────────────────
|
||||
|
||||
enum ReviewSessionStatus {
|
||||
DRAFT
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
}
|
||||
|
||||
model ReviewSession {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
status ReviewSessionStatus @default(DRAFT)
|
||||
|
||||
createdById String
|
||||
createdBy User @relation("ReviewSessionCreator", fields: [createdById], references: [id])
|
||||
organizationId String
|
||||
|
||||
items ReviewSessionItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([status])
|
||||
@@map("review_sessions")
|
||||
}
|
||||
|
||||
model ReviewSessionItem {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session ReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
deliverableStageId String
|
||||
deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id])
|
||||
revisionId String?
|
||||
revision Revision? @relation(fields: [revisionId], references: [id])
|
||||
sortOrder Int
|
||||
decision String? // "APPROVED" | "CHANGES_REQUESTED" — stored as string to avoid enum coupling with D2
|
||||
decisionNote String?
|
||||
decidedById String?
|
||||
decidedBy User? @relation("ReviewSessionDecider", fields: [decidedById], references: [id])
|
||||
decidedAt DateTime?
|
||||
|
||||
@@index([sessionId])
|
||||
@@map("review_session_items")
|
||||
}
|
||||
|
||||
// ─── Color Probes (CMF Eyedropper) ─────────────────────
|
||||
|
||||
model ColorProbe {
|
||||
id String @id @default(cuid())
|
||||
revisionId String
|
||||
revision Revision @relation(fields: [revisionId], references: [id], onDelete: Cascade)
|
||||
index Int // 1-12, display order
|
||||
workingX Float // image-space coordinates on working image
|
||||
workingY Float
|
||||
referenceX Float // image-space coordinates on reference (defaults to same as working)
|
||||
referenceY Float
|
||||
createdById String
|
||||
createdBy User @relation("ColorProbeCreator", fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([revisionId, index])
|
||||
@@index([revisionId])
|
||||
@@map("color_probes")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { format } from "date-fns";
|
|||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
FileText,
|
||||
Lock,
|
||||
RotateCcw,
|
||||
|
|
@ -29,6 +30,8 @@ import { StageDatePopover } from "@/components/stages/stage-date-popover";
|
|||
import { PipelineProgress } from "@/components/deliverables/pipeline-progress";
|
||||
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
|
||||
import { StageDetailSheet } from "@/components/stages/stage-detail-sheet";
|
||||
import { FeedbackIndicator } from "@/components/stages/feedback-indicator";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
|
|
@ -113,6 +116,7 @@ export default function DeliverableDetailPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-6">
|
||||
{/* Back link + header */}
|
||||
<div>
|
||||
|
|
@ -289,7 +293,10 @@ export default function DeliverableDetailPage() {
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<StageStatusBadge status={stage.status} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FeedbackIndicator stageId={stage.id} />
|
||||
<StageStatusBadge status={stage.status} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -376,17 +383,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>
|
||||
|
|
@ -404,5 +425,6 @@ export default function DeliverableDetailPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,641 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Upload,
|
||||
Columns2,
|
||||
Loader2,
|
||||
Images,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
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, type ImageViewerState } from "@/components/review/image-viewer";
|
||||
import { ComparisonViewer } from "@/components/review/comparison-viewer";
|
||||
import {
|
||||
ComparisonToolbar,
|
||||
type ComparisonMode,
|
||||
} from "@/components/review/comparison-toolbar";
|
||||
import { ImageUploadZone } from "@/components/review/image-upload-zone";
|
||||
import { ImageGallery } from "@/components/review/image-gallery";
|
||||
import { AnnotationLayer } from "@/components/review/annotation-layer";
|
||||
import { ReviewSidebar } from "@/components/review/review-sidebar";
|
||||
import { useDeliverable } from "@/hooks/use-deliverables";
|
||||
import { useRevisions, useCreateRevision } from "@/hooks/use-revisions";
|
||||
import { useDeleteAnnotation } from "@/hooks/use-annotations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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);
|
||||
|
||||
// ── Gallery strip state ──────────────────────────────────────────────
|
||||
const [galleryOpen, setGalleryOpen] = useState(true);
|
||||
|
||||
// ── Annotation hover highlight (from feedback sidebar) ──────────────
|
||||
const [hoveredAnnotationId, setHoveredAnnotationId] = useState<string | null>(null);
|
||||
|
||||
// ── Eyedropper image override (swaps canvas between working/reference) ──
|
||||
const [imageOverride, setImageOverride] = useState<string | null>(null);
|
||||
|
||||
// ── Comparison mode state ────────────────────────────────────────────
|
||||
const [comparisonActive, setComparisonActive] = useState(false);
|
||||
const [comparisonMode, setComparisonMode] =
|
||||
useState<ComparisonMode>("side-by-side");
|
||||
const [leftRevisionKey, setLeftRevisionKey] = useState<string>("");
|
||||
const [rightRevisionKey, setRightRevisionKey] = useState<string>("");
|
||||
const [flipA, setFlipA] = useState(false);
|
||||
const [flipB, setFlipB] = useState(false);
|
||||
|
||||
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[]) ?? [];
|
||||
|
||||
// Create revision mutation
|
||||
const createRevision = useCreateRevision(selectedStageId ?? "");
|
||||
const handleCreateRevision = useCallback(async () => {
|
||||
try {
|
||||
await createRevision.mutateAsync({});
|
||||
toast.success("Round created — you can now upload images");
|
||||
setUploadPanelOpen(true);
|
||||
} catch {
|
||||
toast.error("Failed to create revision");
|
||||
}
|
||||
}, [createRevision]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Build revision options for comparison dropdowns
|
||||
const revisionOptions = useMemo(() => {
|
||||
return galleryImages.map((img) => ({
|
||||
revisionId: img.revisionId,
|
||||
roundNumber: img.roundNumber,
|
||||
type: img.type,
|
||||
label: `R${img.roundNumber} — ${img.type === "reference" ? "Reference" : "Render"}`,
|
||||
url: img.url,
|
||||
}));
|
||||
}, [galleryImages]);
|
||||
|
||||
// Auto-select comparison revisions: previous round vs current
|
||||
useEffect(() => {
|
||||
if (!comparisonActive || revisionOptions.length < 2) return;
|
||||
if (leftRevisionKey && rightRevisionKey) return;
|
||||
|
||||
// Default: second-to-last as A, latest as B
|
||||
const latest = revisionOptions[revisionOptions.length - 1];
|
||||
const previous =
|
||||
revisionOptions.length >= 2
|
||||
? revisionOptions[revisionOptions.length - 2]
|
||||
: latest;
|
||||
|
||||
setLeftRevisionKey(`${previous.revisionId}-${previous.type}`);
|
||||
setRightRevisionKey(`${latest.revisionId}-${latest.type}`);
|
||||
}, [comparisonActive, revisionOptions, leftRevisionKey, rightRevisionKey]);
|
||||
|
||||
// Resolve selected revision keys to URLs
|
||||
const leftSrc = useMemo(() => {
|
||||
const opt = revisionOptions.find(
|
||||
(o) => `${o.revisionId}-${o.type}` === leftRevisionKey
|
||||
);
|
||||
return opt?.url ?? null;
|
||||
}, [revisionOptions, leftRevisionKey]);
|
||||
|
||||
const rightSrc = useMemo(() => {
|
||||
const opt = revisionOptions.find(
|
||||
(o) => `${o.revisionId}-${o.type}` === rightRevisionKey
|
||||
);
|
||||
return opt?.url ?? null;
|
||||
}, [revisionOptions, rightRevisionKey]);
|
||||
|
||||
// Auto-select the latest current image
|
||||
useEffect(() => {
|
||||
if (!activeImageUrl && galleryImages.length > 0) {
|
||||
const latestCurrent = galleryImages.find((i) => i.type === "current");
|
||||
setActiveImageUrl(latestCurrent?.url ?? galleryImages[0].url);
|
||||
}
|
||||
}, [galleryImages, activeImageUrl]);
|
||||
|
||||
// Find the revision ID for the currently active image (for annotations)
|
||||
const activeRevisionId = useMemo(() => {
|
||||
const match = galleryImages.find((img) => img.url === activeImageUrl);
|
||||
return match?.revisionId ?? null;
|
||||
}, [galleryImages, activeImageUrl]);
|
||||
|
||||
// ── Image URLs for CMF probe sampling ──────────────────────────────
|
||||
const { workingImageUrl, referenceImageUrl } = useMemo(() => {
|
||||
if (!activeRevisionId) return { workingImageUrl: null, referenceImageUrl: null };
|
||||
const rev = revisions.find((r: any) => r.id === activeRevisionId);
|
||||
if (!rev) return { workingImageUrl: null, referenceImageUrl: null };
|
||||
const att = rev.attachments as RevisionAttachments | null;
|
||||
return {
|
||||
workingImageUrl: att?.currentImage?.url ?? null,
|
||||
referenceImageUrl: att?.referenceImage?.url ?? null,
|
||||
};
|
||||
}, [activeRevisionId, revisions]);
|
||||
|
||||
// ── Delete annotation (from feedback sidebar) ─────────────────────
|
||||
const deleteAnnotationMutation = useDeleteAnnotation(activeRevisionId);
|
||||
const handleDeleteAnnotation = useCallback(
|
||||
(annotationId: string) => {
|
||||
deleteAnnotationMutation.mutate(annotationId);
|
||||
},
|
||||
[deleteAnnotationMutation]
|
||||
);
|
||||
|
||||
// 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);
|
||||
// Reset comparison state when changing stages
|
||||
setLeftRevisionKey("");
|
||||
setRightRevisionKey("");
|
||||
}
|
||||
},
|
||||
[stageIdx, stages]
|
||||
);
|
||||
|
||||
const handleEnterComparison = useCallback(() => {
|
||||
setComparisonActive(true);
|
||||
// Reset revision keys so auto-select picks up
|
||||
setLeftRevisionKey("");
|
||||
setRightRevisionKey("");
|
||||
}, []);
|
||||
|
||||
const handleExitComparison = useCallback(() => {
|
||||
setComparisonActive(false);
|
||||
}, []);
|
||||
|
||||
// ── Revision timeline handlers ──────────────────────────────────────
|
||||
const handleTimelineSelectRevision = useCallback(
|
||||
(revisionId: string, imageUrl: string | null) => {
|
||||
if (imageUrl) {
|
||||
setActiveImageUrl(imageUrl);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTimelineCompareRevisions = useCallback(
|
||||
(leftRevId: string, rightRevId: string) => {
|
||||
// Find gallery images for these revisions (prefer "current" type)
|
||||
const leftImg =
|
||||
galleryImages.find(
|
||||
(img) => img.revisionId === leftRevId && img.type === "current"
|
||||
) ?? galleryImages.find((img) => img.revisionId === leftRevId);
|
||||
const rightImg =
|
||||
galleryImages.find(
|
||||
(img) => img.revisionId === rightRevId && img.type === "current"
|
||||
) ?? galleryImages.find((img) => img.revisionId === rightRevId);
|
||||
|
||||
if (leftImg && rightImg) {
|
||||
setComparisonActive(true);
|
||||
setLeftRevisionKey(`${leftImg.revisionId}-${leftImg.type}`);
|
||||
setRightRevisionKey(`${rightImg.revisionId}-${rightImg.type}`);
|
||||
}
|
||||
},
|
||||
[galleryImages]
|
||||
);
|
||||
|
||||
// ── Keyboard shortcuts for comparison modes ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!comparisonActive) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
switch (e.key) {
|
||||
case "1":
|
||||
e.preventDefault();
|
||||
setComparisonMode("side-by-side");
|
||||
break;
|
||||
case "2":
|
||||
e.preventDefault();
|
||||
setComparisonMode("wipe");
|
||||
break;
|
||||
case "3":
|
||||
e.preventDefault();
|
||||
setComparisonMode("overlay");
|
||||
break;
|
||||
case "4":
|
||||
e.preventDefault();
|
||||
setComparisonMode("toggle");
|
||||
break;
|
||||
case "g":
|
||||
case "G":
|
||||
e.preventDefault();
|
||||
setFlipA((f) => !f);
|
||||
break;
|
||||
case "h":
|
||||
case "H":
|
||||
e.preventDefault();
|
||||
setFlipB((f) => !f);
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
handleExitComparison();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [comparisonActive, handleExitComparison]);
|
||||
|
||||
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-full flex-col -m-4 md:-m-6">
|
||||
{/* ── Top bar ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between border-b bg-[var(--card)] px-4 py-2">
|
||||
{/* Left: back + deliverable name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/projects/${projectId}/deliverables/${deliverableId}`}
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Back
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<h1 className="max-w-[300px] truncate font-heading text-sm font-semibold">
|
||||
{deliverable.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Center: stage navigator */}
|
||||
{selectedStage && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={stageIdx <= 0}
|
||||
onClick={() => navigateStage(-1)}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1.5 rounded-md border bg-[var(--background)] px-2.5 py-1">
|
||||
<span className="font-mono text-[10px] 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-6 w-6 p-0"
|
||||
disabled={stageIdx >= stages.length - 1}
|
||||
onClick={() => navigateStage(1)}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Compare toggle */}
|
||||
{!comparisonActive && galleryImages.length >= 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleEnterComparison}
|
||||
>
|
||||
<Columns2 className="mr-1 h-3 w-3" />
|
||||
Compare
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<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>
|
||||
<SheetDescription className="sr-only">
|
||||
Upload reference and current render images for review
|
||||
</SheetDescription>
|
||||
</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>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
No rounds yet for this stage.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateRevision}
|
||||
disabled={createRevision.isPending}
|
||||
>
|
||||
{createRevision.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
New Round
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]/60">
|
||||
Creates Round 1 so you can start uploading images
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Comparison toolbar (when active) ──────────────────────── */}
|
||||
{comparisonActive && (
|
||||
<ComparisonToolbar
|
||||
mode={comparisonMode}
|
||||
onModeChange={setComparisonMode}
|
||||
revisionOptions={revisionOptions}
|
||||
leftRevisionKey={leftRevisionKey}
|
||||
rightRevisionKey={rightRevisionKey}
|
||||
onLeftChange={setLeftRevisionKey}
|
||||
onRightChange={setRightRevisionKey}
|
||||
flipA={flipA}
|
||||
flipB={flipB}
|
||||
onFlipA={() => setFlipA((f) => !f)}
|
||||
onFlipB={() => setFlipB((f) => !f)}
|
||||
onExit={handleExitComparison}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Main content: viewer + sidebar ────────────────────────── */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── Viewer column ──────────────────────────────────────── */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{comparisonActive ? (
|
||||
<ComparisonViewer
|
||||
leftSrc={leftSrc}
|
||||
rightSrc={rightSrc}
|
||||
mode={comparisonMode}
|
||||
flipA={flipA}
|
||||
flipB={flipB}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<ImageViewer
|
||||
src={imageOverride ?? activeImageUrl}
|
||||
className="min-h-0 flex-1"
|
||||
renderOverlay={(vs: ImageViewerState) => (
|
||||
<AnnotationLayer
|
||||
revisionId={activeRevisionId}
|
||||
stageId={selectedStageId}
|
||||
zoom={vs.zoom}
|
||||
panX={vs.panX}
|
||||
panY={vs.panY}
|
||||
containerWidth={vs.containerWidth}
|
||||
containerHeight={vs.containerHeight}
|
||||
imageDimensions={vs.imageDimensions}
|
||||
hoveredAnnotationId={hoveredAnnotationId}
|
||||
workingImageUrl={workingImageUrl}
|
||||
referenceImageUrl={referenceImageUrl}
|
||||
onImageOverride={setImageOverride}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Gallery strip — collapsible */}
|
||||
{!comparisonActive && galleryImages.length > 0 && (
|
||||
<div className="shrink-0 border-t bg-[var(--card)]">
|
||||
<button
|
||||
onClick={() => setGalleryOpen((p) => !p)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1 text-left transition-colors hover:bg-[var(--muted)]/50"
|
||||
>
|
||||
<Images className="h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Gallery
|
||||
</span>
|
||||
<span className="rounded-full bg-[var(--foreground)]/5 px-1.5 text-[9px] font-medium tabular-nums text-[var(--muted-foreground)]">
|
||||
{galleryImages.length}
|
||||
</span>
|
||||
{galleryOpen ? (
|
||||
<ChevronDown className="ml-auto h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronUp className="ml-auto h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
</button>
|
||||
{galleryOpen && (
|
||||
<div className="px-3 pb-1.5">
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
activeUrl={activeImageUrl}
|
||||
onSelect={(img) => setActiveImageUrl(img.url)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Unified sidebar: revisions + feedback tabs ─────────── */}
|
||||
<ReviewSidebar
|
||||
stageId={selectedStageId}
|
||||
revisions={revisions}
|
||||
activeRevisionId={activeRevisionId}
|
||||
onSelectRevision={handleTimelineSelectRevision}
|
||||
onCompareRevisions={handleTimelineCompareRevisions}
|
||||
onCreateRevision={handleCreateRevision}
|
||||
isCreatingRevision={createRevision.isPending}
|
||||
onAnnotationHover={setHoveredAnnotationId}
|
||||
onDeleteAnnotation={handleDeleteAnnotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
src/app/(app)/reviews/[sessionId]/page.tsx
Normal file
311
src/app/(app)/reviews/[sessionId]/page.tsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Pencil,
|
||||
Grid3x3,
|
||||
List,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useReviewSession,
|
||||
useUpdateReviewSession,
|
||||
} from "@/hooks/use-review-sessions";
|
||||
import { SessionBuilder } from "@/components/review/session-builder";
|
||||
import { SessionPresenter } from "@/components/review/session-presenter";
|
||||
import { SessionSummary } from "@/components/review/session-summary";
|
||||
|
||||
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
|
||||
DRAFT: {
|
||||
label: "Draft",
|
||||
className: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]",
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
label: "In Progress",
|
||||
className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Completed",
|
||||
className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ReviewSessionPage() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialMode = searchParams.get("mode");
|
||||
|
||||
const [view, setView] = useState<"builder" | "summary" | "presenter">(
|
||||
initialMode === "present" ? "presenter" : "builder"
|
||||
);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const { data: session, isLoading } = useReviewSession(sessionId);
|
||||
const updateMutation = useUpdateReviewSession(sessionId);
|
||||
|
||||
const items = (session?.items as any[]) ?? [];
|
||||
const statusConfig = STATUS_STYLES[session?.status] ?? STATUS_STYLES.DRAFT;
|
||||
|
||||
// ── Name editing ────────────────────────────────────────────────────────
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setEditName(session?.name ?? "");
|
||||
setIsEditingName(true);
|
||||
}, [session?.name]);
|
||||
|
||||
const handleSaveName = useCallback(() => {
|
||||
if (!editName.trim()) return;
|
||||
updateMutation.mutate(
|
||||
{ name: editName.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsEditingName(false);
|
||||
toast.success("Name updated");
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [editName, updateMutation]);
|
||||
|
||||
// ── Status transitions ──────────────────────────────────────────────────
|
||||
|
||||
const handleStartSession = useCallback(() => {
|
||||
updateMutation.mutate(
|
||||
{ status: "IN_PROGRESS" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Session started");
|
||||
setView("presenter");
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [updateMutation]);
|
||||
|
||||
const handleCompleteSession = useCallback(() => {
|
||||
updateMutation.mutate(
|
||||
{ status: "COMPLETED" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Session completed");
|
||||
setView("summary");
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [updateMutation]);
|
||||
|
||||
const handleReopenSession = useCallback(() => {
|
||||
updateMutation.mutate(
|
||||
{ status: "IN_PROGRESS" },
|
||||
{
|
||||
onSuccess: () => toast.success("Session reopened"),
|
||||
}
|
||||
);
|
||||
}, [updateMutation]);
|
||||
|
||||
// ── Presenter exit ──────────────────────────────────────────────────────
|
||||
|
||||
const handleExitPresenter = useCallback(() => {
|
||||
setView("builder");
|
||||
// Remove ?mode=present from URL
|
||||
router.replace(`/reviews/${sessionId}`, { scroll: false });
|
||||
}, [router, sessionId]);
|
||||
|
||||
// ── Loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="mt-2 flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
|
||||
Session not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Presenter mode (full height) ───────────────────────────────────────
|
||||
|
||||
if (view === "presenter") {
|
||||
return (
|
||||
<div className="h-[calc(100vh-3.5rem)]">
|
||||
<SessionPresenter
|
||||
sessionId={sessionId}
|
||||
items={items}
|
||||
sessionName={session.name}
|
||||
onExit={handleExitPresenter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Builder / Summary view ──────────────────────────────────────────────
|
||||
|
||||
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">
|
||||
{/* Left: back + name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/reviews"
|
||||
className="flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Sessions
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleSaveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveName();
|
||||
if (e.key === "Escape") setIsEditingName(false);
|
||||
}}
|
||||
className="h-7 w-60 text-sm font-semibold"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartEdit}
|
||||
className="flex items-center gap-1.5 font-heading text-sm font-semibold hover:text-[var(--primary)]"
|
||||
>
|
||||
{session.name}
|
||||
<Pencil className="h-2.5 w-2.5 text-[var(--muted-foreground)]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("text-[10px] uppercase", statusConfig.className)}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
|
||||
{session.createdBy && (
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
by {session.createdBy.name} ·{" "}
|
||||
{format(new Date(session.createdAt), "MMM d")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: view toggle + actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<div className="flex rounded-md border">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === "builder" ? "default" : "ghost"}
|
||||
className="h-7 rounded-r-none text-xs"
|
||||
onClick={() => setView("builder")}
|
||||
>
|
||||
<List className="mr-1 h-3 w-3" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === "summary" ? "default" : "ghost"}
|
||||
className="h-7 rounded-l-none text-xs"
|
||||
onClick={() => setView("summary")}
|
||||
>
|
||||
<Grid3x3 className="mr-1 h-3 w-3" />
|
||||
Grid
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
|
||||
{/* Status actions */}
|
||||
{session.status === "DRAFT" && items.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleStartSession}
|
||||
>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Start Review
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{session.status === "IN_PROGRESS" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setView("presenter")}
|
||||
>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Present
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleCompleteSession}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{session.status === "COMPLETED" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleReopenSession}
|
||||
>
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content ──────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{view === "builder" && (
|
||||
<SessionBuilder
|
||||
sessionId={sessionId}
|
||||
items={items}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "summary" && (
|
||||
<div className="p-4">
|
||||
<SessionSummary
|
||||
items={items}
|
||||
onItemClick={(idx) => {
|
||||
setView("presenter");
|
||||
// The presenter will handle its own index state
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
src/app/(app)/reviews/page.tsx
Normal file
275
src/app/(app)/reviews/page.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
FileCheck,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Presentation,
|
||||
Inbox,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useReviewSessions,
|
||||
useDeleteReviewSession,
|
||||
} from "@/hooks/use-review-sessions";
|
||||
import { CreateSessionDialog } from "@/components/review/create-session-dialog";
|
||||
|
||||
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
|
||||
DRAFT: {
|
||||
label: "Draft",
|
||||
className: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]",
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
label: "In Progress",
|
||||
className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Completed",
|
||||
className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const { data: sessions, isLoading } = useReviewSessions(statusFilter);
|
||||
const deleteMutation = useDeleteReviewSession();
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deleteId) return;
|
||||
deleteMutation.mutate(deleteId, {
|
||||
onSuccess: () => {
|
||||
toast.success("Session deleted");
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: (err) => toast.error("Delete failed", { description: err.message }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-6">
|
||||
{/* ── Header ──────────────────────────────────────────── */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-lg font-bold tracking-tight">
|
||||
Review Sessions
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs text-[var(--muted-foreground)]">
|
||||
Batch review deliverables in a structured walkthrough
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Status filter tabs ──────────────────────────────── */}
|
||||
<div className="mb-4 flex gap-1">
|
||||
{[
|
||||
{ value: undefined, label: "All" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "COMPLETED", label: "Completed" },
|
||||
].map((tab) => (
|
||||
<Button
|
||||
key={tab.label}
|
||||
size="sm"
|
||||
variant={statusFilter === tab.value ? "default" : "ghost"}
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setStatusFilter(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Session list ────────────────────────────────────── */}
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!sessions || sessions.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-16">
|
||||
<Inbox className="h-10 w-10 text-[var(--muted-foreground)]/30" />
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
No review sessions yet
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Create your first session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions && sessions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session: any) => {
|
||||
const statusConfig = STATUS_STYLES[session.status] ?? STATUS_STYLES.DRAFT;
|
||||
const itemCount = session._count?.items ?? 0;
|
||||
const decidedCount = session.items?.filter(
|
||||
(i: any) => i.decision != null
|
||||
).length ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={`/reviews/${session.id}`}
|
||||
className="group flex items-center gap-4 rounded-lg border bg-[var(--card)] px-4 py-3 transition-colors hover:bg-[var(--background)]"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--primary)]/10">
|
||||
<Presentation className="h-4 w-4 text-[var(--primary)]" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">
|
||||
{session.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn("text-[10px] uppercase", statusConfig.className)}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--muted-foreground)]">
|
||||
<span>{itemCount} items</span>
|
||||
{itemCount > 0 && (
|
||||
<span>
|
||||
{decidedCount}/{itemCount} decided
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
by {session.createdBy?.name ?? "Unknown"}
|
||||
</span>
|
||||
<span>
|
||||
{format(new Date(session.updatedAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{session.status === "DRAFT" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={`/reviews/${session.id}`}>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7">
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(session.status === "DRAFT" || session.status === "IN_PROGRESS") && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={`/reviews/${session.id}?mode=present`}>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7">
|
||||
<Play className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">Present</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{session.status === "COMPLETED" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={`/reviews/${session.id}`}>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7">
|
||||
<FileCheck className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">View Summary</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-600"
|
||||
onClick={() => setDeleteId(session.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Create dialog ───────────────────────────────────── */}
|
||||
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
|
||||
{/* ── Delete confirmation ─────────────────────────────── */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete review session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the session and all its items. This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/app/api/feedback/[itemId]/route.ts
Normal file
91
src/app/api/feedback/[itemId]/route.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAuthSession,
|
||||
badRequest,
|
||||
notFound,
|
||||
serverError,
|
||||
} from "@/lib/api-utils";
|
||||
import {
|
||||
updateFeedbackSchema,
|
||||
resolveFeedbackSchema,
|
||||
} from "@/lib/validators/feedback";
|
||||
import {
|
||||
getFeedbackItem,
|
||||
updateFeedbackItem,
|
||||
resolveFeedbackItem,
|
||||
verifyFeedbackItem,
|
||||
reopenFeedbackItem,
|
||||
deleteFeedbackItem,
|
||||
} from "@/lib/services/feedback-service";
|
||||
|
||||
type Params = { params: Promise<{ itemId: string }> };
|
||||
|
||||
// PATCH /api/feedback/:itemId
|
||||
// Supports multiple actions via `action` field: "update" (default), "resolve", "verify", "reopen"
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { itemId } = await params;
|
||||
const body = await request.json();
|
||||
const action = body.action ?? "update";
|
||||
|
||||
const existing = await getFeedbackItem(itemId);
|
||||
if (!existing) return notFound("Feedback item not found");
|
||||
|
||||
let result;
|
||||
|
||||
switch (action) {
|
||||
case "resolve": {
|
||||
const parsed = resolveFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(
|
||||
parsed.error.issues.map((i) => i.message).join(", ")
|
||||
);
|
||||
}
|
||||
result = await resolveFeedbackItem(itemId, session!.user.id, parsed.data);
|
||||
break;
|
||||
}
|
||||
case "verify": {
|
||||
result = await verifyFeedbackItem(itemId, session!.user.id);
|
||||
break;
|
||||
}
|
||||
case "reopen": {
|
||||
result = await reopenFeedbackItem(itemId);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(
|
||||
parsed.error.issues.map((i) => i.message).join(", ")
|
||||
);
|
||||
}
|
||||
result = await updateFeedbackItem(itemId, parsed.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/feedback/:itemId
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { itemId } = await params;
|
||||
const existing = await getFeedbackItem(itemId);
|
||||
if (!existing) return notFound("Feedback item not found");
|
||||
|
||||
await deleteFeedbackItem(itemId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
142
src/app/api/reviews/[sessionId]/route.ts
Normal file
142
src/app/api/reviews/[sessionId]/route.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAuthSession,
|
||||
badRequest,
|
||||
notFound,
|
||||
serverError,
|
||||
} from "@/lib/api-utils";
|
||||
import {
|
||||
updateReviewSessionSchema,
|
||||
addSessionItemsSchema,
|
||||
reorderSessionItemsSchema,
|
||||
recordDecisionSchema,
|
||||
generateSessionItemsSchema,
|
||||
} from "@/lib/validators/review-session";
|
||||
import {
|
||||
getReviewSession,
|
||||
updateReviewSession,
|
||||
deleteReviewSession,
|
||||
addSessionItems,
|
||||
removeSessionItem,
|
||||
reorderSessionItems,
|
||||
recordDecision,
|
||||
clearDecision,
|
||||
generateSessionItems,
|
||||
} from "@/lib/services/review-session-service";
|
||||
|
||||
type Params = { params: Promise<{ sessionId: string }> };
|
||||
|
||||
// GET /api/reviews/:sessionId
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { sessionId } = await params;
|
||||
const session = await getReviewSession(sessionId);
|
||||
if (!session) return notFound("Review session not found");
|
||||
return NextResponse.json(session);
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/reviews/:sessionId
|
||||
// Handles multiple actions via `action` field:
|
||||
// - (default) update session metadata
|
||||
// - "add-items" — add items to session
|
||||
// - "remove-item" — remove an item
|
||||
// - "reorder" — reorder items
|
||||
// - "decide" — record decision on item
|
||||
// - "clear-decision" — clear a decision
|
||||
// - "generate" — generate items from project filters
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const { session: authSession, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json();
|
||||
const action = body.action as string | undefined;
|
||||
|
||||
if (action === "add-items") {
|
||||
const parsed = addSessionItemsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
const items = await addSessionItems(sessionId, parsed.data);
|
||||
// Return full session after modification
|
||||
const updated = await getReviewSession(sessionId);
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
if (action === "remove-item") {
|
||||
const itemId = body.itemId as string;
|
||||
if (!itemId) return badRequest("itemId is required");
|
||||
await removeSessionItem(itemId);
|
||||
const updated = await getReviewSession(sessionId);
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
if (action === "reorder") {
|
||||
const parsed = reorderSessionItemsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
await reorderSessionItems(sessionId, parsed.data);
|
||||
const updated = await getReviewSession(sessionId);
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
if (action === "decide") {
|
||||
const parsed = recordDecisionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
await recordDecision(authSession!.user.id, parsed.data);
|
||||
const updated = await getReviewSession(sessionId);
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
if (action === "clear-decision") {
|
||||
const itemId = body.itemId as string;
|
||||
if (!itemId) return badRequest("itemId is required");
|
||||
await clearDecision(itemId);
|
||||
const updated = await getReviewSession(sessionId);
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
if (action === "generate") {
|
||||
const parsed = generateSessionItemsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
const candidates = await generateSessionItems(parsed.data);
|
||||
return NextResponse.json(candidates);
|
||||
}
|
||||
|
||||
// Default: update session metadata
|
||||
const parsed = updateReviewSessionSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
const updated = await updateReviewSession(sessionId, parsed.data);
|
||||
return NextResponse.json(updated);
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/reviews/:sessionId
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { sessionId } = await params;
|
||||
await deleteReviewSession(sessionId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
51
src/app/api/reviews/route.ts
Normal file
51
src/app/api/reviews/route.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
|
||||
import { createReviewSessionSchema } from "@/lib/validators/review-session";
|
||||
import {
|
||||
listReviewSessions,
|
||||
createReviewSession,
|
||||
} from "@/lib/services/review-session-service";
|
||||
|
||||
// GET /api/reviews
|
||||
// Query params: ?status=DRAFT|IN_PROGRESS|COMPLETED
|
||||
export async function GET(request: Request) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") ?? undefined;
|
||||
|
||||
const sessions = await listReviewSessions(
|
||||
session!.user.organizationId as string,
|
||||
{ status }
|
||||
);
|
||||
return NextResponse.json(sessions);
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/reviews
|
||||
export async function POST(request: Request) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const parsed = createReviewSessionSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const reviewSession = await createReviewSession(
|
||||
session!.user.organizationId as string,
|
||||
session!.user.id,
|
||||
parsed.data
|
||||
);
|
||||
return NextResponse.json(reviewSession, { status: 201 });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
|
||||
import { updateAnnotationSchema } from "@/lib/validators/annotation";
|
||||
import { updateAnnotation, deleteAnnotation } from "@/lib/services/annotation-service";
|
||||
|
||||
type Params = { params: Promise<{ revisionId: string; annotationId: string }> };
|
||||
|
||||
// PATCH /api/revisions/:revisionId/annotations/:annotationId
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { annotationId } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = updateAnnotationSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const annotation = await updateAnnotation(
|
||||
annotationId,
|
||||
session!.user!.id!,
|
||||
parsed.data
|
||||
);
|
||||
return NextResponse.json(annotation);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Annotation not found") {
|
||||
return notFound(e.message);
|
||||
}
|
||||
if (e instanceof Error && e.message === "Not authorized") {
|
||||
return NextResponse.json({ error: e.message }, { status: 403 });
|
||||
}
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/revisions/:revisionId/annotations/:annotationId
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { annotationId } = await params;
|
||||
|
||||
const result = await deleteAnnotation(annotationId, session!.user!.id!);
|
||||
return NextResponse.json(result);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Annotation not found") {
|
||||
return notFound(e.message);
|
||||
}
|
||||
if (e instanceof Error && e.message === "Not authorized") {
|
||||
return NextResponse.json({ error: e.message }, { status: 403 });
|
||||
}
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
59
src/app/api/revisions/[revisionId]/annotations/route.ts
Normal file
59
src/app/api/revisions/[revisionId]/annotations/route.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createAnnotationSchema } from "@/lib/validators/annotation";
|
||||
import { listAnnotations, createAnnotation } from "@/lib/services/annotation-service";
|
||||
|
||||
type Params = { params: Promise<{ revisionId: string }> };
|
||||
|
||||
// GET /api/revisions/:revisionId/annotations
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { revisionId } = await params;
|
||||
|
||||
const revision = await prisma.revision.findUnique({
|
||||
where: { id: revisionId },
|
||||
});
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
const annotations = await listAnnotations(revisionId);
|
||||
return NextResponse.json(annotations);
|
||||
} catch (e) {
|
||||
console.error("[GET annotations] Error:", e);
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/revisions/:revisionId/annotations
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { revisionId } = await params;
|
||||
|
||||
const revision = await prisma.revision.findUnique({
|
||||
where: { id: revisionId },
|
||||
});
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createAnnotationSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const annotation = await createAnnotation(
|
||||
revisionId,
|
||||
session!.user!.id!,
|
||||
parsed.data
|
||||
);
|
||||
return NextResponse.json(annotation, { status: 201 });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
|
||||
import { updateColorProbeSchema } from "@/lib/validators/color-probe";
|
||||
import {
|
||||
updateColorProbe,
|
||||
deleteColorProbe,
|
||||
} from "@/lib/services/color-probe-service";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ revisionId: string; probeId: string }>;
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { probeId } = await ctx.params;
|
||||
const body = await req.json();
|
||||
const parsed = updateColorProbeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.message);
|
||||
}
|
||||
|
||||
const probe = await updateColorProbe(probeId, parsed.data);
|
||||
return NextResponse.json(probe);
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { probeId } = await ctx.params;
|
||||
await deleteColorProbe(probeId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
64
src/app/api/revisions/[revisionId]/color-probes/route.ts
Normal file
64
src/app/api/revisions/[revisionId]/color-probes/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, notFound, serverError } from "@/lib/api-utils";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createColorProbeSchema } from "@/lib/validators/color-probe";
|
||||
import {
|
||||
listColorProbes,
|
||||
createColorProbe,
|
||||
clearColorProbes,
|
||||
} from "@/lib/services/color-probe-service";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ revisionId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { revisionId } = await ctx.params;
|
||||
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
const probes = await listColorProbes(revisionId);
|
||||
return NextResponse.json(probes);
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { revisionId } = await ctx.params;
|
||||
const revision = await prisma.revision.findUnique({ where: { id: revisionId } });
|
||||
if (!revision) return notFound("Revision not found");
|
||||
|
||||
const body = await req.json();
|
||||
const parsed = createColorProbeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.message);
|
||||
}
|
||||
|
||||
const probe = await createColorProbe(revisionId, session!.user.id, parsed.data);
|
||||
return NextResponse.json(probe, { status: 201 });
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: Request, ctx: RouteContext) {
|
||||
try {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
const { revisionId } = await ctx.params;
|
||||
await clearColorProbes(revisionId);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return serverError(err);
|
||||
}
|
||||
}
|
||||
63
src/app/api/stages/[stageId]/feedback/route.ts
Normal file
63
src/app/api/stages/[stageId]/feedback/route.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getAuthSession, badRequest, serverError } from "@/lib/api-utils";
|
||||
import { createFeedbackSchema } from "@/lib/validators/feedback";
|
||||
import {
|
||||
listFeedbackItems,
|
||||
createFeedbackItem,
|
||||
getFeedbackSummary,
|
||||
} from "@/lib/services/feedback-service";
|
||||
|
||||
type Params = { params: Promise<{ stageId: string }> };
|
||||
|
||||
// GET /api/stages/:stageId/feedback
|
||||
// Query params: ?revisionId=&status=&isActionItem=true|false&summary=true
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { stageId } = await params;
|
||||
const url = new URL(request.url);
|
||||
const revisionId = url.searchParams.get("revisionId") ?? undefined;
|
||||
const status = url.searchParams.get("status") ?? undefined;
|
||||
const isActionItemParam = url.searchParams.get("isActionItem");
|
||||
const isActionItem =
|
||||
isActionItemParam === "true" ? true : isActionItemParam === "false" ? false : undefined;
|
||||
const summaryOnly = url.searchParams.get("summary") === "true";
|
||||
|
||||
if (summaryOnly) {
|
||||
const summary = await getFeedbackSummary(stageId);
|
||||
return NextResponse.json(summary);
|
||||
}
|
||||
|
||||
const items = await listFeedbackItems(stageId, {
|
||||
revisionId,
|
||||
status,
|
||||
isActionItem,
|
||||
});
|
||||
return NextResponse.json(items);
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/stages/:stageId/feedback
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const { session, error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
||||
try {
|
||||
const { stageId } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const item = await createFeedbackItem(stageId, session!.user.id, parsed.data);
|
||||
return NextResponse.json(item, { status: 201 });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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" | "screenshot" | null;
|
||||
|
||||
if (!file) return badRequest("No file provided");
|
||||
if (!imageType || !["reference", "current", "screenshot"].includes(imageType)) {
|
||||
return badRequest('Image type must be "reference", "current", or "screenshot"');
|
||||
}
|
||||
|
||||
// Process and store the image
|
||||
const uploaded = await processAndStoreImage(revisionId, file, imageType);
|
||||
|
||||
// Screenshots are stored as annotation data, not in revision attachments
|
||||
if (imageType === "screenshot") {
|
||||
return NextResponse.json(uploaded, { status: 201 });
|
||||
}
|
||||
|
||||
// Update revision attachments JSON for reference/current images
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export async function POST(request: Request, { params }: Params) {
|
|||
return badRequest(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const revision = await createRevision(stageId, parsed.data);
|
||||
const revision = await createRevision(stageId, parsed.data, session!.user.id);
|
||||
return NextResponse.json(revision, { status: 201 });
|
||||
} catch (e) {
|
||||
return serverError(e);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Menu,
|
||||
CalendarDays,
|
||||
FileBarChart,
|
||||
Presentation,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -30,6 +31,7 @@ const navItems = [
|
|||
{ href: "/workload", label: "Workload", icon: Users },
|
||||
{ href: "/timeline", label: "Timeline", icon: GanttChart },
|
||||
{ href: "/calendar", label: "Calendar", icon: CalendarDays },
|
||||
{ href: "/reviews", label: "Reviews", icon: Presentation },
|
||||
{ href: "/reports", label: "Reports", icon: FileBarChart },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell },
|
||||
];
|
||||
|
|
|
|||
518
src/components/review/annotation-layer.tsx
Normal file
518
src/components/review/annotation-layer.tsx
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
|
||||
import { Send, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AnnotationRenderer,
|
||||
type AnnotationShape,
|
||||
} from "@/components/review/annotation-renderer";
|
||||
import { AnnotationTools } from "@/components/review/annotation-tools";
|
||||
import { ColorProbeLayer, type EyedropperViewingImage } from "@/components/review/color-probe-layer";
|
||||
import { useAnnotationState } from "@/hooks/use-annotation-state";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────
|
||||
|
||||
export interface AnnotationLayerProps {
|
||||
revisionId: string | null;
|
||||
stageId: string | null;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
imageDimensions: { width: number; height: number } | null;
|
||||
readOnly?: boolean;
|
||||
/** ID of annotation to highlight (when hovering feedback item) */
|
||||
hoveredAnnotationId?: string | null;
|
||||
/** Working image URL for CMF probe sampling */
|
||||
workingImageUrl?: string | null;
|
||||
/** Reference image URL for CMF probe sampling */
|
||||
referenceImageUrl?: string | null;
|
||||
/** Callback to override the displayed image (pass URL or null to reset) */
|
||||
onImageOverride?: (url: string | null) => void;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────
|
||||
|
||||
export function AnnotationLayer({
|
||||
revisionId,
|
||||
stageId,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
imageDimensions,
|
||||
readOnly = false,
|
||||
hoveredAnnotationId,
|
||||
workingImageUrl,
|
||||
referenceImageUrl,
|
||||
onImageOverride,
|
||||
}: AnnotationLayerProps) {
|
||||
const ann = useAnnotationState(revisionId, stageId);
|
||||
const [probesVisible, setProbesVisible] = useState(true);
|
||||
const [eyedropperViewingImage, setEyedropperViewingImage] =
|
||||
useState<EyedropperViewingImage>("working");
|
||||
|
||||
// When eyedropper viewing image changes, notify parent to swap the canvas image
|
||||
const handleViewingImageChange = useCallback(
|
||||
(image: EyedropperViewingImage) => {
|
||||
setEyedropperViewingImage(image);
|
||||
if (onImageOverride) {
|
||||
if (image === "reference" && referenceImageUrl) {
|
||||
onImageOverride(referenceImageUrl);
|
||||
} else {
|
||||
onImageOverride(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onImageOverride, referenceImageUrl]
|
||||
);
|
||||
|
||||
// Reset viewing image when tool changes away from eyedropper
|
||||
useEffect(() => {
|
||||
if (ann.activeTool !== "eyedropper") {
|
||||
if (eyedropperViewingImage !== "working") {
|
||||
setEyedropperViewingImage("working");
|
||||
onImageOverride?.(null);
|
||||
}
|
||||
}
|
||||
}, [ann.activeTool]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Tab key to toggle W/R when eyedropper active
|
||||
useEffect(() => {
|
||||
if (ann.activeTool !== "eyedropper") return;
|
||||
if (!referenceImageUrl) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const next = eyedropperViewingImage === "working" ? "reference" : "working";
|
||||
handleViewingImageChange(next);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [ann.activeTool, referenceImageUrl, eyedropperViewingImage, handleViewingImageChange]);
|
||||
|
||||
// Drag-to-move annotations — works in move mode
|
||||
const handleAnnotationDragStart = useCallback(
|
||||
(annotationId: string, e: React.MouseEvent) => {
|
||||
if (readOnly) return;
|
||||
if (ann.activeTool !== "move") return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
ann.setSelectedId(annotationId);
|
||||
|
||||
let lastX = e.clientX;
|
||||
let lastY = e.clientY;
|
||||
let moved = false;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - lastX) / zoom;
|
||||
const dy = (me.clientY - lastY) / zoom;
|
||||
lastX = me.clientX;
|
||||
lastY = me.clientY;
|
||||
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
|
||||
moved = true;
|
||||
ann.handleAnnotationMove(annotationId, dx, dy);
|
||||
}
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[readOnly, ann.activeTool, ann.setSelectedId, ann.handleAnnotationMove, zoom]
|
||||
);
|
||||
|
||||
// Clipboard paste handler for screenshots
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (readOnly) return;
|
||||
if (!revisionId || !stageId) return;
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
await ann.handleScreenshotPaste(
|
||||
file,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
panX,
|
||||
panY,
|
||||
zoom,
|
||||
imageDimensions
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [revisionId, stageId, panX, panY, zoom, containerWidth, containerHeight, imageDimensions, ann]);
|
||||
|
||||
// Cursor style
|
||||
const cursorStyle = useMemo(() => {
|
||||
if (ann.activeTool === "move") return "default";
|
||||
if (ann.activeTool === "text") return "text";
|
||||
return "crosshair";
|
||||
}, [ann.activeTool]);
|
||||
|
||||
// Don't render overlay when there's no revision
|
||||
if (!revisionId || !imageDimensions) return null;
|
||||
|
||||
const isEyedropperActive = ann.activeTool === "eyedropper";
|
||||
const hasReferenceImage = !!referenceImageUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Annotation toolbar (floating inside viewport, top-center) ── */}
|
||||
{!readOnly && (
|
||||
<div className="absolute left-1/2 top-2 z-30 -translate-x-1/2">
|
||||
<div className="flex items-center rounded-md border bg-[var(--card)]/90 px-2 py-1 shadow-lg backdrop-blur-sm">
|
||||
<AnnotationTools
|
||||
activeTool={ann.activeTool}
|
||||
onToolChange={ann.setActiveTool}
|
||||
color={ann.color}
|
||||
onColorChange={ann.setColor}
|
||||
canUndo={ann.canUndo}
|
||||
canRedo={ann.canRedo}
|
||||
onUndo={ann.handleUndo}
|
||||
onRedo={ann.handleRedo}
|
||||
visible={ann.visible}
|
||||
onToggleVisibility={() => ann.setVisible((v: boolean) => !v)}
|
||||
hasSelection={!!ann.selectedId}
|
||||
onDeleteSelection={ann.handleDeleteSelection}
|
||||
probesVisible={probesVisible}
|
||||
onToggleProbes={() => setProbesVisible((v) => !v)}
|
||||
eyedropperActive={isEyedropperActive}
|
||||
hasReferenceImage={hasReferenceImage}
|
||||
viewingImage={eyedropperViewingImage}
|
||||
onViewingImageChange={handleViewingImageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── SVG overlay ────────────────────────────────── */}
|
||||
<svg
|
||||
ref={ann.svgRef}
|
||||
className="absolute inset-0 z-20"
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
style={{
|
||||
cursor: readOnly ? "default" : cursorStyle,
|
||||
pointerEvents: readOnly
|
||||
? "none"
|
||||
: ann.activeTool === "move" ? "none" : "auto",
|
||||
}}
|
||||
onMouseDown={readOnly ? undefined : (e) => ann.handleMouseDown(e, panX, panY, zoom)}
|
||||
onMouseMove={readOnly ? undefined : (e) => ann.handleMouseMove(e, panX, panY, zoom)}
|
||||
onMouseUp={readOnly ? undefined : (e) => ann.handleMouseUp(e)}
|
||||
>
|
||||
{/* Glow filter for hover highlight */}
|
||||
<defs>
|
||||
<filter id="annotation-glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feFlood floodColor="#ffffff" floodOpacity="0.8" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{ann.visible && (
|
||||
<g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
|
||||
{/* Persisted annotations */}
|
||||
{ann.annotationShapes
|
||||
.filter((a: AnnotationShape) => a.type !== "SCREENSHOT")
|
||||
.map((a: AnnotationShape) => (
|
||||
<g
|
||||
key={a.id}
|
||||
pointerEvents="auto"
|
||||
filter={hoveredAnnotationId === a.id ? "url(#annotation-glow)" : undefined}
|
||||
style={{
|
||||
transition: "opacity 0.2s",
|
||||
opacity: hoveredAnnotationId && hoveredAnnotationId !== a.id ? 0.3 : 1,
|
||||
}}
|
||||
>
|
||||
<AnnotationRenderer
|
||||
annotation={a}
|
||||
onDragStart={handleAnnotationDragStart}
|
||||
moveActive={ann.activeTool === "move"}
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Drawing preview */}
|
||||
{!readOnly && ann.drawingPreview && (
|
||||
<g opacity={0.8}>
|
||||
<AnnotationRenderer annotation={ann.drawingPreview} />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* ── Screenshot callouts (HTML layer for independent pointer events) ── */}
|
||||
{!readOnly && ann.visible && ann.screenshotAnnotations.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 z-25"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{ann.screenshotAnnotations.map((a: any) => {
|
||||
const d = a.data ?? {};
|
||||
const imgX = d.x ?? 0;
|
||||
const imgY = d.y ?? 0;
|
||||
const w = d.width ?? 200;
|
||||
const h = d.height ?? 150;
|
||||
const screenLeft = panX + imgX * zoom;
|
||||
const screenTop = panY + imgY * zoom;
|
||||
const screenW = w * zoom;
|
||||
const screenH = h * zoom;
|
||||
const isSelected = a.id === ann.selectedId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: screenLeft,
|
||||
top: screenTop,
|
||||
width: screenW,
|
||||
height: screenH,
|
||||
pointerEvents: "auto",
|
||||
cursor: "grab",
|
||||
border: `2px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`,
|
||||
boxShadow: isSelected
|
||||
? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)"
|
||||
: "0 2px 8px rgba(0,0,0,0.4)",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
userSelect: "none",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
ann.setSelectedId(a.id);
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origX = imgX;
|
||||
const origY = imgY;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - startX) / zoom;
|
||||
const dy = (me.clientY - startY) / zoom;
|
||||
ann.handleScreenshotMove(a.id, origX + dx, origY + dy);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={d.imageUrl ?? ""}
|
||||
alt="Screenshot"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{/* Resize handle */}
|
||||
{isSelected && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: "white",
|
||||
border: "1px solid rgba(0,0,0,0.3)",
|
||||
borderRadius: 2,
|
||||
cursor: "nwse-resize",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origW = w;
|
||||
const origH = h;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - startX) / zoom;
|
||||
const dy = (me.clientY - startY) / zoom;
|
||||
ann.handleScreenshotResize(
|
||||
a.id,
|
||||
Math.max(40, origW + dx),
|
||||
Math.max(40, origH + dy)
|
||||
);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Floating text input ────────────────────────── */}
|
||||
{!readOnly && ann.textInput && (
|
||||
<div
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
left: ann.textInput.x,
|
||||
top: ann.textInput.y,
|
||||
transform: "translate(-4px, -14px)",
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={ann.textInputRef}
|
||||
value={ann.textValue}
|
||||
onChange={(e) => ann.setTextValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
ann.commitTextAnnotation();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
ann.setTextInput(null);
|
||||
ann.setTextValue("");
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => ann.commitTextAnnotation(), 150);
|
||||
}}
|
||||
className="h-7 min-w-[180px] border-[var(--primary)] bg-[var(--card)] text-sm"
|
||||
placeholder="Type label..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CMF Color Probe Layer ────────────────────── */}
|
||||
{!readOnly && (
|
||||
<ColorProbeLayer
|
||||
revisionId={revisionId}
|
||||
active={isEyedropperActive}
|
||||
movable={ann.activeTool === "move"}
|
||||
visible={probesVisible}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
containerWidth={containerWidth}
|
||||
containerHeight={containerHeight}
|
||||
workingImageUrl={workingImageUrl ?? null}
|
||||
referenceImageUrl={referenceImageUrl ?? null}
|
||||
viewingImage={eyedropperViewingImage}
|
||||
onViewingImageChange={handleViewingImageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Annotation comment popover ────────────────── */}
|
||||
{!readOnly && ann.pendingAnnotation && (
|
||||
<div
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
left: Math.min(
|
||||
ann.pendingAnnotation.screenX,
|
||||
containerWidth - 280
|
||||
),
|
||||
top: Math.min(
|
||||
ann.pendingAnnotation.screenY + 12,
|
||||
containerHeight - 120
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="w-[260px] rounded-lg border bg-[var(--card)] shadow-xl">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: ann.color }}
|
||||
/>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Add Comment
|
||||
</span>
|
||||
<button
|
||||
onClick={ann.cancelPendingAnnotation}
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<textarea
|
||||
ref={ann.commentInputRef}
|
||||
value={ann.commentValue}
|
||||
onChange={(e) => ann.setCommentValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
ann.commitPendingAnnotation(ann.commentValue);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
ann.cancelPendingAnnotation();
|
||||
}
|
||||
}}
|
||||
className="w-full resize-none rounded-md border bg-[var(--background)] px-2.5 py-2 text-xs leading-relaxed text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:border-[var(--primary)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
rows={2}
|
||||
placeholder="Describe the issue..."
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Enter to save
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() =>
|
||||
ann.commitPendingAnnotation(ann.commentValue)
|
||||
}
|
||||
>
|
||||
<Send className="mr-1 h-2.5 w-2.5" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
296
src/components/review/annotation-renderer.tsx
Normal file
296
src/components/review/annotation-renderer.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import type { AnnotationTypeValue } from "@/lib/validators/annotation";
|
||||
|
||||
export interface AnnotationShape {
|
||||
id: string;
|
||||
type: AnnotationTypeValue;
|
||||
data: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
endX?: number;
|
||||
endY?: number;
|
||||
points?: { x: number; y: number }[];
|
||||
text?: string;
|
||||
color?: string;
|
||||
strokeWidth?: number;
|
||||
imageUrl?: string;
|
||||
};
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
isSelected?: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
interface AnnotationRendererProps {
|
||||
annotation: AnnotationShape;
|
||||
/** Called when user starts dragging this annotation (for move) */
|
||||
onDragStart?: (id: string, e: React.MouseEvent) => void;
|
||||
/** Whether the move tool is active — shows move cursor on all annotations */
|
||||
moveActive?: boolean;
|
||||
}
|
||||
|
||||
function buildArrowHeadPath(
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
headLen: number
|
||||
): string {
|
||||
const angle = Math.atan2(endY - startY, endX - startX);
|
||||
const a1x = endX - headLen * Math.cos(angle - Math.PI / 6);
|
||||
const a1y = endY - headLen * Math.sin(angle - Math.PI / 6);
|
||||
const a2x = endX - headLen * Math.cos(angle + Math.PI / 6);
|
||||
const a2y = endY - headLen * Math.sin(angle + Math.PI / 6);
|
||||
return `M${a1x},${a1y} L${endX},${endY} L${a2x},${a2y}`;
|
||||
}
|
||||
|
||||
function simplifyPoints(
|
||||
points: { x: number; y: number }[],
|
||||
tolerance: number
|
||||
): { x: number; y: number }[] {
|
||||
if (points.length <= 2) return points;
|
||||
|
||||
// Douglas-Peucker simplification
|
||||
let maxDist = 0;
|
||||
let maxIdx = 0;
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const dist = perpendicularDist(points[i], first, last);
|
||||
if (dist > maxDist) {
|
||||
maxDist = dist;
|
||||
maxIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (maxDist > tolerance) {
|
||||
const left = simplifyPoints(points.slice(0, maxIdx + 1), tolerance);
|
||||
const right = simplifyPoints(points.slice(maxIdx), tolerance);
|
||||
return left.slice(0, -1).concat(right);
|
||||
}
|
||||
|
||||
return [first, last];
|
||||
}
|
||||
|
||||
function perpendicularDist(
|
||||
point: { x: number; y: number },
|
||||
lineStart: { x: number; y: number },
|
||||
lineEnd: { x: number; y: number }
|
||||
): number {
|
||||
const dx = lineEnd.x - lineStart.x;
|
||||
const dy = lineEnd.y - lineStart.y;
|
||||
const lenSq = dx * dx + dy * dy;
|
||||
if (lenSq === 0) {
|
||||
const px = point.x - lineStart.x;
|
||||
const py = point.y - lineStart.y;
|
||||
return Math.sqrt(px * px + py * py);
|
||||
}
|
||||
const num = Math.abs(
|
||||
dy * point.x - dx * point.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x
|
||||
);
|
||||
return num / Math.sqrt(lenSq);
|
||||
}
|
||||
|
||||
export function pointsToPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return "";
|
||||
const simplified = simplifyPoints(points, 1.5);
|
||||
const parts = [`M${simplified[0].x},${simplified[0].y}`];
|
||||
for (let i = 1; i < simplified.length; i++) {
|
||||
parts.push(`L${simplified[i].x},${simplified[i].y}`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export const AnnotationRenderer = memo(function AnnotationRenderer({
|
||||
annotation,
|
||||
onDragStart,
|
||||
moveActive,
|
||||
}: AnnotationRendererProps) {
|
||||
const { type, data, isSelected, onClick, id } = annotation;
|
||||
const color = data.color || "#EE5540";
|
||||
const strokeWidth = data.strokeWidth || 6;
|
||||
const selectionExtra = isSelected ? 3 : 0;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.(id);
|
||||
if (onDragStart) {
|
||||
onDragStart(id, e);
|
||||
}
|
||||
};
|
||||
|
||||
// Move tool: always show move cursor; otherwise grab when selected, pointer when not
|
||||
const cursorStyle = moveActive ? "move" : isSelected ? "grab" : ("pointer" as const);
|
||||
|
||||
const sharedProps = {
|
||||
stroke: color,
|
||||
strokeWidth: strokeWidth + selectionExtra,
|
||||
fill: "none",
|
||||
cursor: cursorStyle,
|
||||
pointerEvents: "auto" as const,
|
||||
onMouseDown: handleMouseDown,
|
||||
style: isSelected
|
||||
? { filter: "drop-shadow(0 0 3px rgba(255,255,255,0.6))" }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "RECTANGLE": {
|
||||
const x = data.x ?? 0;
|
||||
const y = data.y ?? 0;
|
||||
const w = data.width ?? 0;
|
||||
const h = data.height ?? 0;
|
||||
return (
|
||||
<rect
|
||||
x={Math.min(x, x + w)}
|
||||
y={Math.min(y, y + h)}
|
||||
width={Math.abs(w)}
|
||||
height={Math.abs(h)}
|
||||
rx={2}
|
||||
{...sharedProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "ELLIPSE": {
|
||||
const x = data.x ?? 0;
|
||||
const y = data.y ?? 0;
|
||||
const w = data.width ?? 0;
|
||||
const h = data.height ?? 0;
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
return (
|
||||
<ellipse
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
rx={Math.abs(w / 2)}
|
||||
ry={Math.abs(h / 2)}
|
||||
{...sharedProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "ARROW": {
|
||||
const x1 = data.x ?? 0;
|
||||
const y1 = data.y ?? 0;
|
||||
const x2 = data.endX ?? x1;
|
||||
const y2 = data.endY ?? y1;
|
||||
const headLen = Math.max(12, strokeWidth * 3);
|
||||
return (
|
||||
<g onMouseDown={handleMouseDown} cursor={cursorStyle} pointerEvents="auto">
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
{...sharedProps}
|
||||
/>
|
||||
<path
|
||||
d={buildArrowHeadPath(x1, y1, x2, y2, headLen)}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth + selectionExtra}
|
||||
fill="none"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
case "FREEHAND": {
|
||||
const points = data.points ?? [];
|
||||
if (points.length === 0) return null;
|
||||
const d = pointsToPath(points);
|
||||
return (
|
||||
<path
|
||||
d={d}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...sharedProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "TEXT": {
|
||||
const x = data.x ?? 0;
|
||||
const y = data.y ?? 0;
|
||||
const text = data.text ?? "";
|
||||
const fontSize = 32;
|
||||
return (
|
||||
<g onMouseDown={handleMouseDown} cursor={cursorStyle} pointerEvents="auto">
|
||||
{/* Text background for readability */}
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="black"
|
||||
stroke="black"
|
||||
strokeWidth={8}
|
||||
fontSize={fontSize}
|
||||
fontFamily="Montserrat, Inter, sans-serif"
|
||||
fontWeight={600}
|
||||
paintOrder="stroke"
|
||||
strokeLinejoin="round"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={color}
|
||||
fontSize={fontSize}
|
||||
fontFamily="Montserrat, Inter, sans-serif"
|
||||
fontWeight={600}
|
||||
style={{
|
||||
userSelect: "none",
|
||||
...(isSelected
|
||||
? { filter: "drop-shadow(0 0 4px rgba(255,255,255,0.7))" }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
case "PIN": {
|
||||
const x = data.x ?? 0;
|
||||
const y = data.y ?? 0;
|
||||
const r = 16;
|
||||
return (
|
||||
<g onMouseDown={handleMouseDown} cursor={cursorStyle} pointerEvents="auto">
|
||||
{/* Pin drop shadow */}
|
||||
<circle cx={x} cy={y} r={r + 3} fill="rgba(0,0,0,0.35)" />
|
||||
{/* Pin outer ring */}
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={r}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={3 + selectionExtra}
|
||||
style={isSelected
|
||||
? { filter: "drop-shadow(0 0 4px rgba(255,255,255,0.7))" }
|
||||
: undefined}
|
||||
/>
|
||||
{/* Pin inner dot */}
|
||||
<circle cx={x} cy={y} r={4} fill="white" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
case "SCREENSHOT": {
|
||||
// Screenshots are rendered by the ScreenshotCallout component via foreignObject
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
327
src/components/review/annotation-tools.tsx
Normal file
327
src/components/review/annotation-tools.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Move,
|
||||
Square,
|
||||
Circle,
|
||||
ArrowUpRight,
|
||||
Pencil,
|
||||
Type,
|
||||
MapPin,
|
||||
Pipette,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EyedropperViewingImage } from "@/components/review/color-probe-layer";
|
||||
|
||||
export type AnnotationTool =
|
||||
| "move"
|
||||
| "rectangle"
|
||||
| "ellipse"
|
||||
| "arrow"
|
||||
| "freehand"
|
||||
| "text"
|
||||
| "pin"
|
||||
| "eyedropper";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ label: "Red", value: "#EE5540" },
|
||||
{ label: "Blue", value: "#3B82F6" },
|
||||
{ label: "Green", value: "#22C55E" },
|
||||
{ label: "Yellow", value: "#EAB308" },
|
||||
{ label: "White", value: "#FFFFFF" },
|
||||
{ label: "Orange", value: "#F97316" },
|
||||
{ label: "Purple", value: "#A855F7" },
|
||||
{ label: "Cyan", value: "#06B6D4" },
|
||||
];
|
||||
|
||||
interface AnnotationToolsProps {
|
||||
activeTool: AnnotationTool;
|
||||
onToolChange: (tool: AnnotationTool) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
visible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
hasSelection: boolean;
|
||||
onDeleteSelection: () => void;
|
||||
/** CMF eyedropper probe visibility toggle */
|
||||
probesVisible?: boolean;
|
||||
onToggleProbes?: () => void;
|
||||
/** Eyedropper image toggle props */
|
||||
eyedropperActive?: boolean;
|
||||
hasReferenceImage?: boolean;
|
||||
viewingImage?: EyedropperViewingImage;
|
||||
onViewingImageChange?: (image: EyedropperViewingImage) => void;
|
||||
}
|
||||
|
||||
const tools: { id: AnnotationTool; icon: typeof Square; label: string; shortcut?: string }[] = [
|
||||
{ id: "move", icon: Move, label: "Move", shortcut: "V" },
|
||||
{ id: "rectangle", icon: Square, label: "Rectangle", shortcut: "R" },
|
||||
{ id: "ellipse", icon: Circle, label: "Ellipse", shortcut: "E" },
|
||||
{ id: "arrow", icon: ArrowUpRight, label: "Arrow", shortcut: "A" },
|
||||
{ id: "freehand", icon: Pencil, label: "Freehand", shortcut: "F" },
|
||||
{ id: "text", icon: Type, label: "Text label", shortcut: "T" },
|
||||
{ id: "pin", icon: MapPin, label: "Pin", shortcut: "P" },
|
||||
{ id: "eyedropper", icon: Pipette, label: "CMF Eyedropper", shortcut: "D" },
|
||||
];
|
||||
|
||||
export function AnnotationTools({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
color,
|
||||
onColorChange,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
visible,
|
||||
onToggleVisibility,
|
||||
hasSelection,
|
||||
onDeleteSelection,
|
||||
probesVisible,
|
||||
onToggleProbes,
|
||||
eyedropperActive,
|
||||
hasReferenceImage,
|
||||
viewingImage = "working",
|
||||
onViewingImageChange,
|
||||
}: AnnotationToolsProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Drawing tools */}
|
||||
{tools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const isActive = activeTool === tool.id;
|
||||
return (
|
||||
<Tooltip key={tool.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
isActive && "bg-[var(--primary)] text-white"
|
||||
)}
|
||||
onClick={() => onToolChange(tool.id)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{tool.label}
|
||||
{tool.shortcut && (
|
||||
<kbd className="ml-1.5 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">
|
||||
{tool.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* ── Working / Reference toggle (when eyedropper active) ── */}
|
||||
{eyedropperActive && hasReferenceImage && onViewingImageChange && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center rounded-md border bg-[var(--background)] p-0.5">
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-sm px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
|
||||
viewingImage === "working"
|
||||
? "bg-emerald-500/20 text-emerald-400"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
)}
|
||||
onClick={() => onViewingImageChange("working")}
|
||||
>
|
||||
W
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-sm px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
|
||||
viewingImage === "reference"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
)}
|
||||
onClick={() => onViewingImageChange("reference")}
|
||||
>
|
||||
R
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Toggle Working / Reference
|
||||
<kbd className="ml-1.5 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">
|
||||
Tab
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
|
||||
{/* Color picker */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="h-7 w-7 p-0">
|
||||
<div
|
||||
className="h-4 w-4 rounded-full border border-white/20"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-2"
|
||||
side="bottom"
|
||||
align="start"
|
||||
>
|
||||
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Stroke Color
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full border-2 transition-transform hover:scale-110",
|
||||
color === c.value
|
||||
? "border-white scale-110"
|
||||
: "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: c.value }}
|
||||
onClick={() => onColorChange(c.value)}
|
||||
title={c.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={!canUndo}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Undo <kbd className="ml-1 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">Cmd+Z</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={!canRedo}
|
||||
onClick={onRedo}
|
||||
>
|
||||
<Redo2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Redo <kbd className="ml-1 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">Cmd+Shift+Z</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onToggleVisibility}
|
||||
>
|
||||
{visible ? (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeOff className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{visible ? "Hide annotations" : "Show annotations"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* CMF probes visibility toggle */}
|
||||
{onToggleProbes && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0",
|
||||
probesVisible && "text-[var(--primary)]"
|
||||
)}
|
||||
onClick={onToggleProbes}
|
||||
>
|
||||
<Pipette className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
!probesVisible && "text-[var(--muted-foreground)]"
|
||||
)} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{probesVisible ? "Hide CMF probes" : "Show CMF probes"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Delete selection */}
|
||||
{hasSelection && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={onDeleteSelection}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Delete selected
|
||||
<kbd className="ml-1 rounded bg-black/20 px-1 py-0.5 font-mono text-[10px]">Del</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
454
src/components/review/color-probe-layer.tsx
Normal file
454
src/components/review/color-probe-layer.tsx
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ColorProbeMarker, ColorProbeGhostMarker, PendingProbeMarker } from "@/components/review/color-probe-marker";
|
||||
import { ColorProbePanel } from "@/components/review/color-probe-panel";
|
||||
import {
|
||||
useColorProbes,
|
||||
useCreateColorProbe,
|
||||
useUpdateColorProbe,
|
||||
useDeleteColorProbe,
|
||||
useClearColorProbes,
|
||||
} from "@/hooks/use-color-probes";
|
||||
import {
|
||||
rgbToHsb,
|
||||
compareHsb,
|
||||
sampleFromImage,
|
||||
type ProbeResult,
|
||||
} from "@/lib/utils/color";
|
||||
|
||||
export type EyedropperViewingImage = "working" | "reference";
|
||||
|
||||
export interface ColorProbeLayerProps {
|
||||
revisionId: string | null;
|
||||
/** Whether the eyedropper tool is active (allows placing new probes) */
|
||||
active: boolean;
|
||||
/** Whether the move tool is active (allows dragging existing probes) */
|
||||
movable?: boolean;
|
||||
/** Whether probes are visible */
|
||||
visible: boolean;
|
||||
/** Viewport state */
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
/** Working image URL (the current render) */
|
||||
workingImageUrl: string | null;
|
||||
/** Reference image URL */
|
||||
referenceImageUrl: string | null;
|
||||
/** Which image is currently displayed on the canvas */
|
||||
viewingImage: EyedropperViewingImage;
|
||||
/** Request to change which image the canvas shows */
|
||||
onViewingImageChange: (image: EyedropperViewingImage) => void;
|
||||
}
|
||||
|
||||
interface PendingProbe {
|
||||
workingX: number;
|
||||
workingY: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const MAX_PROBES = 12;
|
||||
|
||||
export function ColorProbeLayer({
|
||||
revisionId,
|
||||
active,
|
||||
movable = false,
|
||||
visible,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
workingImageUrl,
|
||||
referenceImageUrl,
|
||||
viewingImage,
|
||||
onViewingImageChange,
|
||||
}: ColorProbeLayerProps) {
|
||||
const { data: probes = [] } = useColorProbes(revisionId);
|
||||
const createProbe = useCreateColorProbe(revisionId);
|
||||
const updateProbe = useUpdateColorProbe(revisionId);
|
||||
const deleteProbe = useDeleteColorProbe(revisionId);
|
||||
const clearProbes = useClearColorProbes(revisionId);
|
||||
|
||||
const [selectedProbeId, setSelectedProbeId] = useState<string | null>(null);
|
||||
const [pendingProbe, setPendingProbe] = useState<PendingProbe | null>(null);
|
||||
|
||||
// Loaded image refs for sampling
|
||||
const workingImgRef = useRef<HTMLImageElement | null>(null);
|
||||
const referenceImgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
// Load working image
|
||||
useEffect(() => {
|
||||
if (!workingImageUrl) {
|
||||
workingImgRef.current = null;
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => { workingImgRef.current = img; };
|
||||
img.src = workingImageUrl;
|
||||
}, [workingImageUrl]);
|
||||
|
||||
// Load reference image
|
||||
useEffect(() => {
|
||||
if (!referenceImageUrl) {
|
||||
referenceImgRef.current = null;
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => { referenceImgRef.current = img; };
|
||||
img.src = referenceImageUrl;
|
||||
}, [referenceImageUrl]);
|
||||
|
||||
// Cancel pending probe when tool deactivates
|
||||
useEffect(() => {
|
||||
if (!active && pendingProbe) {
|
||||
setPendingProbe(null);
|
||||
onViewingImageChange("working");
|
||||
}
|
||||
}, [active]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Compute probe results by sampling both images
|
||||
const probeResults = useMemo(() => {
|
||||
const results = new Map<number, ProbeResult>();
|
||||
const workingImg = workingImgRef.current;
|
||||
const refImg = referenceImgRef.current;
|
||||
|
||||
if (!workingImg || !refImg) return results;
|
||||
|
||||
for (const probe of probes) {
|
||||
const workingSample = sampleFromImage(workingImg, probe.workingX, probe.workingY);
|
||||
const refSample = sampleFromImage(refImg, probe.referenceX, probe.referenceY);
|
||||
|
||||
if (workingSample && refSample) {
|
||||
const workingHsb = rgbToHsb(workingSample);
|
||||
const refHsb = rgbToHsb(refSample);
|
||||
results.set(probe.index, compareHsb(workingHsb, refHsb));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
// Re-sample whenever probes or images change
|
||||
}, [probes, workingImageUrl, referenceImageUrl]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle click — two-step probe creation
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!active || !revisionId) return;
|
||||
|
||||
const svg = e.currentTarget;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
const imgX = (e.clientX - rect.left - panX) / zoom;
|
||||
const imgY = (e.clientY - rect.top - panY) / zoom;
|
||||
|
||||
if (!pendingProbe) {
|
||||
// ── Step 1: Place working point ──
|
||||
if (probes.length >= MAX_PROBES) return;
|
||||
|
||||
// Find next available index
|
||||
const usedIndices = new Set(probes.map((p) => p.index));
|
||||
let nextIndex = 1;
|
||||
while (usedIndices.has(nextIndex) && nextIndex <= MAX_PROBES) {
|
||||
nextIndex++;
|
||||
}
|
||||
if (nextIndex > MAX_PROBES) return;
|
||||
|
||||
setPendingProbe({
|
||||
workingX: Math.round(imgX),
|
||||
workingY: Math.round(imgY),
|
||||
index: nextIndex,
|
||||
});
|
||||
|
||||
// Auto-swap to reference image if available
|
||||
if (referenceImageUrl) {
|
||||
onViewingImageChange("reference");
|
||||
}
|
||||
} else {
|
||||
// ── Step 2: Place reference point ──
|
||||
createProbe.mutate({
|
||||
index: pendingProbe.index,
|
||||
workingX: pendingProbe.workingX,
|
||||
workingY: pendingProbe.workingY,
|
||||
referenceX: Math.round(imgX),
|
||||
referenceY: Math.round(imgY),
|
||||
});
|
||||
|
||||
setPendingProbe(null);
|
||||
onViewingImageChange("working");
|
||||
}
|
||||
},
|
||||
[active, revisionId, probes, panX, panY, zoom, pendingProbe, referenceImageUrl, createProbe, onViewingImageChange]
|
||||
);
|
||||
|
||||
// Cancel pending probe on Escape
|
||||
useEffect(() => {
|
||||
if (!pendingProbe) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPendingProbe(null);
|
||||
onViewingImageChange("working");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [pendingProbe, onViewingImageChange]);
|
||||
|
||||
// Drag handler for working-side probe
|
||||
const handleProbeDrag = useCallback(
|
||||
(probeId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedProbeId(probeId);
|
||||
|
||||
const probe = probes.find((p) => p.id === probeId);
|
||||
if (!probe) return;
|
||||
|
||||
let lastX = e.clientX;
|
||||
let lastY = e.clientY;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - lastX) / zoom;
|
||||
const dy = (me.clientY - lastY) / zoom;
|
||||
lastX = me.clientX;
|
||||
lastY = me.clientY;
|
||||
|
||||
probe.workingX += dx;
|
||||
probe.workingY += dy;
|
||||
if (!me.altKey) {
|
||||
probe.referenceX += dx;
|
||||
probe.referenceY += dy;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
updateProbe.mutate({
|
||||
probeId,
|
||||
data: {
|
||||
workingX: Math.round(probe.workingX),
|
||||
workingY: Math.round(probe.workingY),
|
||||
referenceX: Math.round(probe.referenceX),
|
||||
referenceY: Math.round(probe.referenceY),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[probes, zoom, updateProbe]
|
||||
);
|
||||
|
||||
// Drag handler for reference-side ghost marker
|
||||
const handleRefDrag = useCallback(
|
||||
(probeId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const probe = probes.find((p) => p.id === probeId);
|
||||
if (!probe) return;
|
||||
|
||||
let lastX = e.clientX;
|
||||
let lastY = e.clientY;
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - lastX) / zoom;
|
||||
const dy = (me.clientY - lastY) / zoom;
|
||||
lastX = me.clientX;
|
||||
lastY = me.clientY;
|
||||
probe.referenceX += dx;
|
||||
probe.referenceY += dy;
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
updateProbe.mutate({
|
||||
probeId,
|
||||
data: {
|
||||
referenceX: Math.round(probe.referenceX),
|
||||
referenceY: Math.round(probe.referenceY),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[probes, zoom, updateProbe]
|
||||
);
|
||||
|
||||
// Delete probe on right-click
|
||||
const handleContextMenu = useCallback(
|
||||
(probeId: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
deleteProbe.mutate(probeId);
|
||||
},
|
||||
[deleteProbe]
|
||||
);
|
||||
|
||||
// Delete key handler
|
||||
useEffect(() => {
|
||||
if (!selectedProbeId) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
deleteProbe.mutate(selectedProbeId);
|
||||
setSelectedProbeId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedProbeId, deleteProbe]);
|
||||
|
||||
if (!visible || !revisionId) return null;
|
||||
|
||||
// Check if any reference point is offset from working point
|
||||
const hasOffsets = probes.some(
|
||||
(p) =>
|
||||
Math.abs(p.referenceX - p.workingX) > 1 ||
|
||||
Math.abs(p.referenceY - p.workingY) > 1
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Clickable overlay for placing probes — only in eyedropper mode */}
|
||||
{active && (
|
||||
<svg
|
||||
className="absolute inset-0 z-22"
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
style={{ pointerEvents: "auto", cursor: "crosshair" }}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Probe markers SVG */}
|
||||
<svg
|
||||
className="absolute inset-0 z-23"
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* Ghost markers + connecting lines for offset reference points */}
|
||||
{hasOffsets &&
|
||||
probes.map((probe) => {
|
||||
const isOffset =
|
||||
Math.abs(probe.referenceX - probe.workingX) > 1 ||
|
||||
Math.abs(probe.referenceY - probe.workingY) > 1;
|
||||
if (!isOffset) return null;
|
||||
const wsx = panX + probe.workingX * zoom;
|
||||
const wsy = panY + probe.workingY * zoom;
|
||||
const rsx = panX + probe.referenceX * zoom;
|
||||
const rsy = panY + probe.referenceY * zoom;
|
||||
return (
|
||||
<g key={`ref-${probe.id}`} onContextMenu={(e) => e.preventDefault()}>
|
||||
<line
|
||||
x1={wsx}
|
||||
y1={wsy}
|
||||
x2={rsx}
|
||||
y2={rsy}
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 4"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<ColorProbeGhostMarker
|
||||
index={probe.index}
|
||||
x={rsx}
|
||||
y={rsy}
|
||||
onDragStart={(e) => handleRefDrag(probe.id, e)}
|
||||
moveActive={movable}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Working-side probe markers */}
|
||||
{probes.map((probe) => {
|
||||
const sx = panX + probe.workingX * zoom;
|
||||
const sy = panY + probe.workingY * zoom;
|
||||
return (
|
||||
<g
|
||||
key={probe.id}
|
||||
onContextMenu={(e) => handleContextMenu(probe.id, e)}
|
||||
>
|
||||
<ColorProbeMarker
|
||||
index={probe.index}
|
||||
x={sx}
|
||||
y={sy}
|
||||
result={probeResults.get(probe.index) ?? null}
|
||||
isSelected={selectedProbeId === probe.id}
|
||||
onDragStart={(e) => handleProbeDrag(probe.id, e)}
|
||||
moveActive={movable}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Pending probe marker (shown as ghost on reference image) */}
|
||||
{pendingProbe && (
|
||||
<PendingProbeMarker
|
||||
index={pendingProbe.index}
|
||||
x={panX + pendingProbe.workingX * zoom}
|
||||
y={panY + pendingProbe.workingY * zoom}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Step indicator when pending probe exists */}
|
||||
{active && pendingProbe && (
|
||||
<div className="absolute left-1/2 bottom-14 z-30 -translate-x-1/2">
|
||||
<div className="rounded-md border bg-[var(--card)]/95 px-3 py-1.5 shadow-lg backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Click the reference image to set comparison point
|
||||
</p>
|
||||
<p className="mt-0.5 text-[9px] text-[var(--muted-foreground)]/60">
|
||||
Press Esc to cancel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image label badge */}
|
||||
{active && (
|
||||
<div className="absolute right-3 bottom-3 z-30">
|
||||
<div
|
||||
className="rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider backdrop-blur-sm transition-colors duration-200"
|
||||
style={{
|
||||
backgroundColor: viewingImage === "working"
|
||||
? "rgba(34, 197, 94, 0.2)"
|
||||
: "rgba(59, 130, 246, 0.2)",
|
||||
color: viewingImage === "working" ? "#22C55E" : "#3B82F6",
|
||||
border: `1px solid ${viewingImage === "working" ? "rgba(34,197,94,0.3)" : "rgba(59,130,246,0.3)"}`,
|
||||
}}
|
||||
>
|
||||
{viewingImage === "working" ? "Working" : "Reference"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary panel — bottom-left */}
|
||||
{probes.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 z-30">
|
||||
<ColorProbePanel
|
||||
probeResults={probeResults}
|
||||
probeCount={probes.length}
|
||||
onClearAll={() => clearProbes.mutate()}
|
||||
isClearing={clearProbes.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
308
src/components/review/color-probe-marker.tsx
Normal file
308
src/components/review/color-probe-marker.tsx
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ProbeResult } from "@/lib/utils/color";
|
||||
|
||||
interface ColorProbeMarkerProps {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
result: ProbeResult | null;
|
||||
isSelected?: boolean;
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
/** Whether the move tool is active — shows move cursor */
|
||||
moveActive?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pass: "#22C55E",
|
||||
warn: "#F97316",
|
||||
fail: "#EF4444",
|
||||
unknown: "#6B7280",
|
||||
} as const;
|
||||
|
||||
const MARKER_RADIUS = 14;
|
||||
|
||||
export function ColorProbeMarker({
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
result,
|
||||
isSelected,
|
||||
onDragStart,
|
||||
moveActive,
|
||||
}: ColorProbeMarkerProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const status = result?.status ?? "unknown";
|
||||
const color = STATUS_COLORS[status];
|
||||
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x}, ${y})`}
|
||||
style={{ cursor: moveActive ? "move" : "grab" }}
|
||||
pointerEvents="auto"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
{/* Outer ring */}
|
||||
<circle
|
||||
r={MARKER_RADIUS + 3}
|
||||
fill="none"
|
||||
stroke={isSelected ? "#fff" : "rgba(0,0,0,0.5)"}
|
||||
strokeWidth={isSelected ? 2.5 : 1.5}
|
||||
/>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
r={MARKER_RADIUS}
|
||||
fill="#1a1a1a"
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
{/* Index number */}
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="13"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
fill="#fff"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{index}
|
||||
</text>
|
||||
|
||||
{/* Status pill — positioned to the right */}
|
||||
<g transform="translate(22, 0)">
|
||||
{/* Compact pill (always visible) */}
|
||||
<rect
|
||||
x={0}
|
||||
y={-7}
|
||||
width={14}
|
||||
height={14}
|
||||
rx={7}
|
||||
fill={color}
|
||||
opacity={0.9}
|
||||
/>
|
||||
|
||||
{/* Expanded detail panel on hover */}
|
||||
{hovered && result && (
|
||||
<g transform="translate(20, -55)">
|
||||
{/* Panel background */}
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={185}
|
||||
height={88}
|
||||
rx={5}
|
||||
fill="rgba(15,15,15,0.94)"
|
||||
stroke="rgba(255,255,255,0.15)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
{/* Header */}
|
||||
<text
|
||||
x={8}
|
||||
y={16}
|
||||
fontSize="9"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
letterSpacing="0.08em"
|
||||
fill="rgba(255,255,255,0.5)"
|
||||
>
|
||||
PROBE {index}
|
||||
</text>
|
||||
<rect
|
||||
x={64}
|
||||
y={6}
|
||||
width={40}
|
||||
height={14}
|
||||
rx={7}
|
||||
fill={color}
|
||||
opacity={0.85}
|
||||
/>
|
||||
<text
|
||||
x={84}
|
||||
y={16}
|
||||
textAnchor="middle"
|
||||
fontSize="8"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
fill="#fff"
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</text>
|
||||
|
||||
<HsbRow
|
||||
label="H"
|
||||
working={`${result.working.h}°`}
|
||||
reference={`${result.reference.h}°`}
|
||||
delta={`Δ${result.delta.h.toFixed(1)}°`}
|
||||
pass={result.delta.h <= 4}
|
||||
y={34}
|
||||
/>
|
||||
<HsbRow
|
||||
label="S"
|
||||
working={`${result.working.s}%`}
|
||||
reference={`${result.reference.s}%`}
|
||||
delta={`Δ${result.delta.s.toFixed(1)}%`}
|
||||
pass={result.delta.s <= 3}
|
||||
y={52}
|
||||
/>
|
||||
<HsbRow
|
||||
label="B"
|
||||
working={`${result.working.b}%`}
|
||||
reference={`${result.reference.b}%`}
|
||||
delta={`Δ${result.delta.b.toFixed(1)}%`}
|
||||
pass={result.delta.b <= 3}
|
||||
y={70}
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function HsbRow({
|
||||
label,
|
||||
working,
|
||||
reference,
|
||||
delta,
|
||||
pass,
|
||||
y,
|
||||
}: {
|
||||
label: string;
|
||||
working: string;
|
||||
reference: string;
|
||||
delta: string;
|
||||
pass: boolean;
|
||||
y: number;
|
||||
}) {
|
||||
return (
|
||||
<g>
|
||||
<text x={8} y={y} fontSize="10" fontWeight="700" fontFamily="monospace" fill="rgba(255,255,255,0.6)">
|
||||
{label}:
|
||||
</text>
|
||||
<text x={24} y={y} fontSize="10" fontFamily="monospace" fill="rgba(255,255,255,0.85)">
|
||||
{working}
|
||||
</text>
|
||||
<text x={60} y={y} fontSize="10" fontFamily="monospace" fill="rgba(255,255,255,0.45)">
|
||||
→
|
||||
</text>
|
||||
<text x={72} y={y} fontSize="10" fontFamily="monospace" fill="rgba(255,255,255,0.85)">
|
||||
{reference}
|
||||
</text>
|
||||
<text x={118} y={y} fontSize="10" fontWeight="600" fontFamily="monospace" fill={pass ? "#22C55E" : "#EF4444"}>
|
||||
{delta}
|
||||
</text>
|
||||
<text x={168} y={y} fontSize="10" fontFamily="monospace" fill={pass ? "#22C55E" : "#EF4444"}>
|
||||
{pass ? "✓" : "✗"}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending probe marker — pulsing dashed circle shown while waiting
|
||||
* for the user to place the reference point.
|
||||
*/
|
||||
export function PendingProbeMarker({
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
return (
|
||||
<g transform={`translate(${x}, ${y})`} pointerEvents="none">
|
||||
{/* Pulsing outer ring */}
|
||||
<circle
|
||||
r={MARKER_RADIUS + 6}
|
||||
fill="none"
|
||||
stroke="rgba(59,130,246,0.5)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
>
|
||||
<animate
|
||||
attributeName="r"
|
||||
values={`${MARKER_RADIUS + 4};${MARKER_RADIUS + 8};${MARKER_RADIUS + 4}`}
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.6;0.2;0.6"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
{/* Dashed circle */}
|
||||
<circle
|
||||
r={MARKER_RADIUS}
|
||||
fill="rgba(59,130,246,0.15)"
|
||||
stroke="#3B82F6"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
/>
|
||||
{/* Index number */}
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="13"
|
||||
fontWeight="700"
|
||||
fontFamily="monospace"
|
||||
fill="#3B82F6"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{index}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghost marker shown on the reference image for an offset probe.
|
||||
*/
|
||||
export function ColorProbeGhostMarker({
|
||||
index,
|
||||
x,
|
||||
y,
|
||||
onDragStart,
|
||||
moveActive,
|
||||
}: {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
onDragStart?: (e: React.MouseEvent) => void;
|
||||
moveActive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x}, ${y})`}
|
||||
style={{ cursor: moveActive ? "move" : "grab" }}
|
||||
pointerEvents="auto"
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
<circle
|
||||
r={MARKER_RADIUS}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fontFamily="monospace"
|
||||
fill="rgba(255,255,255,0.6)"
|
||||
style={{ userSelect: "none" }}
|
||||
>
|
||||
{index}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
175
src/components/review/color-probe-panel.tsx
Normal file
175
src/components/review/color-probe-panel.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"use client";
|
||||
|
||||
import { Trash2, Pipette } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ProbeResult } from "@/lib/utils/color";
|
||||
|
||||
interface ColorProbePanelProps {
|
||||
probeResults: Map<number, ProbeResult>;
|
||||
probeCount: number;
|
||||
onClearAll: () => void;
|
||||
isClearing?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pass: "#22C55E",
|
||||
warn: "#F97316",
|
||||
fail: "#EF4444",
|
||||
} as const;
|
||||
|
||||
export function ColorProbePanel({
|
||||
probeResults,
|
||||
probeCount,
|
||||
onClearAll,
|
||||
isClearing,
|
||||
}: ColorProbePanelProps) {
|
||||
if (probeCount === 0) return null;
|
||||
|
||||
const passCount = Array.from(probeResults.values()).filter(
|
||||
(r) => r.status === "pass"
|
||||
).length;
|
||||
const failCount = Array.from(probeResults.values()).filter(
|
||||
(r) => r.status === "fail"
|
||||
).length;
|
||||
const warnCount = Array.from(probeResults.values()).filter(
|
||||
(r) => r.status === "warn"
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="w-[200px] rounded-lg border bg-[var(--card)]/95 shadow-xl backdrop-blur-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Pipette className="h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
CMF Probes
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-300"
|
||||
onClick={onClearAll}
|
||||
disabled={isClearing}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Clear all probes
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{probeCount} of 12
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{passCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: "rgba(34,197,94,0.15)", color: STATUS_COLORS.pass }}
|
||||
>
|
||||
{passCount}
|
||||
</span>
|
||||
)}
|
||||
{warnCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: "rgba(249,115,22,0.15)", color: STATUS_COLORS.warn }}
|
||||
>
|
||||
{warnCount}
|
||||
</span>
|
||||
)}
|
||||
{failCount > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-semibold"
|
||||
style={{ backgroundColor: "rgba(239,68,68,0.15)", color: STATUS_COLORS.fail }}
|
||||
>
|
||||
{failCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Probe rows */}
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{Array.from({ length: probeCount }, (_, i) => i + 1).map((idx) => {
|
||||
const result = probeResults.get(idx);
|
||||
if (!result) return null;
|
||||
const status = result.status;
|
||||
const color = STATUS_COLORS[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 border-b border-[var(--border)]/30 px-3 py-1 last:border-b-0"
|
||||
>
|
||||
{/* Index */}
|
||||
<span className="w-3 text-right font-mono text-[9px] font-bold text-[var(--muted-foreground)]">
|
||||
{idx}
|
||||
</span>
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Deltas */}
|
||||
<div className="flex flex-1 items-center gap-1 font-mono text-[9px]">
|
||||
<DeltaValue
|
||||
label="H"
|
||||
value={result.delta.h}
|
||||
unit="°"
|
||||
pass={result.delta.h <= 4}
|
||||
/>
|
||||
<DeltaValue
|
||||
label="S"
|
||||
value={result.delta.s}
|
||||
unit="%"
|
||||
pass={result.delta.s <= 3}
|
||||
/>
|
||||
<DeltaValue
|
||||
label="B"
|
||||
value={result.delta.b}
|
||||
unit="%"
|
||||
pass={result.delta.b <= 3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaValue({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
pass,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
pass: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span style={{ color: pass ? "rgba(255,255,255,0.5)" : "#EF4444" }}>
|
||||
<span className="opacity-60">{label}</span>
|
||||
{value.toFixed(1)}
|
||||
{unit}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
207
src/components/review/comparison-toolbar.tsx
Normal file
207
src/components/review/comparison-toolbar.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Columns2,
|
||||
SplitSquareHorizontal,
|
||||
Layers,
|
||||
ToggleLeft,
|
||||
FlipHorizontal2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export type ComparisonMode = "side-by-side" | "wipe" | "overlay" | "toggle";
|
||||
|
||||
interface RevisionOption {
|
||||
revisionId: string;
|
||||
roundNumber: number;
|
||||
type: "reference" | "current";
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ComparisonToolbarProps {
|
||||
mode: ComparisonMode;
|
||||
onModeChange: (mode: ComparisonMode) => void;
|
||||
revisionOptions: RevisionOption[];
|
||||
leftRevisionKey: string;
|
||||
rightRevisionKey: string;
|
||||
onLeftChange: (key: string) => void;
|
||||
onRightChange: (key: string) => void;
|
||||
flipA: boolean;
|
||||
flipB: boolean;
|
||||
onFlipA: () => void;
|
||||
onFlipB: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const MODE_CONFIG: {
|
||||
value: ComparisonMode;
|
||||
label: string;
|
||||
icon: typeof Columns2;
|
||||
shortcut: string;
|
||||
}[] = [
|
||||
{ value: "side-by-side", label: "Side by Side", icon: Columns2, shortcut: "1" },
|
||||
{ value: "wipe", label: "A/B Wipe", icon: SplitSquareHorizontal, shortcut: "2" },
|
||||
{ value: "overlay", label: "Overlay", icon: Layers, shortcut: "3" },
|
||||
{ value: "toggle", label: "Toggle", icon: ToggleLeft, shortcut: "4" },
|
||||
];
|
||||
|
||||
export function ComparisonToolbar({
|
||||
mode,
|
||||
onModeChange,
|
||||
revisionOptions,
|
||||
leftRevisionKey,
|
||||
rightRevisionKey,
|
||||
onLeftChange,
|
||||
onRightChange,
|
||||
flipA,
|
||||
flipB,
|
||||
onFlipA,
|
||||
onFlipB,
|
||||
onExit,
|
||||
}: ComparisonToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mode selector */}
|
||||
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--card)] p-0.5">
|
||||
{MODE_CONFIG.map(({ value, label, icon: Icon, shortcut }) => (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={mode === value ? "default" : "ghost"}
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onModeChange(value)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{label} <kbd className="ml-1 rounded bg-[var(--border)] px-1 font-mono text-[10px]">{shortcut}</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mx-0.5 h-4 w-px bg-[var(--border)]" />
|
||||
|
||||
{/* Flip controls */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={flipA ? "default" : "ghost"}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={onFlipA}
|
||||
>
|
||||
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
||||
Flip A
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Mirror image A horizontally
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={flipB ? "default" : "ghost"}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={onFlipB}
|
||||
>
|
||||
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
||||
Flip B
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Mirror image B horizontally
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mx-0.5 h-4 w-px bg-[var(--border)]" />
|
||||
|
||||
{/* Revision selectors */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
A
|
||||
</span>
|
||||
<Select value={leftRevisionKey} onValueChange={onLeftChange}>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisionOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={`${opt.revisionId}-${opt.type}`}
|
||||
value={`${opt.revisionId}-${opt.type}`}
|
||||
className="text-xs"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
B
|
||||
</span>
|
||||
<Select value={rightRevisionKey} onValueChange={onRightChange}>
|
||||
<SelectTrigger className="h-7 w-[160px] text-xs">
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{revisionOptions.map((opt) => (
|
||||
<SelectItem
|
||||
key={`${opt.revisionId}-${opt.type}`}
|
||||
value={`${opt.revisionId}-${opt.type}`}
|
||||
className="text-xs"
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exit button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 gap-1 px-2 text-xs text-[var(--muted-foreground)]"
|
||||
onClick={onExit}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Exit Compare
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Exit comparison mode <kbd className="ml-1 rounded bg-[var(--border)] px-1 font-mono text-[10px]">Esc</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
458
src/components/review/comparison-viewer.tsx
Normal file
458
src/components/review/comparison-viewer.tsx
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2, ImageIcon } from "lucide-react";
|
||||
import { useImageViewer } from "@/hooks/use-image-viewer";
|
||||
import { ZoomControls } from "@/components/review/zoom-controls";
|
||||
import { WipeDivider } from "@/components/review/wipe-divider";
|
||||
import { OverlayControls } from "@/components/review/overlay-controls";
|
||||
import type { ComparisonMode } from "@/components/review/comparison-toolbar";
|
||||
|
||||
interface ComparisonViewerProps {
|
||||
leftSrc: string | null;
|
||||
rightSrc: string | null;
|
||||
mode: ComparisonMode;
|
||||
/** Mirror image A horizontally */
|
||||
flipA?: boolean;
|
||||
/** Mirror image B horizontally */
|
||||
flipB?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ZOOM_STEP = 1.15;
|
||||
const MIN_ZOOM = 0.05;
|
||||
const MAX_ZOOM = 5;
|
||||
|
||||
/**
|
||||
* Comparison viewer that renders two images in the selected comparison mode.
|
||||
* Manages shared zoom/pan state so both images stay synced.
|
||||
*/
|
||||
export function ComparisonViewer({
|
||||
leftSrc,
|
||||
rightSrc,
|
||||
mode,
|
||||
flipA = false,
|
||||
flipB = false,
|
||||
className,
|
||||
}: ComparisonViewerProps) {
|
||||
// ── Shared zoom/pan state ──────────────────────────────────────────────
|
||||
const [zoom, setZoomState] = useState(1);
|
||||
const [panX, setPanX] = useState(0);
|
||||
const [panY, setPanY] = useState(0);
|
||||
const zoomRef = useRef(zoom);
|
||||
const panRef = useRef({ x: panX, y: panY });
|
||||
zoomRef.current = zoom;
|
||||
panRef.current = { x: panX, y: panY };
|
||||
|
||||
// ── Overlay opacity ────────────────────────────────────────────────────
|
||||
const [overlayOpacity, setOverlayOpacity] = useState(50);
|
||||
|
||||
// ── Toggle state ───────────────────────────────────────────────────────
|
||||
const [toggleShowRight, setToggleShowRight] = useState(false);
|
||||
|
||||
// ── Canvas-based viewers for side-by-side mode ─────────────────────────
|
||||
const leftViewer = useImageViewer();
|
||||
const rightViewer = useImageViewer();
|
||||
|
||||
// Load images into canvas viewers when sources change
|
||||
useEffect(() => {
|
||||
if (leftSrc && mode === "side-by-side") {
|
||||
leftViewer.loadImage(leftSrc);
|
||||
}
|
||||
}, [leftSrc, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (rightSrc && mode === "side-by-side") {
|
||||
rightViewer.loadImage(rightSrc);
|
||||
}
|
||||
}, [rightSrc, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Sync right viewer to left viewer state in side-by-side mode
|
||||
useEffect(() => {
|
||||
if (mode !== "side-by-side") return;
|
||||
const { zoom: lz, panX: lx, panY: ly } = leftViewer.state;
|
||||
rightViewer.setPan(lx, ly);
|
||||
// We need to set zoom without re-centering, so set pan after zoom
|
||||
if (Math.abs(rightViewer.state.zoom - lz) > 0.001) {
|
||||
rightViewer.setZoom(lz);
|
||||
rightViewer.setPan(lx, ly);
|
||||
}
|
||||
}, [leftViewer.state.zoom, leftViewer.state.panX, leftViewer.state.panY, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Shared pan handler for non-side-by-side modes ──────────────────────
|
||||
const handlePan = useCallback((newPanX: number, newPanY: number) => {
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
}, []);
|
||||
|
||||
const handleZoom = useCallback(
|
||||
(newZoom: number, centerX: number, centerY: number) => {
|
||||
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom));
|
||||
const oldZoom = zoomRef.current;
|
||||
const scale = clamped / oldZoom;
|
||||
const newPanX = centerX - (centerX - panRef.current.x) * scale;
|
||||
const newPanY = centerY - (centerY - panRef.current.y) * scale;
|
||||
setZoomState(clamped);
|
||||
setPanX(newPanX);
|
||||
setPanY(newPanY);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ── Fit images to container on mode change or source change ────────────
|
||||
const sharedContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Track loaded image dimensions for proper centering
|
||||
const [imgDimensions, setImgDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
|
||||
// Load image dimensions when source changes
|
||||
useEffect(() => {
|
||||
const src = leftSrc ?? rightSrc;
|
||||
if (!src) return;
|
||||
const img = new Image();
|
||||
img.onload = () =>
|
||||
setImgDimensions({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.src = src;
|
||||
}, [leftSrc, rightSrc]);
|
||||
|
||||
const getViewportCenter = useCallback(() => {
|
||||
const container = sharedContainerRef.current;
|
||||
if (!container) return { x: 0, y: 0 };
|
||||
const rect = container.getBoundingClientRect();
|
||||
return { x: rect.width / 2, y: rect.height / 2 };
|
||||
}, []);
|
||||
|
||||
const fitSharedView = useCallback(() => {
|
||||
const container = sharedContainerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
|
||||
if (imgDimensions) {
|
||||
// Fit image into viewport with padding
|
||||
const scaleX = rect.width / imgDimensions.width;
|
||||
const scaleY = rect.height / imgDimensions.height;
|
||||
const fitZoom = Math.min(scaleX, scaleY) * 0.9; // 90% to add breathing room
|
||||
const panX =
|
||||
(rect.width - imgDimensions.width * fitZoom) / 2;
|
||||
const panY =
|
||||
(rect.height - imgDimensions.height * fitZoom) / 2;
|
||||
setZoomState(fitZoom);
|
||||
setPanX(panX);
|
||||
setPanY(panY);
|
||||
} else {
|
||||
// Fallback: center at 1x
|
||||
setZoomState(1);
|
||||
setPanX(rect.width / 2);
|
||||
setPanY(rect.height / 2);
|
||||
}
|
||||
}, [imgDimensions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "side-by-side") {
|
||||
fitSharedView();
|
||||
}
|
||||
}, [mode, leftSrc, rightSrc, fitSharedView]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Toggle keyboard shortcut (Space) ───────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (mode !== "toggle") return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
if (e.code === "Space") {
|
||||
e.preventDefault();
|
||||
setToggleShowRight((prev) => !prev);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [mode]);
|
||||
|
||||
// ── Shared pan/zoom interaction for overlay, toggle, wipe containers ───
|
||||
const isPanning = useRef(false);
|
||||
const lastMouse = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleContainerPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
isPanning.current = true;
|
||||
lastMouse.current = { x: e.clientX, y: e.clientY };
|
||||
}, []);
|
||||
|
||||
const handleContainerPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isPanning.current) return;
|
||||
const dx = e.clientX - lastMouse.current.x;
|
||||
const dy = e.clientY - lastMouse.current.y;
|
||||
lastMouse.current = { x: e.clientX, y: e.clientY };
|
||||
setPanX((prev) => prev + dx);
|
||||
setPanY((prev) => prev + dy);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleContainerPointerUp = useCallback(() => {
|
||||
isPanning.current = false;
|
||||
}, []);
|
||||
|
||||
const handleContainerWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const container = sharedContainerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||
handleZoom(zoomRef.current * factor, mouseX, mouseY);
|
||||
},
|
||||
[handleZoom]
|
||||
);
|
||||
|
||||
const imgTransform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||||
const imgW = imgDimensions?.width ?? 0;
|
||||
const imgH = imgDimensions?.height ?? 0;
|
||||
// Image visual center in screen coordinates
|
||||
const imgCenterX = panX + (imgW * zoom) / 2;
|
||||
const imgCenterY = panY + (imgH * zoom) / 2;
|
||||
|
||||
function makeFlipStyle(flipped: boolean, extraStyles?: React.CSSProperties): React.CSSProperties {
|
||||
if (!flipped) return { transform: imgTransform, ...extraStyles };
|
||||
return {
|
||||
transform: `${imgTransform} scaleX(-1)`,
|
||||
transformOrigin: `${imgCenterX}px ${imgCenterY}px`,
|
||||
...extraStyles,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────
|
||||
if (!leftSrc || !rightSrc) {
|
||||
return (
|
||||
<div className={`relative flex flex-col ${className ?? ""}`}>
|
||||
<div className="flex flex-1 items-center justify-center bg-[#1a1a1a] text-[var(--muted-foreground)]">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ImageIcon className="h-12 w-12 opacity-30" />
|
||||
<p className="text-sm">
|
||||
Select two revisions to compare
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Side-by-side mode ──────────────────────────────────────────────────
|
||||
if (mode === "side-by-side") {
|
||||
return (
|
||||
<div className={`relative flex flex-col ${className ?? ""}`}>
|
||||
{/* Shared zoom controls */}
|
||||
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
|
||||
<ZoomControls
|
||||
zoom={leftViewer.state.zoom}
|
||||
onZoomIn={leftViewer.zoomIn}
|
||||
onZoomOut={leftViewer.zoomOut}
|
||||
onFitToView={leftViewer.fitToView}
|
||||
onZoomToPreset={leftViewer.zoomToPreset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1">
|
||||
{/* Left pane */}
|
||||
<div className="relative flex-1 overflow-hidden border-r border-[var(--border)]">
|
||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
|
||||
A
|
||||
</div>
|
||||
<div
|
||||
ref={leftViewer.containerRef}
|
||||
className="h-full w-full overflow-hidden bg-[#1a1a1a]"
|
||||
>
|
||||
{leftViewer.isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
)}
|
||||
<canvas
|
||||
ref={leftViewer.canvasRef}
|
||||
className="block h-full w-full"
|
||||
style={flipA ? { transform: "scaleX(-1)" } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right pane */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div className="absolute right-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
|
||||
B
|
||||
</div>
|
||||
<div
|
||||
ref={rightViewer.containerRef}
|
||||
className="h-full w-full overflow-hidden bg-[#1a1a1a]"
|
||||
>
|
||||
{rightViewer.isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
)}
|
||||
<canvas
|
||||
ref={rightViewer.canvasRef}
|
||||
className="block h-full w-full"
|
||||
style={flipB ? { transform: "scaleX(-1)" } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wipe mode ──────────────────────────────────────────────────────────
|
||||
if (mode === "wipe") {
|
||||
return (
|
||||
<div className={`relative flex flex-col ${className ?? ""}`}>
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => { const c = getViewportCenter(); handleZoom(zoom * ZOOM_STEP, c.x, c.y); }}
|
||||
onZoomOut={() => { const c = getViewportCenter(); handleZoom(zoom / ZOOM_STEP, c.x, c.y); }}
|
||||
onFitToView={fitSharedView}
|
||||
onZoomToPreset={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" ref={sharedContainerRef}>
|
||||
<WipeDivider
|
||||
leftSrc={leftSrc}
|
||||
rightSrc={rightSrc}
|
||||
flipA={flipA}
|
||||
flipB={flipB}
|
||||
imgWidth={imgW}
|
||||
imgHeight={imgH}
|
||||
zoom={zoom}
|
||||
panX={panX}
|
||||
panY={panY}
|
||||
onPan={handlePan}
|
||||
onZoom={handleZoom}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overlay mode ───────────────────────────────────────────────────────
|
||||
if (mode === "overlay") {
|
||||
return (
|
||||
<div className={`relative flex flex-col ${className ?? ""}`}>
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => { const c = getViewportCenter(); handleZoom(zoom * ZOOM_STEP, c.x, c.y); }}
|
||||
onZoomOut={() => { const c = getViewportCenter(); handleZoom(zoom / ZOOM_STEP, c.x, c.y); }}
|
||||
onFitToView={fitSharedView}
|
||||
onZoomToPreset={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={sharedContainerRef}
|
||||
className="relative flex-1 cursor-grab overflow-hidden bg-[#1a1a1a] active:cursor-grabbing"
|
||||
onPointerDown={handleContainerPointerDown}
|
||||
onPointerMove={handleContainerPointerMove}
|
||||
onPointerUp={handleContainerPointerUp}
|
||||
onPointerLeave={handleContainerPointerUp}
|
||||
onWheel={handleContainerWheel}
|
||||
>
|
||||
{/* Base image (A) */}
|
||||
<img
|
||||
src={leftSrc}
|
||||
alt="Version A"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-0 top-0 select-none"
|
||||
style={makeFlipStyle(flipA)}
|
||||
/>
|
||||
|
||||
{/* Overlay image (B) */}
|
||||
<img
|
||||
src={rightSrc}
|
||||
alt="Version B"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-0 top-0 select-none"
|
||||
style={makeFlipStyle(flipB, { opacity: overlayOpacity / 100 })}
|
||||
/>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
|
||||
A
|
||||
</div>
|
||||
<div className="absolute right-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
|
||||
B ({overlayOpacity}%)
|
||||
</div>
|
||||
|
||||
<OverlayControls
|
||||
opacity={overlayOpacity}
|
||||
onOpacityChange={setOverlayOpacity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Toggle mode ────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className={`relative flex flex-col ${className ?? ""}`}>
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center justify-between border-b bg-[var(--card)] px-3 py-1.5">
|
||||
<ZoomControls
|
||||
zoom={zoom}
|
||||
onZoomIn={() => { const c = getViewportCenter(); handleZoom(zoom * ZOOM_STEP, c.x, c.y); }}
|
||||
onZoomOut={() => { const c = getViewportCenter(); handleZoom(zoom / ZOOM_STEP, c.x, c.y); }}
|
||||
onFitToView={fitSharedView}
|
||||
onZoomToPreset={() => {}}
|
||||
/>
|
||||
<span className="font-mono text-[10px] text-[var(--muted-foreground)]">
|
||||
Press <kbd className="rounded bg-[var(--border)] px-1.5 py-0.5">Space</kbd> to toggle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={sharedContainerRef}
|
||||
className="relative flex-1 cursor-grab overflow-hidden bg-[#1a1a1a] active:cursor-grabbing"
|
||||
onClick={() => setToggleShowRight((prev) => !prev)}
|
||||
onPointerDown={handleContainerPointerDown}
|
||||
onPointerMove={handleContainerPointerMove}
|
||||
onPointerUp={handleContainerPointerUp}
|
||||
onPointerLeave={handleContainerPointerUp}
|
||||
onWheel={handleContainerWheel}
|
||||
>
|
||||
{/* Image A */}
|
||||
<img
|
||||
src={leftSrc}
|
||||
alt="Version A"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-0 top-0 select-none"
|
||||
style={makeFlipStyle(flipA, { opacity: toggleShowRight ? 0 : 1 })}
|
||||
/>
|
||||
|
||||
{/* Image B */}
|
||||
<img
|
||||
src={rightSrc}
|
||||
alt="Version B"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-0 top-0 select-none"
|
||||
style={makeFlipStyle(flipB, { opacity: toggleShowRight ? 1 : 0 })}
|
||||
/>
|
||||
|
||||
{/* Active label */}
|
||||
<div className="absolute left-1/2 top-3 z-10 -translate-x-1/2 rounded bg-black/60 px-3 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm transition-all">
|
||||
{toggleShowRight ? "B" : "A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/components/review/create-session-dialog.tsx
Normal file
104
src/components/review/create-session-dialog.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateReviewSession } from "@/hooks/use-review-sessions";
|
||||
|
||||
interface CreateSessionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateSessionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateSessionDialogProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const createMutation = useCreateReviewSession();
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Session name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate(
|
||||
{ name: name.trim(), description: description.trim() || undefined },
|
||||
{
|
||||
onSuccess: (session: any) => {
|
||||
toast.success("Session created");
|
||||
onOpenChange(false);
|
||||
setName("");
|
||||
setDescription("");
|
||||
router.push(`/reviews/${session.id}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error("Failed to create session", { description: err.message }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading text-sm font-semibold">
|
||||
New Review Session
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Session Name
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Q1 Catalog Images Review"
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Optional notes about this session..."
|
||||
className="mt-1 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={createMutation.isPending || !name.trim()}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create Session"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
334
src/components/review/feedback-checklist.tsx
Normal file
334
src/components/review/feedback-checklist.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ClipboardList,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useFeedbackItems,
|
||||
useUpdateFeedback,
|
||||
useResolveFeedback,
|
||||
useVerifyFeedback,
|
||||
useReopenFeedback,
|
||||
useDeleteFeedback,
|
||||
} from "@/hooks/use-feedback";
|
||||
import { FeedbackItemCard } from "./feedback-item-card";
|
||||
import { FeedbackProgressBar } from "./feedback-progress-bar";
|
||||
|
||||
interface FeedbackChecklistProps {
|
||||
stageId: string;
|
||||
revisionId?: string;
|
||||
className?: string;
|
||||
onAnnotationClick?: (annotation: {
|
||||
id: string;
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
}) => void;
|
||||
onAnnotationHover?: (annotationId: string | null) => void;
|
||||
onDeleteAnnotation?: (annotationId: string) => void;
|
||||
}
|
||||
|
||||
type TypeFilter = "ALL" | "ACTION" | "INFO";
|
||||
type StatusFilter = "ALL" | "OPEN" | "RESOLVED";
|
||||
|
||||
export function FeedbackChecklist({
|
||||
stageId,
|
||||
revisionId,
|
||||
className,
|
||||
onAnnotationClick,
|
||||
onAnnotationHover,
|
||||
onDeleteAnnotation,
|
||||
}: FeedbackChecklistProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("ALL");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
||||
|
||||
const { data: items = [], isLoading } = useFeedbackItems(stageId, revisionId);
|
||||
const updateMutation = useUpdateFeedback(stageId);
|
||||
const resolveMutation = useResolveFeedback(stageId);
|
||||
const verifyMutation = useVerifyFeedback(stageId);
|
||||
const reopenMutation = useReopenFeedback(stageId);
|
||||
const deleteMutation = useDeleteFeedback(stageId);
|
||||
|
||||
const isPending =
|
||||
updateMutation.isPending ||
|
||||
resolveMutation.isPending ||
|
||||
verifyMutation.isPending ||
|
||||
reopenMutation.isPending ||
|
||||
deleteMutation.isPending;
|
||||
|
||||
// Filter items
|
||||
const filteredItems = useMemo(() => {
|
||||
let result = [...items];
|
||||
|
||||
if (typeFilter === "ACTION") {
|
||||
result = result.filter((i: any) => i.isActionItem);
|
||||
} else if (typeFilter === "INFO") {
|
||||
result = result.filter((i: any) => !i.isActionItem);
|
||||
}
|
||||
|
||||
if (statusFilter === "OPEN") {
|
||||
result = result.filter(
|
||||
(i: any) =>
|
||||
i.status === "OPEN" ||
|
||||
i.status === "IN_PROGRESS" ||
|
||||
i.status === "REOPENED"
|
||||
);
|
||||
} else if (statusFilter === "RESOLVED") {
|
||||
result = result.filter(
|
||||
(i: any) => i.status === "RESOLVED" || i.status === "VERIFIED"
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, typeFilter, statusFilter]);
|
||||
|
||||
// Separate action items and info callouts
|
||||
const actionItems = useMemo(
|
||||
() => filteredItems.filter((i: any) => i.isActionItem),
|
||||
[filteredItems]
|
||||
);
|
||||
const infoItems = useMemo(
|
||||
() => filteredItems.filter((i: any) => !i.isActionItem),
|
||||
[filteredItems]
|
||||
);
|
||||
|
||||
// Stats (only count action items for progress)
|
||||
const allActionItems = items.filter((i: any) => i.isActionItem);
|
||||
const totalCount = allActionItems.length;
|
||||
const resolvedCount = allActionItems.filter(
|
||||
(i: any) => i.status === "RESOLVED" || i.status === "VERIFIED"
|
||||
).length;
|
||||
const infoCount = items.filter((i: any) => !i.isActionItem).length;
|
||||
|
||||
const handleResolve = (itemId: string, resolutionNote?: string) => {
|
||||
resolveMutation.mutate(
|
||||
{ itemId, data: { resolutionNote } },
|
||||
{
|
||||
onError: (err) => toast.error("Failed to resolve", { description: err.message }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleVerify = (itemId: string) => {
|
||||
verifyMutation.mutate(itemId, {
|
||||
onError: (err) => toast.error("Failed to verify", { description: err.message }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleReopen = (itemId: string) => {
|
||||
reopenMutation.mutate(itemId, {
|
||||
onError: (err) => toast.error("Failed to reopen", { description: err.message }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (itemId: string, annotationId?: string | null) => {
|
||||
deleteMutation.mutate(itemId, {
|
||||
onSuccess: () => {
|
||||
// Also delete the linked annotation from the canvas
|
||||
if (annotationId && onDeleteAnnotation) {
|
||||
onDeleteAnnotation(annotationId);
|
||||
}
|
||||
},
|
||||
onError: (err) => toast.error("Failed to delete", { description: err.message }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleType = (itemId: string, isActionItem: boolean) => {
|
||||
updateMutation.mutate(
|
||||
{ itemId, data: { isActionItem } },
|
||||
{
|
||||
onError: (err) => toast.error("Failed to update", { description: err.message }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateSummary = (itemId: string, summary: string) => {
|
||||
updateMutation.mutate(
|
||||
{ itemId, data: { summary } },
|
||||
{
|
||||
onError: (err) => toast.error("Failed to update", { description: err.message }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[var(--card)] transition-all",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-[var(--muted)]/50"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-3.5 w-3.5 text-[var(--primary)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Feedback
|
||||
</span>
|
||||
{totalCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4 px-1.5 text-[9px] font-bold tabular-nums",
|
||||
resolvedCount === totalCount
|
||||
? "bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
|
||||
: "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
)}
|
||||
>
|
||||
{resolvedCount}/{totalCount}
|
||||
</Badge>
|
||||
)}
|
||||
{infoCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1.5 text-[9px] tabular-nums text-[var(--muted-foreground)]"
|
||||
>
|
||||
{infoCount} info
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
) : (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-3">
|
||||
{/* Progress bar (action items only) */}
|
||||
{totalCount > 0 && (
|
||||
<FeedbackProgressBar
|
||||
resolved={resolvedCount}
|
||||
total={totalCount}
|
||||
className="mb-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{items.length > 0 && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{/* Status filter */}
|
||||
<div className="flex rounded-md border">
|
||||
{(["ALL", "OPEN", "RESOLVED"] as StatusFilter[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-[10px] font-medium transition-colors",
|
||||
statusFilter === s
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
|
||||
)}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
>
|
||||
{s === "ALL" ? "All" : s === "OPEN" ? "Open" : "Done"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Type filter */}
|
||||
<div className="flex rounded-md border">
|
||||
{(["ALL", "ACTION", "INFO"] as TypeFilter[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-[10px] font-medium transition-colors",
|
||||
typeFilter === t
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
|
||||
)}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
>
|
||||
{t === "ALL" ? "All" : t === "ACTION" ? "Actions" : "Info"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item list */}
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
|
||||
Loading feedback items...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
|
||||
No feedback items yet. Annotations will automatically create
|
||||
action items.
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
|
||||
No items match the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Action items first */}
|
||||
{actionItems.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
|
||||
Action Items ({actionItems.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{actionItems.map((item: any) => (
|
||||
<FeedbackItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onResolve={handleResolve}
|
||||
onVerify={handleVerify}
|
||||
onReopen={handleReopen}
|
||||
onDelete={handleDelete}
|
||||
onUpdateSummary={handleUpdateSummary}
|
||||
onToggleType={handleToggleType}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onAnnotationHover={onAnnotationHover}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info callouts */}
|
||||
{infoItems.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
|
||||
Info Callouts ({infoItems.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{infoItems.map((item: any) => (
|
||||
<FeedbackItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onResolve={handleResolve}
|
||||
onVerify={handleVerify}
|
||||
onReopen={handleReopen}
|
||||
onDelete={handleDelete}
|
||||
onUpdateSummary={handleUpdateSummary}
|
||||
onToggleType={handleToggleType}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onAnnotationHover={onAnnotationHover}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
423
src/components/review/feedback-item-card.tsx
Normal file
423
src/components/review/feedback-item-card.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
MapPin,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
ArrowRight,
|
||||
CircleDot,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FeedbackItemData {
|
||||
id: string;
|
||||
summary: string;
|
||||
isActionItem: boolean;
|
||||
status: "OPEN" | "IN_PROGRESS" | "RESOLVED" | "VERIFIED" | "REOPENED";
|
||||
resolutionNote?: string | null;
|
||||
annotation?: {
|
||||
id: string;
|
||||
type: string;
|
||||
data?: any;
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
} | null;
|
||||
carriedFrom?: { id: string; summary: string; revisionId: string } | null;
|
||||
createdBy?: { id: string; name: string | null; email: string } | null;
|
||||
resolvedBy?: { id: string; name: string | null } | null;
|
||||
verifiedBy?: { id: string; name: string | null } | null;
|
||||
assignedTo?: { id: string; name: string | null } | null;
|
||||
}
|
||||
|
||||
interface FeedbackItemCardProps {
|
||||
item: FeedbackItemData;
|
||||
onResolve: (itemId: string, resolutionNote?: string) => void;
|
||||
onVerify: (itemId: string) => void;
|
||||
onReopen: (itemId: string) => void;
|
||||
onDelete: (itemId: string, annotationId?: string | null) => void;
|
||||
onUpdateSummary?: (itemId: string, summary: string) => void;
|
||||
onToggleType?: (itemId: string, isActionItem: boolean) => void;
|
||||
onAnnotationClick?: (annotation: {
|
||||
id: string;
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
}) => void;
|
||||
onAnnotationHover?: (annotationId: string | null) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: "Open",
|
||||
IN_PROGRESS: "In Progress",
|
||||
RESOLVED: "Resolved",
|
||||
VERIFIED: "Verified",
|
||||
REOPENED: "Reopened",
|
||||
};
|
||||
|
||||
export function FeedbackItemCard({
|
||||
item,
|
||||
onResolve,
|
||||
onVerify,
|
||||
onReopen,
|
||||
onDelete,
|
||||
onUpdateSummary,
|
||||
onToggleType,
|
||||
onAnnotationClick,
|
||||
onAnnotationHover,
|
||||
isPending,
|
||||
}: FeedbackItemCardProps) {
|
||||
// Extract annotation color from data (default to accent)
|
||||
const annotationColor = item.annotation?.data?.color ?? null;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(item.summary);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [resolutionNote, setResolutionNote] = useState("");
|
||||
|
||||
const isResolved = item.status === "RESOLVED" || item.status === "VERIFIED";
|
||||
|
||||
const handleResolve = () => {
|
||||
onResolve(item.id, resolutionNote || undefined);
|
||||
setResolutionNote("");
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative rounded-lg border border-l-[3px] p-2.5 transition-all",
|
||||
!annotationColor && item.isActionItem && "border-l-[var(--accent)]",
|
||||
!annotationColor && !item.isActionItem && "border-l-[var(--muted-foreground)]",
|
||||
isResolved && "opacity-60"
|
||||
)}
|
||||
style={annotationColor ? { borderLeftColor: annotationColor } : undefined}
|
||||
onMouseEnter={() => {
|
||||
if (item.annotation && onAnnotationHover) {
|
||||
onAnnotationHover(item.annotation.id);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (onAnnotationHover) {
|
||||
onAnnotationHover(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Checkbox (only for action items) */}
|
||||
{item.isActionItem ? (
|
||||
<Checkbox
|
||||
checked={isResolved}
|
||||
disabled={isPending || item.status === "VERIFIED"}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
if (item.summary.length < 50) {
|
||||
onResolve(item.id);
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
} else {
|
||||
onReopen(item.id);
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-4 px-1 text-[9px] font-bold",
|
||||
!annotationColor && item.isActionItem
|
||||
&& "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]",
|
||||
!annotationColor && !item.isActionItem
|
||||
&& "border-[var(--muted-foreground)]/30 bg-[var(--muted)]/50 text-[var(--muted-foreground)]",
|
||||
)}
|
||||
style={
|
||||
annotationColor
|
||||
? {
|
||||
borderColor: `${annotationColor}30`,
|
||||
backgroundColor: `${annotationColor}18`,
|
||||
color: annotationColor,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.isActionItem ? "Action" : "Info"}
|
||||
</Badge>
|
||||
|
||||
{item.status !== "OPEN" && !isResolved && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 px-1 text-[9px]"
|
||||
>
|
||||
{STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{item.status === "VERIFIED" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CheckCheck className="h-3 w-3 text-[var(--status-approved)]" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Verified{item.verifiedBy?.name ? ` by ${item.verifiedBy.name}` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{item.carriedFrom && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Carried forward from previous round
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-0.5">
|
||||
<textarea
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== item.summary && onUpdateSummary) {
|
||||
onUpdateSummary(item.id, trimmed);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setEditValue(item.summary);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== item.summary && onUpdateSummary) {
|
||||
onUpdateSummary(item.id, trimmed);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
className="w-full resize-none rounded border bg-[var(--background)] px-1.5 py-1 text-xs leading-relaxed text-[var(--foreground)] focus:border-[var(--primary)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-[9px] text-[var(--muted-foreground)]">
|
||||
Enter to save, Esc to cancel
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 cursor-text text-xs leading-relaxed",
|
||||
isResolved && "line-through"
|
||||
)}
|
||||
onDoubleClick={() => {
|
||||
if (!isResolved && onUpdateSummary) {
|
||||
setEditValue(item.summary);
|
||||
setIsEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.summary.replace(/\s+at\s+\(\d+,\s*\d+\)$/, "")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Resolution note display */}
|
||||
{item.resolutionNote && isResolved && (
|
||||
<p className="mt-1 text-[10px] italic text-[var(--muted-foreground)]">
|
||||
Resolution: {item.resolutionNote}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Expanded resolve form */}
|
||||
{expanded && !isResolved && item.isActionItem && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Textarea
|
||||
placeholder="Resolution note (optional)..."
|
||||
value={resolutionNote}
|
||||
onChange={(e) => setResolutionNote(e.target.value)}
|
||||
className="h-16 text-xs"
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
disabled={isPending}
|
||||
onClick={handleResolve}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{item.annotation && onAnnotationClick && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => onAnnotationClick(item.annotation!)}
|
||||
>
|
||||
<MapPin className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Jump to annotation</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Edit summary */}
|
||||
{onUpdateSummary && !isResolved && !isEditing && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => {
|
||||
setEditValue(item.summary);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit comment</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Toggle action item / info */}
|
||||
{onToggleType && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={isPending}
|
||||
onClick={() => onToggleType(item.id, !item.isActionItem)}
|
||||
>
|
||||
{item.isActionItem ? (
|
||||
<Info className="h-3 w-3" />
|
||||
) : (
|
||||
<CircleDot className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{item.isActionItem ? "Change to info callout" : "Change to action item"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{item.isActionItem && !isResolved && !expanded && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Resolve with note</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{item.status === "RESOLVED" && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--status-approved)]"
|
||||
disabled={isPending}
|
||||
onClick={() => onVerify(item.id)}
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Verify fix</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--status-in-review)]"
|
||||
disabled={isPending}
|
||||
onClick={() => onReopen(item.id)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reopen</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--muted-foreground)] hover:text-red-500"
|
||||
disabled={isPending}
|
||||
onClick={() => onDelete(item.id, item.annotation?.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/review/feedback-progress-bar.tsx
Normal file
47
src/components/review/feedback-progress-bar.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FeedbackProgressBarProps {
|
||||
resolved: number;
|
||||
total: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FeedbackProgressBar({
|
||||
resolved,
|
||||
total,
|
||||
className,
|
||||
}: FeedbackProgressBarProps) {
|
||||
const percentage = total > 0 ? Math.round((resolved / total) * 100) : 0;
|
||||
const allDone = resolved === total && total > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Action Items
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium tabular-nums",
|
||||
allDone
|
||||
? "text-[var(--status-approved)]"
|
||||
: "text-[var(--foreground)]"
|
||||
)}
|
||||
>
|
||||
{resolved} of {total} resolved
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-300",
|
||||
allDone ? "bg-[var(--status-approved)]" : "bg-[var(--primary)]"
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
146
src/components/review/image-viewer.tsx
Normal file
146
src/components/review/image-viewer.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"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";
|
||||
|
||||
export interface ImageViewerState {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
imageDimensions: { width: number; height: number } | null;
|
||||
}
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string | null;
|
||||
imageDimensionsOverride?: { width: number; height: number } | null;
|
||||
className?: string;
|
||||
/** Render prop for overlays positioned inside the canvas viewport */
|
||||
renderOverlay?: (state: ImageViewerState) => React.ReactNode;
|
||||
/** Render prop for extra toolbar items (placed after zoom controls) */
|
||||
renderToolbar?: (state: ImageViewerState) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ImageViewer({
|
||||
src,
|
||||
imageDimensionsOverride,
|
||||
className,
|
||||
renderOverlay,
|
||||
renderToolbar,
|
||||
}: ImageViewerProps) {
|
||||
const viewer = useImageViewer();
|
||||
|
||||
// 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;
|
||||
|
||||
const viewerState: ImageViewerState = {
|
||||
zoom: viewer.state.zoom,
|
||||
panX: viewer.state.panX,
|
||||
panY: viewer.state.panY,
|
||||
containerWidth: containerW,
|
||||
containerHeight: containerH,
|
||||
imageDimensions: dims,
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* Extra toolbar (annotation tools) */}
|
||||
{renderToolbar?.(viewerState)}
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
|
||||
{/* Annotation overlay */}
|
||||
{renderOverlay?.(viewerState)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
42
src/components/review/overlay-controls.tsx
Normal file
42
src/components/review/overlay-controls.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface OverlayControlsProps {
|
||||
opacity: number;
|
||||
onOpacityChange: (opacity: number) => void;
|
||||
}
|
||||
|
||||
export function OverlayControls({
|
||||
opacity,
|
||||
onOpacityChange,
|
||||
}: OverlayControlsProps) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onOpacityChange(Number(e.target.value));
|
||||
},
|
||||
[onOpacityChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 items-center gap-3 rounded-lg border bg-[var(--card)]/90 px-4 py-2 shadow-lg backdrop-blur-sm">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Opacity
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={handleChange}
|
||||
className="h-1 w-32 cursor-pointer appearance-none rounded-full bg-[var(--border)] accent-[var(--primary)]
|
||||
[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-[var(--primary)]"
|
||||
/>
|
||||
<span className="min-w-[32px] text-right font-mono text-[10px] text-[var(--muted-foreground)]">
|
||||
{opacity}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
src/components/review/review-sidebar.tsx
Normal file
231
src/components/review/review-sidebar.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
History,
|
||||
MessageSquareText,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Plus,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RevisionTimeline } from "@/components/review/revision-timeline";
|
||||
import { FeedbackChecklist } from "@/components/review/feedback-checklist";
|
||||
|
||||
type SidebarTab = "revisions" | "feedback";
|
||||
|
||||
interface ReviewSidebarProps {
|
||||
stageId: string | null;
|
||||
revisions: any[];
|
||||
activeRevisionId: string | null;
|
||||
onSelectRevision: (revisionId: string, imageUrl: string | null) => void;
|
||||
onCompareRevisions: (leftId: string, rightId: string) => void;
|
||||
onCreateRevision?: () => void;
|
||||
isCreatingRevision?: boolean;
|
||||
onAnnotationClick?: (annotation: {
|
||||
id: string;
|
||||
imageX: number;
|
||||
imageY: number;
|
||||
}) => void;
|
||||
onAnnotationHover?: (annotationId: string | null) => void;
|
||||
onDeleteAnnotation?: (annotationId: string) => void;
|
||||
}
|
||||
|
||||
export function ReviewSidebar({
|
||||
stageId,
|
||||
revisions,
|
||||
activeRevisionId,
|
||||
onSelectRevision,
|
||||
onCompareRevisions,
|
||||
onCreateRevision,
|
||||
isCreatingRevision,
|
||||
onAnnotationClick,
|
||||
onAnnotationHover,
|
||||
onDeleteAnnotation,
|
||||
}: ReviewSidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<SidebarTab>("revisions");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 border-l bg-[var(--card)] px-1.5 py-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="Expand sidebar"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Expand sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="my-1 h-px w-5 bg-[var(--border)]" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
setActiveTab("revisions");
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md transition-colors",
|
||||
activeTab === "revisions"
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
)}
|
||||
aria-label="Revisions"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Revisions
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCollapsed(false);
|
||||
setActiveTab("feedback");
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md transition-colors",
|
||||
activeTab === "feedback"
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
)}
|
||||
aria-label="Feedback"
|
||||
>
|
||||
<MessageSquareText className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Feedback
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[320px] shrink-0 flex-col border-l bg-[var(--card)]">
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center border-b">
|
||||
<div className="flex flex-1">
|
||||
<button
|
||||
onClick={() => setActiveTab("revisions")}
|
||||
className={cn(
|
||||
"relative flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
|
||||
activeTab === "revisions"
|
||||
? "text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
)}
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
Revisions
|
||||
{revisions.length > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 text-[9px] font-bold tabular-nums",
|
||||
activeTab === "revisions"
|
||||
? "bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
: "bg-[var(--foreground)]/5 text-[var(--muted-foreground)]"
|
||||
)}
|
||||
>
|
||||
{revisions.length}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === "revisions" && (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-[2px] rounded-t-full bg-[var(--primary)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("feedback")}
|
||||
className={cn(
|
||||
"relative flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-[10px] font-semibold uppercase tracking-wider transition-colors",
|
||||
activeTab === "feedback"
|
||||
? "text-[var(--primary)]"
|
||||
: "text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
)}
|
||||
>
|
||||
<MessageSquareText className="h-3 w-3" />
|
||||
Feedback
|
||||
{activeTab === "feedback" && (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-[2px] rounded-t-full bg-[var(--primary)]" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="mr-1 h-6 w-6"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-label="Collapse sidebar"
|
||||
>
|
||||
<PanelRightClose className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Collapse
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === "revisions" && (
|
||||
<RevisionTimeline
|
||||
stageId={stageId}
|
||||
revisions={revisions}
|
||||
activeRevisionId={activeRevisionId}
|
||||
onSelectRevision={onSelectRevision}
|
||||
onCompareRevisions={onCompareRevisions}
|
||||
onCreateRevision={onCreateRevision}
|
||||
isCreatingRevision={isCreatingRevision}
|
||||
embedded
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "feedback" && stageId && (
|
||||
<FeedbackChecklist
|
||||
stageId={stageId}
|
||||
revisionId={activeRevisionId ?? undefined}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
onAnnotationHover={onAnnotationHover}
|
||||
onDeleteAnnotation={onDeleteAnnotation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "feedback" && !stageId && (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center">
|
||||
<MessageSquareText className="h-8 w-8 text-[var(--muted-foreground)]/30" />
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Select a stage to view feedback
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
src/components/review/revision-node.tsx
Normal file
267
src/components/review/revision-node.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"use client";
|
||||
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
MessageSquare,
|
||||
PenLine,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
GitCompareArrows,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EnrichedRevision, RevisionStatus } from "@/hooks/use-revision-history";
|
||||
|
||||
// ── Status config ────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_STYLES: Record<RevisionStatus, { label: string; className: string }> = {
|
||||
SUBMITTED: {
|
||||
label: "Submitted",
|
||||
className: "bg-[var(--status-in-progress)]/10 text-[var(--status-in-progress)]",
|
||||
},
|
||||
IN_REVIEW: {
|
||||
label: "In Review",
|
||||
className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]",
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
label: "Changes",
|
||||
className: "bg-[var(--status-blocked)]/10 text-[var(--status-blocked)]",
|
||||
},
|
||||
APPROVED: {
|
||||
label: "Approved",
|
||||
className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_DOT: Record<RevisionStatus, string> = {
|
||||
SUBMITTED: "bg-[var(--status-in-progress)]",
|
||||
IN_REVIEW: "bg-[var(--status-in-review)]",
|
||||
CHANGES_REQUESTED: "bg-[var(--status-blocked)]",
|
||||
APPROVED: "bg-[var(--status-approved)]",
|
||||
};
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RevisionNodeProps {
|
||||
revision: EnrichedRevision;
|
||||
isActive: boolean;
|
||||
isFocused: boolean;
|
||||
isLast: boolean;
|
||||
onSelect: () => void;
|
||||
onAnnotationClick: () => void;
|
||||
onCompareFrom: () => void;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export const RevisionNode = forwardRef<HTMLDivElement, RevisionNodeProps>(
|
||||
function RevisionNode(
|
||||
{
|
||||
revision,
|
||||
isActive,
|
||||
isFocused,
|
||||
isLast,
|
||||
onSelect,
|
||||
onAnnotationClick,
|
||||
onCompareFrom,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const statusStyle = STATUS_STYLES[revision.status];
|
||||
const dotColor = STATUS_DOT[revision.status];
|
||||
const timeAgo = formatDistanceToNow(new Date(revision.createdAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
|
||||
const handleCompareClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onCompareFrom();
|
||||
},
|
||||
[onCompareFrom]
|
||||
);
|
||||
|
||||
const handleAnnotationClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onAnnotationClick();
|
||||
},
|
||||
[onAnnotationClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
className={cn(
|
||||
"group relative flex w-full cursor-pointer gap-3 rounded-md px-2 py-2 text-left transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--primary)]",
|
||||
isActive
|
||||
? "bg-[var(--primary)]/8 ring-1 ring-[var(--primary)]/30"
|
||||
: "hover:bg-[var(--accent)]/50",
|
||||
isFocused && !isActive && "bg-[var(--accent)]/30"
|
||||
)}
|
||||
>
|
||||
{/* ── Timeline rail ────────────────────────────────────────── */}
|
||||
<div className="flex flex-col items-center pt-1">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0 rounded-full border-2",
|
||||
isActive
|
||||
? `${dotColor} border-[var(--primary)]`
|
||||
: `${dotColor}/60 border-[var(--border)]`
|
||||
)}
|
||||
/>
|
||||
{!isLast && (
|
||||
<div className="mt-1 w-px flex-1 bg-[var(--border)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Content ──────────────────────────────────────────────── */}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{/* Header row: round number + badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-xs font-semibold text-[var(--foreground)]">
|
||||
R{revision.roundNumber}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-4 px-1 text-[9px] font-medium uppercase",
|
||||
statusStyle.className
|
||||
)}
|
||||
>
|
||||
{statusStyle.label}
|
||||
</Badge>
|
||||
{revision.isLatest && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 bg-[var(--primary)]/15 px-1 text-[9px] font-medium uppercase text-[var(--primary)]"
|
||||
>
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
{isActive && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 bg-[var(--foreground)]/10 px-1 text-[9px] font-medium uppercase text-[var(--foreground)]"
|
||||
>
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnail + meta row */}
|
||||
<div className="flex gap-2">
|
||||
{revision.thumbnailUrl ? (
|
||||
<img
|
||||
src={revision.thumbnailUrl}
|
||||
alt={`Round ${revision.roundNumber} preview`}
|
||||
className="h-10 w-10 shrink-0 rounded border border-[var(--border)] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded border border-dashed border-[var(--border)] bg-[var(--card)]">
|
||||
<span className="text-[8px] text-[var(--muted-foreground)]">
|
||||
No img
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
{/* Timestamp */}
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{timeAgo}
|
||||
</p>
|
||||
|
||||
{/* Feedback preview */}
|
||||
{revision.feedbackPreview && (
|
||||
<p className="truncate text-[10px] text-[var(--foreground)]/70">
|
||||
{revision.feedbackPreview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats + actions row */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Annotation count */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleAnnotationClick}
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<PenLine className="h-3 w-3" />
|
||||
{revision.annotationCount}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{revision.annotationCount} annotation{revision.annotationCount !== 1 ? "s" : ""} — click to filter
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Comment count (stage-level) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--muted-foreground)]">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{revision.stageCommentCount}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{revision.stageCommentCount} stage comment{revision.stageCommentCount !== 1 ? "s" : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Decision record for approved/rejected */}
|
||||
{revision.status === "APPROVED" && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--status-approved)]">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Approved
|
||||
</span>
|
||||
)}
|
||||
{revision.status === "CHANGES_REQUESTED" && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--status-blocked)]">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Changes
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Compare action — visible on hover */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={handleCompareClick}
|
||||
>
|
||||
<GitCompareArrows className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Compare from this round
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
416
src/components/review/revision-timeline.tsx
Normal file
416
src/components/review/revision-timeline.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Filter,
|
||||
History,
|
||||
Inbox,
|
||||
Plus,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRevisionHistory } from "@/hooks/use-revision-history";
|
||||
import type { EnrichedRevision } from "@/hooks/use-revision-history";
|
||||
import { RevisionNode } from "@/components/review/revision-node";
|
||||
|
||||
// ── Filter options ───────────────────────────────────────────────────────
|
||||
|
||||
type FilterMode = "all" | "with-feedback";
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RevisionTimelineProps {
|
||||
stageId: string | null;
|
||||
revisions: any[];
|
||||
activeRevisionId: string | null;
|
||||
onSelectRevision: (revisionId: string, imageUrl: string | null) => void;
|
||||
onCompareRevisions: (leftId: string, rightId: string) => void;
|
||||
onCreateRevision?: () => void;
|
||||
isCreatingRevision?: boolean;
|
||||
/** When true, renders without outer wrapper/header (for embedding in a parent sidebar) */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function RevisionTimeline({
|
||||
stageId,
|
||||
revisions: _rawRevisions,
|
||||
activeRevisionId,
|
||||
onSelectRevision,
|
||||
onCompareRevisions,
|
||||
onCreateRevision,
|
||||
isCreatingRevision,
|
||||
embedded = false,
|
||||
}: RevisionTimelineProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>("all");
|
||||
const [focusIndex, setFocusIndex] = useState(-1);
|
||||
|
||||
const {
|
||||
revisions: enrichedRevisions,
|
||||
isLoading,
|
||||
isEmpty,
|
||||
} = useRevisionHistory(stageId);
|
||||
|
||||
// ── Filtering ──────────────────────────────────────────────────────────
|
||||
|
||||
const filteredRevisions = useMemo(() => {
|
||||
if (filterMode === "with-feedback") {
|
||||
return enrichedRevisions.filter(
|
||||
(r) => r.feedbackNotes || r.annotationCount > 0
|
||||
);
|
||||
}
|
||||
return enrichedRevisions;
|
||||
}, [enrichedRevisions, filterMode]);
|
||||
|
||||
// ── Refs for keyboard navigation + scroll-into-view ────────────────────
|
||||
|
||||
const nodeRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const setNodeRef = useCallback(
|
||||
(index: number, el: HTMLDivElement | null) => {
|
||||
if (el) nodeRefs.current.set(index, el);
|
||||
else nodeRefs.current.delete(index);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Scroll active revision into view on mount or when active changes
|
||||
useEffect(() => {
|
||||
if (activeRevisionId == null) return;
|
||||
const idx = filteredRevisions.findIndex((r) => r.id === activeRevisionId);
|
||||
if (idx === -1) return;
|
||||
const el = nodeRefs.current.get(idx);
|
||||
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, [activeRevisionId, filteredRevisions]);
|
||||
|
||||
// ── Keyboard navigation ────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || filteredRevisions.length === 0) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip if focus is in an input or textarea
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
// Only handle arrow keys when timeline panel or its children have focus
|
||||
const container = scrollContainerRef.current?.closest(
|
||||
"[data-revision-timeline]"
|
||||
);
|
||||
if (!container?.contains(document.activeElement)) return;
|
||||
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusIndex((prev) => {
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? Math.min(prev + 1, filteredRevisions.length - 1)
|
||||
: Math.max(prev - 1, 0);
|
||||
// Focus and scroll the target node
|
||||
const el = nodeRefs.current.get(next);
|
||||
el?.focus();
|
||||
el?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && focusIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const rev = filteredRevisions[focusIndex];
|
||||
if (rev) {
|
||||
onSelectRevision(rev.id, rev.imageUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [collapsed, filteredRevisions, focusIndex, onSelectRevision]);
|
||||
|
||||
// ── Compare handler ────────────────────────────────────────────────────
|
||||
|
||||
const handleCompareFrom = useCallback(
|
||||
(revision: EnrichedRevision) => {
|
||||
// Compare this revision with the latest
|
||||
const latest = enrichedRevisions.find((r) => r.isLatest);
|
||||
if (latest && latest.id !== revision.id) {
|
||||
onCompareRevisions(revision.id, latest.id);
|
||||
}
|
||||
},
|
||||
[enrichedRevisions, onCompareRevisions]
|
||||
);
|
||||
|
||||
// ── Annotation click — select that revision to filter annotations ──────
|
||||
|
||||
const handleAnnotationClick = useCallback(
|
||||
(revision: EnrichedRevision) => {
|
||||
onSelectRevision(revision.id, revision.imageUrl);
|
||||
},
|
||||
[onSelectRevision]
|
||||
);
|
||||
|
||||
// ── Toggle filter ──────────────────────────────────────────────────────
|
||||
|
||||
const toggleFilter = useCallback(() => {
|
||||
setFilterMode((prev) =>
|
||||
prev === "all" ? "with-feedback" : "all"
|
||||
);
|
||||
setFocusIndex(-1);
|
||||
}, []);
|
||||
|
||||
// ── Collapsed state — standalone only ───────────────────────────────
|
||||
|
||||
if (!embedded && collapsed) {
|
||||
return (
|
||||
<div
|
||||
data-revision-timeline
|
||||
className="flex flex-col items-center border-l bg-[var(--card)] py-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-label="Expand revision history"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Show revision history
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<History className="mt-2 h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Embedded: render just the content, no wrapper ─────────────────────
|
||||
|
||||
const timelineContent = (
|
||||
<>
|
||||
{/* ── Filter bar (embedded gets a compact filter row) ─────── */}
|
||||
{!embedded ? null : enrichedRevisions.length > 0 ? (
|
||||
<div className="flex items-center justify-between border-b px-3 py-1.5">
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{enrichedRevisions.length} round{enrichedRevisions.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
filterMode === "with-feedback" &&
|
||||
"bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
)}
|
||||
onClick={toggleFilter}
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{filterMode === "all" ? "Show with feedback only" : "Show all"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onCreateRevision && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={onCreateRevision}
|
||||
disabled={isCreatingRevision}
|
||||
>
|
||||
{isCreatingRevision ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
New Round
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-2 py-2"
|
||||
role="list"
|
||||
aria-label="Revision rounds"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="space-y-3 px-2 py-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-[var(--border)]" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-3 w-16 animate-pulse rounded bg-[var(--border)]" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-[var(--border)]" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<Inbox className="h-8 w-8 text-[var(--muted-foreground)]/40" />
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
No revisions yet
|
||||
</p>
|
||||
{onCreateRevision ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 h-7 text-xs"
|
||||
onClick={onCreateRevision}
|
||||
disabled={isCreatingRevision}
|
||||
>
|
||||
{isCreatingRevision ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
New Round
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-[10px] text-[var(--muted-foreground)]/60">
|
||||
Submit a revision to start tracking history
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filterMode === "with-feedback" && filteredRevisions.length === 0 && !isEmpty && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<Filter className="h-6 w-6 text-[var(--muted-foreground)]/40" />
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
No rounds with feedback
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setFilterMode("all")}
|
||||
>
|
||||
Show all rounds
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredRevisions.map((revision, index) => (
|
||||
<div key={revision.id} role="listitem">
|
||||
<RevisionNode
|
||||
ref={(el) => setNodeRef(index, el)}
|
||||
revision={revision}
|
||||
isActive={revision.id === activeRevisionId}
|
||||
isFocused={index === focusIndex}
|
||||
isLast={index === filteredRevisions.length - 1}
|
||||
onSelect={() =>
|
||||
onSelectRevision(revision.id, revision.imageUrl)
|
||||
}
|
||||
onAnnotationClick={() => handleAnnotationClick(revision)}
|
||||
onCompareFrom={() => handleCompareFrom(revision)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Embedded: return content directly, no outer wrapper
|
||||
if (embedded) {
|
||||
return (
|
||||
<div data-revision-timeline className="flex flex-1 flex-col overflow-hidden">
|
||||
{timelineContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Standalone: full panel with header ──────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
data-revision-timeline
|
||||
className="flex w-[300px] shrink-0 flex-col border-l bg-[var(--card)]"
|
||||
>
|
||||
{/* ── Panel header ────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<History className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Revision History
|
||||
</span>
|
||||
{enrichedRevisions.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-[var(--foreground)]/10 px-1.5 text-[9px] font-medium text-[var(--muted-foreground)]">
|
||||
{enrichedRevisions.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
filterMode === "with-feedback" &&
|
||||
"bg-[var(--primary)]/10 text-[var(--primary)]"
|
||||
)}
|
||||
onClick={toggleFilter}
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{filterMode === "all" ? "Show with feedback only" : "Show all"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setCollapsed(true)}
|
||||
>
|
||||
<PanelRightClose className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Collapse
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{timelineContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/review/screenshot-callout.tsx
Normal file
160
src/components/review/screenshot-callout.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface ScreenshotCalloutProps {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isSelected: boolean;
|
||||
zoom: number;
|
||||
onSelect: (id: string) => void;
|
||||
onMove: (id: string, x: number, y: number) => void;
|
||||
onResize: (id: string, width: number, height: number) => void;
|
||||
}
|
||||
|
||||
const MIN_SIZE = 40;
|
||||
|
||||
export function ScreenshotCallout({
|
||||
id,
|
||||
imageUrl,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
isSelected,
|
||||
zoom,
|
||||
onSelect,
|
||||
onMove,
|
||||
onResize,
|
||||
}: ScreenshotCalloutProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const dragStart = useRef({ mouseX: 0, mouseY: 0, originX: x, originY: y });
|
||||
const resizeStart = useRef({ mouseX: 0, mouseY: 0, originW: width, originH: height });
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSelect(id);
|
||||
setIsDragging(true);
|
||||
dragStart.current = { mouseX: e.clientX, mouseY: e.clientY, originX: x, originY: y };
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - dragStart.current.mouseX) / zoom;
|
||||
const dy = (me.clientY - dragStart.current.mouseY) / zoom;
|
||||
onMove(id, dragStart.current.originX + dx, dragStart.current.originY + dy);
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
setIsDragging(false);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[id, x, y, zoom, onSelect, onMove]
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
resizeStart.current = {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY,
|
||||
originW: width,
|
||||
originH: height,
|
||||
};
|
||||
|
||||
const handleMove = (me: MouseEvent) => {
|
||||
const dx = (me.clientX - resizeStart.current.mouseX) / zoom;
|
||||
const dy = (me.clientY - resizeStart.current.mouseY) / zoom;
|
||||
const newW = Math.max(MIN_SIZE, resizeStart.current.originW + dx);
|
||||
const newH = Math.max(MIN_SIZE, resizeStart.current.originH + dy);
|
||||
onResize(id, newW, newH);
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
setIsResizing(false);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
[id, width, height, zoom, onResize]
|
||||
);
|
||||
|
||||
const borderWidth = 2;
|
||||
|
||||
return (
|
||||
<foreignObject
|
||||
x={x}
|
||||
y={y}
|
||||
width={width + borderWidth * 2}
|
||||
height={height + borderWidth * 2}
|
||||
style={{ overflow: "visible", pointerEvents: "auto" }}
|
||||
>
|
||||
<div
|
||||
onMouseDown={handleDragStart}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(id);
|
||||
}}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: width,
|
||||
height: height,
|
||||
border: `${borderWidth}px solid ${isSelected ? "#fff" : "rgba(0,0,0,0.6)"}`,
|
||||
boxShadow: isSelected
|
||||
? "0 0 0 1px rgba(255,255,255,0.8), 0 4px 12px rgba(0,0,0,0.5)"
|
||||
: "0 2px 8px rgba(0,0,0,0.4)",
|
||||
borderRadius: "3px",
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
userSelect: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Screenshot annotation"
|
||||
draggable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Resize handle — bottom-right corner */}
|
||||
{isSelected && (
|
||||
<div
|
||||
onMouseDown={handleResizeStart}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: "white",
|
||||
border: "1px solid rgba(0,0,0,0.3)",
|
||||
borderRadius: 2,
|
||||
cursor: "nwse-resize",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</foreignObject>
|
||||
);
|
||||
}
|
||||
478
src/components/review/session-builder.tsx
Normal file
478
src/components/review/session-builder.tsx
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
GripVertical,
|
||||
Trash2,
|
||||
Plus,
|
||||
Wand2,
|
||||
Image as ImageIcon,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
useAddSessionItems,
|
||||
useRemoveSessionItem,
|
||||
useReorderSessionItems,
|
||||
useGenerateSessionItems,
|
||||
} from "@/hooks/use-review-sessions";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionItem {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
decision: string | null;
|
||||
decisionNote: string | null;
|
||||
deliverableStage: {
|
||||
id: string;
|
||||
status: string;
|
||||
template: { id: string; name: string; slug: string; order: number };
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: string;
|
||||
project: { id: string; name: string; projectCode: string };
|
||||
};
|
||||
revisions: { id: string; roundNumber: number; attachments: any }[];
|
||||
assignments: { user: { id: string; name: string; image: string | null } }[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionBuilderProps {
|
||||
sessionId: string;
|
||||
items: SessionItem[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// ── Stage status options for the generate filter ──────────────────────────
|
||||
|
||||
const STAGE_STATUSES = [
|
||||
{ value: "IN_REVIEW", label: "In Review" },
|
||||
{ value: "CHANGES_REQUESTED", label: "Changes Requested" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "DELIVERED", label: "Delivered" },
|
||||
];
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function SessionBuilder({
|
||||
sessionId,
|
||||
items,
|
||||
isLoading,
|
||||
}: SessionBuilderProps) {
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [genProjectId, setGenProjectId] = useState("");
|
||||
const [genStatus, setGenStatus] = useState("");
|
||||
const [genCandidates, setGenCandidates] = useState<any[] | null>(null);
|
||||
|
||||
const addItemsMutation = useAddSessionItems(sessionId);
|
||||
const removeItemMutation = useRemoveSessionItem(sessionId);
|
||||
const reorderMutation = useReorderSessionItems(sessionId);
|
||||
const generateMutation = useGenerateSessionItems(sessionId);
|
||||
|
||||
const { data: projectsData } = useProjects();
|
||||
const projects = (projectsData as any[]) ?? [];
|
||||
|
||||
// ── Drag-and-drop reorder (simplified: move up/down buttons) ────────────
|
||||
|
||||
const moveItem = useCallback(
|
||||
(index: number, direction: -1 | 1) => {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= items.length) return;
|
||||
|
||||
const reordered = [...items];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
|
||||
reorderMutation.mutate(reordered.map((i) => i.id));
|
||||
},
|
||||
[items, reorderMutation]
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(itemId: string) => {
|
||||
removeItemMutation.mutate(itemId, {
|
||||
onError: (err) =>
|
||||
toast.error("Failed to remove item", { description: err.message }),
|
||||
});
|
||||
},
|
||||
[removeItemMutation]
|
||||
);
|
||||
|
||||
// ── Generate from filters ───────────────────────────────────────────────
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
if (!genProjectId) {
|
||||
toast.error("Select a project");
|
||||
return;
|
||||
}
|
||||
|
||||
generateMutation.mutate(
|
||||
{
|
||||
projectId: genProjectId,
|
||||
stageStatus: genStatus || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (data: any) => {
|
||||
setGenCandidates(data);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error("Failed to generate", { description: err.message }),
|
||||
}
|
||||
);
|
||||
}, [genProjectId, genStatus, generateMutation]);
|
||||
|
||||
const handleAddGenerated = useCallback(() => {
|
||||
if (!genCandidates || genCandidates.length === 0) return;
|
||||
|
||||
// Filter out stages already in the session
|
||||
const existingStageIds = new Set(
|
||||
items.map((i) => i.deliverableStage.id)
|
||||
);
|
||||
const newItems = genCandidates.filter(
|
||||
(c) => !existingStageIds.has(c.deliverableStageId)
|
||||
);
|
||||
|
||||
if (newItems.length === 0) {
|
||||
toast.info("All matching items are already in the session");
|
||||
setGenerateOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
addItemsMutation.mutate(
|
||||
newItems.map((c) => ({
|
||||
deliverableStageId: c.deliverableStageId,
|
||||
revisionId: c.revisionId,
|
||||
})),
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Added ${newItems.length} items`);
|
||||
setGenerateOpen(false);
|
||||
setGenCandidates(null);
|
||||
setGenProjectId("");
|
||||
setGenStatus("");
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error("Failed to add items", { description: err.message }),
|
||||
}
|
||||
);
|
||||
}, [genCandidates, items, addItemsMutation]);
|
||||
|
||||
// ── Thumbnail helper ────────────────────────────────────────────────────
|
||||
|
||||
const getThumbnail = (item: SessionItem) => {
|
||||
const rev = item.deliverableStage.revisions?.[0];
|
||||
if (!rev?.attachments) return null;
|
||||
const att = rev.attachments as any;
|
||||
const img = att.currentImage ?? att.referenceImage;
|
||||
return img?.url ?? null;
|
||||
};
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-14 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* ── Toolbar ────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Session Items ({items.length})
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setGenerateOpen(true)}
|
||||
>
|
||||
<Wand2 className="mr-1 h-3 w-3" />
|
||||
Auto-Fill
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Item list ──────────────────────────────────────────── */}
|
||||
{items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<Plus className="h-8 w-8 text-[var(--muted-foreground)]/30" />
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
No items in this session yet
|
||||
</p>
|
||||
<p className="max-w-xs text-[10px] text-[var(--muted-foreground)]/60">
|
||||
Use Auto-Fill to add deliverable stages from a project, or add them
|
||||
individually from the deliverable review page.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2 h-7 text-xs"
|
||||
onClick={() => setGenerateOpen(true)}
|
||||
>
|
||||
<Wand2 className="mr-1.5 h-3 w-3" />
|
||||
Auto-Fill from Project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{items.map((item, index) => {
|
||||
const thumb = getThumbnail(item);
|
||||
const stage = item.deliverableStage;
|
||||
const deliverable = stage.deliverable;
|
||||
const artists = stage.assignments?.map((a) => a.user.name).join(", ");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 border-b px-4 py-2.5 transition-colors hover:bg-[var(--background)]/50",
|
||||
item.decision === "APPROVED" &&
|
||||
"bg-[var(--status-approved)]/5",
|
||||
item.decision === "CHANGES_REQUESTED" &&
|
||||
"bg-[var(--status-in-review)]/5"
|
||||
)}
|
||||
>
|
||||
{/* Drag handle / order number */}
|
||||
<div className="flex w-6 shrink-0 flex-col items-center gap-0.5">
|
||||
<span className="text-[10px] font-mono text-[var(--muted-foreground)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<GripVertical className="h-3 w-3 text-[var(--muted-foreground)]/40" />
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="h-10 w-14 shrink-0 overflow-hidden rounded border bg-[var(--muted)]/20">
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-[var(--muted-foreground)]/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-xs font-medium">
|
||||
{deliverable.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
— {stage.template.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{deliverable.project.projectCode}
|
||||
</span>
|
||||
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
|
||||
{artists && (
|
||||
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
|
||||
{artists}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision badge */}
|
||||
{item.decision && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"shrink-0 text-[9px] uppercase",
|
||||
item.decision === "APPROVED"
|
||||
? "bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
|
||||
: "bg-amber-500/10 text-amber-600"
|
||||
)}
|
||||
>
|
||||
{item.decision === "APPROVED" ? "Approved" : "Changes"}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Move / Remove actions */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveItem(index, -1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 rotate-180" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => moveItem(index, 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-red-500 hover:text-red-600"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
aria-label="Remove"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Auto-fill dialog ───────────────────────────────────── */}
|
||||
<Dialog open={generateOpen} onOpenChange={setGenerateOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading text-sm font-semibold">
|
||||
Auto-Fill from Project
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Project
|
||||
</label>
|
||||
<Select value={genProjectId} onValueChange={setGenProjectId}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select a project..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p: any) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.projectCode} — {p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Stage Status (optional)
|
||||
</label>
|
||||
<Select value={genStatus} onValueChange={setGenStatus}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Any status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Any status</SelectItem>
|
||||
{STAGE_STATUSES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!genCandidates && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={generateMutation.isPending || !genProjectId}
|
||||
>
|
||||
{generateMutation.isPending ? "Searching..." : "Find Matching Stages"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{genCandidates && (
|
||||
<div className="space-y-2">
|
||||
<Separator />
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Found {genCandidates.length} matching{" "}
|
||||
{genCandidates.length === 1 ? "stage" : "stages"}
|
||||
</p>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{genCandidates.map((c: any) => (
|
||||
<div
|
||||
key={c.deliverableStageId}
|
||||
className="rounded border px-2 py-1.5 text-xs"
|
||||
>
|
||||
{c.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{genCandidates.length === 0 && (
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
No stages match the selected filters.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGenerateOpen(false);
|
||||
setGenCandidates(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{genCandidates && genCandidates.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddGenerated}
|
||||
disabled={addItemsMutation.isPending}
|
||||
>
|
||||
{addItemsMutation.isPending
|
||||
? "Adding..."
|
||||
: `Add ${genCandidates.length} Items`}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
569
src/components/review/session-presenter.tsx
Normal file
569
src/components/review/session-presenter.tsx
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Check,
|
||||
RotateCcw,
|
||||
MessageSquare,
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
|
||||
import { ImageViewer, type ImageViewerState } from "@/components/review/image-viewer";
|
||||
import { AnnotationLayer } from "@/components/review/annotation-layer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useRecordDecision,
|
||||
useClearDecision,
|
||||
} from "@/hooks/use-review-sessions";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionItem {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
decision: string | null;
|
||||
decisionNote: string | null;
|
||||
decidedBy: { id: string; name: string; image: string | null } | null;
|
||||
decidedAt: string | null;
|
||||
revisionId: string | null;
|
||||
deliverableStage: {
|
||||
id: string;
|
||||
status: string;
|
||||
template: { id: string; name: string; slug: string; order: number };
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: string;
|
||||
project: { id: string; name: string; projectCode: string };
|
||||
};
|
||||
revisions: {
|
||||
id: string;
|
||||
roundNumber: number;
|
||||
status: string;
|
||||
attachments: any;
|
||||
}[];
|
||||
assignments: {
|
||||
user: { id: string; name: string; image: string | null };
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionPresenterProps {
|
||||
sessionId: string;
|
||||
items: SessionItem[];
|
||||
sessionName: string;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function SessionPresenter({
|
||||
sessionId,
|
||||
items,
|
||||
sessionName,
|
||||
onExit,
|
||||
}: SessionPresenterProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [decisionNote, setDecisionNote] = useState("");
|
||||
const [showNote, setShowNote] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const recordDecision = useRecordDecision(sessionId);
|
||||
const clearDecision = useClearDecision(sessionId);
|
||||
|
||||
const currentItem = items[currentIndex] ?? null;
|
||||
|
||||
// ── Image URL ───────────────────────────────────────────────────────────
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!currentItem) return null;
|
||||
const rev = currentItem.deliverableStage.revisions?.[0];
|
||||
if (!rev?.attachments) return null;
|
||||
const att = rev.attachments as any;
|
||||
return att.currentImage?.url ?? att.referenceImage?.url ?? null;
|
||||
}, [currentItem]);
|
||||
|
||||
const revisionId = useMemo(() => {
|
||||
if (!currentItem) return null;
|
||||
return (
|
||||
currentItem.revisionId ??
|
||||
currentItem.deliverableStage.revisions?.[0]?.id ??
|
||||
null
|
||||
);
|
||||
}, [currentItem]);
|
||||
|
||||
// ── Progress ────────────────────────────────────────────────────────────
|
||||
|
||||
const progress = useMemo(() => {
|
||||
const decided = items.filter((i) => i.decision != null).length;
|
||||
return { decided, total: items.length };
|
||||
}, [items]);
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────────
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < items.length) {
|
||||
setCurrentIndex(index);
|
||||
setDecisionNote("");
|
||||
setShowNote(false);
|
||||
}
|
||||
},
|
||||
[items.length]
|
||||
);
|
||||
|
||||
const goPrev = useCallback(() => goTo(currentIndex - 1), [currentIndex, goTo]);
|
||||
const goNext = useCallback(() => goTo(currentIndex + 1), [currentIndex, goTo]);
|
||||
|
||||
// ── Decisions ───────────────────────────────────────────────────────────
|
||||
|
||||
const handleDecision = useCallback(
|
||||
(decision: "APPROVED" | "CHANGES_REQUESTED") => {
|
||||
if (!currentItem) return;
|
||||
|
||||
recordDecision.mutate(
|
||||
{
|
||||
itemId: currentItem.id,
|
||||
decision,
|
||||
decisionNote: decisionNote.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
decision === "APPROVED" ? "Approved" : "Changes requested"
|
||||
);
|
||||
setDecisionNote("");
|
||||
setShowNote(false);
|
||||
// Auto-advance to next undecided item
|
||||
const nextUndecided = items.findIndex(
|
||||
(item, idx) => idx > currentIndex && item.decision == null
|
||||
);
|
||||
if (nextUndecided !== -1) {
|
||||
goTo(nextUndecided);
|
||||
} else if (currentIndex < items.length - 1) {
|
||||
goNext();
|
||||
}
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error("Failed to record decision", {
|
||||
description: err.message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
},
|
||||
[currentItem, currentIndex, decisionNote, recordDecision, items, goTo, goNext]
|
||||
);
|
||||
|
||||
const handleClearDecision = useCallback(() => {
|
||||
if (!currentItem) return;
|
||||
clearDecision.mutate(currentItem.id, {
|
||||
onSuccess: () => toast.success("Decision cleared"),
|
||||
onError: (err) =>
|
||||
toast.error("Failed", { description: err.message }),
|
||||
});
|
||||
}, [currentItem, clearDecision]);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
goPrev();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
goNext();
|
||||
break;
|
||||
case "a":
|
||||
e.preventDefault();
|
||||
handleDecision("APPROVED");
|
||||
break;
|
||||
case "c":
|
||||
e.preventDefault();
|
||||
handleDecision("CHANGES_REQUESTED");
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
onExit();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [goPrev, goNext, handleDecision, onExit]);
|
||||
|
||||
// ── Fullscreen ──────────────────────────────────────────────────────────
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", handler);
|
||||
return () => document.removeEventListener("fullscreenchange", handler);
|
||||
}, []);
|
||||
|
||||
if (!currentItem) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
|
||||
No items to present.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stage = currentItem.deliverableStage;
|
||||
const deliverable = stage.deliverable;
|
||||
const latestRev = stage.revisions?.[0];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-[var(--background)]">
|
||||
{/* ── Top bar ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
{/* Left: session info + navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={onExit}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Exit
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{sessionName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Center: item navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={currentIndex <= 0}
|
||||
onClick={goPrev}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="font-mono text-xs text-[var(--muted-foreground)]">
|
||||
{currentIndex + 1} / {items.length}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={currentIndex >= items.length - 1}
|
||||
onClick={goNext}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
{/* Progress */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--primary)] transition-all"
|
||||
style={{
|
||||
width: `${progress.total > 0 ? (progress.decided / progress.total) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
{progress.decided}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: fullscreen toggle */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content: viewer + info panel ────────────────── */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Image viewer */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{imageUrl ? (
|
||||
<ImageViewer
|
||||
src={imageUrl}
|
||||
className="flex-1"
|
||||
renderOverlay={(vs: ImageViewerState) => (
|
||||
<AnnotationLayer
|
||||
revisionId={revisionId}
|
||||
stageId={stage.id}
|
||||
zoom={vs.zoom}
|
||||
panX={vs.panX}
|
||||
panY={vs.panY}
|
||||
containerWidth={vs.containerWidth}
|
||||
containerHeight={vs.containerHeight}
|
||||
imageDimensions={vs.imageDimensions}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center bg-[var(--muted)]/10">
|
||||
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]/40">
|
||||
<ImageIcon className="h-12 w-12" />
|
||||
<span className="text-xs">No image uploaded</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right info panel ──────────────────────────────── */}
|
||||
<div className="flex w-[320px] shrink-0 flex-col border-l bg-[var(--card)]">
|
||||
{/* Item details */}
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
{deliverable.project.projectCode}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-1 font-heading text-sm font-semibold">
|
||||
{deliverable.name}
|
||||
</h2>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{stage.template.name}
|
||||
</span>
|
||||
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
|
||||
</div>
|
||||
{latestRev && (
|
||||
<div className="mt-1 text-[10px] text-[var(--muted-foreground)]">
|
||||
Round {latestRev.roundNumber} — {latestRev.status.replace("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
{stage.assignments?.length > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<span className="text-[10px] text-[var(--muted-foreground)]">
|
||||
Assigned:
|
||||
</span>
|
||||
{stage.assignments.map((a) => (
|
||||
<Badge
|
||||
key={a.user.id}
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{a.user.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decision area */}
|
||||
<div className="flex flex-1 flex-col justify-end">
|
||||
{/* Current decision display */}
|
||||
{currentItem.decision && (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-3 rounded-lg px-3 py-2",
|
||||
currentItem.decision === "APPROVED"
|
||||
? "bg-[var(--status-approved)]/10"
|
||||
: "bg-amber-500/10"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"text-[10px] uppercase",
|
||||
currentItem.decision === "APPROVED"
|
||||
? "bg-[var(--status-approved)]/20 text-[var(--status-approved)]"
|
||||
: "bg-amber-500/20 text-amber-600"
|
||||
)}
|
||||
>
|
||||
{currentItem.decision === "APPROVED"
|
||||
? "Approved"
|
||||
: "Changes Requested"}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={handleClearDecision}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
{currentItem.decisionNote && (
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{currentItem.decisionNote}
|
||||
</p>
|
||||
)}
|
||||
{currentItem.decidedBy && (
|
||||
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">
|
||||
by {currentItem.decidedBy.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision buttons */}
|
||||
<div className="border-t px-4 py-3">
|
||||
{showNote && (
|
||||
<div className="mb-2">
|
||||
<Textarea
|
||||
value={decisionNote}
|
||||
onChange={(e) => setDecisionNote(e.target.value)}
|
||||
placeholder="Add a note (optional)..."
|
||||
className="resize-none text-xs"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 flex-1 text-xs text-amber-600 hover:bg-amber-500/10 hover:text-amber-700"
|
||||
onClick={() => handleDecision("CHANGES_REQUESTED")}
|
||||
disabled={recordDecision.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Changes
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 flex-1 bg-[var(--status-approved)] text-xs text-white hover:bg-[var(--status-approved)]/90"
|
||||
onClick={() => handleDecision("APPROVED")}
|
||||
disabled={recordDecision.isPending}
|
||||
>
|
||||
<Check className="mr-1.5 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px] text-[var(--muted-foreground)]"
|
||||
onClick={() => setShowNote(!showNote)}
|
||||
>
|
||||
<MessageSquare className="mr-1 h-2.5 w-2.5" />
|
||||
{showNote ? "Hide note" : "Add note"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-[9px] text-[var(--muted-foreground)]/60">
|
||||
Keyboard: A = approve, C = changes, ←→ = navigate, Esc = exit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom thumbnail strip ───────────────────────────── */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-t px-3 py-1.5">
|
||||
{items.map((item, idx) => {
|
||||
const rev = item.deliverableStage.revisions?.[0];
|
||||
const att = rev?.attachments as any;
|
||||
const thumbUrl =
|
||||
att?.currentImage?.url ?? att?.referenceImage?.url ?? null;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => goTo(idx)}
|
||||
className={cn(
|
||||
"relative h-10 w-14 shrink-0 overflow-hidden rounded border transition-all",
|
||||
idx === currentIndex
|
||||
? "border-[var(--primary)] ring-1 ring-[var(--primary)]"
|
||||
: "border-transparent opacity-60 hover:opacity-100",
|
||||
item.decision === "APPROVED" &&
|
||||
idx !== currentIndex &&
|
||||
"border-[var(--status-approved)]/50",
|
||||
item.decision === "CHANGES_REQUESTED" &&
|
||||
idx !== currentIndex &&
|
||||
"border-amber-500/50"
|
||||
)}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-[var(--muted)]/20">
|
||||
<ImageIcon className="h-3 w-3 text-[var(--muted-foreground)]/30" />
|
||||
</div>
|
||||
)}
|
||||
{/* Decision dot */}
|
||||
{item.decision && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0.5 top-0.5 h-2 w-2 rounded-full",
|
||||
item.decision === "APPROVED"
|
||||
? "bg-[var(--status-approved)]"
|
||||
: "bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-xs">
|
||||
{item.deliverableStage.deliverable.name} —{" "}
|
||||
{item.deliverableStage.template.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/review/session-summary.tsx
Normal file
160
src/components/review/session-summary.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Check,
|
||||
RotateCcw,
|
||||
Clock,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SessionItem {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
decision: string | null;
|
||||
decisionNote: string | null;
|
||||
decidedBy: { id: string; name: string; image: string | null } | null;
|
||||
deliverableStage: {
|
||||
id: string;
|
||||
status: string;
|
||||
template: { id: string; name: string; order: number };
|
||||
deliverable: {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: string;
|
||||
project: { id: string; name: string; projectCode: string };
|
||||
};
|
||||
revisions: { id: string; roundNumber: number; attachments: any }[];
|
||||
assignments: { user: { id: string; name: string; image: string | null } }[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionSummaryProps {
|
||||
items: SessionItem[];
|
||||
onItemClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function SessionSummary({ items, onItemClick }: SessionSummaryProps) {
|
||||
const stats = useMemo(() => {
|
||||
const approved = items.filter((i) => i.decision === "APPROVED").length;
|
||||
const changes = items.filter(
|
||||
(i) => i.decision === "CHANGES_REQUESTED"
|
||||
).length;
|
||||
const pending = items.filter((i) => i.decision == null).length;
|
||||
return { approved, changes, pending, total: items.length };
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Stats strip ──────────────────────────────────────── */}
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--status-approved)]/10">
|
||||
<Check className="h-3 w-3 text-[var(--status-approved)]" />
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{stats.approved} Approved
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<RotateCcw className="h-3 w-3 text-amber-600" />
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{stats.changes} Changes
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--muted)]/50">
|
||||
<Clock className="h-3 w-3 text-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
<span className="text-xs font-medium">
|
||||
{stats.pending} Pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Thumbnail grid ───────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{items.map((item, idx) => {
|
||||
const rev = item.deliverableStage.revisions?.[0];
|
||||
const att = rev?.attachments as any;
|
||||
const thumbUrl =
|
||||
att?.currentImage?.url ?? att?.referenceImage?.url ?? null;
|
||||
const stage = item.deliverableStage;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onItemClick?.(idx)}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-lg border bg-[var(--card)] text-left transition-all hover:shadow-md",
|
||||
item.decision === "APPROVED" &&
|
||||
"border-[var(--status-approved)]/40",
|
||||
item.decision === "CHANGES_REQUESTED" &&
|
||||
"border-amber-500/40",
|
||||
item.decision == null && "border-[var(--border)]"
|
||||
)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-[4/3] overflow-hidden bg-[var(--muted)]/10">
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ImageIcon className="h-6 w-6 text-[var(--muted-foreground)]/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decision overlay badge */}
|
||||
{item.decision && (
|
||||
<div className="absolute right-1 top-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"text-[9px] uppercase shadow-sm",
|
||||
item.decision === "APPROVED"
|
||||
? "bg-[var(--status-approved)] text-white"
|
||||
: "bg-amber-500 text-white"
|
||||
)}
|
||||
>
|
||||
{item.decision === "APPROVED" ? (
|
||||
<Check className="mr-0.5 h-2.5 w-2.5" />
|
||||
) : (
|
||||
<RotateCcw className="mr-0.5 h-2.5 w-2.5" />
|
||||
)}
|
||||
{item.decision === "APPROVED" ? "OK" : "Changes"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-[11px] font-medium">
|
||||
{stage.deliverable.name}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
|
||||
{stage.template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/components/review/wipe-divider.tsx
Normal file
190
src/components/review/wipe-divider.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface WipeDividerProps {
|
||||
/** Left image (A) source URL */
|
||||
leftSrc: string;
|
||||
/** Right image (B) source URL */
|
||||
rightSrc: string;
|
||||
/** Mirror image A horizontally */
|
||||
flipA?: boolean;
|
||||
/** Mirror image B horizontally */
|
||||
flipB?: boolean;
|
||||
/** Image natural dimensions (for centered flip) */
|
||||
imgWidth?: number;
|
||||
imgHeight?: number;
|
||||
/** Shared zoom level */
|
||||
zoom: number;
|
||||
/** Shared pan X offset */
|
||||
panX: number;
|
||||
/** Shared pan Y offset */
|
||||
panY: number;
|
||||
/** Called when the user drags to pan */
|
||||
onPan: (panX: number, panY: number) => void;
|
||||
/** Called when the user scrolls to zoom */
|
||||
onZoom: (newZoom: number, centerX: number, centerY: number) => void;
|
||||
}
|
||||
|
||||
const ZOOM_STEP = 1.15;
|
||||
|
||||
export function WipeDivider({
|
||||
leftSrc,
|
||||
rightSrc,
|
||||
flipA = false,
|
||||
flipB = false,
|
||||
imgWidth = 0,
|
||||
imgHeight = 0,
|
||||
zoom,
|
||||
panX,
|
||||
panY,
|
||||
onPan,
|
||||
onZoom,
|
||||
}: WipeDividerProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dividerPercent, setDividerPercent] = useState(50);
|
||||
const isDraggingDivider = useRef(false);
|
||||
const isPanning = useRef(false);
|
||||
const lastMouse = useRef({ x: 0, y: 0 });
|
||||
const panRef = useRef({ x: panX, y: panY });
|
||||
panRef.current = { x: panX, y: panY };
|
||||
|
||||
const handleDividerPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDraggingDivider.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (isDraggingDivider.current) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const pct = Math.max(0, Math.min(100, (x / rect.width) * 100));
|
||||
setDividerPercent(pct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPanning.current) {
|
||||
const dx = e.clientX - lastMouse.current.x;
|
||||
const dy = e.clientY - lastMouse.current.y;
|
||||
lastMouse.current = { x: e.clientX, y: e.clientY };
|
||||
onPan(panRef.current.x + dx, panRef.current.y + dy);
|
||||
}
|
||||
},
|
||||
[onPan]
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
// Only start pan if not on the divider handle
|
||||
if (isDraggingDivider.current) return;
|
||||
if (e.button !== 0) return;
|
||||
isPanning.current = true;
|
||||
lastMouse.current = { x: e.clientX, y: e.clientY };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
isDraggingDivider.current = false;
|
||||
isPanning.current = false;
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
|
||||
onZoom(zoom * factor, mouseX, mouseY);
|
||||
},
|
||||
[zoom, onZoom]
|
||||
);
|
||||
|
||||
const imgTransform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||||
const imgCenterX = panX + (imgWidth * zoom) / 2;
|
||||
const imgCenterY = panY + (imgHeight * zoom) / 2;
|
||||
|
||||
function makeFlipStyle(flipped: boolean): React.CSSProperties {
|
||||
if (!flipped) return { transform: imgTransform };
|
||||
return {
|
||||
transform: `${imgTransform} scaleX(-1)`,
|
||||
transformOrigin: `${imgCenterX}px ${imgCenterY}px`,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full cursor-grab overflow-hidden bg-[#1a1a1a] active:cursor-grabbing"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Left image (A) — full frame, visible behind the right clipped layer */}
|
||||
<img
|
||||
src={leftSrc}
|
||||
alt="Version A"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-0 top-0 select-none"
|
||||
style={makeFlipStyle(flipA)}
|
||||
/>
|
||||
|
||||
{/* Right image (B) — clipped to reveal from divider rightward */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
clipPath: `inset(0 ${100 - dividerPercent}% 0 0)`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={rightSrc}
|
||||
alt="Version B"
|
||||
draggable={false}
|
||||
className="pointer-events-none absolute left-0 top-0 select-none"
|
||||
style={makeFlipStyle(flipB)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider handle */}
|
||||
<div
|
||||
className="absolute top-0 z-10 h-full"
|
||||
style={{ left: `${dividerPercent}%`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
{/* Line */}
|
||||
<div className="h-full w-px bg-white/80 shadow-[0_0_4px_rgba(0,0,0,0.5)]" />
|
||||
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 z-20 flex h-10 w-6 -translate-x-1/2 -translate-y-1/2 cursor-col-resize items-center justify-center rounded-full border border-white/30 bg-[var(--card)]/90 shadow-lg backdrop-blur-sm"
|
||||
onPointerDown={handleDividerPointerDown}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="h-4 w-px rounded-full bg-[var(--muted-foreground)]" />
|
||||
<div className="h-4 w-px rounded-full bg-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
|
||||
A
|
||||
</div>
|
||||
<div className="absolute right-3 top-3 z-10 rounded bg-black/60 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white/90 backdrop-blur-sm">
|
||||
B
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
53
src/components/stages/feedback-indicator.tsx
Normal file
53
src/components/stages/feedback-indicator.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFeedbackSummary } from "@/hooks/use-feedback";
|
||||
|
||||
interface FeedbackIndicatorProps {
|
||||
stageId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FeedbackIndicator({
|
||||
stageId,
|
||||
className,
|
||||
}: FeedbackIndicatorProps) {
|
||||
const { data: summary } = useFeedbackSummary(stageId);
|
||||
|
||||
if (!summary || summary.total === 0) return null;
|
||||
|
||||
const allDone = summary.resolved === summary.total;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-4 gap-0.5 px-1 text-[9px] font-bold tabular-nums",
|
||||
allDone
|
||||
? "border-[var(--status-approved)]/30 bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
|
||||
: "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{summary.resolved}/{summary.total}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{allDone
|
||||
? "All action items resolved"
|
||||
: `${summary.open} open action item${summary.open !== 1 ? "s" : ""}`}
|
||||
{summary.infoCount > 0 && ` · ${summary.infoCount} info callout${summary.infoCount !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border p-6 shadow-[var(--shadow-lg)] duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
657
src/hooks/use-annotation-state.ts
Normal file
657
src/hooks/use-annotation-state.ts
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { AnnotationShape } from "@/components/review/annotation-renderer";
|
||||
import type { AnnotationTool } from "@/components/review/annotation-tools";
|
||||
import {
|
||||
useAnnotations,
|
||||
useCreateAnnotation,
|
||||
useDeleteAnnotation,
|
||||
useUpdateAnnotation,
|
||||
} from "@/hooks/use-annotations";
|
||||
import type { AnnotationTypeValue, CreateAnnotationInput } from "@/lib/validators/annotation";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────
|
||||
|
||||
interface DrawingState {
|
||||
type: AnnotationTypeValue;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
/** Pending annotation waiting for user comment before saving */
|
||||
export interface PendingAnnotation {
|
||||
type: AnnotationTypeValue;
|
||||
data: Record<string, any>;
|
||||
imgX: number;
|
||||
imgY: number;
|
||||
/** Screen position where the comment input should appear */
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
}
|
||||
|
||||
interface UndoEntry {
|
||||
action: "create" | "delete";
|
||||
annotationId: string;
|
||||
input?: CreateAnnotationInput;
|
||||
}
|
||||
|
||||
const TOOL_TO_TYPE: Record<Exclude<AnnotationTool, "move" | "eyedropper">, AnnotationTypeValue> = {
|
||||
rectangle: "RECTANGLE",
|
||||
ellipse: "ELLIPSE",
|
||||
arrow: "ARROW",
|
||||
freehand: "FREEHAND",
|
||||
text: "TEXT",
|
||||
pin: "PIN",
|
||||
};
|
||||
|
||||
function screenToImage(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - panX) / zoom,
|
||||
y: (screenY - panY) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────
|
||||
|
||||
export function useAnnotationState(
|
||||
revisionId: string | null,
|
||||
stageId: string | null
|
||||
) {
|
||||
const [activeTool, setActiveTool] = useState<AnnotationTool>("move");
|
||||
const [color, setColor] = useState("#EE5540");
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState<DrawingState | null>(null);
|
||||
const [textInput, setTextInput] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
imgX: number;
|
||||
imgY: number;
|
||||
} | null>(null);
|
||||
const [textValue, setTextValue] = useState("");
|
||||
const [undoStack, setUndoStack] = useState<UndoEntry[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<UndoEntry[]>([]);
|
||||
const [pendingAnnotation, setPendingAnnotation] = useState<PendingAnnotation | null>(null);
|
||||
const [commentValue, setCommentValue] = useState("");
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const textInputRef = useRef<HTMLInputElement>(null);
|
||||
const commentInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Data hooks
|
||||
const { data: annotationsRaw } = useAnnotations(revisionId);
|
||||
const annotations = (annotationsRaw as any[]) ?? [];
|
||||
const createMutation = useCreateAnnotation(revisionId);
|
||||
const deleteMutation = useDeleteAnnotation(revisionId);
|
||||
const updateMutation = useUpdateAnnotation(revisionId);
|
||||
|
||||
// Derived shapes
|
||||
const annotationShapes: AnnotationShape[] = useMemo(() => {
|
||||
return annotations.map((a: any) => ({
|
||||
id: a.id,
|
||||
type: a.type as AnnotationTypeValue,
|
||||
data: a.data ?? {},
|
||||
imageX: a.imageX,
|
||||
imageY: a.imageY,
|
||||
isSelected: a.id === selectedId,
|
||||
onClick: (id: string) => setSelectedId(id),
|
||||
}));
|
||||
}, [annotations, selectedId]);
|
||||
|
||||
const screenshotAnnotations = useMemo(() => {
|
||||
return annotations.filter((a: any) => a.type === "SCREENSHOT");
|
||||
}, [annotations]);
|
||||
|
||||
// Queue annotation — stores it pending and shows comment input
|
||||
const queueAnnotation = useCallback(
|
||||
(
|
||||
type: AnnotationTypeValue,
|
||||
data: Record<string, any>,
|
||||
imgX: number,
|
||||
imgY: number,
|
||||
screenX: number,
|
||||
screenY: number
|
||||
) => {
|
||||
setPendingAnnotation({ type, data, imgX, imgY, screenX, screenY });
|
||||
setCommentValue("");
|
||||
setTimeout(() => commentInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Commit pending annotation with user comment
|
||||
const commitPendingAnnotation = useCallback(
|
||||
(comment?: string) => {
|
||||
if (!pendingAnnotation || !revisionId || !stageId) {
|
||||
setPendingAnnotation(null);
|
||||
setCommentValue("");
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, data, imgX, imgY } = pendingAnnotation;
|
||||
const typeLabel = type.charAt(0) + type.slice(1).toLowerCase();
|
||||
const commentContent =
|
||||
comment?.trim() ||
|
||||
`${typeLabel} annotation at (${Math.round(imgX)}, ${Math.round(imgY)})`;
|
||||
|
||||
const input: CreateAnnotationInput = {
|
||||
type,
|
||||
data: data as any,
|
||||
imageX: imgX,
|
||||
imageY: imgY,
|
||||
commentContent,
|
||||
stageId,
|
||||
};
|
||||
|
||||
createMutation.mutate(input, {
|
||||
onSuccess: (result: any) => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{ action: "create", annotationId: result.id, input },
|
||||
]);
|
||||
setRedoStack([]);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to save annotation: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
setPendingAnnotation(null);
|
||||
setCommentValue("");
|
||||
},
|
||||
[pendingAnnotation, revisionId, stageId, createMutation]
|
||||
);
|
||||
|
||||
// Cancel pending annotation
|
||||
const cancelPendingAnnotation = useCallback(() => {
|
||||
setPendingAnnotation(null);
|
||||
setCommentValue("");
|
||||
}, []);
|
||||
|
||||
// Direct save (for redo, screenshots, pins — skip comment prompt)
|
||||
const saveAnnotation = useCallback(
|
||||
(
|
||||
type: AnnotationTypeValue,
|
||||
data: Record<string, any>,
|
||||
imgX: number,
|
||||
imgY: number
|
||||
) => {
|
||||
if (!revisionId || !stageId) return;
|
||||
|
||||
const typeLabel = type.charAt(0) + type.slice(1).toLowerCase();
|
||||
const commentContent = `${typeLabel} annotation at (${Math.round(imgX)}, ${Math.round(imgY)})`;
|
||||
|
||||
const input: CreateAnnotationInput = {
|
||||
type,
|
||||
data: data as any,
|
||||
imageX: imgX,
|
||||
imageY: imgY,
|
||||
commentContent,
|
||||
stageId,
|
||||
};
|
||||
|
||||
createMutation.mutate(input, {
|
||||
onSuccess: (result: any) => {
|
||||
setUndoStack((prev) => [...prev, { action: "create", annotationId: result.id, input }]);
|
||||
setRedoStack([]);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to save annotation: ${err.message}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
[revisionId, stageId, createMutation]
|
||||
);
|
||||
|
||||
// Get image coordinates from screen event
|
||||
const getImageCoords = useCallback(
|
||||
(e: React.MouseEvent, panX: number, panY: number, zoom: number) => {
|
||||
const svgEl = svgRef.current;
|
||||
if (!svgEl) return { x: 0, y: 0 };
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const screenX = e.clientX - rect.left;
|
||||
const screenY = e.clientY - rect.top;
|
||||
return screenToImage(screenX, screenY, panX, panY, zoom);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Mouse handlers (need zoom/pan passed in)
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, panX: number, panY: number, zoom: number) => {
|
||||
if (activeTool === "move" || activeTool === "eyedropper") {
|
||||
setSelectedId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const img = getImageCoords(e, panX, panY, zoom);
|
||||
|
||||
if (activeTool === "text") {
|
||||
const svgEl = svgRef.current;
|
||||
if (!svgEl) return;
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
setTextInput({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
imgX: img.x,
|
||||
imgY: img.y,
|
||||
});
|
||||
setTextValue("");
|
||||
setTimeout(() => textInputRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool === "pin") {
|
||||
saveAnnotation("PIN", { x: img.x, y: img.y, color }, img.x, img.y);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = TOOL_TO_TYPE[activeTool];
|
||||
setDrawing({
|
||||
type,
|
||||
startX: img.x,
|
||||
startY: img.y,
|
||||
currentX: img.x,
|
||||
currentY: img.y,
|
||||
points: [{ x: img.x, y: img.y }],
|
||||
});
|
||||
},
|
||||
[activeTool, getImageCoords, color, saveAnnotation]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent, panX: number, panY: number, zoom: number) => {
|
||||
if (!drawing) return;
|
||||
const img = getImageCoords(e, panX, panY, zoom);
|
||||
setDrawing((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
currentX: img.x,
|
||||
currentY: img.y,
|
||||
points:
|
||||
prev.type === "FREEHAND"
|
||||
? [...prev.points, { x: img.x, y: img.y }]
|
||||
: prev.points,
|
||||
};
|
||||
});
|
||||
},
|
||||
[drawing, getImageCoords]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
if (!drawing) return;
|
||||
|
||||
const { type, startX, startY, currentX, currentY, points } = drawing;
|
||||
let data: Record<string, any> = { color };
|
||||
|
||||
switch (type) {
|
||||
case "RECTANGLE":
|
||||
case "ELLIPSE":
|
||||
data = {
|
||||
...data,
|
||||
x: Math.min(startX, currentX),
|
||||
y: Math.min(startY, currentY),
|
||||
width: Math.abs(currentX - startX),
|
||||
height: Math.abs(currentY - startY),
|
||||
};
|
||||
break;
|
||||
case "ARROW":
|
||||
data = { ...data, x: startX, y: startY, endX: currentX, endY: currentY };
|
||||
break;
|
||||
case "FREEHAND":
|
||||
data = { ...data, points };
|
||||
break;
|
||||
}
|
||||
|
||||
// Discard tiny accidental clicks
|
||||
if (type === "RECTANGLE" || type === "ELLIPSE") {
|
||||
if (Math.abs(currentX - startX) < 3 && Math.abs(currentY - startY) < 3) {
|
||||
setDrawing(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (type === "ARROW") {
|
||||
const dist = Math.sqrt((currentX - startX) ** 2 + (currentY - startY) ** 2);
|
||||
if (dist < 3) {
|
||||
setDrawing(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (type === "FREEHAND" && points.length < 3) {
|
||||
setDrawing(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute screen position for comment popover (near end of drawing)
|
||||
const svgEl = svgRef.current;
|
||||
let screenX = 200;
|
||||
let screenY = 200;
|
||||
if (svgEl && e) {
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
screenX = e.clientX - rect.left;
|
||||
screenY = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
queueAnnotation(type, data, startX, startY, screenX, screenY);
|
||||
setDrawing(null);
|
||||
},
|
||||
[drawing, color, queueAnnotation]
|
||||
);
|
||||
|
||||
// Text commit
|
||||
const commitTextAnnotation = useCallback(() => {
|
||||
if (!textInput || !textValue.trim()) {
|
||||
setTextInput(null);
|
||||
setTextValue("");
|
||||
return;
|
||||
}
|
||||
saveAnnotation(
|
||||
"TEXT",
|
||||
{ x: textInput.imgX, y: textInput.imgY, text: textValue.trim(), color },
|
||||
textInput.imgX,
|
||||
textInput.imgY
|
||||
);
|
||||
setTextInput(null);
|
||||
setTextValue("");
|
||||
}, [textInput, textValue, color, saveAnnotation]);
|
||||
|
||||
// Undo / Redo
|
||||
const handleUndo = useCallback(() => {
|
||||
const last = undoStack[undoStack.length - 1];
|
||||
if (!last) return;
|
||||
setUndoStack((prev) => prev.slice(0, -1));
|
||||
if (last.action === "create") {
|
||||
deleteMutation.mutate(last.annotationId, {
|
||||
onSuccess: () => setRedoStack((prev) => [...prev, last]),
|
||||
});
|
||||
}
|
||||
}, [undoStack, deleteMutation]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
const last = redoStack[redoStack.length - 1];
|
||||
if (!last || !last.input) return;
|
||||
setRedoStack((prev) => prev.slice(0, -1));
|
||||
if (last.action === "create") {
|
||||
createMutation.mutate(last.input, {
|
||||
onSuccess: (result: any) => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{ action: "create", annotationId: result.id, input: last.input },
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [redoStack, createMutation]);
|
||||
|
||||
// Delete selection
|
||||
const handleDeleteSelection = useCallback(() => {
|
||||
if (!selectedId) return;
|
||||
deleteMutation.mutate(selectedId, {
|
||||
onSuccess: () => {
|
||||
setSelectedId(null);
|
||||
toast.success("Annotation deleted");
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
}, [selectedId, deleteMutation]);
|
||||
|
||||
// Screenshot move/resize
|
||||
const handleScreenshotMove = useCallback(
|
||||
(id: string, newX: number, newY: number) => {
|
||||
const ann = annotations.find((a: any) => a.id === id);
|
||||
if (!ann) return;
|
||||
const newData = { ...(ann.data as any), x: newX, y: newY };
|
||||
updateMutation.mutate({ annotationId: id, data: { data: newData, imageX: newX, imageY: newY } });
|
||||
},
|
||||
[annotations, updateMutation]
|
||||
);
|
||||
|
||||
const handleScreenshotResize = useCallback(
|
||||
(id: string, newW: number, newH: number) => {
|
||||
const ann = annotations.find((a: any) => a.id === id);
|
||||
if (!ann) return;
|
||||
const newData = { ...(ann.data as any), width: newW, height: newH };
|
||||
updateMutation.mutate({ annotationId: id, data: { data: newData } });
|
||||
},
|
||||
[annotations, updateMutation]
|
||||
);
|
||||
|
||||
// Move any annotation by delta
|
||||
const handleAnnotationMove = useCallback(
|
||||
(id: string, dx: number, dy: number) => {
|
||||
const ann = annotations.find((a: any) => a.id === id);
|
||||
if (!ann) return;
|
||||
const oldData = (ann.data as any) ?? {};
|
||||
const newData = { ...oldData };
|
||||
|
||||
// Shift position-based fields by delta
|
||||
if (typeof newData.x === "number") newData.x += dx;
|
||||
if (typeof newData.y === "number") newData.y += dy;
|
||||
if (typeof newData.endX === "number") newData.endX += dx;
|
||||
if (typeof newData.endY === "number") newData.endY += dy;
|
||||
|
||||
// Shift freehand points
|
||||
if (Array.isArray(newData.points)) {
|
||||
newData.points = newData.points.map((p: { x: number; y: number }) => ({
|
||||
x: p.x + dx,
|
||||
y: p.y + dy,
|
||||
}));
|
||||
}
|
||||
|
||||
const newImgX = ann.imageX + dx;
|
||||
const newImgY = ann.imageY + dy;
|
||||
|
||||
updateMutation.mutate({
|
||||
annotationId: id,
|
||||
data: { data: newData, imageX: newImgX, imageY: newImgY },
|
||||
});
|
||||
},
|
||||
[annotations, updateMutation]
|
||||
);
|
||||
|
||||
// Screenshot paste
|
||||
const handleScreenshotPaste = useCallback(
|
||||
async (
|
||||
file: File,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
panX: number,
|
||||
panY: number,
|
||||
zoom: number,
|
||||
imageDimensions: { width: number; height: number } | null
|
||||
) => {
|
||||
if (!revisionId || !stageId) return;
|
||||
|
||||
const MAX_SCREENSHOT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > MAX_SCREENSHOT_SIZE) {
|
||||
toast.error("Screenshot too large", {
|
||||
description: `Maximum size is 5MB. Your screenshot is ${(file.size / 1024 / 1024).toFixed(1)}MB. Try cropping or reducing the capture area.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", "screenshot");
|
||||
|
||||
try {
|
||||
const uploadRes = await fetch(
|
||||
`/api/stages/${stageId}/revisions/${revisionId}/upload`,
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const errBody = await uploadRes.json().catch(() => ({}));
|
||||
toast.error(errBody.error || "Failed to upload screenshot");
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded = await uploadRes.json();
|
||||
const centerImg = screenToImage(
|
||||
containerWidth / 2,
|
||||
containerHeight / 2,
|
||||
panX,
|
||||
panY,
|
||||
zoom
|
||||
);
|
||||
|
||||
const defaultWidth = Math.min(300, (imageDimensions?.width ?? 600) * 0.3);
|
||||
const defaultHeight = defaultWidth * 0.75;
|
||||
|
||||
queueAnnotation(
|
||||
"SCREENSHOT",
|
||||
{
|
||||
x: centerImg.x - defaultWidth / 2,
|
||||
y: centerImg.y - defaultHeight / 2,
|
||||
width: defaultWidth,
|
||||
height: defaultHeight,
|
||||
imageUrl: uploaded.url,
|
||||
color,
|
||||
},
|
||||
centerImg.x,
|
||||
centerImg.y,
|
||||
containerWidth / 2,
|
||||
containerHeight / 2
|
||||
);
|
||||
|
||||
toast.success("Screenshot pasted — add a comment");
|
||||
} catch {
|
||||
toast.error("Failed to paste screenshot");
|
||||
}
|
||||
},
|
||||
[revisionId, stageId, color, queueAnnotation]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement
|
||||
)
|
||||
return;
|
||||
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "v": setActiveTool("move"); return;
|
||||
case "r": setActiveTool("rectangle"); return;
|
||||
case "e": setActiveTool("ellipse"); return;
|
||||
case "a": setActiveTool("arrow"); return;
|
||||
case "f": setActiveTool("freehand"); return;
|
||||
case "t": setActiveTool("text"); return;
|
||||
case "p": setActiveTool("pin"); return;
|
||||
case "d": setActiveTool("eyedropper"); return;
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (selectedId) { e.preventDefault(); handleDeleteSelection(); }
|
||||
return;
|
||||
case "escape":
|
||||
setSelectedId(null);
|
||||
setActiveTool("move");
|
||||
setTextInput(null);
|
||||
setDrawing(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
return;
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedId, handleDeleteSelection, handleUndo, handleRedo]);
|
||||
|
||||
// Drawing preview
|
||||
const drawingPreview = useMemo((): AnnotationShape | null => {
|
||||
if (!drawing) return null;
|
||||
const { type, startX, startY, currentX, currentY, points } = drawing;
|
||||
const data: Record<string, any> = { color };
|
||||
|
||||
switch (type) {
|
||||
case "RECTANGLE":
|
||||
case "ELLIPSE":
|
||||
data.x = Math.min(startX, currentX);
|
||||
data.y = Math.min(startY, currentY);
|
||||
data.width = Math.abs(currentX - startX);
|
||||
data.height = Math.abs(currentY - startY);
|
||||
break;
|
||||
case "ARROW":
|
||||
data.x = startX; data.y = startY;
|
||||
data.endX = currentX; data.endY = currentY;
|
||||
break;
|
||||
case "FREEHAND":
|
||||
data.points = points;
|
||||
break;
|
||||
}
|
||||
|
||||
return { id: "__drawing__", type, data, imageX: startX, imageY: startY };
|
||||
}, [drawing, color]);
|
||||
|
||||
return {
|
||||
// Tool state
|
||||
activeTool,
|
||||
setActiveTool,
|
||||
color,
|
||||
setColor,
|
||||
visible,
|
||||
setVisible,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
drawing,
|
||||
textInput,
|
||||
setTextInput,
|
||||
textValue,
|
||||
setTextValue,
|
||||
// Comment input for annotations
|
||||
pendingAnnotation,
|
||||
commentValue,
|
||||
setCommentValue,
|
||||
commitPendingAnnotation,
|
||||
cancelPendingAnnotation,
|
||||
// Refs
|
||||
svgRef,
|
||||
textInputRef,
|
||||
commentInputRef,
|
||||
// Derived
|
||||
annotationShapes,
|
||||
screenshotAnnotations,
|
||||
drawingPreview,
|
||||
// Undo/redo
|
||||
canUndo: undoStack.length > 0,
|
||||
canRedo: redoStack.length > 0,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
// Actions
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
commitTextAnnotation,
|
||||
handleDeleteSelection,
|
||||
handleAnnotationMove,
|
||||
handleScreenshotMove,
|
||||
handleScreenshotResize,
|
||||
handleScreenshotPaste,
|
||||
};
|
||||
}
|
||||
78
src/hooks/use-annotations.ts
Normal file
78
src/hooks/use-annotations.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation";
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useAnnotations(revisionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["annotations", revisionId],
|
||||
queryFn: () => fetchJson(`/api/revisions/${revisionId}/annotations`),
|
||||
enabled: !!revisionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAnnotation(revisionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateAnnotationInput) =>
|
||||
fetchJson(`/api/revisions/${revisionId}/annotations`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] });
|
||||
// Also refresh feedback — annotations auto-create feedback items
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback-summary"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAnnotation(revisionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
annotationId,
|
||||
data,
|
||||
}: {
|
||||
annotationId: string;
|
||||
data: UpdateAnnotationInput;
|
||||
}) =>
|
||||
fetchJson(`/api/revisions/${revisionId}/annotations/${annotationId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAnnotation(revisionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (annotationId: string) =>
|
||||
fetchJson(`/api/revisions/${revisionId}/annotations/${annotationId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["annotations", revisionId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback-summary"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
100
src/hooks/use-color-probes.ts
Normal file
100
src/hooks/use-color-probes.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ColorProbe {
|
||||
id: string;
|
||||
revisionId: string;
|
||||
index: number;
|
||||
workingX: number;
|
||||
workingY: number;
|
||||
referenceX: number;
|
||||
referenceY: number;
|
||||
createdById: string;
|
||||
createdBy: { id: string; name: string | null };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function useColorProbes(revisionId: string | null) {
|
||||
return useQuery<ColorProbe[]>({
|
||||
queryKey: ["color-probes", revisionId],
|
||||
queryFn: () => fetchJson(`/api/revisions/${revisionId}/color-probes`),
|
||||
enabled: !!revisionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateColorProbe(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateColorProbeInput) =>
|
||||
fetchJson<ColorProbe>(`/api/revisions/${revisionId}/color-probes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateColorProbe(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
probeId,
|
||||
data,
|
||||
}: {
|
||||
probeId: string;
|
||||
data: UpdateColorProbeInput;
|
||||
}) =>
|
||||
fetchJson<ColorProbe>(
|
||||
`/api/revisions/${revisionId}/color-probes/${probeId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteColorProbe(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (probeId: string) =>
|
||||
fetchJson(`/api/revisions/${revisionId}/color-probes/${probeId}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearColorProbes(revisionId: string | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
fetchJson(`/api/revisions/${revisionId}/color-probes`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["color-probes", revisionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
195
src/hooks/use-feedback.ts
Normal file
195
src/hooks/use-feedback.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
CreateFeedbackInput,
|
||||
UpdateFeedbackInput,
|
||||
ResolveFeedbackInput,
|
||||
} from "@/lib/validators/feedback";
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Queries ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all feedback items for a stage, optionally filtered by revision.
|
||||
*/
|
||||
export function useFeedbackItems(stageId: string, revisionId?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (revisionId) params.set("revisionId", revisionId);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["feedback", stageId, revisionId ?? "all"],
|
||||
queryFn: () =>
|
||||
fetchJson<any[]>(
|
||||
`/api/stages/${stageId}/feedback${params.toString() ? `?${params}` : ""}`
|
||||
),
|
||||
enabled: !!stageId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch feedback summary counts for a stage (for badges/indicators).
|
||||
*/
|
||||
export function useFeedbackSummary(stageId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
queryFn: () =>
|
||||
fetchJson<{
|
||||
total: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
infoCount: number;
|
||||
}>(`/api/stages/${stageId}/feedback?summary=true`),
|
||||
enabled: !!stageId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Mutations ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new feedback item on a stage.
|
||||
*/
|
||||
export function useCreateFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateFeedbackInput) =>
|
||||
fetchJson(`/api/stages/${stageId}/feedback`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback", stageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feedback item (summary, isActionItem, status, assignment, sort).
|
||||
*/
|
||||
export function useUpdateFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
itemId: string;
|
||||
data: UpdateFeedbackInput;
|
||||
}) =>
|
||||
fetchJson(`/api/feedback/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback", stageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feedback item.
|
||||
*/
|
||||
export function useResolveFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
itemId,
|
||||
data,
|
||||
}: {
|
||||
itemId: string;
|
||||
data: ResolveFeedbackInput;
|
||||
}) =>
|
||||
fetchJson(`/api/feedback/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "resolve", ...data }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback", stageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a resolved feedback item.
|
||||
*/
|
||||
export function useVerifyFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
fetchJson(`/api/feedback/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "verify" }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback", stageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a feedback item.
|
||||
*/
|
||||
export function useReopenFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
fetchJson(`/api/feedback/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "reopen" }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback", stageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feedback item.
|
||||
*/
|
||||
export function useDeleteFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
fetchJson(`/api/feedback/${itemId}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["feedback", stageId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback-summary", stageId],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
178
src/hooks/use-review-sessions.ts
Normal file
178
src/hooks/use-review-sessions.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
CreateReviewSessionInput,
|
||||
UpdateReviewSessionInput,
|
||||
RecordDecisionInput,
|
||||
} from "@/lib/validators/review-session";
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Queries ────────────────────────────────────────────
|
||||
|
||||
export function useReviewSessions(status?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["review-sessions", status ?? "all"],
|
||||
queryFn: () =>
|
||||
fetchJson<any[]>(
|
||||
`/api/reviews${params.toString() ? `?${params}` : ""}`
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewSession(sessionId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["review-session", sessionId],
|
||||
queryFn: () => fetchJson<any>(`/api/reviews/${sessionId}`),
|
||||
enabled: !!sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Mutations ──────────────────────────────────────────
|
||||
|
||||
export function useCreateReviewSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateReviewSessionInput) =>
|
||||
fetchJson("/api/reviews", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-sessions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateReviewSession(sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateReviewSessionInput) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["review-sessions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteReviewSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-sessions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddSessionItems(sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (items: { deliverableStageId: string; revisionId?: string }[]) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "add-items", items }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveSessionItem(sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "remove-item", itemId }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderSessionItems(sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemIds: string[]) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "reorder", itemIds }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecordDecision(sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: RecordDecisionInput) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "decide", ...data }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useClearDecision(sessionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (itemId: string) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "clear-decision", itemId }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateSessionItems(sessionId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (data: { projectId: string; stageStatus?: string; stageTemplateId?: string }) =>
|
||||
fetchJson(`/api/reviews/${sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate", ...data }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
156
src/hooks/use-revision-history.ts
Normal file
156
src/hooks/use-revision-history.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRevisions } from "@/hooks/use-revisions";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export type RevisionStatus =
|
||||
| "SUBMITTED"
|
||||
| "IN_REVIEW"
|
||||
| "CHANGES_REQUESTED"
|
||||
| "APPROVED";
|
||||
|
||||
interface AttachedImage {
|
||||
url: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
uploadedAt: string;
|
||||
originalUrl?: string;
|
||||
}
|
||||
|
||||
interface RevisionAttachments {
|
||||
referenceImage?: AttachedImage;
|
||||
currentImage?: AttachedImage;
|
||||
}
|
||||
|
||||
export interface EnrichedRevision {
|
||||
id: string;
|
||||
roundNumber: number;
|
||||
status: RevisionStatus;
|
||||
feedbackNotes: string | null;
|
||||
internalNotes: string | null;
|
||||
attachments: RevisionAttachments | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Thumbnail URL for the current image (preferred) or reference image */
|
||||
thumbnailUrl: string | null;
|
||||
/** Full URL for the current image (preferred) or reference image */
|
||||
imageUrl: string | null;
|
||||
/** Number of annotations on this revision (0 if still loading) */
|
||||
annotationCount: number;
|
||||
/**
|
||||
* Total comment count for the stage. Comments are currently per-stage,
|
||||
* not per-revision, so this is the same value for all revisions.
|
||||
* TODO: When comments become per-revision, filter by revisionId here.
|
||||
*/
|
||||
stageCommentCount: number;
|
||||
/** First line of feedback notes, truncated for display */
|
||||
feedbackPreview: string | null;
|
||||
/** Whether this is the most recently submitted round */
|
||||
isLatest: boolean;
|
||||
}
|
||||
|
||||
// ── Lightweight annotation count fetcher ─────────────────────────────────
|
||||
|
||||
async function fetchAnnotationCount(revisionId: string): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(`/api/revisions/${revisionId}/annotations`);
|
||||
if (!res.ok) return 0;
|
||||
const data = await res.json();
|
||||
return Array.isArray(data) ? data.length : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function useAnnotationCounts(revisionIds: string[]) {
|
||||
return useQuery({
|
||||
queryKey: ["annotation-counts", ...revisionIds],
|
||||
queryFn: async () => {
|
||||
if (revisionIds.length === 0) return {} as Record<string, number>;
|
||||
const counts = await Promise.all(
|
||||
revisionIds.map(async (id) => {
|
||||
const count = await fetchAnnotationCount(id);
|
||||
return [id, count] as const;
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(counts) as Record<string, number>;
|
||||
},
|
||||
enabled: revisionIds.length > 0,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hook ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useRevisionHistory(stageId: string | null) {
|
||||
const { data: revisionsData, isLoading: revisionsLoading } = useRevisions(
|
||||
stageId ?? ""
|
||||
);
|
||||
const { data: commentsData } = useComments(stageId ?? "");
|
||||
|
||||
const revisions = (revisionsData as any[]) ?? [];
|
||||
const comments = (commentsData as any[]) ?? [];
|
||||
|
||||
const revisionIds = useMemo(
|
||||
() => revisions.map((r: any) => r.id),
|
||||
[revisions]
|
||||
);
|
||||
|
||||
const { data: annotationCounts } = useAnnotationCounts(revisionIds);
|
||||
|
||||
const enrichedRevisions: EnrichedRevision[] = useMemo(() => {
|
||||
if (revisions.length === 0) return [];
|
||||
|
||||
// Find the highest round number to mark "latest"
|
||||
const maxRound = Math.max(...revisions.map((r: any) => r.roundNumber));
|
||||
|
||||
return revisions.map((rev: any) => {
|
||||
const attachments = rev.attachments as RevisionAttachments | null;
|
||||
|
||||
// Prefer currentImage for thumbnail, fall back to referenceImage
|
||||
const primaryImage =
|
||||
attachments?.currentImage ?? attachments?.referenceImage ?? null;
|
||||
|
||||
const imageUrl = primaryImage?.url ?? null;
|
||||
const thumbnailUrl = imageUrl
|
||||
? imageUrl.replace(/\.(png|jpg|jpeg|webp)$/i, "_thumb.jpg")
|
||||
: null;
|
||||
|
||||
// Truncate feedback notes for preview
|
||||
const feedbackPreview = rev.feedbackNotes
|
||||
? rev.feedbackNotes.length > 80
|
||||
? rev.feedbackNotes.slice(0, 80) + "..."
|
||||
: rev.feedbackNotes
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: rev.id,
|
||||
roundNumber: rev.roundNumber,
|
||||
status: rev.status as RevisionStatus,
|
||||
feedbackNotes: rev.feedbackNotes,
|
||||
internalNotes: rev.internalNotes,
|
||||
attachments,
|
||||
createdAt: rev.createdAt,
|
||||
updatedAt: rev.updatedAt,
|
||||
thumbnailUrl,
|
||||
imageUrl,
|
||||
annotationCount: annotationCounts?.[rev.id] ?? 0,
|
||||
stageCommentCount: comments.length,
|
||||
feedbackPreview,
|
||||
isLatest: rev.roundNumber === maxRound,
|
||||
};
|
||||
});
|
||||
}, [revisions, annotationCounts, comments]);
|
||||
|
||||
return {
|
||||
revisions: enrichedRevisions,
|
||||
isLoading: revisionsLoading,
|
||||
isEmpty: !revisionsLoading && enrichedRevisions.length === 0,
|
||||
};
|
||||
}
|
||||
132
src/lib/services/annotation-service.ts
Normal file
132
src/lib/services/annotation-service.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type { CreateAnnotationInput, UpdateAnnotationInput } from "@/lib/validators/annotation";
|
||||
import { createFeedbackFromAnnotation } from "@/lib/services/feedback-service";
|
||||
|
||||
/**
|
||||
* List all annotations for a revision, including the linked comment + author.
|
||||
*/
|
||||
export async function listAnnotations(revisionId: string) {
|
||||
return prisma.annotation.findMany({
|
||||
where: { revisionId },
|
||||
include: {
|
||||
comment: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
},
|
||||
createdBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an annotation and its linked comment in a single transaction.
|
||||
* The comment is created on the stage associated with the revision.
|
||||
*/
|
||||
export async function createAnnotation(
|
||||
revisionId: string,
|
||||
userId: string,
|
||||
input: CreateAnnotationInput
|
||||
) {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Create the linked comment on the stage
|
||||
const comment = await tx.comment.create({
|
||||
data: {
|
||||
deliverableStageId: input.stageId,
|
||||
authorId: userId,
|
||||
content: input.commentContent,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the annotation linked to the comment and revision
|
||||
const annotation = await tx.annotation.create({
|
||||
data: {
|
||||
revisionId,
|
||||
commentId: comment.id,
|
||||
type: input.type,
|
||||
data: input.data as any,
|
||||
imageX: input.imageX,
|
||||
imageY: input.imageY,
|
||||
createdById: userId,
|
||||
},
|
||||
include: {
|
||||
comment: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
},
|
||||
createdBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return annotation;
|
||||
});
|
||||
|
||||
// Auto-create a feedback checklist item from the annotation (after transaction commits)
|
||||
try {
|
||||
await createFeedbackFromAnnotation(
|
||||
input.stageId,
|
||||
revisionId,
|
||||
result.id,
|
||||
result.comment.id,
|
||||
input.commentContent,
|
||||
userId
|
||||
);
|
||||
} catch {
|
||||
// Non-critical: don't fail annotation creation if feedback creation fails
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update annotation data (position, shape data).
|
||||
*/
|
||||
export async function updateAnnotation(
|
||||
annotationId: string,
|
||||
userId: string,
|
||||
input: UpdateAnnotationInput
|
||||
) {
|
||||
const annotation = await prisma.annotation.findUnique({
|
||||
where: { id: annotationId },
|
||||
});
|
||||
|
||||
if (!annotation) throw new Error("Annotation not found");
|
||||
if (annotation.createdById !== userId) throw new Error("Not authorized");
|
||||
|
||||
return prisma.annotation.update({
|
||||
where: { id: annotationId },
|
||||
data: {
|
||||
...(input.data !== undefined && { data: input.data as any }),
|
||||
...(input.imageX !== undefined && { imageX: input.imageX }),
|
||||
...(input.imageY !== undefined && { imageY: input.imageY }),
|
||||
},
|
||||
include: {
|
||||
comment: {
|
||||
include: {
|
||||
author: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
},
|
||||
createdBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an annotation and its linked comment (cascade via Comment relation).
|
||||
*/
|
||||
export async function deleteAnnotation(annotationId: string, userId: string) {
|
||||
const annotation = await prisma.annotation.findUnique({
|
||||
where: { id: annotationId },
|
||||
select: { id: true, commentId: true, createdById: true },
|
||||
});
|
||||
|
||||
if (!annotation) throw new Error("Annotation not found");
|
||||
if (annotation.createdById !== userId) throw new Error("Not authorized");
|
||||
|
||||
// Deleting the comment cascades to the annotation
|
||||
await prisma.comment.delete({ where: { id: annotation.commentId } });
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
105
src/lib/services/color-probe-service.ts
Normal file
105
src/lib/services/color-probe-service.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type { CreateColorProbeInput, UpdateColorProbeInput } from "@/lib/validators/color-probe";
|
||||
|
||||
/**
|
||||
* List all color probes for a revision, ordered by index.
|
||||
*/
|
||||
export async function listColorProbes(revisionId: string) {
|
||||
return prisma.colorProbe.findMany({
|
||||
where: { revisionId },
|
||||
orderBy: { index: "asc" },
|
||||
include: { createdBy: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new color probe on a revision.
|
||||
*/
|
||||
export async function createColorProbe(
|
||||
revisionId: string,
|
||||
userId: string,
|
||||
input: CreateColorProbeInput
|
||||
) {
|
||||
return prisma.colorProbe.create({
|
||||
data: {
|
||||
revisionId,
|
||||
createdById: userId,
|
||||
index: input.index,
|
||||
workingX: input.workingX,
|
||||
workingY: input.workingY,
|
||||
referenceX: input.referenceX,
|
||||
referenceY: input.referenceY,
|
||||
},
|
||||
include: { createdBy: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a color probe's position.
|
||||
*/
|
||||
export async function updateColorProbe(
|
||||
probeId: string,
|
||||
input: UpdateColorProbeInput
|
||||
) {
|
||||
return prisma.colorProbe.update({
|
||||
where: { id: probeId },
|
||||
data: {
|
||||
...(input.workingX !== undefined && { workingX: input.workingX }),
|
||||
...(input.workingY !== undefined && { workingY: input.workingY }),
|
||||
...(input.referenceX !== undefined && { referenceX: input.referenceX }),
|
||||
...(input.referenceY !== undefined && { referenceY: input.referenceY }),
|
||||
},
|
||||
include: { createdBy: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single color probe.
|
||||
*/
|
||||
export async function deleteColorProbe(probeId: string) {
|
||||
await prisma.colorProbe.delete({ where: { id: probeId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all color probes for a revision (wipe).
|
||||
*/
|
||||
export async function clearColorProbes(revisionId: string) {
|
||||
await prisma.colorProbe.deleteMany({ where: { revisionId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy probes from one revision to another (for R1 → R2 carry-over).
|
||||
* Keeps the same coordinates and indices.
|
||||
*/
|
||||
export async function copyProbes(
|
||||
fromRevisionId: string,
|
||||
toRevisionId: string,
|
||||
userId: string
|
||||
) {
|
||||
const existing = await prisma.colorProbe.findMany({
|
||||
where: { revisionId: fromRevisionId },
|
||||
orderBy: { index: "asc" },
|
||||
});
|
||||
|
||||
if (existing.length === 0) return [];
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
existing.map((probe) =>
|
||||
prisma.colorProbe.create({
|
||||
data: {
|
||||
revisionId: toRevisionId,
|
||||
createdById: userId,
|
||||
index: probe.index,
|
||||
workingX: probe.workingX,
|
||||
workingY: probe.workingY,
|
||||
referenceX: probe.referenceX,
|
||||
referenceY: probe.referenceY,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return created;
|
||||
}
|
||||
260
src/lib/services/feedback-service.ts
Normal file
260
src/lib/services/feedback-service.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type {
|
||||
CreateFeedbackInput,
|
||||
UpdateFeedbackInput,
|
||||
ResolveFeedbackInput,
|
||||
} from "@/lib/validators/feedback";
|
||||
|
||||
const FEEDBACK_INCLUDE = {
|
||||
assignedTo: { select: { id: true, name: true, email: true, image: true } },
|
||||
createdBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
resolvedBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
verifiedBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
annotation: { select: { id: true, type: true, data: true, imageX: true, imageY: true } },
|
||||
carriedFrom: { select: { id: true, summary: true, revisionId: true } },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* List feedback items for a stage, optionally filtered by revision/status/type.
|
||||
*/
|
||||
export async function listFeedbackItems(
|
||||
stageId: string,
|
||||
filters?: { revisionId?: string; status?: string; isActionItem?: boolean }
|
||||
) {
|
||||
const where: any = { deliverableStageId: stageId };
|
||||
|
||||
if (filters?.revisionId) where.revisionId = filters.revisionId;
|
||||
if (filters?.status) where.status = filters.status;
|
||||
if (filters?.isActionItem !== undefined) where.isActionItem = filters.isActionItem;
|
||||
|
||||
return prisma.feedbackItem.findMany({
|
||||
where,
|
||||
include: FEEDBACK_INCLUDE,
|
||||
orderBy: [{ isActionItem: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single feedback item by ID.
|
||||
*/
|
||||
export async function getFeedbackItem(itemId: string) {
|
||||
return prisma.feedbackItem.findUnique({
|
||||
where: { id: itemId },
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a feedback item manually.
|
||||
*/
|
||||
export async function createFeedbackItem(
|
||||
stageId: string,
|
||||
userId: string,
|
||||
input: CreateFeedbackInput
|
||||
) {
|
||||
const maxSort = await prisma.feedbackItem.aggregate({
|
||||
where: { revisionId: input.revisionId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
|
||||
return prisma.feedbackItem.create({
|
||||
data: {
|
||||
deliverableStageId: stageId,
|
||||
revisionId: input.revisionId,
|
||||
annotationId: input.annotationId ?? null,
|
||||
commentId: input.commentId ?? null,
|
||||
summary: input.summary,
|
||||
isActionItem: input.isActionItem ?? true,
|
||||
status: "OPEN",
|
||||
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
||||
assignedToId: input.assignedToId ?? null,
|
||||
createdById: userId,
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-create a feedback item when an annotation is created.
|
||||
* All annotations are action items by default.
|
||||
*/
|
||||
export async function createFeedbackFromAnnotation(
|
||||
stageId: string,
|
||||
revisionId: string,
|
||||
annotationId: string,
|
||||
commentId: string,
|
||||
commentText: string,
|
||||
userId: string,
|
||||
isActionItem: boolean = true
|
||||
) {
|
||||
const summary =
|
||||
commentText.length > 200
|
||||
? commentText.slice(0, 197) + "..."
|
||||
: commentText;
|
||||
|
||||
const maxSort = await prisma.feedbackItem.aggregate({
|
||||
where: { revisionId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
|
||||
return prisma.feedbackItem.create({
|
||||
data: {
|
||||
deliverableStageId: stageId,
|
||||
revisionId,
|
||||
annotationId,
|
||||
commentId,
|
||||
summary,
|
||||
isActionItem,
|
||||
status: "OPEN",
|
||||
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
||||
createdById: userId,
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feedback item.
|
||||
*/
|
||||
export async function updateFeedbackItem(
|
||||
itemId: string,
|
||||
input: UpdateFeedbackInput
|
||||
) {
|
||||
return prisma.feedbackItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
...(input.summary !== undefined && { summary: input.summary }),
|
||||
...(input.isActionItem !== undefined && { isActionItem: input.isActionItem }),
|
||||
...(input.status !== undefined && { status: input.status as any }),
|
||||
...(input.assignedToId !== undefined && {
|
||||
assignedToId: input.assignedToId,
|
||||
}),
|
||||
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder }),
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a feedback item with an optional resolution note.
|
||||
*/
|
||||
export async function resolveFeedbackItem(
|
||||
itemId: string,
|
||||
userId: string,
|
||||
input: ResolveFeedbackInput
|
||||
) {
|
||||
return prisma.feedbackItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
status: "RESOLVED",
|
||||
resolvedById: userId,
|
||||
resolvedAt: new Date(),
|
||||
resolutionNote: input.resolutionNote ?? null,
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a resolved feedback item.
|
||||
*/
|
||||
export async function verifyFeedbackItem(itemId: string, userId: string) {
|
||||
return prisma.feedbackItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
status: "VERIFIED",
|
||||
verifiedById: userId,
|
||||
verifiedAt: new Date(),
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a feedback item.
|
||||
*/
|
||||
export async function reopenFeedbackItem(itemId: string) {
|
||||
return prisma.feedbackItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
status: "OPEN",
|
||||
resolvedById: null,
|
||||
resolvedAt: null,
|
||||
resolutionNote: null,
|
||||
verifiedById: null,
|
||||
verifiedAt: null,
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feedback item.
|
||||
*/
|
||||
export async function deleteFeedbackItem(itemId: string) {
|
||||
await prisma.feedbackItem.delete({ where: { id: itemId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry forward unresolved action items from a previous revision to a new one.
|
||||
*/
|
||||
export async function carryForwardFeedback(
|
||||
stageId: string,
|
||||
previousRevisionId: string,
|
||||
newRevisionId: string,
|
||||
userId: string
|
||||
) {
|
||||
const unresolvedItems = await prisma.feedbackItem.findMany({
|
||||
where: {
|
||||
revisionId: previousRevisionId,
|
||||
status: { in: ["OPEN", "IN_PROGRESS", "REOPENED"] },
|
||||
isActionItem: true, // only carry forward action items, not info callouts
|
||||
},
|
||||
});
|
||||
|
||||
if (unresolvedItems.length === 0) return [];
|
||||
|
||||
const carriedItems = await prisma.$transaction(
|
||||
unresolvedItems.map((item, idx) =>
|
||||
prisma.feedbackItem.create({
|
||||
data: {
|
||||
deliverableStageId: stageId,
|
||||
revisionId: newRevisionId,
|
||||
annotationId: item.annotationId,
|
||||
commentId: item.commentId,
|
||||
summary: item.summary,
|
||||
isActionItem: true,
|
||||
status: "OPEN",
|
||||
sortOrder: idx + 1,
|
||||
assignedToId: item.assignedToId,
|
||||
createdById: userId,
|
||||
carriedFromId: item.id,
|
||||
},
|
||||
include: FEEDBACK_INCLUDE,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return carriedItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback summary counts for a stage (for badge/indicator display).
|
||||
*/
|
||||
export async function getFeedbackSummary(stageId: string) {
|
||||
const items = await prisma.feedbackItem.findMany({
|
||||
where: { deliverableStageId: stageId },
|
||||
select: { status: true, isActionItem: true },
|
||||
});
|
||||
|
||||
const actionItems = items.filter((i) => i.isActionItem);
|
||||
const total = actionItems.length;
|
||||
const resolved = actionItems.filter(
|
||||
(i) => i.status === "RESOLVED" || i.status === "VERIFIED"
|
||||
).length;
|
||||
const open = total - resolved;
|
||||
const infoCount = items.filter((i) => !i.isActionItem).length;
|
||||
|
||||
return { total, resolved, open, infoCount };
|
||||
}
|
||||
264
src/lib/services/review-session-service.ts
Normal file
264
src/lib/services/review-session-service.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type {
|
||||
CreateReviewSessionInput,
|
||||
UpdateReviewSessionInput,
|
||||
AddSessionItemsInput,
|
||||
ReorderSessionItemsInput,
|
||||
RecordDecisionInput,
|
||||
GenerateSessionItemsInput,
|
||||
} from "@/lib/validators/review-session";
|
||||
|
||||
const SESSION_INCLUDE = {
|
||||
createdBy: { select: { id: true, name: true, email: true, image: true } },
|
||||
items: {
|
||||
orderBy: { sortOrder: "asc" as const },
|
||||
include: {
|
||||
deliverableStage: {
|
||||
include: {
|
||||
template: { select: { id: true, name: true, slug: true, order: true } },
|
||||
deliverable: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
priority: true,
|
||||
project: { select: { id: true, name: true, projectCode: true } },
|
||||
},
|
||||
},
|
||||
revisions: {
|
||||
orderBy: { roundNumber: "desc" as const },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
roundNumber: true,
|
||||
status: true,
|
||||
attachments: true,
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
revision: {
|
||||
select: {
|
||||
id: true,
|
||||
roundNumber: true,
|
||||
status: true,
|
||||
attachments: true,
|
||||
},
|
||||
},
|
||||
decidedBy: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const SESSION_LIST_INCLUDE = {
|
||||
createdBy: { select: { id: true, name: true, image: true } },
|
||||
_count: { select: { items: true } },
|
||||
items: {
|
||||
select: { decision: true },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* List all review sessions for an organization.
|
||||
*/
|
||||
export async function listReviewSessions(
|
||||
organizationId: string,
|
||||
filters?: { status?: string }
|
||||
) {
|
||||
const where: any = { organizationId };
|
||||
if (filters?.status) where.status = filters.status;
|
||||
|
||||
return prisma.reviewSession.findMany({
|
||||
where,
|
||||
include: SESSION_LIST_INCLUDE,
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single review session with all items and related data.
|
||||
*/
|
||||
export async function getReviewSession(sessionId: string) {
|
||||
return prisma.reviewSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: SESSION_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new review session.
|
||||
*/
|
||||
export async function createReviewSession(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
input: CreateReviewSessionInput
|
||||
) {
|
||||
return prisma.reviewSession.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
status: "DRAFT",
|
||||
createdById: userId,
|
||||
organizationId,
|
||||
},
|
||||
include: SESSION_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a review session (name, description, status).
|
||||
*/
|
||||
export async function updateReviewSession(
|
||||
sessionId: string,
|
||||
input: UpdateReviewSessionInput
|
||||
) {
|
||||
return prisma.reviewSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.description !== undefined && { description: input.description }),
|
||||
...(input.status !== undefined && { status: input.status as any }),
|
||||
},
|
||||
include: SESSION_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a review session.
|
||||
*/
|
||||
export async function deleteReviewSession(sessionId: string) {
|
||||
await prisma.reviewSession.delete({ where: { id: sessionId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add items to a review session.
|
||||
*/
|
||||
export async function addSessionItems(
|
||||
sessionId: string,
|
||||
input: AddSessionItemsInput
|
||||
) {
|
||||
// Get current max sort order
|
||||
const maxSort = await prisma.reviewSessionItem.aggregate({
|
||||
where: { sessionId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
let nextSort = (maxSort._max.sortOrder ?? 0) + 1;
|
||||
|
||||
const items = await prisma.$transaction(
|
||||
input.items.map((item) =>
|
||||
prisma.reviewSessionItem.create({
|
||||
data: {
|
||||
sessionId,
|
||||
deliverableStageId: item.deliverableStageId,
|
||||
revisionId: item.revisionId ?? null,
|
||||
sortOrder: nextSort++,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from a review session.
|
||||
*/
|
||||
export async function removeSessionItem(itemId: string) {
|
||||
await prisma.reviewSessionItem.delete({ where: { id: itemId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder items in a review session.
|
||||
*/
|
||||
export async function reorderSessionItems(
|
||||
sessionId: string,
|
||||
input: ReorderSessionItemsInput
|
||||
) {
|
||||
await prisma.$transaction(
|
||||
input.itemIds.map((id, index) =>
|
||||
prisma.reviewSessionItem.update({
|
||||
where: { id },
|
||||
data: { sortOrder: index + 1 },
|
||||
})
|
||||
)
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a decision on a session item.
|
||||
*/
|
||||
export async function recordDecision(
|
||||
userId: string,
|
||||
input: RecordDecisionInput
|
||||
) {
|
||||
return prisma.reviewSessionItem.update({
|
||||
where: { id: input.itemId },
|
||||
data: {
|
||||
decision: input.decision,
|
||||
decisionNote: input.decisionNote ?? null,
|
||||
decidedById: userId,
|
||||
decidedAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
decidedBy: { select: { id: true, name: true, image: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a decision from a session item.
|
||||
*/
|
||||
export async function clearDecision(itemId: string) {
|
||||
return prisma.reviewSessionItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
decision: null,
|
||||
decisionNote: null,
|
||||
decidedById: null,
|
||||
decidedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate session items from a project filtered by stage status and/or template.
|
||||
* Returns the deliverable stage IDs that match.
|
||||
*/
|
||||
export async function generateSessionItems(input: GenerateSessionItemsInput) {
|
||||
const where: any = {
|
||||
deliverable: { projectId: input.projectId },
|
||||
};
|
||||
if (input.stageStatus) where.status = input.stageStatus;
|
||||
if (input.stageTemplateId) where.templateId = input.stageTemplateId;
|
||||
|
||||
const stages = await prisma.deliverableStage.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
template: { select: { name: true, order: true } },
|
||||
deliverable: { select: { name: true } },
|
||||
revisions: {
|
||||
orderBy: { roundNumber: "desc" },
|
||||
take: 1,
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ deliverable: { name: "asc" } },
|
||||
{ template: { order: "asc" } },
|
||||
],
|
||||
});
|
||||
|
||||
return stages.map((s) => ({
|
||||
deliverableStageId: s.id,
|
||||
revisionId: s.revisions[0]?.id ?? undefined,
|
||||
label: `${s.deliverable.name} — ${s.template.name}`,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import type { CreateRevisionInput, UpdateRevisionInput } from "@/lib/validators/revision";
|
||||
import type { RevisionStatus } from "@/generated/prisma/client";
|
||||
import { copyProbes } from "@/lib/services/color-probe-service";
|
||||
|
||||
/**
|
||||
* Create a new revision round for a stage.
|
||||
|
|
@ -8,7 +9,8 @@ import type { RevisionStatus } from "@/generated/prisma/client";
|
|||
*/
|
||||
export async function createRevision(
|
||||
stageId: string,
|
||||
data: CreateRevisionInput
|
||||
data: CreateRevisionInput,
|
||||
userId?: string
|
||||
) {
|
||||
const lastRevision = await prisma.revision.findFirst({
|
||||
where: { deliverableStageId: stageId },
|
||||
|
|
@ -17,7 +19,7 @@ export async function createRevision(
|
|||
|
||||
const roundNumber = (lastRevision?.roundNumber ?? 0) + 1;
|
||||
|
||||
return prisma.revision.create({
|
||||
const revision = await prisma.revision.create({
|
||||
data: {
|
||||
deliverableStageId: stageId,
|
||||
roundNumber,
|
||||
|
|
@ -27,6 +29,17 @@ export async function createRevision(
|
|||
attachments: data.attachments ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Copy color probes from previous revision (non-blocking)
|
||||
if (lastRevision && userId) {
|
||||
try {
|
||||
await copyProbes(lastRevision.id, revision.id, userId);
|
||||
} catch {
|
||||
// Non-critical: probes are a convenience, not a requirement
|
||||
}
|
||||
}
|
||||
|
||||
return revision;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
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" | "screenshot"
|
||||
): 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/lib/utils/color.ts
Normal file
209
src/lib/utils/color.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Color utilities for CMF eyedropper probes.
|
||||
* RGB ↔ HSB conversion, 5×5 pixel sampling, and delta calculation.
|
||||
*/
|
||||
|
||||
export interface HSB {
|
||||
h: number; // 0-360 degrees
|
||||
s: number; // 0-100 percent
|
||||
b: number; // 0-100 percent
|
||||
}
|
||||
|
||||
export interface RGB {
|
||||
r: number; // 0-255
|
||||
g: number; // 0-255
|
||||
b: number; // 0-255
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
working: HSB;
|
||||
reference: HSB;
|
||||
delta: { h: number; s: number; b: number };
|
||||
status: "pass" | "warn" | "fail";
|
||||
}
|
||||
|
||||
// CMF tolerances
|
||||
const TOLERANCE = { h: 4, s: 3, b: 3 };
|
||||
// Warn when within 1.5× tolerance
|
||||
const WARN_MULTIPLIER = 1.5;
|
||||
|
||||
/**
|
||||
* Convert RGB to HSB (same as Photoshop's HSB).
|
||||
*/
|
||||
export function rgbToHsb(rgb: RGB): HSB {
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
|
||||
// Brightness
|
||||
const brightness = max * 100;
|
||||
|
||||
// Saturation
|
||||
const saturation = max === 0 ? 0 : (delta / max) * 100;
|
||||
|
||||
// Hue
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === r) {
|
||||
hue = ((g - b) / delta) % 6;
|
||||
} else if (max === g) {
|
||||
hue = (b - r) / delta + 2;
|
||||
} else {
|
||||
hue = (r - g) / delta + 4;
|
||||
}
|
||||
hue *= 60;
|
||||
if (hue < 0) hue += 360;
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(hue * 10) / 10,
|
||||
s: Math.round(saturation * 10) / 10,
|
||||
b: Math.round(brightness * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hue delta accounting for circular wraparound.
|
||||
* e.g., 358° vs 2° = Δ4°, not Δ356°.
|
||||
*/
|
||||
export function hueDelta(h1: number, h2: number): number {
|
||||
const diff = Math.abs(h1 - h2);
|
||||
return Math.min(diff, 360 - diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two HSB values and return status.
|
||||
*/
|
||||
export function compareHsb(working: HSB, reference: HSB): ProbeResult {
|
||||
const delta = {
|
||||
h: hueDelta(working.h, reference.h),
|
||||
s: Math.abs(working.s - reference.s),
|
||||
b: Math.abs(working.b - reference.b),
|
||||
};
|
||||
|
||||
const hFail = delta.h > TOLERANCE.h;
|
||||
const sFail = delta.s > TOLERANCE.s;
|
||||
const bFail = delta.b > TOLERANCE.b;
|
||||
|
||||
const hWarn = delta.h > TOLERANCE.h / WARN_MULTIPLIER;
|
||||
const sWarn = delta.s > TOLERANCE.s / WARN_MULTIPLIER;
|
||||
const bWarn = delta.b > TOLERANCE.b / WARN_MULTIPLIER;
|
||||
|
||||
let status: ProbeResult["status"] = "pass";
|
||||
if (hFail || sFail || bFail) {
|
||||
status = "fail";
|
||||
} else if (hWarn || sWarn || bWarn) {
|
||||
status = "warn";
|
||||
}
|
||||
|
||||
return { working, reference, delta, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample a 5×5 pixel area from a canvas, averaging RGB values.
|
||||
* Coordinates are in image space.
|
||||
*/
|
||||
export function samplePixels(
|
||||
canvas: HTMLCanvasElement,
|
||||
imageX: number,
|
||||
imageY: number,
|
||||
zoom: number,
|
||||
panX: number,
|
||||
panY: number
|
||||
): RGB | null {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Sample 5×5 grid centered on the point
|
||||
let totalR = 0;
|
||||
let totalG = 0;
|
||||
let totalB = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const imgPx = imageX + dx;
|
||||
const imgPy = imageY + dy;
|
||||
|
||||
// Convert image coords to canvas pixel coords
|
||||
const screenX = (panX + imgPx * zoom) * dpr;
|
||||
const screenY = (panY + imgPy * zoom) * dpr;
|
||||
|
||||
// Bounds check
|
||||
if (screenX < 0 || screenY < 0 || screenX >= canvas.width || screenY >= canvas.height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixel = ctx.getImageData(Math.round(screenX), Math.round(screenY), 1, 1).data;
|
||||
totalR += pixel[0];
|
||||
totalG += pixel[1];
|
||||
totalB += pixel[2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return {
|
||||
r: Math.round(totalR / count),
|
||||
g: Math.round(totalG / count),
|
||||
b: Math.round(totalB / count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample pixels from an image URL by drawing to an offscreen canvas.
|
||||
* Used when we don't have a visible canvas (e.g., reference image in comparison modes).
|
||||
*/
|
||||
export function sampleFromImage(
|
||||
image: HTMLImageElement,
|
||||
imageX: number,
|
||||
imageY: number
|
||||
): RGB | null {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
// Draw white background then image (matches main viewer behavior)
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
let totalR = 0;
|
||||
let totalG = 0;
|
||||
let totalB = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const px = Math.round(imageX + dx);
|
||||
const py = Math.round(imageY + dy);
|
||||
|
||||
if (px < 0 || py < 0 || px >= canvas.width || py >= canvas.height) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pixel = ctx.getImageData(px, py, 1, 1).data;
|
||||
totalR += pixel[0];
|
||||
totalG += pixel[1];
|
||||
totalB += pixel[2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return {
|
||||
r: Math.round(totalR / count),
|
||||
g: Math.round(totalG / count),
|
||||
b: Math.round(totalB / count),
|
||||
};
|
||||
}
|
||||
50
src/lib/validators/annotation.ts
Normal file
50
src/lib/validators/annotation.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
const annotationTypeEnum = z.enum([
|
||||
"RECTANGLE",
|
||||
"ELLIPSE",
|
||||
"ARROW",
|
||||
"FREEHAND",
|
||||
"TEXT",
|
||||
"PIN",
|
||||
"SCREENSHOT",
|
||||
]);
|
||||
|
||||
export type AnnotationTypeValue = z.infer<typeof annotationTypeEnum>;
|
||||
|
||||
/**
|
||||
* Shape data varies by annotation type. We use a loose Json schema
|
||||
* here and validate more specifically in the service layer if needed.
|
||||
*/
|
||||
const annotationDataSchema = z.object({
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
endX: z.number().optional(),
|
||||
endY: z.number().optional(),
|
||||
points: z.array(z.object({ x: z.number(), y: z.number() })).optional(),
|
||||
text: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
strokeWidth: z.number().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const createAnnotationSchema = z.object({
|
||||
type: annotationTypeEnum,
|
||||
data: annotationDataSchema,
|
||||
imageX: z.number(),
|
||||
imageY: z.number(),
|
||||
commentContent: z.string().min(1, "Comment text is required"),
|
||||
stageId: z.string().min(1, "Stage ID is required"),
|
||||
});
|
||||
|
||||
export type CreateAnnotationInput = z.infer<typeof createAnnotationSchema>;
|
||||
|
||||
export const updateAnnotationSchema = z.object({
|
||||
data: annotationDataSchema.optional(),
|
||||
imageX: z.number().optional(),
|
||||
imageY: z.number().optional(),
|
||||
});
|
||||
|
||||
export type UpdateAnnotationInput = z.infer<typeof updateAnnotationSchema>;
|
||||
20
src/lib/validators/color-probe.ts
Normal file
20
src/lib/validators/color-probe.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
export const createColorProbeSchema = z.object({
|
||||
index: z.number().int().min(1).max(12),
|
||||
workingX: z.number(),
|
||||
workingY: z.number(),
|
||||
referenceX: z.number(),
|
||||
referenceY: z.number(),
|
||||
});
|
||||
|
||||
export type CreateColorProbeInput = z.infer<typeof createColorProbeSchema>;
|
||||
|
||||
export const updateColorProbeSchema = z.object({
|
||||
workingX: z.number().optional(),
|
||||
workingY: z.number().optional(),
|
||||
referenceX: z.number().optional(),
|
||||
referenceY: z.number().optional(),
|
||||
});
|
||||
|
||||
export type UpdateColorProbeInput = z.infer<typeof updateColorProbeSchema>;
|
||||
42
src/lib/validators/feedback.ts
Normal file
42
src/lib/validators/feedback.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
const feedbackStatusEnum = z.enum([
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
"RESOLVED",
|
||||
"VERIFIED",
|
||||
"REOPENED",
|
||||
]);
|
||||
|
||||
export const createFeedbackSchema = z.object({
|
||||
revisionId: z.string().min(1, "Revision ID is required"),
|
||||
annotationId: z.string().optional(),
|
||||
commentId: z.string().optional(),
|
||||
summary: z.string().min(1, "Summary is required"),
|
||||
isActionItem: z.boolean().optional(), // default true
|
||||
assignedToId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateFeedbackInput = z.infer<typeof createFeedbackSchema>;
|
||||
|
||||
export const updateFeedbackSchema = z.object({
|
||||
summary: z.string().min(1).optional(),
|
||||
isActionItem: z.boolean().optional(),
|
||||
status: feedbackStatusEnum.optional(),
|
||||
assignedToId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type UpdateFeedbackInput = z.infer<typeof updateFeedbackSchema>;
|
||||
|
||||
export const resolveFeedbackSchema = z.object({
|
||||
resolutionNote: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ResolveFeedbackInput = z.infer<typeof resolveFeedbackSchema>;
|
||||
|
||||
export const verifyFeedbackSchema = z.object({
|
||||
reopen: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type VerifyFeedbackInput = z.infer<typeof verifyFeedbackSchema>;
|
||||
52
src/lib/validators/review-session.ts
Normal file
52
src/lib/validators/review-session.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
export const createReviewSessionSchema = z.object({
|
||||
name: z.string().min(1, "Session name is required"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateReviewSessionInput = z.infer<typeof createReviewSessionSchema>;
|
||||
|
||||
export const updateReviewSessionSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
status: z.enum(["DRAFT", "IN_PROGRESS", "COMPLETED"]).optional(),
|
||||
});
|
||||
|
||||
export type UpdateReviewSessionInput = z.infer<typeof updateReviewSessionSchema>;
|
||||
|
||||
export const addSessionItemsSchema = z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
deliverableStageId: z.string().min(1),
|
||||
revisionId: z.string().optional(),
|
||||
})
|
||||
).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
export type AddSessionItemsInput = z.infer<typeof addSessionItemsSchema>;
|
||||
|
||||
export const reorderSessionItemsSchema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
export type ReorderSessionItemsInput = z.infer<typeof reorderSessionItemsSchema>;
|
||||
|
||||
export const recordDecisionSchema = z.object({
|
||||
itemId: z.string().min(1, "Item ID is required"),
|
||||
decision: z.enum(["APPROVED", "CHANGES_REQUESTED"]),
|
||||
decisionNote: z.string().optional(),
|
||||
});
|
||||
|
||||
export type RecordDecisionInput = z.infer<typeof recordDecisionSchema>;
|
||||
|
||||
export const generateSessionItemsSchema = z.object({
|
||||
projectId: z.string().min(1, "Project ID is required"),
|
||||
stageStatus: z.enum([
|
||||
"BLOCKED", "NOT_STARTED", "IN_PROGRESS", "IN_REVIEW",
|
||||
"CHANGES_REQUESTED", "APPROVED", "DELIVERED", "SKIPPED",
|
||||
]).optional(),
|
||||
stageTemplateId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GenerateSessionItemsInput = z.infer<typeof generateSessionItemsSchema>;
|
||||
Loading…
Add table
Reference in a new issue