Merge pull request #2 from packman86/feature/visual-review-tool

Feature/visual review tool
This commit is contained in:
Leivur R. Djurhuus 2026-03-17 22:25:59 -05:00 committed by GitHub
commit 082b91b09e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 12506 additions and 144 deletions

2
.gitignore vendored
View file

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

View file

@ -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 (0100%)
- **Toggle** — click or press Space to crossfade between images (default on narrow screens)
- **Revision selectors** — dropdowns for left/right revision (default: previous round vs. current)
- **Synced navigation** — zoom/pan one pane, both move together across all modes
- **Keyboard shortcuts** — 1/2/3/4 to switch modes, left/right arrows to cycle revisions
**Key files:**
- `src/components/review/image-viewer.tsx`
- `src/components/review/zoom-controls.tsx`
- `src/components/review/minimap.tsx`
- `src/hooks/use-image-viewer.ts`
- `src/components/review/comparison-viewer.tsx` — Dual-pane orchestrator
- `src/components/review/wipe-divider.tsx` — Draggable split control
- `src/components/review/overlay-controls.tsx` — Opacity slider + mode toggles
**Dependencies:** Requires A1
---
#### A3 — Pixel-Accurate Annotations
#### A3 — Annotations `[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
View file

@ -40,6 +40,7 @@
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
@ -1225,7 +1226,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@ -13675,7 +13675,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@ -13719,7 +13718,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},

View file

@ -51,6 +51,7 @@
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",

View file

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

View file

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

View file

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

View 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} &middot;{" "}
{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>
);
}

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

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

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

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

View file

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

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

View file

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

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

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

View file

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

View file

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

View file

@ -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 },
];

View 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>
)}
</>
);
}

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

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

View 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>
)}
</>
);
}

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,60 @@
"use client";
import { cn } from "@/lib/utils";
interface RevisionImage {
revisionId: string;
roundNumber: number;
type: "reference" | "current";
url: string;
thumbnailUrl: string;
filename: string;
}
interface ImageGalleryProps {
images: RevisionImage[];
activeUrl: string | null;
onSelect: (image: RevisionImage) => void;
}
export function ImageGallery({
images,
activeUrl,
onSelect,
}: ImageGalleryProps) {
if (images.length === 0) return null;
return (
<div className="flex items-center gap-1 overflow-x-auto rounded-lg border bg-[var(--card)] p-1.5">
<span className="shrink-0 px-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Gallery
</span>
<div className="mx-1 h-8 w-px shrink-0 bg-[var(--border)]" />
{images.map((img) => (
<button
key={`${img.revisionId}-${img.type}`}
className={cn(
"group relative shrink-0 overflow-hidden rounded border transition-all",
activeUrl === img.url
? "border-[var(--primary)] ring-1 ring-[var(--primary)]"
: "border-transparent hover:border-[var(--muted-foreground)]"
)}
onClick={() => onSelect(img)}
title={`Round ${img.roundNumber}${img.type === "reference" ? "Reference" : "Current"}`}
>
<img
src={img.thumbnailUrl}
alt={`Round ${img.roundNumber} ${img.type}`}
className="h-10 w-10 object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-0.5 text-center">
<span className="text-[8px] font-medium text-white">
R{img.roundNumber}
{img.type === "reference" ? " ref" : ""}
</span>
</div>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,196 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Upload, ImageIcon, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
interface ImageUploadZoneProps {
stageId: string;
revisionId: string;
imageType: "reference" | "current";
existingImage?: {
url: string;
filename: string;
width: number;
height: number;
} | null;
onUploadComplete: () => void;
compact?: boolean;
}
export function ImageUploadZone({
stageId,
revisionId,
imageType,
existingImage,
onUploadComplete,
compact = false,
}: ImageUploadZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = useCallback(
async (file: File) => {
setIsUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("type", imageType);
const res = await fetch(
`/api/stages/${stageId}/revisions/${revisionId}/upload`,
{ method: "POST", body: formData }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || "Upload failed");
}
toast.success(
`${imageType === "reference" ? "Reference" : "Current"} image uploaded`
);
onUploadComplete();
} catch (e) {
toast.error("Upload failed", {
description: e instanceof Error ? e.message : "Unknown error",
});
} finally {
setIsUploading(false);
}
},
[stageId, revisionId, imageType, onUploadComplete]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
},
[uploadFile]
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
// Reset input so the same file can be selected again
e.target.value = "";
},
[uploadFile]
);
const handleDelete = useCallback(async () => {
try {
const res = await fetch(
`/api/stages/${stageId}/revisions/${revisionId}/upload?type=${imageType}`,
{ method: "DELETE" }
);
if (!res.ok) throw new Error("Delete failed");
toast.success("Image removed");
onUploadComplete();
} catch (e) {
toast.error("Failed to remove image");
}
}, [stageId, revisionId, imageType, onUploadComplete]);
// Existing image: show thumbnail with replace/delete actions
if (existingImage) {
return (
<div className={cn("group relative", compact ? "h-16 w-16" : "")}>
<img
src={existingImage.url}
alt={existingImage.filename}
className={cn(
"rounded border object-cover",
compact ? "h-16 w-16" : "h-24 w-full"
)}
/>
<div className="absolute inset-0 flex items-center justify-center gap-1 rounded bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-[10px] text-white hover:bg-white/20 hover:text-white"
onClick={() => fileInputRef.current?.click()}
>
Replace
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 text-[10px] text-white hover:bg-red-500/50 hover:text-white"
onClick={handleDelete}
>
<X className="h-3 w-3" />
</Button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/tiff"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}
// Empty state: drop zone
return (
<div
className={cn(
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors",
isDragging
? "border-[var(--primary)] bg-[var(--primary)]/5"
: "border-[var(--border)] hover:border-[var(--muted-foreground)]",
compact ? "h-16 w-16 p-1" : "p-6",
isUploading && "pointer-events-none opacity-60"
)}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !isUploading && fileInputRef.current?.click()}
role="button"
tabIndex={0}
>
{isUploading ? (
<Loader2
className={cn(
"animate-spin text-[var(--muted-foreground)]",
compact ? "h-4 w-4" : "h-6 w-6"
)}
/>
) : compact ? (
<ImageIcon className="h-4 w-4 text-[var(--muted-foreground)]" />
) : (
<>
<Upload className="mb-2 h-6 w-6 text-[var(--muted-foreground)]" />
<p className="text-xs font-medium">
{imageType === "reference" ? "Reference Image" : "Current Render"}
</p>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
Drop file or click to browse
</p>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">
PNG, JPEG, WebP, TIFF up to 50MB
</p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/tiff"
className="hidden"
onChange={handleFileSelect}
/>
</div>
);
}

View file

@ -0,0 +1,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>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
interface MinimapProps {
imageSrc: string | null;
imageWidth: number;
imageHeight: number;
zoom: number;
panX: number;
panY: number;
containerWidth: number;
containerHeight: number;
onNavigate: (panX: number, panY: number) => void;
}
const MINIMAP_SIZE = 150;
export function Minimap({
imageSrc,
imageWidth,
imageHeight,
zoom,
panX,
panY,
containerWidth,
containerHeight,
onNavigate,
}: MinimapProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
// Only show minimap when zoomed in past fit
const imageDisplayW = imageWidth * zoom;
const imageDisplayH = imageHeight * zoom;
const isZoomedIn =
imageDisplayW > containerWidth || imageDisplayH > containerHeight;
const scale = Math.min(
MINIMAP_SIZE / imageWidth,
MINIMAP_SIZE / imageHeight
);
const mapW = Math.ceil(imageWidth * scale);
const mapH = Math.ceil(imageHeight * scale);
const render = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const img = imageRef.current;
if (!canvas || !ctx || !img) return;
ctx.clearRect(0, 0, mapW, mapH);
// Draw scaled image
ctx.drawImage(img, 0, 0, mapW, mapH);
// Draw viewport rectangle
const vpX = (-panX / zoom) * scale;
const vpY = (-panY / zoom) * scale;
const vpW = (containerWidth / zoom) * scale;
const vpH = (containerHeight / zoom) * scale;
ctx.strokeStyle = "rgba(255, 255, 255, 0.9)";
ctx.lineWidth = 1.5;
ctx.strokeRect(vpX, vpY, vpW, vpH);
ctx.fillStyle = "rgba(255, 255, 255, 0.08)";
ctx.fillRect(vpX, vpY, vpW, vpH);
}, [mapW, mapH, panX, panY, zoom, containerWidth, containerHeight, scale]);
// Load minimap image
useEffect(() => {
if (!imageSrc) return;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageRef.current = img;
render();
};
img.src = imageSrc;
}, [imageSrc, render]);
// Re-render on viewport changes
useEffect(() => {
render();
}, [render]);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Convert minimap coordinates to image coordinates
const imgX = clickX / scale;
const imgY = clickY / scale;
// Center the viewport on this point
const newPanX = containerWidth / 2 - imgX * zoom;
const newPanY = containerHeight / 2 - imgY * zoom;
onNavigate(newPanX, newPanY);
},
[scale, zoom, containerWidth, containerHeight, onNavigate]
);
if (!isZoomedIn || !imageSrc) return null;
return (
<div className="absolute bottom-3 right-3 overflow-hidden rounded border border-white/20 bg-black/60 shadow-lg backdrop-blur-sm">
<canvas
ref={canvasRef}
width={mapW}
height={mapH}
className="block cursor-crosshair"
onClick={handleClick}
/>
</div>
);
}

View file

@ -0,0 +1,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>
);
}

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

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

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

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

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

View 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, &larr;&rarr; = 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>
);
}

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

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

View file

@ -0,0 +1,106 @@
"use client";
import { Minus, Plus, Maximize, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { ZoomPreset } from "@/hooks/use-image-viewer";
const PRESETS: { label: string; value: ZoomPreset }[] = [
{ label: "Fit", value: "fit" },
{ label: "50%", value: "50" },
{ label: "100%", value: "100" },
{ label: "150%", value: "150" },
{ label: "200%", value: "200" },
];
interface ZoomControlsProps {
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onFitToView: () => void;
onZoomToPreset: (preset: ZoomPreset) => void;
}
export function ZoomControls({
zoom,
onZoomIn,
onZoomOut,
onFitToView,
onZoomToPreset,
}: ZoomControlsProps) {
const percent = Math.round(zoom * 100);
return (
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--card)] p-0.5">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onZoomOut}
title="Zoom out ()"
>
<Minus className="h-3.5 w-3.5" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 min-w-[52px] px-1.5 font-mono text-xs"
>
{percent}%
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center">
{PRESETS.map((p) => (
<DropdownMenuItem
key={p.value}
onClick={() => onZoomToPreset(p.value)}
className="font-mono text-xs"
>
{p.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onZoomIn}
title="Zoom in (+)"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<div className="mx-0.5 h-4 w-px bg-[var(--border)]" />
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onFitToView}
title="Fit to view (0)"
>
<Maximize className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => onZoomToPreset("100")}
title="Actual pixels (1)"
>
<Square className="h-3 w-3" />
</Button>
</div>
);
}

View file

@ -0,0 +1,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>
);
}

View 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,
}

View 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,
};
}

View 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"] });
},
});
}

View 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
View 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],
});
},
});
}

View file

@ -0,0 +1,395 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export type ZoomPreset = "fit" | "50" | "100" | "150" | "200";
interface ViewerState {
zoom: number;
panX: number;
panY: number;
}
interface ImageDimensions {
width: number;
height: number;
}
export interface UseImageViewerReturn {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
containerRef: React.RefObject<HTMLDivElement | null>;
state: ViewerState;
imageDimensions: ImageDimensions | null;
fitZoom: number;
// Actions
setZoom: (zoom: number, centerX?: number, centerY?: number) => void;
setPan: (panX: number, panY: number) => void;
zoomToPreset: (preset: ZoomPreset) => void;
zoomIn: () => void;
zoomOut: () => void;
fitToView: () => void;
// Pixel info
pixelInfo: { x: number; y: number; color: string } | null;
// Image loading
loadImage: (src: string) => void;
isLoading: boolean;
}
const MIN_ZOOM = 0.05;
const MAX_ZOOM = 5;
const ZOOM_STEP = 1.15;
export function useImageViewer(): UseImageViewerReturn {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const rafRef = useRef<number>(0);
const [state, setState] = useState<ViewerState>({
zoom: 1,
panX: 0,
panY: 0,
});
const [imageDimensions, setImageDimensions] =
useState<ImageDimensions | null>(null);
const [fitZoom, setFitZoom] = useState(1);
const [pixelInfo, setPixelInfo] = useState<{
x: number;
y: number;
color: string;
} | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Use refs for state in event handlers to avoid stale closures
const stateRef = useRef(state);
stateRef.current = state;
const isPanning = useRef(false);
const lastMouse = useRef({ x: 0, y: 0 });
// ── Render ──────────────────────────────────────────────────────────
const render = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
const img = imageRef.current;
if (!canvas || !ctx || !img) return;
const container = containerRef.current;
if (!container) return;
// Handle retina
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
if (
canvas.width !== rect.width * dpr ||
canvas.height !== rect.height * dpr
) {
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
}
const { zoom, panX, panY } = stateRef.current;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw checkerboard background for transparency indication
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(panX, panY);
ctx.scale(zoom, zoom);
// Draw white background behind image area
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, img.width, img.height);
ctx.imageSmoothingEnabled = zoom < 1;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0);
}, []);
const requestRender = useCallback(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(render);
}, [render]);
// ── Fit calculation ─────────────────────────────────────────────────
const calculateFitZoom = useCallback(() => {
const container = containerRef.current;
const img = imageRef.current;
if (!container || !img) return 1;
const rect = container.getBoundingClientRect();
const padding = 20;
const availW = rect.width - padding * 2;
const availH = rect.height - padding * 2;
return Math.min(availW / img.width, availH / img.height, 1);
}, []);
// ── Actions ─────────────────────────────────────────────────────────
const fitToView = useCallback(() => {
const container = containerRef.current;
const img = imageRef.current;
if (!container || !img) return;
const zoom = calculateFitZoom();
const rect = container.getBoundingClientRect();
const panX = (rect.width - img.width * zoom) / 2;
const panY = (rect.height - img.height * zoom) / 2;
setFitZoom(zoom);
setState({ zoom, panX, panY });
requestRender();
}, [calculateFitZoom, requestRender]);
const setZoom = useCallback(
(newZoom: number, centerX?: number, centerY?: number) => {
const container = containerRef.current;
if (!container) return;
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, newZoom));
const rect = container.getBoundingClientRect();
// Default center is the middle of the container
const cx = centerX ?? rect.width / 2;
const cy = centerY ?? rect.height / 2;
const { zoom: oldZoom, panX, panY } = stateRef.current;
const scale = clamped / oldZoom;
// Zoom toward the cursor/center point
const newPanX = cx - (cx - panX) * scale;
const newPanY = cy - (cy - panY) * scale;
setState({ zoom: clamped, panX: newPanX, panY: newPanY });
requestRender();
},
[requestRender]
);
const setPan = useCallback(
(panX: number, panY: number) => {
setState((prev) => ({ ...prev, panX, panY }));
requestRender();
},
[requestRender]
);
const zoomIn = useCallback(() => {
setZoom(stateRef.current.zoom * ZOOM_STEP);
}, [setZoom]);
const zoomOut = useCallback(() => {
setZoom(stateRef.current.zoom / ZOOM_STEP);
}, [setZoom]);
const zoomToPreset = useCallback(
(preset: ZoomPreset) => {
if (preset === "fit") {
fitToView();
return;
}
const level = parseInt(preset) / 100;
const container = containerRef.current;
const img = imageRef.current;
if (!container || !img) return;
const rect = container.getBoundingClientRect();
const panX = (rect.width - img.width * level) / 2;
const panY = (rect.height - img.height * level) / 2;
setState({ zoom: level, panX, panY });
requestRender();
},
[fitToView, requestRender]
);
// ── Load image ──────────────────────────────────────────────────────
const loadImage = useCallback(
(src: string) => {
setIsLoading(true);
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
imageRef.current = img;
setImageDimensions({ width: img.width, height: img.height });
setIsLoading(false);
// Delay fit to allow container to settle
requestAnimationFrame(() => fitToView());
};
img.onerror = () => {
setIsLoading(false);
};
img.src = src;
},
[fitToView]
);
// ── Event handlers ──────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// Wheel → zoom
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
setZoom(stateRef.current.zoom * factor, mouseX, mouseY);
};
// Mouse → pan
const handleMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return; // left click only
isPanning.current = true;
lastMouse.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = "grabbing";
};
const handleMouseMove = (e: MouseEvent) => {
// Update pixel info
const img = imageRef.current;
if (img) {
const rect = canvas.getBoundingClientRect();
const { zoom, panX, panY } = stateRef.current;
const imgX = Math.floor((e.clientX - rect.left - panX) / zoom);
const imgY = Math.floor((e.clientY - rect.top - panY) / zoom);
if (imgX >= 0 && imgX < img.width && imgY >= 0 && imgY < img.height) {
// Read pixel color from the canvas
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
if (ctx) {
const pixel = ctx.getImageData(
(e.clientX - rect.left) * dpr,
(e.clientY - rect.top) * dpr,
1,
1
).data;
const hex = `#${pixel[0].toString(16).padStart(2, "0")}${pixel[1].toString(16).padStart(2, "0")}${pixel[2].toString(16).padStart(2, "0")}`;
setPixelInfo({ x: imgX, y: imgY, color: hex.toUpperCase() });
}
} else {
setPixelInfo(null);
}
}
if (!isPanning.current) return;
const dx = e.clientX - lastMouse.current.x;
const dy = e.clientY - lastMouse.current.y;
lastMouse.current = { x: e.clientX, y: e.clientY };
setState((prev) => ({
...prev,
panX: prev.panX + dx,
panY: prev.panY + dy,
}));
requestRender();
};
const handleMouseUp = () => {
isPanning.current = false;
canvas.style.cursor = "grab";
};
const handleMouseLeave = () => {
isPanning.current = false;
canvas.style.cursor = "grab";
setPixelInfo(null);
};
canvas.addEventListener("wheel", handleWheel, { passive: false });
canvas.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
canvas.addEventListener("mouseleave", handleMouseLeave);
canvas.style.cursor = "grab";
return () => {
canvas.removeEventListener("wheel", handleWheel);
canvas.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
canvas.removeEventListener("mouseleave", handleMouseLeave);
};
}, [setZoom, requestRender]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't capture if typing in an input
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
switch (e.key) {
case "=":
case "+":
e.preventDefault();
zoomIn();
break;
case "-":
e.preventDefault();
zoomOut();
break;
case "0":
e.preventDefault();
fitToView();
break;
case "1":
e.preventDefault();
zoomToPreset("100");
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [zoomIn, zoomOut, fitToView, zoomToPreset]);
// Resize observer
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
requestRender();
});
observer.observe(container);
return () => observer.disconnect();
}, [requestRender]);
// Re-render when state changes
useEffect(() => {
requestRender();
}, [state, requestRender]);
return {
canvasRef,
containerRef,
state,
imageDimensions,
fitZoom,
setZoom,
setPan,
zoomToPreset,
zoomIn,
zoomOut,
fitToView,
pixelInfo,
loadImage,
isLoading,
};
}

View file

@ -0,0 +1,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 }),
}),
});
}

View 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,
};
}

View 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 };
}

View 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;
}

View 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 };
}

View 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}`,
}));
}

View file

@ -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;
}
/**

View file

@ -0,0 +1,145 @@
import { writeFile, mkdir, unlink } from "fs/promises";
import { existsSync } from "fs";
import path from "path";
import sharp from "sharp";
const UPLOADS_DIR = path.join(process.cwd(), "public", "uploads", "revisions");
const ALLOWED_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/tiff",
];
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
export interface UploadedImage {
url: string;
filename: string;
size: number;
width: number;
height: number;
uploadedAt: string;
originalUrl?: string; // kept for transparent PNGs
}
/**
* Process and store an uploaded image for a revision.
*
* - Validates file type and size
* - For PNGs with alpha: flattens onto white background (CG renders
* have semi-transparent drop shadows that break comparison modes)
* - Stores the processed image and returns metadata
*/
export async function processAndStoreImage(
revisionId: string,
file: File,
imageType: "reference" | "current" | "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
View 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),
};
}

View 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>;

View 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>;

View 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>;

View 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>;