Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
node_modules
npm-debug.log
.env
.env.local
.git
.gitignore
.next
.swc
.turbo
coverage
dist
build
.DS_Store
*.pem
.vscode
.idea
*.iml
.eslintignore
.prettier*
.editorconfig
docker-compose*.yml
Dockerfile*
.github
migrations
README.md
+64
View File
@@ -0,0 +1,64 @@
# ──────────────────────────────────────────────
# VFX Review Environment Configuration
# Copy to .env.local and fill in values
# ──────────────────────────────────────────────
# ── DATABASE ──────────────────────────────────
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/vfxreview"
# ── AUTH ──────────────────────────────────────
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET="your-secret-here-change-in-production"
NEXTAUTH_URL="http://localhost:3000"
# ── UPLOADTHING (simplest upload option) ──────
# Get from https://uploadthing.com
UPLOADTHING_SECRET=""
UPLOADTHING_APP_ID=""
# ── S3-COMPATIBLE STORAGE (optional) ──────────
# Set STORAGE_PROVIDER to: local | uploadthing | s3 | r2 | b2 | minio
STORAGE_PROVIDER="uploadthing"
# AWS S3
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_REGION="us-east-1"
AWS_BUCKET_NAME=""
# Cloudflare R2
R2_ACCESS_KEY_ID=""
R2_SECRET_ACCESS_KEY=""
R2_ACCOUNT_ID=""
R2_BUCKET_NAME=""
R2_PUBLIC_URL=""
# Backblaze B2
B2_APPLICATION_KEY_ID=""
B2_APPLICATION_KEY=""
B2_BUCKET_NAME=""
B2_ENDPOINT=""
# MinIO (self-hosted)
MINIO_ENDPOINT="http://localhost:9000"
MINIO_ACCESS_KEY=""
MINIO_SECRET_KEY=""
MINIO_BUCKET_NAME="vfx-review"
# Local storage (dev only)
LOCAL_UPLOAD_DIR="./uploads"
# ── EMAIL (nodemailer) ─────────────────────────
EMAIL_FROM="noreply@yourcompany.com"
EMAIL_SERVER_HOST="smtp.gmail.com"
EMAIL_SERVER_PORT=587
EMAIL_SERVER_USER=""
EMAIL_SERVER_PASSWORD=""
# ── SLACK ─────────────────────────────────────
# Default webhook (can be overridden per-project)
SLACK_DEFAULT_WEBHOOK=""
# ── APP ───────────────────────────────────────
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_APP_NAME="VFX Review"
+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Next.js
.next/
out/
build/
# Prisma
prisma/migrations/*.sql
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Uploads (local dev)
uploads/
public/uploads/
# Misc
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vercel
*.tsbuildinfo
next-env.d.ts
+47
View File
@@ -0,0 +1,47 @@
# syntax=docker/dockerfile:1
ARG NODE_VERSION=20
FROM --platform=$BUILDPLATFORM node:${NODE_VERSION}-alpine AS base
# ── Dependencies ────────────────────────────────────────
FROM --platform=$BUILDPLATFORM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package*.json ./
RUN npm ci
# ── Builder ─────────────────────────────────────────────
FROM --platform=$BUILDPLATFORM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# ── Runner ──────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/uploads && chown nextjs:nodejs /app/uploads
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+75
View File
@@ -0,0 +1,75 @@
We need to update how shotcodes are generated and stored.
to do this:
projects need a new field called
- showId (110 chars, `[A-Z0-9_]`, uppercase)
- projectType (`STANDARD` or `EPISODIC`)
# Shot Naming Conventions
## Shot Code Format
Shot codes are auto-generated on creation
### Standard Project
```
{SHOW_ID}_{SCENE}_{SHOT_NUMBER}
```
Example: `PRJX_10_0010`
### Episodic Project
```
{SHOW_ID}_{EPISODE}_{SCENE}_{SHOT_NUMBER}
```
Example: `PRJX_101_10_0010`
---
## Segments
| Segment | Source | Notes |
|---|---|---|
| `SHOW_ID` | `Project.showId` | 110 chars, letters/numbers/underscores, stored uppercase. Set once on the project. |
| `EPISODE` | `Shot.episode` | Only present on `EPISODIC` projects. Free-text, stored uppercase (e.g. `EP01`). |
| `SCENE` | `Shot.scene` | Required on every shot. Stored uppercase (e.g. `SC010`). |
| `SHOT_NUMBER` | Auto-incremented | 4-digit zero-padded integer, increments by 10 (e.g. `0010`, `0020`). |
---
## Auto-Incrementing Shot Numbers
The shot number is scoped to the **scene within a project** (and additionally to the **episode** for episodic projects):
- On create, the system queries `MAX(shotNumber)` for all shots sharing the same `projectId + scene` (+ `episode` for episodic).
- The new shot number = `MAX + 10`, starting at `0010` when no prior shots exist in that scene.
- The number is zero-padded to 4 digits.
This means shot numbers are **non-contiguous by design** — gaps allow shots to be inserted between existing ones without renumbering.
When a shot is **duplicated**, it inherits the scene/episode of the original and receives the next available shot number in that scope. Only the trailing 4-digit segment in the code is replaced; the rest of the code is preserved.
---
## Where Fields Come From
| Field | Model | Required? |
|---|---|---|
| `showId` | `Project` | Yes — validated 110 chars, `[A-Z0-9_]` |
| `projectCode` | `Project` | Yes — unique across all projects, used for billing/reference (not in shot code) |
| `projectType` | `Project` | Yes — `STANDARD` or `EPISODIC`; controls whether episode segment is included |
| `scene` | `Shot` | Yes — required by `shotSchema` |
| `episode` | `Shot` | Optional — only meaningful (and included in code) when `projectType === EPISODIC` |
| `shotGroup` | `Shot` | Optional — organisational grouping only, not part of the shot code |
---
## Notes
- `projectCode` is a separate billing/admin identifier on the Project and does **not** appear in shot codes. Shot codes use `showId` instead.
- Shot codes are not regenerated on edit — if scene or episode values change after creation, the stored `shotCode` will not reflect those changes.
- All string segments are uppercased before being written into the code.
+300
View File
@@ -0,0 +1,300 @@
# FeedBack — App Overview & Status
> Last updated: May 2026
---
## What It Is
FeedBack is a self-hosted VFX review and approval platform for boutique studios. Internal teams (admins, producers, supervisors, artists) manage projects and shots, upload versioned video files, draw frame-accurate annotations, and leave timestamped comments. External clients receive token-gated review links and can approve, reject, or request changes — no login required.
---
## Current Status
All core features are built and functional:
| Area | Status |
|------|--------|
| Auth (login, roles, sessions) | ✅ Complete |
| Projects & shots (CRUD) | ✅ Complete |
| Version upload (local storage) | ✅ Complete |
| Review player + frame timeline | ✅ Complete |
| Annotation drawing (canvas) | ✅ Complete — persists across frame navigation |
| Frame-anchored comments + replies | ✅ Complete |
| Approval workflow | ✅ Complete |
| Client management (internal) | ✅ Complete |
| Client portal (external, token-gated) | ✅ Complete |
| Tokenized review links | ✅ Complete |
| Slack notifications | ✅ Complete (optional webhook) |
| In-app notifications | ✅ Complete |
| Email (nodemailer) | ⚙️ Wired, requires SMTP env vars |
| S3 / R2 / MinIO / B2 storage | ⚙️ Wired, requires env vars |
| Docker Compose deployment | ✅ Complete |
---
## Route Map
### Public / Unauthenticated
| Route | Description |
|-------|-------------|
| `/login` | Credentials sign-in page |
| `/client/[token]` | External client project overview — lists shots visible to client |
| `/client/[token]/review/[versionId]` | External client review page — video player, comments, approve/reject |
### Dashboard (requires login)
| Route | Description |
|-------|-------------|
| `/dashboard` | Home — stats cards, shot queue, recent activity |
| `/projects` | Project list |
| `/projects/[id]` | Project detail — shot grid, stats |
| `/projects/[id]/shots/[shotId]` | Shot detail — version list, approval history |
| `/review/[versionId]` | Full-screen review player (internal) |
| `/clients` | Client list — ADMIN/PRODUCER only |
| `/clients/[clientId]` | Client detail — linked projects, active review sessions |
| `/settings` | User profile settings |
---
## API Routes
### Internal (authenticated)
| Method | Route | Purpose |
|--------|-------|---------|
| GET/POST | `/api/projects` | List / create projects |
| GET/POST/DELETE | `/api/shots` | Shot CRUD |
| GET | `/api/versions/[versionId]/comments` | Fetch comments for a version |
| GET | `/api/versions/[versionId]/annotations` | Fetch annotations for a version |
| GET/POST | `/api/clients` | List / create clients |
| GET/POST/DELETE | `/api/review-sessions` | Manage tokenized review links |
| GET/POST | `/api/notifications` | In-app notification CRUD |
| GET | `/api/files/[...key]` | Serve locally-stored files (no auth) |
| POST | `/api/upload/local` | Local file upload handler |
### Client portal (no auth — token-gated)
| Method | Route | Purpose |
|--------|-------|---------|
| GET | `/api/client/[token]/project` | Project + shots (CLIENT_REVIEW / REVISIONS / APPROVED / FINAL only) |
| GET | `/api/client/[token]/versions/[versionId]` | Version detail + comments |
| POST | `/api/client/[token]/approve` | Submit approval decision |
| POST | `/api/client/[token]/comment` | Post a comment |
---
## Server Actions (`/actions`)
| File | Exports |
|------|---------|
| `projects.ts` | `createProject`, `updateProject` |
| `shots.ts` | `createShot`, `updateShotStatus`, `getShotById` |
| `versions.ts` | `createVersion`, `setLatestVersion` |
| `comments.ts` | `addComment`, `addReply`, `resolveComment` |
| `annotations.ts` | `saveAnnotation`, `getAnnotationsForVersion`, `getAnnotationsForFrame` |
| `approvals.ts` | `submitApproval` |
---
## Database Schema
### Enums
| Enum | Values |
|------|--------|
| `Role` | `ADMIN` · `PRODUCER` · `SUPERVISOR` · `ARTIST` · `CLIENT` |
| `ProjectStatus` | `ACTIVE` · `ON_HOLD` · `COMPLETED` · `ARCHIVED` |
| `ShotStatus` | `WAITING` · `IN_PROGRESS` · `INTERNAL_REVIEW` · `CLIENT_REVIEW` · `REVISIONS` · `APPROVED` · `FINAL` |
| `ShotPriority` | `LOW` · `NORMAL` · `HIGH` · `URGENT` |
| `ApprovalStatus` | `PENDING_REVIEW` · `APPROVED` · `REJECTED` · `NEEDS_CHANGES` |
| `NotificationType` | `VERSION_UPLOADED` · `FEEDBACK_ADDED` · `SHOT_APPROVED` · `SHOT_REJECTED` · `COMMENT_REPLY` · `MENTION` · `REVISION_REQUESTED` |
### Models
#### `User`
Core identity. Has a `role` (see enum). `passwordHash` used for credentials login. NextAuth `Account` and `Session` models hang off this.
| Key field | Type | Notes |
|-----------|------|-------|
| `id` | cuid | PK |
| `email` | String | unique |
| `role` | Role | default `ARTIST` |
| `passwordHash` | String? | bcrypt hash |
| `isActive` | Boolean | soft-disable |
#### `Client`
Represents an external studio or client company.
| Key field | Type | Notes |
|-----------|------|-------|
| `company` | String | |
| `contactPerson` | String | |
| `email` | String | unique |
| `phone`, `notes`, `logoUrl` | optional | |
#### `ClientAccess`
Junction between a `User` (with `CLIENT` role) and a `Client` record. Allows a portal user account to be linked to a specific client company.
#### `Project`
Top-level container. Linked to an optional `Client`, `producer` (User), and `supervisor` (User).
| Key field | Type | Notes |
|-----------|------|-------|
| `code` | String | unique, used as short label |
| `status` | ProjectStatus | default `ACTIVE` |
| `clientId` | String? | nullable |
| `slackWebhook` | String? | per-project Slack alerts |
#### `Shot`
Belongs to a `Project`. Tracks pipeline status and priority.
| Key field | Type | Notes |
|-----------|------|-------|
| `shotCode` | String | unique per project |
| `sequence` | String? | grouping label |
| `status` | ShotStatus | default `WAITING` |
| `priority` | ShotPriority | default `NORMAL` |
| `fps` | Float | default 24 |
| `frameStart`, `frameEnd` | Int? | optional frame range |
> **Client visibility rule**: only shots with status `CLIENT_REVIEW`, `REVISIONS`, `APPROVED`, or `FINAL` are returned by the client portal API.
#### `Version`
A specific upload for a shot. Multiple versions per shot; only one has `isLatest = true`.
| Key field | Type | Notes |
|-----------|------|-------|
| `versionNumber` | Int | unique per shot |
| `fileUrl` | String | path/URL to video |
| `fileSize` | BigInt? | serialized to string in API responses |
| `fps` | Float | |
| `approvalStatus` | ApprovalStatus | default `PENDING_REVIEW` |
| `isLatest` | Boolean | |
#### `Comment`
Frame-anchored comment on a version. Supports replies via `CommentReply`.
| Key field | Type | Notes |
|-----------|------|-------|
| `frameNumber` | Int | exact frame |
| `timestamp` | Float | seconds |
| `text` | Text | |
| `isResolved` | Boolean | |
#### `CommentReply`
Flat child of `Comment`. No threading beyond one level.
#### `Annotation`
Canvas drawing saved against a version + frame. `drawingData` is JSON containing an array of `AnnotationShape` objects with normalized (01) coordinates.
| Key field | Type | Notes |
|-----------|------|-------|
| `frameNumber` | Int | |
| `drawingData` | Json | `{ shapes, canvasWidth, canvasHeight, version }` |
| `color` | String | hex, default `#ef4444` |
| `isVisible` | Boolean | soft-hide |
| `commentId` | String? | optional companion comment link |
#### `Approval`
Immutable approval record created each time a user submits a decision. The version's `approvalStatus` is updated separately.
| Key field | Type | Notes |
|-----------|------|-------|
| `status` | ApprovalStatus | |
| `notes` | Text? | |
#### `Notification`
In-app notification for a user.
| Key field | Type | Notes |
|-----------|------|-------|
| `type` | NotificationType | |
| `data` | Json? | extra context (versionId, shotCode, etc.) |
| `isRead` | Boolean | |
#### `ReviewSession`
A tokenized, time-limited review link for external clients. Tied to a `Project`.
| Key field | Type | Notes |
|-----------|------|-------|
| `token` | String | unique cuid, used in client portal URLs |
| `label` | String? | friendly name shown to client |
| `email` | String? | recipient email |
| `expiresAt` | DateTime | |
| `isActive` | Boolean | can be deactivated manually |
| `accessCount` | Int | incremented on each portal visit |
---
## Key Component Map
```
components/
player/
ReviewPlayer.tsx — Forwardref player, keyboard shortcuts, fullscreen
FrameTimeline.tsx — Canvas ruler with comment/annotation markers
PlaybackControls.tsx — Transport bar, speed selector, annotation toggle
annotations/
AnnotationCanvas.tsx — Canvas overlay; per-frame shape map persists navigation
AnnotationTools.tsx — Tool/colour/stroke picker (shown when annotating)
comments/
CommentPanel.tsx — Comment list, filter, reply, resolve; seekToFrame on click
versions/
VersionUpload.tsx — Drag-and-drop uploader
VersionList.tsx — Version history with approval badges
shots/
ShotCard.tsx — Shot grid card with status dropdown
NewShotDialog.tsx — Create shot form
projects/
ProjectCard.tsx — Project list card
NewProjectDialog.tsx — Create project form
clients/
NewClientDialog.tsx — Create client form
ShareReviewDialog.tsx — Generate tokenized review link
ReviewSessionList.tsx — Active sessions with copy/deactivate actions
dashboard/
StatsCards.tsx — Top-level counts
ShotQueue.tsx — Shots needing attention
RecentActivity.tsx — Latest version/comment events
layout/
Sidebar.tsx — Nav + role-based link visibility
Header.tsx — Breadcrumb + notification bell
NotificationBell.tsx — Dropdown of unread notifications
```
---
## Auth & Roles
Authentication uses **NextAuth v5** with a Credentials provider (email + bcrypt password). Sessions are JWT-based.
| Role | Access |
|------|--------|
| `ADMIN` | Full access including user management, client management |
| `PRODUCER` | Full project/shot/version access, client management |
| `SUPERVISOR` | Full project/shot/version access, no client management |
| `ARTIST` | Own shots and versions; can comment |
| `CLIENT` | Dashboard login only (legacy); primary access via portal token links |
The **client portal** routes (`/client/[token]/*` and `/api/client/[token]/*`) bypass auth entirely — access is controlled by token validity, `isActive`, and `expiresAt`.
---
## Storage
Controlled by `STORAGE_PROVIDER` env var. Currently defaults to `local` (files saved in `/public/uploads/`, served via `/api/files/[...key]`). Switching to S3/R2/B2/MinIO/UploadThing requires only env var changes.
---
## Environment Variables (minimum for local dev)
```env
DATABASE_URL="postgresql://user:pass@localhost:5432/feedback"
AUTH_SECRET="<32-char random string>"
NEXTAUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
STORAGE_PROVIDER="local"
```
+400
View File
@@ -0,0 +1,400 @@
Build a modern web-based VFX review and approval platform prototype focused on simplicity, speed, and reliability for boutique VFX/animation studios.
The goal is NOT to compete with enterprise systems immediately.
The goal is:
* dead simple uploads
* fast review cycles
* frame-accurate feedback
* artist/client collaboration
* approval tracking
* production-ready architecture
The UX should feel modern, cinematic, lightweight, and extremely responsive.
Tech stack requirements:
* Next.js latest App Router
* TypeScript
* TailwindCSS
* shadcn/ui
* PostgreSQL
* Prisma ORM
* React Query / TanStack Query
* UploadThing or S3-compatible uploads
* Server Actions where appropriate
* Docker support
* Self-hostable architecture
* Dark mode first
The system should support:
* clients
* projects
* shots
* versions
* comments
* annotations
* approvals
# CORE GOAL
Artists upload shot versions.
Clients and supervisors can:
* watch versions
* pause on frames
* leave timestamped comments
* draw annotations directly on frames
* approve/reject versions
Everything should feel frictionless.
# DATA MODEL
## CLIENTS
* company
* contact person
* email
## PROJECTS
* project name
* code
* linked client
* status
* due dates
## SHOTS
* shot code
* sequence
* description
* status
* assigned artist
* priority
Statuses:
* Waiting
* In Progress
* Internal Review
* Client Review
* Revisions
* Approved
* Final
## VERSIONS
Each shot can have many versions.
Fields:
* version number
* upload file
* thumbnail
* artist
* upload date
* notes
* fps
* duration
* frame count
* approval status
Approval statuses:
* Pending Review
* Approved
* Rejected
* Needs Changes
Support:
* mp4
* mov
* jpg sequences
* png sequences
# VIDEO REVIEW PLAYER
Build a custom review player optimized for frame review.
Features:
* frame stepping
* JKL playback
* timeline scrubbing
* timestamp display
* frame number display
* fullscreen
* playback speed controls
VERY IMPORTANT:
Comments must attach to:
* exact frame number
* timestamp
* version
Example:
Frame 115:
"Tracking slips here."
# FRAME-ACCURATE COMMENTS
Users can:
* pause playback
* click "Add Comment"
* comment attaches to exact frame
Comment structure:
* author
* frame number
* timestamp
* text
* resolved/unresolved
* replies
* created date
Allow threaded replies.
# DRAW-OVER ANNOTATIONS
Users should be able to:
* draw directly on paused frame
* arrows
* circles
* freehand lines
* rectangles
Annotations must save:
* frame number
* vector drawing data
* author
* linked comment
Allow:
* toggling annotations
* multiple annotations per frame
Use HTML canvas or Konva.js.
# REVIEW WORKFLOW
Implement a lightweight approval pipeline.
Review statuses:
* Pending
* Internal Approved
* Client Approved
* Needs Changes
* Final Approved
Clients should be able to:
* approve version
* reject version
* request revisions
Artists should:
* receive notifications
* upload new versions
* compare against previous versions
# SIDE-BY-SIDE VERSION COMPARISON
Allow:
* A/B version comparison
* wipe comparison slider
* previous version overlay
Useful for:
* comp revisions
* cleanup changes
* color tweaks
# NOTIFICATIONS
Add notification system.
Examples:
* New version uploaded
* New feedback added
* Shot approved
* Shot rejected
* Comment reply added
Support:
* in-app notifications
* email notifications
* Slack integration
# SLACK INTEGRATION
Integrate with Slack webhooks.
Examples:
```plaintext id="6j6t2g"
SH020 v004 approved by client
```
```plaintext id="p5v8rz"
New feedback added on SH035 frame 122
```
Allow configurable channels per project.
# FILE STORAGE
Use S3-compatible architecture.
Support:
* local storage
* Backblaze B2
* AWS S3
* Cloudflare R2
* self-hosted MinIO
Generate:
* thumbnails
* proxies
* poster frames
# SECURITY
Implement:
* signed upload URLs
* secure client review links
* expiring review sessions
* role permissions
Roles:
* Admin
* Producer
* Supervisor
* Artist
* Client
Clients should ONLY see:
* assigned projects
* approved review material
# CLIENT REVIEW PORTAL
Clients should have a very simple portal:
* open project
* review shots
* leave notes
* approve/reject
No production complexity exposed.
Keep it clean and minimal.
# DASHBOARD
Internal dashboard should show:
* shots awaiting review
* shots needing revisions
* approved shots
* overdue shots
* recent comments
* artist assignments
# UI REQUIREMENTS
The UI should feel:
* modern
* film-industry oriented
* dark themed
* fast
* minimal clutter
Inspiration:
* Frame.io
* SyncSketch
* ShotGrid Review
* Vimeo Review
But simpler and cleaner.
# IMPORTANT PERFORMANCE REQUIREMENTS
Optimize for:
* fast scrubbing
* lightweight annotation rendering
* low-latency comments
* proxy playback
Avoid bloated enterprise complexity.
# BONUS FEATURES
If possible implement:
* LUT viewing
* EXR sequence support
* burn-ins
* watermarking
* presentation playlists
* review sessions
* audio comments
* webhook API
* task linking
* AI feedback summaries
# ARCHITECTURE
Design the system so it can later integrate into a larger VFX pipeline platform including:
* bidding
* production tracking
* artist scheduling
* invoicing
* asset management
* render tracking
Avoid hardcoding review-specific assumptions.
# MVP PRIORITY
Prioritize these features FIRST:
1. Upload video
2. Scrub by frame
3. Add timestamp comments
4. Draw annotations
5. Approve/reject versions
6. View feedback history
Those six features are the core product.
Everything else is secondary.
The app should feel like:
“A modern lightweight review system that actually respects artists time.”
+916
View File
@@ -0,0 +1,916 @@
Update the existing FeedBack platform to add a lightweight production tracking system directly into the current architecture.
IMPORTANT:
Do NOT redesign the app from scratch.
Do NOT build enterprise production management.
Do NOT attempt full ShotGrid/ftrack complexity.
The goal is:
* simple
* fast
* usable
* production-focused
* minimal friction
FeedBack already has:
* projects
* shots
* versions
* reviews
* comments
* approvals
* client portal
We are now extending it into a lightweight VFX production tracker.
The philosophy should be:
“A clean operational layer for boutique VFX studios.”
---
# HIGH LEVEL GOAL
Add:
* production tracking
* tasks
* artist assignment
* deadlines
* kanban boards
* simple scheduling
* upload/review workflow integration
The existing review/version system should become directly attached to production tasks.
IMPORTANT:
The TASK should become the core production object.
Architecture should evolve toward:
```plaintext id="c2o8kh"
Project
├── Shots
│ ├── Tasks
│ │ ├── Versions
│ │ ├── Comments
│ │ └── Approvals
├── Assets
│ ├── Tasks
│ │ ├── Versions
│ │ ├── Comments
│ │ └── Approvals
```
---
# NEW CORE CONCEPTS
Add:
* Assets
* Tasks
* Artist Assignment
* Kanban Views
* Deadlines
* Task Statuses
DO NOT add:
* timesheets
* complex dependencies
* render management
* resource planning
* advanced scheduling
* Gantt charts
Keep the system intentionally lightweight.
---
# DATABASE CHANGES
## NEW ENUM: `TaskStatus`
Add:
```ts
TODO
IN_PROGRESS
REVIEW
CHANGES
DONE
```
---
## NEW ENUM: `TaskType`
Add:
```ts
TRACK
ROTO
KEY
COMP
FX
LIGHTING
RENDER
ANIMATION
MODEL
TEXTURE
RIG
LOOKDEV
GENERAL
```
Allow custom values later.
---
# NEW MODEL: `Asset`
Assets should behave similarly to shots.
Fields:
* id
* projectId
* assetCode
* name
* description
* status
* priority
* assignedLeadId
* dueDate
* createdAt
* updatedAt
Assets belong to:
* Project
Assets contain:
* Tasks
---
# NEW MODEL: `Task`
This becomes the core operational object.
Fields:
* id
* title
* description
* type
* status
* priority
* dueDate
* estimatedHours
* sortOrder
* shotId (nullable)
* assetId (nullable)
* assignedArtistId (nullable)
* createdById
* createdAt
* updatedAt
Rules:
* task belongs to either:
* a Shot
* or an Asset
* never both
Tasks can contain:
* Versions
* Comments
* Approvals
IMPORTANT:
Refactor existing Version model:
* versions should optionally belong to a Task
* NOT only a Shot
This is critical.
---
# VERSION REFACTOR
Currently:
```plaintext id="vw7z0h"
Shot -> Versions
```
Refactor to:
```plaintext id="7wfp9e"
Task -> Versions
```
Reason:
The review process is task-specific.
Example:
```plaintext id="x4fq6o"
SH010
├── Track Task
│ └── Versions
├── Roto Task
│ └── Versions
└── Comp Task
└── Versions
```
This massively improves production clarity.
Still allow:
* shot-level overview
* latest task version summary
---
# TASK WORKFLOW
Tasks should support:
* assignment
* deadlines
* uploads
* review
* approvals
Simple lifecycle:
```plaintext id="y8y95u"
TODO
→ IN_PROGRESS
→ REVIEW
→ CHANGES
→ DONE
```
---
# KANBAN BOARD
Implement lightweight kanban production views.
Views:
* By Project
* By Shot
* By Asset
* By Artist
Columns:
* Todo
* In Progress
* Review
* Changes
* Done
Cards should display:
* task title
* shot/asset code
* assigned artist
* due date
* latest version
* comment count
* approval state
Support:
* drag and drop
* optimistic updates
* instant status changes
Use:
* dnd-kit
NOT:
* giant enterprise scheduling systems
---
# NEW ROUTES
## Dashboard
Add:
```plaintext id="pwttmf"
/tasks
/tasks/[taskId]
```
---
## Project Views
Extend:
```plaintext id="6yq0o5"
/projects/[id]
```
Add tabs:
* Shots
* Assets
* Tasks
* Kanban
* Activity
---
## Asset Views
Add:
```plaintext id="y5xj4q"
/projects/[id]/assets/[assetId]
```
---
## Task Views
Add:
```plaintext id="g3fcdj"
/tasks/[taskId]
```
Task page should contain:
* task details
* status
* assignment
* uploads
* comments
* review player
* annotations
* approval history
Essentially:
task page = review page + production metadata
---
# REVIEW SYSTEM INTEGRATION
DO NOT duplicate review systems.
Existing:
* review player
* comments
* annotations
* approvals
should now attach to:
* Task Versions
The review player remains mostly unchanged.
---
# VERSION UPLOAD FLOW
New upload flow:
```plaintext id="v3r0xy"
Project
→ Shot / Asset
→ Task
→ Upload Version
```
Version uploads should:
* automatically increment version number
* optionally notify assigned reviewers
* optionally move task to REVIEW
---
# TASK CREATION UX
Allow:
* manual task creation
* templates
* quick-add buttons
Example:
For shots:
* Add Track
* Add Roto
* Add Comp
For assets:
* Add Model
* Add Texture
* Add Rig
---
# ARTIST ASSIGNMENTS
Tasks should support:
* assigned artist
* assigned supervisor
* watcher/follower users
Add lightweight workload views:
* my tasks
* due this week
* overdue
* awaiting review
---
# DASHBOARD IMPROVEMENTS
Add widgets:
* Tasks Due Today
* Tasks In Review
* Overdue Tasks
* Latest Uploads
* Recent Feedback
* Artist Workload
---
# NOTIFICATIONS
Extend notification system.
New notification types:
```ts
TASK_ASSIGNED
TASK_OVERDUE
TASK_APPROVED
TASK_CHANGES_REQUESTED
TASK_READY_FOR_REVIEW
```
Slack examples:
```plaintext id="hjlwmz"
SH010 COMP task ready for review
```
```plaintext id="64q9vx"
ASSET_CAR01 MODEL task approved
```
---
# PERMISSIONS
Maintain current auth architecture.
Permissions:
* ADMIN/PRODUCER/SUPERVISOR:
full task control
* ARTIST:
view assigned tasks
upload versions
comment
* CLIENT:
review approved review sessions only
Clients should NEVER see:
* kanban
* production internals
* task assignments
---
# UI REQUIREMENTS
The UI should feel:
* modern
* dark-mode first
* fast
* clean
* film-production oriented
Inspiration:
* Linear
* Trello
* Frame.io
* ShotGrid (but MUCH simpler)
---
# IMPORTANT PRODUCT DIRECTION
This should feel like:
“Trello + Frame.io specifically for boutique VFX studios.”
NOT:
“Enterprise production software.”
Keep:
* interactions fast
* layouts uncluttered
* onboarding minimal
---
---
# CLIENT REVIEW SHARING FLOW
Add a lightweight “Share With Client” workflow directly into the production/review system.
The goal is:
* one-click client sharing
* minimal friction
* no complicated publishing workflows
* tightly integrated into task review
This should feel extremely simple for producers/supervisors.
---
# CORE CONCEPT
A task version can move through two review stages:
```plaintext id="y5ibk0"
Internal Review
→ Client Review
→ Approved / Changes
```
When a user clicks:
```plaintext id="l5l90p"
Share With Client
```
the system should:
* mark the task/version as ready for client review
* expose the version in the client portal
* optionally notify the client
* generate/share a review link if needed
IMPORTANT:
Do NOT require duplicate uploads or separate publish steps.
The uploaded task version itself becomes the client review version.
---
# NEW TASK STATUS
Extend `TaskStatus` enum:
```ts id="z1vc6r"
TODO
IN_PROGRESS
INTERNAL_REVIEW
CLIENT_REVIEW
CHANGES
DONE
```
This mirrors actual VFX review flow more accurately.
---
# SHARE WITH CLIENT BUTTON
Add a prominent:
```plaintext id="mqkn6v"
Share With Client
```
button in multiple locations.
## PRIMARY LOCATION
Inside the internal review player page:
```plaintext id="m1sjc9"
/review/[versionId]
```
This is the most important placement.
Supervisors reviewing a version internally should be able to immediately push it to client review.
---
## ADDITIONAL LOCATIONS
Add button/action in:
* task detail page
* version list rows
* kanban task cards
* project shot overview
* latest version cards
Use:
* dropdown action menu
* quick action button
* contextual right-click menu where appropriate
---
# SHARE FLOW
When clicked:
## IF NO ACTIVE CLIENT REVIEW SESSION EXISTS
Prompt:
```plaintext id="4v0j9h"
Create review link for client?
```
Allow:
* create new tokenized review session
* select existing review session
* choose expiry date
* optional password later
* optional email send
---
## IF REVIEW SESSION EXISTS
Immediately:
* mark version as client-visible
* move task to CLIENT_REVIEW
* notify client if enabled
---
# VERSION VISIBILITY
Add fields to `Version`:
```ts id="e88z4o"
isClientVisible Boolean @default(false)
sharedAt DateTime?
sharedById String?
```
Only versions with:
```plaintext id="vlfvhy"
isClientVisible = true
```
appear in client portal APIs.
This is MUCH cleaner than relying only on shot status.
---
# CLIENT REVIEW RULES
Clients should ONLY see:
* explicitly shared versions
* latest shared version by default
Clients should NEVER see:
* internal versions
* WIP uploads
* rejected internal passes
* production notes
* kanban/task internals
---
# CLIENT REVIEW UX
When shared:
* client sees version immediately in portal
* can comment
* approve
* request changes
Client actions should:
* update task status automatically
* create notifications
* appear in activity feed
Example:
```plaintext id="yv2hfe"
Client approved SH010 COMP v004
```
Or:
```plaintext id="z61rq5"
Client requested changes on SH020 TRACK v002
```
---
# OPTIONAL EMAIL FLOW
Allow optional:
```plaintext id="jk3o5i"
Send Review Email
```
Email contains:
* project name
* shot/task name
* review link
* optional message
---
# SMART STATUS AUTOMATION
Recommended automatic transitions:
```plaintext id="mxti1g"
Upload Version
→ INTERNAL_REVIEW
Click "Share With Client"
→ CLIENT_REVIEW
Client Approves
→ DONE
Client Requests Changes
→ CHANGES
```
This keeps production flow extremely simple.
---
# UI REQUIREMENTS
The "Share With Client" action should feel:
* fast
* obvious
* production-friendly
Suggested styling:
* accent button
* paper-plane/share icon
* visible near approval controls
Avoid:
* buried menus
* multi-step publish systems
* complicated permission dialogs
---
# IMPORTANT PRODUCT DIRECTION
This feature is one of the biggest differentiators of the platform.
The ideal workflow should feel like:
```plaintext id="v5z9eo"
Artist uploads →
Supervisor reviews →
Clicks "Share With Client" →
Client reviews instantly
```
No exporting.
No re-uploading.
No external tools.
No confusion.
That simplicity is the product advantage.
---
# IMPLEMENTATION PLAN
Implement in phases.
## PHASE 1 — DATABASE & CORE MODELS
1. Add Asset model
2. Add Task model
3. Add Task enums
4. Refactor Version relationships
5. Add Prisma migrations
6. Seed example tasks/assets
---
## PHASE 2 — TASK CRUD
1. Task creation dialogs
2. Task detail page
3. Task assignment
4. Status updates
5. Due dates
6. Quick-add task templates
---
## PHASE 3 — VERSION INTEGRATION
1. Attach uploads to tasks
2. Refactor review routes
3. Task-based version history
4. Latest version indicators
---
## PHASE 4 — KANBAN
1. Build kanban board
2. Drag/drop task movement
3. Real-time updates
4. Artist filtering
5. Project filtering
---
## PHASE 5 — DASHBOARD & NOTIFICATIONS
1. Task widgets
2. Workload summaries
3. Slack updates
4. In-app notifications
5. Overdue indicators
6. Client Review button implementation
---
# IMPORTANT TECHNICAL REQUIREMENTS
Maintain:
* current auth system
* current storage abstraction
* current review functionality
* current client portal
Avoid:
* breaking existing APIs
* rewriting review logic
* giant refactors
This should be an additive evolution of the existing FeedBack architecture.
---
+523
View File
@@ -0,0 +1,523 @@
The current architecture has a conceptual problem around:
* shots
* tasks
* versions
* approvals
* client review visibility
We need to refactor the production/review workflow so that TASKS become the true operational and review unit.
IMPORTANT:
Do NOT redesign the whole app.
This is a workflow/data relationship correction and cleanup.
The goal is:
* clearer production flow
* correct review behavior
* correct client review visibility
* correct kanban behavior
* proper task-based versioning
---
# CORE ARCHITECTURAL DECISION
A SHOT is:
* a container/grouping
* a production entity
* NOT directly reviewable
A TASK is:
* the actual work unit
* the review unit
* the approval unit
* the kanban unit
* the upload/version unit
This means:
```plaintext id="b89ah6"
Shot
├── Track Task
│ └── Versions
├── Roto Task
│ └── Versions
└── Comp Task
└── Versions
```
NOT:
```plaintext id="6fjlwm"
Shot
└── Versions
```
That old structure is incorrect.
---
# REQUIRED REFACTOR
## 1. VERSIONS MUST BELONG TO TASKS
Currently:
```plaintext id="y7txg8"
Shot -> Versions
```
Refactor to:
```plaintext id="dh4mf6"
Task -> Versions
```
A version upload must ALWAYS belong to:
* exactly one task
Remove the concept of:
* "shot latest version"
Instead:
* each task has its own latest version
Examples:
```plaintext id="hskx5e"
SH010
├── Track Task
│ └── latest: v003
├── Roto Task
│ └── latest: v005
└── Comp Task
└── latest: v008
```
This is the correct production model.
---
# 2. SHOTS SHOULD NOT DIRECTLY DRIVE CLIENT REVIEW
Currently:
* shot status = CLIENT_REVIEW
* portal shows latest uploaded version
* task context lost
This is incorrect.
Client review should be TASK-BASED.
---
# NEW CLIENT REVIEW RULE
The client portal should display:
```plaintext id="rlazoz"
Tasks
where latest version is marked:
isClientVisible = true
```
NOT:
```plaintext id="cr7j27"
Shots where shot.status = CLIENT_REVIEW
```
This fixes:
* incorrect versions
* missing task context
* review ambiguity
---
# CLIENT PORTAL SHOULD SHOW
Instead of:
```plaintext id="l3l0xr"
SH010
```
Show:
```plaintext id="1e2dn4"
SH010 — COMP
v008
```
Or:
```plaintext id="nyc0h4"
SH010 — ROTO
v005
```
Clients review TASK DELIVERABLES.
Not abstract shots.
---
# 3. SHOT STATUS SHOULD BE DERIVED, NOT MANUALLY DRIVEN
Current shot statuses:
```plaintext id="5f9n1d"
WAITING
IN_PROGRESS
INTERNAL_REVIEW
CLIENT_REVIEW
REVISIONS
APPROVED
FINAL
```
This is creating confusion because:
* shots are containers
* tasks are actual workflow units
Refactor shot statuses to be:
* simplified
* mostly derived automatically from task states
---
# NEW SHOT STATUS MODEL
Reduce shot statuses to:
```ts id="8km4hh"
WAITING
IN_PROGRESS
IN_REVIEW
REVISIONS
COMPLETE
```
Definitions:
## WAITING
No active tasks started.
## IN_PROGRESS
At least one task is:
* TODO
* IN_PROGRESS
## IN_REVIEW
At least one task is:
* INTERNAL_REVIEW
* CLIENT_REVIEW
## REVISIONS
At least one task is:
* CHANGES
## COMPLETE
All tasks are:
* DONE
IMPORTANT:
Shot status should mostly be AUTO-CALCULATED from tasks.
NOT manually set constantly.
---
# 4. REMOVE "FINAL"
The current:
```plaintext id="qmv5ps"
APPROVED
FINAL
```
is redundant/confusing.
Recommendation:
REMOVE `FINAL`.
Use:
```plaintext id="tyf56q"
DONE
```
at task level.
And:
```plaintext id="7u6wvp"
COMPLETE
```
at shot level.
Cleaner.
Much easier mentally.
---
# 5. TASK STATUS IS THE TRUE KANBAN STATUS
Task statuses should drive:
* kanban columns
* review state
* client review state
Task statuses:
```ts id="l0m39n"
TODO
IN_PROGRESS
INTERNAL_REVIEW
CLIENT_REVIEW
CHANGES
DONE
```
These statuses represent:
* actual workflow state
* actual review state
---
# 6. REVIEW FLOW RULES
## Upload Version
When artist uploads new version:
```plaintext id="0q6qzx"
Task → INTERNAL_REVIEW
```
Kanban card moves automatically:
```plaintext id="ym9x1s"
IN_PROGRESS
→ INTERNAL_REVIEW
```
---
## Supervisor Shares With Client
When clicking:
```plaintext id="rxry5f"
Share With Client
```
System should:
* mark latest version:
isClientVisible = true
* set task status:
CLIENT_REVIEW
Kanban updates automatically:
```plaintext id="7j69vz"
INTERNAL_REVIEW
→ CLIENT_REVIEW
```
---
## Client Approves
Client approval should:
* approve ONLY the task
* NOT the entire shot
System:
```plaintext id="xcl9sy"
Task.status = DONE
Version.approvalStatus = APPROVED
```
Kanban:
```plaintext id="p1m31i"
CLIENT_REVIEW
→ DONE
```
---
## Client Requests Changes
System:
```plaintext id="z5fprm"
Task.status = CHANGES
Version.approvalStatus = NEEDS_CHANGES
```
Kanban:
```plaintext id="r6phfx"
CLIENT_REVIEW
→ CHANGES
```
---
# 7. SHOT COMPLETION RULE
A shot becomes:
```plaintext id="l16lq6"
COMPLETE
```
ONLY when:
```plaintext id="t62l6n"
ALL TASKS == DONE
```
Example:
```plaintext id="0b2b7h"
SH010
├── Track → DONE
├── Roto → DONE
└── Comp → DONE
=> Shot COMPLETE
```
---
# 8. CLIENT PORTAL STRUCTURE
Refactor client portal to show:
```plaintext id="t1qn79"
Project
├── Shot
│ ├── Task
│ │ └── Latest Shared Version
```
Example UI:
```plaintext id="mjgr1k"
SH010
COMP — v008
ROTO — v005
SH020
COMP — v003
```
This is MUCH clearer.
---
# 9. TASK DETAIL PAGE SHOULD BECOME PRIMARY REVIEW HUB
Task page should become:
* upload page
* review page
* approval page
* history page
Structure:
```plaintext id="e2bd4f"
/tasks/[taskId]
```
Contains:
* task info
* latest version
* version history
* comments
* annotations
* approvals
* review actions
---
# 10. IMPORTANT IMPLEMENTATION NOTES
DO NOT:
* rewrite comment system
* rewrite annotation system
* rewrite player
JUST:
* move relationships from Shot → Task
* update logic
* update queries
* update review flow
This is mostly:
* schema cleanup
* workflow cleanup
* state cleanup
---
# IMPORTANT PRODUCT PRINCIPLE
The production hierarchy should now be:
```plaintext id="crkxfm"
Project
→ Shot
→ Task
→ Version
→ Comments / Annotations / Approvals
```
NOT:
```plaintext id="zebvlz"
Project
→ Shot
→ Version
```
That distinction fixes nearly all current workflow inconsistencies.
+413
View File
@@ -0,0 +1,413 @@
Add a lightweight scheduling system to the existing FeedBack production tracking platform.
IMPORTANT:
This is NOT enterprise scheduling.
Do NOT build Microsoft Project.
Do NOT build ShotGrid resource planning.
The goal is:
* simple visual scheduling
* artist workload visibility
* drag-and-drop planning
* lightweight production coordination
Think:
“Trello timeline view for VFX tasks.”
The complexity level should match the existing kanban implementation.
---
# CORE GOAL
Allow producers/supervisors to:
* assign tasks to artists
* schedule when tasks should be worked on
* visualize artist workload
* see upcoming deadlines
* drag tasks around visually
The scheduling system should integrate directly with:
* existing Tasks
* existing Artists/Users
* existing Due Dates
* existing Kanban workflow
---
# IMPORTANT PRODUCT PRINCIPLE
Scheduling is:
* lightweight planning
* not time tracking
* not timesheets
* not attendance management
We are ONLY planning:
* who works on what
* when
* for roughly how long
---
# NEW PAGE
Add:
```plaintext id="9m0ynj"
/schedule
```
Add navigation item:
```plaintext id="9cxij0"
Schedule
```
---
# SCHEDULING MODEL
Each task already has:
* assignedArtistId
* estimatedHours
* dueDate
Add:
* scheduledStartDate
* scheduledEndDate
Optional:
* scheduleNotes
---
# ARTIST WORKLOAD ASSUMPTION
Assume:
```plaintext id="sls7sz"
8 working hours per day
```
Use estimatedHours to determine task duration visually.
Example:
```plaintext id="1o9r76"
16 estimated hours
= 2 scheduled work days
```
Keep calculations simple.
---
# MAIN UI
The scheduling page should be a hybrid:
* kanban
* timeline/calendar
Layout:
```plaintext id="8fys2r"
Artists vertically
Dates horizontally
```
Example:
```plaintext id="ltm7d0"
Mon Tue Wed Thu Fri
Chris [SH010 Comp------]
Sarah [SH020 Roto--]
Mike [Asset Car Model----]
```
This should feel:
* visual
* fast
* draggable
* uncluttered
---
# TASK CARDS
Each scheduled task block should show:
* shot/asset code
* task type
* task status
* due date
* estimated hours
Optional:
* latest version indicator
* review status badge
Color coding:
* TODO
* IN_PROGRESS
* INTERNAL_REVIEW
* CLIENT_REVIEW
* CHANGES
* DONE
---
# DRAG AND DROP
Use:
* dnd-kit
Allow:
* drag tasks between artists
* resize tasks across days
* move tasks along timeline
When dragged:
* update scheduledStartDate
* update scheduledEndDate
* update assignedArtistId if moved to another artist row
Use optimistic updates.
---
# SCHEDULING RULES
Simple rules only.
## WARNING STATES
Highlight:
* overdue tasks
* tasks ending after due date
* artist overload
Example overload:
```plaintext id="qff2t0"
Artist scheduled > 8h/day
```
Use visual indicators only.
DO NOT block scheduling.
---
# FILTERS
Add:
* project filter
* artist filter
* task status filter
Optional:
* department filter
---
# TASK SOURCE
The schedule should ONLY display:
* tasks with assigned artists
OR
* tasks with scheduled dates
Unscheduled tasks remain in kanban/backlog.
---
# BACKLOG PANEL
Add optional side panel:
```plaintext id="o0lm45"
Unscheduled Tasks
```
Allow dragging tasks:
* from backlog
* onto artist schedule
This is VERY useful.
---
# REVIEW INTEGRATION
Scheduling should integrate with review flow.
Example:
```plaintext id="hr4lxg"
Task enters CLIENT_REVIEW
→ visually marked on schedule
```
Example:
```plaintext id="4f8pl5"
Task enters CHANGES
→ warning badge appears
```
This creates production awareness.
---
# KANBAN INTEGRATION
The schedule and kanban should share:
* same task objects
* same statuses
* same assignments
Changing status in:
* kanban
* task page
* review flow
should instantly reflect in schedule.
Example:
```plaintext id="rmy3ud"
CLIENT_REVIEW
→ DONE
```
Task visually updates in schedule immediately.
---
# DASHBOARD WIDGETS
Add:
* Artist Utilization
* Tasks Due This Week
* Overloaded Artists
* Upcoming Reviews
Simple summaries only.
---
# UI REQUIREMENTS
The scheduler should feel:
* modern
* clean
* cinematic
* dark mode first
Inspiration:
* Linear roadmap
* Trello timeline
* Notion timeline
* Monday.com timeline
BUT:
simpler and faster.
Avoid:
* giant grids
* enterprise clutter
* complex dependencies
* gantt-chart nightmares
---
# IMPLEMENTATION PLAN
## PHASE 1 — DATABASE
1. Add scheduling fields to Task
2. Add indexes for schedule queries
3. Add task duration helpers
---
## PHASE 2 — BASIC TIMELINE
1. Build schedule page
2. Artist rows
3. Date columns
4. Render scheduled tasks
---
## PHASE 3 — DRAG/DROP
1. Move tasks
2. Resize durations
3. Reassign artists
4. Optimistic updates
---
## PHASE 4 — BACKLOG
1. Unscheduled task panel
2. Drag from backlog to schedule
3. Quick scheduling
---
## PHASE 5 — POLISH
1. Filters
2. Overload indicators
3. Review status badges
4. Utilization summaries
---
# IMPORTANT
Keep this system intentionally lightweight.
The ideal workflow should feel like:
```plaintext id="xmv8lx"
Create task
→ Assign artist
→ Drag onto schedule
→ Artist uploads version
→ Review
→ Approve
```
That simplicity is the product advantage.
+171
View File
@@ -0,0 +1,171 @@
# FeedBack — VFX Review & Approval Platform
Frame-accurate video review, annotation drawing, and approval tracking for boutique VFX and animation studios.
---
## Features
- **Dead-simple video upload** — drag & drop MP4/MOV, up to 2 GB, version-tracked
- **Frame-accurate scrubbing** — canvas-based timeline ruler with click-and-drag seek
- **Timestamp comments** — comments anchored to exact frame numbers, threaded replies, resolve/unresolve
- **Annotation drawing** — freehand, arrow, rectangle, circle tools with color + stroke width picker
- **Approval workflow** — Approve / Reject / Needs Changes with notes, full history
- **Client review links** — shareable token-based review URLs for external clients (no login required)
- **Role-based access** — Admin / Producer / Supervisor / Artist / Client roles
- **Slack notifications** — webhook alerts for uploads and approvals
- **Self-hostable** — Docker Compose with PostgreSQL + MinIO (S3-compatible) included
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Next.js 15 App Router (Turbopack) |
| Language | TypeScript 5 strict |
| Styling | TailwindCSS 3 + shadcn/ui (zinc, dark-only) |
| Auth | NextAuth.js v5 (Credentials + JWT) |
| Database | PostgreSQL 16 via Prisma 6 |
| File Storage | UploadThing / S3 / R2 / B2 / MinIO / local |
| State | Zustand 5 (player) + TanStack Query 5 (server) |
| Annotations | HTML5 Canvas API (normalized coordinates) |
| Video Playback | HTML5 `<video>` with custom controls |
| Container | Docker multi-stage build |
---
## Quick Start (Docker)
```bash
# 1. Clone and configure
cp .env.example .env
# Edit .env — set AUTH_SECRET, DATABASE_URL, storage provider
# 2. Start services (PostgreSQL + MinIO + App)
docker compose up -d
# 3. Run migrations and seed
docker compose exec app npx prisma migrate deploy
docker compose exec app npx tsx prisma/seed.ts
# 4. Open http://localhost:3000
```
**Demo accounts (after seed):**
| Email | Password | Role |
|-------|----------|------|
| admin@vfxreview.local | admin123 | Admin |
| producer@vfxreview.local | producer123 | Producer |
| supervisor@vfxreview.local | supervisor123 | Supervisor |
| artist@vfxreview.local | artist123 | Artist |
| client@studio.com | client123 | Client |
---
## Local Development
```bash
# Install dependencies
npm install
# Set up database (Postgres must be running)
npx prisma migrate dev
npx tsx prisma/seed.ts
# Start dev server
npm run dev
```
Open [http://localhost:3000](http://localhost:3000).
---
## Environment Variables
See `.env.example` for full reference. Key variables:
```env
# Required
DATABASE_URL="postgresql://..."
AUTH_SECRET="your-32-char-secret"
NEXTAUTH_URL="http://localhost:3000"
# Storage (choose one)
STORAGE_PROVIDER="local" # local | uploadthing | s3 | r2 | b2 | minio
# UploadThing (if STORAGE_PROVIDER=uploadthing)
UPLOADTHING_SECRET="sk_live_..."
UPLOADTHING_APP_ID="..."
# AWS S3 / R2 / B2 compatible
S3_ACCESS_KEY_ID=""
S3_SECRET_ACCESS_KEY=""
S3_BUCKET_NAME=""
S3_REGION=""
S3_ENDPOINT="" # for R2/B2/MinIO
# Slack (optional)
SLACK_BOT_TOKEN=""
# Email (optional, nodemailer)
SMTP_HOST=""
SMTP_PORT="587"
SMTP_USER=""
SMTP_PASS=""
```
---
## Project Structure
```
app/
(auth)/login/ — Sign-in page
(dashboard)/ — Protected dashboard layout
dashboard/ — Home stats + shot queue
projects/ — Project list + detail pages
settings/ — User profile
review/[versionId]/ — Full-screen review player
client/[token]/ — Unauthenticated client review link
api/
auth/[...nextauth]/ — NextAuth route handler
uploadthing/ — UploadThing route handler
notifications/ — Notification CRUD
projects/ — Projects REST endpoint
components/
player/ — ReviewPlayer, FrameTimeline, PlaybackControls
annotations/ — AnnotationCanvas, AnnotationTools
comments/ — CommentPanel (threads + replies)
versions/ — VersionUpload, VersionList
shots/ — ShotCard, NewShotDialog
projects/ — ProjectCard, NewProjectDialog
dashboard/ — StatsCards, ShotQueue, RecentActivity
layout/ — Sidebar, Header, NotificationBell, Providers
ui/ — shadcn/ui base components
actions/ — Server Actions (projects, shots, versions, comments, annotations, approvals)
lib/ — db, auth, utils, frame-utils, storage, slack, notifications, uploadthing
hooks/ — use-review-player (Zustand), use-annotations, use-frame-comments
prisma/ — schema.prisma, seed.ts
```
---
## Review Player Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `Space` / `K` | Play / Pause |
| `J` | Reverse playback |
| `L` | Forward playback |
| `←` / `→` | Step one frame |
| `F` | Toggle fullscreen |
---
## License
MIT
+378
View File
@@ -0,0 +1,378 @@
# xBid Design System & Style Guide
A reference for the visual language, component patterns, and design principles used in xBid so that future apps can maintain the same look and feel.
---
## 1. Core Principles
- **Dark-first**: The app is permanently dark mode (`className="dark"` on `<html>`). There is no light/dark toggle.
- **Minimal chrome**: Surfaces are dark zinc grays with amber as the single accent colour. No gradients, no heavy decoration.
- **Dense but readable**: Data-heavy UI uses compact sizing (`text-xs`, `text-sm`) with generous whitespace in page-level padding.
- **Amber accent, zinc foundation**: Brand colour is amber (`#f59e0b` / `amber-500`). All structural surfaces use the zinc palette.
---
## 2. Typography
### Font Family
- **Primary (body & UI)**: **Inter** — loaded via `next/font/google`, subset `latin`.
- **Mono**: Geist Mono (via `--font-geist-mono` CSS variable from shadcn).
- Applied globally: `font-sans` on `<html>`.
### Scale (Tailwind defaults, used in practice)
| Usage | Class(es) | Notes |
|---|---|---|
| Page title | `text-3xl font-bold text-white` | e.g. Dashboard h1 |
| Section title | `text-xl font-semibold text-white` | e.g. "Recent Jobs" h2 |
| Detail page title | `text-xl font-bold text-white` | Truncated with `truncate` |
| Card title | `text-base font-medium` (via `CardTitle`) | |
| Body / default | `text-sm` | |
| Captions / meta | `text-sm text-zinc-400` or `text-zinc-500` | |
| Table headers | `text-xs uppercase tracking-wider text-zinc-500` | |
| Table cells | `text-xs` or `text-sm` | |
| Error messages | `text-xs text-red-400` | Form validation |
| Stat value | `text-3xl font-bold text-white` | Dashboard stat cards |
---
## 3. Colour Palette
The app uses **Tailwind CSS v4 with OKLCH CSS variables**. All semantic tokens map to zinc grays in dark mode.
### Brand / Accent
| Token | Tailwind class | Hex approx | Usage |
|---|---|---|---|
| Brand amber | `amber-500` | `#f59e0b` | Logo bg, icon colour |
| Active nav highlight | `amber-500/10` bg + `amber-400` text | — | Active nav item |
| Amber text | `text-amber-400` | `#fbbf24` | Active nav, stat icons |
| Amber text bold | `text-amber-500` | `#f59e0b` | Logo "Bid" wordmark |
### Surface / Layout
| Purpose | Tailwind class | Notes |
|---|---|---|
| Page background | `bg-zinc-950` | Top bar, outermost bg |
| Sidebar & card bg | `bg-zinc-900` | Primary surface |
| Hover / secondary surface | `bg-zinc-800` | Hover states, secondary card |
| Input fields | `bg-zinc-900 border-zinc-700` | Form inputs override |
| Borders | `border-zinc-800` | Dividers, card outlines |
| Input borders | `border-zinc-700` | Slightly lighter than surface borders |
### Text
| Purpose | Tailwind class | Hex approx |
|---|---|---|
| Primary / headings | `text-white` | `#ffffff` |
| Secondary / labels | `text-zinc-400` | `#a1a1aa` |
| Tertiary / placeholders | `text-zinc-500` | `#71717a` |
| Disabled / subtle | `text-zinc-600` | `#52525b` |
| Bright secondary | `text-zinc-300` | `#d4d4d8` |
### Semantic / Status Badges
These appear on `Job` and `Invoice` status badges. Pattern: coloured bg at low opacity + matching light text.
| Status | Classes |
|---|---|
| DRAFT | `bg-zinc-700 text-zinc-300` |
| SENT | `bg-blue-900/60 text-blue-300` |
| APPROVED / PAID | `bg-green-900/60 text-green-300` |
| REJECTED / OVERDUE | `bg-red-900/60 text-red-300` |
| ARCHIVED | `bg-yellow-900/60 text-yellow-300` |
| VIEWED | `bg-purple-900/60 text-purple-300` |
| PARTIALLY_PAID | `bg-orange-900/60 text-orange-300` |
| CANCELLED | `bg-zinc-800 text-zinc-500` |
### Icon Colours (Dashboard Stats)
| Metric | Icon colour |
|---|---|
| Jobs / Total | `text-amber-400` |
| Clients | `text-blue-400` |
| Drafts | `text-zinc-400` |
| Sent / Pending | `text-yellow-400` |
| Approved | `text-green-400` |
---
## 4. Spacing & Layout
### App Shell
```
┌─────────────────────────────────────────┐
│ Sidebar (w-60 / w-16 collapsed) │ Main content (flex-1 overflow-y-auto)
│ bg-zinc-900 │ p-8 on page content
│ border-r border-zinc-800 │
└─────────────────────────────────────────┘
```
- Root: `flex h-full overflow-hidden`
- Sidebar: `flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-200`
- Main: `flex-1 overflow-y-auto`
- Page padding: `p-8`
### Page Header pattern
```tsx
<div className="mb-8">
<h1 className="text-3xl font-bold text-white">Page Title</h1>
<p className="text-zinc-400 mt-1">Subtitle / description.</p>
</div>
```
### Detail Page Top Bar
```tsx
<div className="border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950">
{/* back arrow + title + meta + actions */}
</div>
```
### Grid layouts
| Pattern | Class |
|---|---|
| Stat cards | `grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5` |
| Form fields | `grid grid-cols-1 sm:grid-cols-2 gap-5` |
| Form field spacing | `space-y-1.5` (label + input) |
| Form section spacing | `space-y-6` |
---
## 5. Border Radius
Defined by CSS variable `--radius: 0.625rem` (10px).
| Token | Size | Tailwind class |
|---|---|---|
| sm | ~6px | `rounded-sm` / `rounded` |
| md | ~8px | `rounded-md` |
| lg | 10px (base) | `rounded-lg` |
| xl | ~14px | `rounded-xl` |
| 4xl (badges) | ~26px | `rounded-4xl` |
**In practice**: `rounded-lg` is the default for buttons, inputs, cards, nav items. Cards use `rounded-xl`. Badges use `rounded-4xl` (pill shape).
---
## 6. Component Patterns
### Sidebar Navigation
- Container: `bg-zinc-900 border-r border-zinc-800`
- Logo area: `px-4 py-5 border-b border-zinc-800`
- Icon: `w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center` with black icon inside
- Wordmark: `font-bold text-lg tracking-tight text-white` — "x" white, "Bid" amber
- Nav item inactive: `text-zinc-400 hover:text-white hover:bg-zinc-800 px-3 py-2.5 rounded-lg text-sm font-medium`
- Nav item active: `bg-amber-500/10 text-amber-400`
- Icon size: `h-4 w-4`
### Cards
```tsx
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent></CardContent>
</Card>
```
- Card has `ring-1 ring-foreground/10` and `rounded-xl` by default via shadcn base-nova.
- Override bg/border on dark surfaces: `bg-zinc-900 border-zinc-800`.
### Buttons
Variants from shadcn `buttonVariants`:
| Variant | Appearance |
|---|---|
| `default` | Dark bg (`primary`), light text — effectively `bg-zinc-900 text-white` in dark mode |
| `outline` | Border, transparent bg, hover muted |
| `secondary` | Muted bg |
| `ghost` | No border, hover muted |
| `destructive` | Red tinted bg, red text |
| `link` | Underline on hover |
Sizes: `xs`, `sm`, `default` (h-8), `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg`.
### Badges / Status Pills
```tsx
<Badge className="bg-green-900/60 text-green-300">Approved</Badge>
```
- Custom status colours applied via `className` override (see §3 Semantic/Status Badges).
- Default shape: pill (`rounded-4xl`), `h-5`, `text-xs`.
### Form Fields
```tsx
<div className="space-y-1.5">
<Label>Field Label *</Label>
<Input className="bg-zinc-900 border-zinc-700" placeholder="…" />
{error && <p className="text-red-400 text-xs">{error.message}</p>}
</div>
```
- Input base: `h-8 rounded-lg border border-input bg-transparent` — always override with `bg-zinc-900 border-zinc-700` for dark surface visibility.
- Textarea, Select follow the same pattern.
### Tables
Line-item tables are custom `<table>` elements styled with:
- `text-xs w-full`
- Header row: `border-b border-zinc-800 text-zinc-500 uppercase tracking-wider`
- Cells: `px-4 py-2 text-left`
- Alternating/hover rows: use `hover:bg-zinc-800/50`
shadcn `<Table>` component uses `text-sm` and standard `border-b` between rows.
### Tabs
```tsx
<Tabs defaultValue="…">
<TabsList>
<TabsTrigger value="…">Label</TabsTrigger>
</TabsList>
<TabsContent value="…"></TabsContent>
</Tabs>
```
- `TabsList` bg: `bg-muted` (zinc-800 in dark), `rounded-lg`.
- `TabsTrigger` active: white text, slight bg lift.
### Dialogs / Sheets
Standard shadcn `Dialog` / `Sheet`. Content uses `bg-zinc-900` / card surface. Destructive actions use red confirm buttons.
### Toasts / Notifications
- Library: **Sonner** (`<Toaster richColors position="top-right" />`)
- `toast.success(…)` — green
- `toast.error(…)` — red
- Always called after async actions complete.
### Dropdown Menus
Standard shadcn `DropdownMenu`. Destructive actions get `text-destructive` styling on the `DropdownMenuItem`.
### Collapsible / Expandable Rows
Shot and asset cards use a chevron toggle (`ChevronDown` / `ChevronRight`) to expand inline line-item tables. Pattern:
```tsx
<button onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown /> : <ChevronRight />}
</button>
```
---
## 7. Icons
Library: **Lucide React** (`lucide-react`)
Standard icon size: `h-4 w-4` (16px). Larger decorative: `h-5 w-5`.
Common icons used:
| Purpose | Icon |
|---|---|
| Dashboard | `LayoutDashboard` |
| Clients | `Users` |
| Jobs / Quotes | `Briefcase` |
| Invoices | `FileText` |
| Rate Cards | `CreditCard` |
| Settings | `Settings` |
| Back | `ArrowLeft` |
| Download PDF | `Download` |
| Duplicate | `Copy` |
| Delete | `Trash2` |
| Edit | `Pencil` |
| Add | `Plus` |
| More options | `MoreHorizontal` |
| Search | `Search` |
| Loading | `Loader2` (with `animate-spin`) |
| Drag handle | `GripVertical` |
| App logo | `Film` |
| Send | `Send` |
| Approve / Mark paid | `Check` / `CheckCircle` |
---
## 8. Motion & Transitions
- Sidebar collapse: `transition-all duration-200`
- Nav item colour: `transition-colors`
- Buttons: `transition-all`
- Drag-and-drop (DnD Kit): opacity `0.5` while dragging.
- Animation library: `tw-animate-css` (imported in globals.css).
---
## 9. Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router, React Server Components) |
| Styling | Tailwind CSS v4 |
| Component system | shadcn/ui (`base-nova` style) + `@base-ui/react` primitives |
| Icon library | Lucide React |
| Font | Inter (Google Fonts via `next/font`) |
| Notifications | Sonner |
| Forms | React Hook Form + Zod |
| Drag & Drop | `@dnd-kit/core` + `@dnd-kit/sortable` |
| ORM | Prisma |
| DB | PostgreSQL |
| Color space | OKLCH (CSS variables) |
---
## 10. Quick Reference — Recurring Class Combinations
```
# Page wrapper
p-8
# Page title
text-3xl font-bold text-white
# Page subtitle
text-zinc-400 mt-1
# Section heading
text-xl font-semibold text-white mb-4
# Top bar (detail pages)
border-b border-zinc-800 px-8 py-4 flex items-center gap-4 bg-zinc-950
# Card (dark surface)
bg-zinc-900 border-zinc-800
# Card text hierarchy
text-white (value) | text-zinc-400 (label) | text-zinc-500 (meta)
# Form input (dark)
bg-zinc-900 border-zinc-700
# Form error
text-red-400 text-xs
# Table header
border-b border-zinc-800 text-zinc-500 uppercase tracking-wider text-xs
# Empty state text
text-center text-sm text-zinc-500
# Active nav
bg-amber-500/10 text-amber-400
# Inactive nav
text-zinc-400 hover:text-white hover:bg-zinc-800
```
+102
View File
@@ -0,0 +1,102 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import type { AnnotationDrawingData } from "@/types";
const saveAnnotationSchema = z.object({
versionId: z.string().cuid(),
commentId: z.string().cuid().optional(),
frameNumber: z.number().int().min(0),
drawingData: z.object({
shapes: z.array(z.any()),
canvasWidth: z.number(),
canvasHeight: z.number(),
version: z.literal("1.0"),
}),
color: z.string().default("#ef4444"),
});
export async function saveAnnotation(data: z.infer<typeof saveAnnotationSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = saveAnnotationSchema.parse(data);
const annotation = await db.annotation.create({
data: {
versionId: parsed.versionId,
commentId: parsed.commentId,
authorId: session.user.id,
frameNumber: parsed.frameNumber,
drawingData: parsed.drawingData as any,
color: parsed.color,
},
});
revalidatePath(`/review/${parsed.versionId}`);
return { success: true, annotation };
}
export async function getAnnotationsForVersion(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.annotation.findMany({
where: { versionId, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
orderBy: { frameNumber: "asc" },
});
}
export async function getAnnotationsForFrame(
versionId: string,
frameNumber: number
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.annotation.findMany({
where: { versionId, frameNumber, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
});
}
export async function toggleAnnotationVisibility(
annotationId: string,
visible: boolean
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
await db.annotation.update({
where: { id: annotationId },
data: { isVisible: visible },
});
return { success: true };
}
export async function deleteAnnotation(annotationId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const annotation = await db.annotation.findUnique({
where: { id: annotationId },
});
if (!annotation) throw new Error("Annotation not found");
if (annotation.authorId !== session.user.id && session.user.role !== "ADMIN") {
throw new Error("Unauthorized");
}
await db.annotation.delete({ where: { id: annotationId } });
revalidatePath(`/review/${annotation.versionId}`);
return { success: true };
}
+130
View File
@@ -0,0 +1,130 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import { notifyApprovalChange } from "@/lib/notifications";
import { slackNotifyApproval } from "@/lib/slack";
import { versionLabel } from "@/lib/utils";
const approvalSchema = z.object({
versionId: z.string().cuid(),
status: z.nativeEnum(ApprovalStatus),
notes: z.string().optional(),
});
export async function submitApproval(data: z.infer<typeof approvalSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = approvalSchema.parse(data);
// Only supervisors, producers, admins, and clients can approve
const allowedRoles = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"];
if (!allowedRoles.includes(session.user.role)) {
throw new Error("Insufficient permissions to review versions");
}
// Create the approval record
const approval = await db.approval.create({
data: {
versionId: parsed.versionId,
userId: session.user.id,
status: parsed.status,
notes: parsed.notes,
},
});
// Update version approval status
await db.version.update({
where: { id: parsed.versionId },
data: { approvalStatus: parsed.status },
});
// Load version + task + shot + project for downstream effects
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
task: {
include: {
shot: true,
project: true,
},
},
},
});
if (!version) throw new Error("Version not found");
// Update task status based on approval result
if (version.task && parsed.status !== "PENDING_REVIEW") {
if (parsed.status === "APPROVED") {
await db.task.update({
where: { id: version.task.id },
data: { status: "DONE" },
});
} else if (parsed.status === "REJECTED" || parsed.status === "NEEDS_CHANGES") {
await db.task.update({
where: { id: version.task.id },
data: { status: "CHANGES" },
});
}
// Recalculate shot status from updated task states
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
}
// Notifications
const reviewer = await db.user.findUnique({ where: { id: session.user.id } });
const reviewerName = reviewer?.name ?? "Reviewer";
if (version.task && parsed.status !== "PENDING_REVIEW") {
const contextCode = version.task.shot?.shotCode ?? null;
await notifyApprovalChange({
artistId: version.task.assignedArtistId ?? version.artistId,
shotCode: contextCode ?? version.task.title,
versionId: parsed.versionId,
status: parsed.status as "APPROVED" | "REJECTED" | "NEEDS_CHANGES",
reviewerName,
});
// Slack
if (version.task.project.slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyApproval(version.task.project.slackWebhook, {
shotCode: contextCode ?? version.task.title,
versionLabel: versionLabel(version.versionNumber),
status: parsed.status as "APPROVED" | "REJECTED" | "NEEDS_CHANGES",
reviewerName,
projectName: version.task.project.name,
reviewUrl: `${appUrl}/review/${parsed.versionId}`,
});
}
}
revalidatePath(`/review/${parsed.versionId}`);
if (version.task) {
revalidatePath(`/tasks/${version.task.id}`);
revalidatePath(`/projects/${version.task.projectId}`);
}
return { success: true, approval };
}
export async function getApprovalHistory(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.approval.findMany({
where: { versionId },
include: {
user: { select: { id: true, name: true, email: true, image: true, role: true } },
},
orderBy: { createdAt: "desc" },
});
}
+116
View File
@@ -0,0 +1,116 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ShotStatus, ShotPriority } from "@prisma/client";
const createAssetSchema = z.object({
assetCode: z.string().min(1, "Asset code is required").max(30).regex(/^[A-Z0-9_\-]+$/i),
name: z.string().min(1, "Name is required").max(200),
description: z.string().optional(),
status: z.nativeEnum(ShotStatus).default("WAITING"),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
leadId: z.string().cuid().optional().or(z.literal("")),
dueDate: z.string().optional(),
projectId: z.string().cuid(),
});
export async function createAsset(data: z.infer<typeof createAssetSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = createAssetSchema.parse(data);
const asset = await db.asset.create({
data: {
assetCode: parsed.assetCode.toUpperCase(),
name: parsed.name,
description: parsed.description,
status: parsed.status,
priority: parsed.priority,
leadId: parsed.leadId || undefined,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
projectId: parsed.projectId,
},
});
revalidatePath(`/projects/${parsed.projectId}`);
return { success: true, asset };
}
const updateAssetSchema = z.object({
name: z.string().min(1).max(200).optional(),
description: z.string().optional(),
status: z.nativeEnum(ShotStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(),
leadId: z.string().cuid().nullable().optional(),
dueDate: z.string().nullable().optional(),
});
export async function updateAsset(assetId: string, data: z.infer<typeof updateAssetSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = updateAssetSchema.parse(data);
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Asset not found");
const updated = await db.asset.update({
where: { id: assetId },
data: {
...(parsed.name !== undefined && { name: parsed.name }),
...(parsed.description !== undefined && { description: parsed.description }),
...(parsed.status !== undefined && { status: parsed.status }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
...(parsed.leadId !== undefined && { leadId: parsed.leadId }),
...(parsed.dueDate !== undefined && {
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
}),
},
});
revalidatePath(`/projects/${asset.projectId}`);
return { success: true, asset: updated };
}
export async function deleteAsset(assetId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const asset = await db.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new Error("Asset not found");
await db.asset.delete({ where: { id: assetId } });
revalidatePath(`/projects/${asset.projectId}`);
return { success: true };
}
export async function getProjectAssets(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.asset.findMany({
where: { projectId },
orderBy: { assetCode: "asc" },
include: {
lead: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { tasks: true } },
tasks: {
orderBy: { sortOrder: "asc" },
select: { id: true, status: true, title: true, type: true },
},
},
});
}
+154
View File
@@ -0,0 +1,154 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { notifyFeedbackAdded, notifyCommentReply } from "@/lib/notifications";
import { slackNotifyNewFeedback } from "@/lib/slack";
const addCommentSchema = z.object({
versionId: z.string().cuid(),
frameNumber: z.number().int().min(0),
timestamp: z.number().min(0),
text: z.string().min(1).max(5000),
});
export async function addComment(data: z.infer<typeof addCommentSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = addCommentSchema.parse(data);
const comment = await db.comment.create({
data: {
versionId: parsed.versionId,
authorId: session.user.id,
frameNumber: parsed.frameNumber,
timestamp: parsed.timestamp,
text: parsed.text,
},
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: true,
annotations: true,
},
});
// Notify the shot artist if commenter is not the artist
const version = await db.version.findUnique({
where: { id: parsed.versionId },
include: {
shot: { include: { project: true } },
task: { include: { project: true, shot: true } },
},
});
const slackWebhook =
version?.shot?.project?.slackWebhook ??
version?.task?.project?.slackWebhook ??
null;
const shotCode =
version?.shot?.shotCode ??
version?.task?.shot?.shotCode ??
version?.task?.title ??
"Task";
if (version?.shot?.artistId && version.shot.artistId !== session.user.id) {
const user = await db.user.findUnique({ where: { id: session.user.id } });
await notifyFeedbackAdded({
artistId: version.shot.artistId,
shotCode: version.shot.shotCode,
frameNumber: parsed.frameNumber,
versionId: parsed.versionId,
authorName: user?.name ?? session.user.email ?? "Someone",
});
}
// Slack notification
if (slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const user = await db.user.findUnique({ where: { id: session.user.id } });
await slackNotifyNewFeedback(slackWebhook, {
shotCode,
frameNumber: parsed.frameNumber,
authorName: user?.name ?? "Someone",
commentText: parsed.text,
reviewUrl: `${appUrl}/review/${parsed.versionId}`,
});
}
revalidatePath(`/review/${parsed.versionId}`);
return { success: true, comment };
}
export async function addReply(commentId: string, text: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!text?.trim()) throw new Error("Reply text is required");
const reply = await db.commentReply.create({
data: {
commentId,
authorId: session.user.id,
text: text.trim(),
},
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
});
// Notify the original comment author
const comment = await db.comment.findUnique({
where: { id: commentId },
include: {
version: { include: { shot: true, task: { select: { title: true } } } },
},
});
if (comment && comment.authorId && comment.authorId !== session.user.id) {
const user = await db.user.findUnique({ where: { id: session.user.id } });
await notifyCommentReply({
commentAuthorId: comment.authorId,
replierName: user?.name ?? "Someone",
shotCode: comment.version.shot?.shotCode ?? comment.version.task?.title ?? "Task",
frameNumber: comment.frameNumber,
versionId: comment.versionId,
});
}
revalidatePath(`/review/${comment?.versionId}`);
return { success: true, reply };
}
export async function resolveComment(commentId: string, resolved: boolean) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const comment = await db.comment.update({
where: { id: commentId },
data: { isResolved: resolved },
});
revalidatePath(`/review/${comment.versionId}`);
return { success: true };
}
export async function deleteComment(commentId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const comment = await db.comment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Comment not found");
// Only the author or admin can delete
if (comment.authorId !== session.user.id && session.user.role !== "ADMIN") {
throw new Error("Unauthorized");
}
await db.comment.delete({ where: { id: commentId } });
revalidatePath(`/review/${comment.versionId}`);
return { success: true };
}
+179
View File
@@ -0,0 +1,179 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ProjectStatus } from "@prisma/client";
const createProjectSchema = z.object({
name: z.string().min(1).max(100),
code: z.string().min(1).max(20).toUpperCase(),
showId: z.string().min(1).max(10).regex(/^[A-Z0-9_]+$/i, "110 chars, letters/numbers/underscores").toUpperCase(),
projectType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
description: z.string().optional(),
clientId: z.string().cuid().optional(),
dueDate: z.string().optional(),
deadline: z.union([z.string(), z.date()]).optional(),
startDate: z.string().optional(),
slackWebhook: z.string().url().optional().or(z.literal("")),
slackChannel: z.string().optional(),
});
export async function createProject(data: z.infer<typeof createProjectSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = createProjectSchema.parse(data);
// Verify the session user still exists in the DB (guards against stale JWT after a DB reset)
const dbUser = await db.user.findUnique({
where: { id: session.user.id },
select: { id: true },
});
if (!dbUser) throw new Error("Session expired — please sign out and sign back in.");
const project = await db.project.create({
data: {
name: parsed.name,
code: parsed.code,
showId: parsed.showId,
projectType: parsed.projectType,
description: parsed.description,
clientId: parsed.clientId || undefined,
producerId: session.user.id,
dueDate: parsed.dueDate
? new Date(parsed.dueDate)
: parsed.deadline
? new Date(parsed.deadline)
: undefined,
startDate: parsed.startDate ? new Date(parsed.startDate) : undefined,
slackWebhook: parsed.slackWebhook || undefined,
slackChannel: parsed.slackChannel || undefined,
},
include: { client: true },
});
revalidatePath("/projects");
return { success: true, project };
}
const updateProjectSchema = z.object({
id: z.string().cuid(),
name: z.string().min(1).max(100).optional(),
code: z.string().min(1).max(20).optional(),
showId: z.string().min(1).max(10).regex(/^[A-Z0-9_]+$/i).toUpperCase().optional(),
projectType: z.enum(["STANDARD", "EPISODIC"]).optional(),
description: z.string().optional().nullable(),
status: z.nativeEnum(ProjectStatus).optional(),
clientId: z.string().cuid().optional().nullable(),
producerId: z.string().cuid().optional().nullable(),
supervisorId: z.string().cuid().optional().nullable(),
dueDate: z.string().optional().nullable(),
startDate: z.string().optional().nullable(),
slackWebhook: z.string().url().optional().or(z.literal("")).nullable(),
slackChannel: z.string().optional().nullable(),
});
export async function updateProject(data: z.infer<typeof updateProjectSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const { id, ...rest } = updateProjectSchema.parse(data);
const updated = await db.project.update({
where: { id },
data: {
...(rest.name !== undefined && { name: rest.name }),
...(rest.code !== undefined && { code: rest.code.toUpperCase() }),
...(rest.showId !== undefined && { showId: rest.showId }),
...(rest.projectType !== undefined && { projectType: rest.projectType }),
...(rest.description !== undefined && { description: rest.description ?? undefined }),
...(rest.status !== undefined && { status: rest.status }),
...(rest.clientId !== undefined && { clientId: rest.clientId }),
...(rest.producerId !== undefined && { producerId: rest.producerId }),
...(rest.supervisorId !== undefined && { supervisorId: rest.supervisorId }),
...(rest.dueDate !== undefined && { dueDate: rest.dueDate ? new Date(rest.dueDate) : null }),
...(rest.startDate !== undefined && { startDate: rest.startDate ? new Date(rest.startDate) : null }),
...(rest.slackWebhook !== undefined && { slackWebhook: rest.slackWebhook || null }),
...(rest.slackChannel !== undefined && { slackChannel: rest.slackChannel || null }),
},
});
revalidatePath(`/projects/${id}`);
revalidatePath("/projects");
return { success: true, project: updated };
}
export async function updateProjectStatus(
projectId: string,
status: ProjectStatus
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
await db.project.update({ where: { id: projectId }, data: { status } });
revalidatePath(`/projects/${projectId}`);
return { success: true };
}
export async function getProjects() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
// Clients only see their assigned projects
if (session.user.role === "CLIENT") {
const access = await db.clientAccess.findMany({
where: { userId: session.user.id },
select: { clientId: true },
});
const clientIds = access.map((a) => a.clientId);
return db.project.findMany({
where: { clientId: { in: clientIds }, status: { not: "ARCHIVED" } },
include: {
client: true,
_count: { select: { shots: true } },
},
orderBy: { updatedAt: "desc" },
});
}
return db.project.findMany({
include: {
client: true,
producer: { select: { id: true, name: true } },
_count: { select: { shots: true } },
},
orderBy: { updatedAt: "desc" },
});
}
export async function getProjectById(id: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.project.findUnique({
where: { id },
include: {
client: true,
producer: { select: { id: true, name: true, email: true } },
supervisor: { select: { id: true, name: true, email: true } },
shots: {
include: {
artist: { select: { id: true, name: true, image: true } },
_count: { select: { versions: true } },
},
orderBy: { shotCode: "asc" },
},
},
});
}
+100
View File
@@ -0,0 +1,100 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
function requireScheduleAccess(role: string) {
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(role)) {
throw new Error("Insufficient permissions");
}
}
const scheduleTaskSchema = z.object({
taskId: z.string().cuid(),
scheduledStartDate: z.string().nullable(),
scheduledEndDate: z.string().nullable(),
assignedArtistId: z.string().cuid().nullable().optional(),
scheduleNotes: z.string().nullable().optional(),
estimatedHours: z.number().positive().optional(),
});
export async function scheduleTask(data: z.infer<typeof scheduleTaskSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireScheduleAccess(session.user.role);
const parsed = scheduleTaskSchema.parse(data);
await db.task.update({
where: { id: parsed.taskId },
data: {
scheduledStartDate: parsed.scheduledStartDate
? new Date(parsed.scheduledStartDate)
: null,
scheduledEndDate: parsed.scheduledEndDate
? new Date(parsed.scheduledEndDate)
: null,
...(parsed.assignedArtistId !== undefined && {
assignedArtistId: parsed.assignedArtistId,
}),
...(parsed.scheduleNotes !== undefined && {
scheduleNotes: parsed.scheduleNotes,
}),
...(parsed.estimatedHours !== undefined && {
estimatedHours: parsed.estimatedHours,
}),
},
});
revalidatePath("/schedule");
revalidatePath("/dashboard");
return { success: true };
}
export async function unscheduleTask(taskId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireScheduleAccess(session.user.role);
await db.task.update({
where: { id: taskId },
data: {
scheduledStartDate: null,
scheduledEndDate: null,
},
});
revalidatePath("/schedule");
return { success: true };
}
export async function bulkSchedule(
tasks: {
taskId: string;
scheduledStartDate: string;
scheduledEndDate: string;
assignedArtistId?: string;
}[]
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireScheduleAccess(session.user.role);
await db.$transaction(
tasks.map((t) =>
db.task.update({
where: { id: t.taskId },
data: {
scheduledStartDate: new Date(t.scheduledStartDate),
scheduledEndDate: new Date(t.scheduledEndDate),
...(t.assignedArtistId ? { assignedArtistId: t.assignedArtistId } : {}),
},
})
)
);
revalidatePath("/schedule");
return { success: true };
}
+278
View File
@@ -0,0 +1,278 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ShotStatus, ShotPriority } from "@prisma/client";
const createShotSchema = z.object({
scene: z.string().min(1).max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
episode: z.string().max(50).optional(),
description: z.string().optional(),
projectId: z.string().cuid(),
artistId: z.string().cuid().optional().or(z.literal("")),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
fps: z.number().default(24),
frameStart: z.number().int().optional(),
frameEnd: z.number().int().optional(),
dueDate: z.string().optional(),
thumbnailUrl: z.string().optional(),
});
export async function createShot(data: z.infer<typeof createShotSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = createShotSchema.parse(data);
const scene = parsed.scene.toUpperCase();
const episode = parsed.episode?.toUpperCase() ?? null;
// Fetch project for showId and projectType
const project = await db.project.findUnique({
where: { id: parsed.projectId },
select: { showId: true, projectType: true },
});
if (!project) throw new Error("Project not found");
if (!project.showId) {
throw new Error("Project has no Show ID set. Please edit the project to add one.");
}
// For episodic projects, episode is required
if (project.projectType === "EPISODIC" && !episode) {
throw new Error("Episode is required for episodic projects.");
}
// Determine shot number scope: projectId + scene (+ episode for episodic)
const scopeWhere = {
projectId: parsed.projectId,
scene,
...(project.projectType === "EPISODIC" ? { episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
// Build shot code per naming convention
const shotCode =
project.projectType === "EPISODIC" && episode
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
: `${project.showId}_${scene}_${paddedNumber}`;
const shot = await db.shot.create({
data: {
shotCode,
scene,
episode,
shotNumber,
description: parsed.description,
projectId: parsed.projectId,
artistId: parsed.artistId || undefined,
priority: parsed.priority,
fps: parsed.fps,
frameStart: parsed.frameStart,
frameEnd: parsed.frameEnd,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
thumbnailUrl: parsed.thumbnailUrl,
},
});
revalidatePath(`/projects/${parsed.projectId}`);
return { success: true, shot };
}
// ── Update Shot ───────────────────────────────────────────────────────────────
const updateShotSchema = z.object({
shotId: z.string().cuid(),
description: z.string().optional(),
status: z.nativeEnum(ShotStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(),
fps: z.number().optional(),
frameStart: z.number().int().optional().nullable(),
frameEnd: z.number().int().optional().nullable(),
dueDate: z.string().optional().nullable(),
artistId: z.string().cuid().optional().nullable().or(z.literal("")),
thumbnailUrl: z.string().optional().nullable(),
});
export async function updateShot(data: z.infer<typeof updateShotSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = updateShotSchema.parse(data);
const { shotId, dueDate, artistId, ...rest } = parsed;
const shot = await db.shot.update({
where: { id: shotId },
data: {
...rest,
dueDate: dueDate ? new Date(dueDate) : dueDate === null ? null : undefined,
artistId: artistId === "" ? null : artistId,
},
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true, shot };
}
// ── CSV Import ────────────────────────────────────────────────────────────────
export async function importShotsFromCsv(
projectId: string,
rows: Array<{
scene: string;
episode?: string;
description?: string;
priority?: string;
fps?: number;
frameStart?: number;
frameEnd?: number;
}>
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const project = await db.project.findUnique({
where: { id: projectId },
select: { showId: true, projectType: true },
});
if (!project) throw new Error("Project not found");
if (!project.showId) throw new Error("Project has no Show ID. Add one in Project Settings first.");
const VALID_PRIORITIES = ["LOW", "NORMAL", "HIGH", "CRITICAL"];
const created: string[] = [];
const errors: string[] = [];
for (const row of rows) {
try {
const scene = row.scene.trim().toUpperCase();
if (!scene) { errors.push("Empty scene name — skipped"); continue; }
const episode = row.episode?.trim().toUpperCase() || null;
if (project.projectType === "EPISODIC" && !episode) {
errors.push(`${scene}: episode required for episodic project — skipped`);
continue;
}
const rawPriority = row.priority?.trim().toUpperCase();
const priority = VALID_PRIORITIES.includes(rawPriority ?? "")
? (rawPriority as ShotPriority)
: ShotPriority.NORMAL;
const scopeWhere = {
projectId,
scene,
...(project.projectType === "EPISODIC" ? { episode } : {}),
};
const maxShot = await db.shot.findFirst({
where: scopeWhere,
orderBy: { shotNumber: "desc" },
select: { shotNumber: true },
});
const shotNumber = (maxShot?.shotNumber ?? 0) + 10;
const paddedNumber = shotNumber.toString().padStart(4, "0");
const shotCode =
project.projectType === "EPISODIC" && episode
? `${project.showId}_${episode}_${scene}_${paddedNumber}`
: `${project.showId}_${scene}_${paddedNumber}`;
await db.shot.create({
data: {
shotCode,
scene,
episode,
shotNumber,
description: row.description?.trim() || undefined,
projectId,
priority,
fps: row.fps ?? 24,
frameStart: row.frameStart ?? undefined,
frameEnd: row.frameEnd ?? undefined,
},
});
created.push(shotCode);
} catch (e: unknown) {
errors.push(`${row.scene}: ${e instanceof Error ? e.message : "Unknown error"}`);
}
}
revalidatePath(`/projects/${projectId}`);
return { success: true, created, errors };
}
export async function updateShotStatus(
shotId: string,
status: ShotStatus
) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const shot = await db.shot.update({
where: { id: shotId },
data: { status },
include: { project: true },
});
revalidatePath(`/projects/${shot.projectId}`);
revalidatePath(`/projects/${shot.projectId}/shots/${shotId}`);
return { success: true };
}
export async function getShotById(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.shot.findUnique({
where: { id: shotId },
include: {
project: { include: { client: true } },
artist: { select: { id: true, name: true, email: true, image: true } },
versions: {
include: {
artist: { select: { id: true, name: true, image: true } },
_count: { select: { comments: true } },
},
orderBy: { versionNumber: "desc" },
},
},
});
}
export async function getShotsByProject(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.shot.findMany({
where: { projectId },
include: {
artist: { select: { id: true, name: true, image: true } },
versions: {
where: { isLatest: true },
take: 1,
include: { _count: { select: { comments: true } } },
},
_count: { select: { versions: true } },
},
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
});
}
+394
View File
@@ -0,0 +1,394 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { TaskStatus, TaskType, ShotPriority } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
import {
notifyTaskAssigned,
notifyTaskReadyForReview,
notifyTaskApproved,
notifyTaskChangesRequested,
} from "@/lib/notifications";
import {
slackNotifyTaskAssigned,
slackNotifyTaskReadyForReview,
} from "@/lib/slack";
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().optional(),
type: z.nativeEnum(TaskType).default("GENERAL"),
priority: z.nativeEnum(ShotPriority).default("NORMAL"),
dueDate: z.string().optional(),
estimatedHours: z.coerce.number().positive().optional().or(z.literal("")),
shotId: z.string().cuid().optional().or(z.literal("")),
assetId: z.string().cuid().optional().or(z.literal("")),
assignedArtistId: z.string().cuid().optional().or(z.literal("")),
projectId: z.string().cuid(),
});
export async function createTask(data: z.infer<typeof createTaskSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const parsed = createTaskSchema.parse(data);
// Get sort order (append at end)
const lastTask = await db.task.findFirst({
where: {
shotId: parsed.shotId || undefined,
assetId: parsed.assetId || undefined,
},
orderBy: { sortOrder: "desc" },
select: { sortOrder: true },
});
const task = await db.task.create({
data: {
title: parsed.title,
description: parsed.description,
type: parsed.type,
priority: parsed.priority,
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : undefined,
estimatedHours: parsed.estimatedHours ? Number(parsed.estimatedHours) : undefined,
sortOrder: (lastTask?.sortOrder ?? -1) + 1,
shotId: parsed.shotId || undefined,
assetId: parsed.assetId || undefined,
assignedArtistId: parsed.assignedArtistId || undefined,
createdById: session.user.id,
projectId: parsed.projectId,
},
});
// Recalculate shot status when a new task is added
if (parsed.shotId) {
await recalcShotStatus(parsed.shotId).catch(() => {});
}
// Notifications: task assigned
if (parsed.assignedArtistId) {
const project = await db.project.findUnique({
where: { id: parsed.projectId },
select: { name: true, code: true, slackWebhook: true },
});
const contextShot = parsed.shotId
? await db.shot.findUnique({ where: { id: parsed.shotId }, select: { shotCode: true } })
: null;
const contextAsset = parsed.assetId
? await db.asset.findUnique({ where: { id: parsed.assetId }, select: { assetCode: true } })
: null;
const contextCode = contextShot?.shotCode ?? contextAsset?.assetCode ?? null;
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${task.id}`;
const assignedByName = session.user.name ?? session.user.email ?? "Someone";
const artist = await db.user.findUnique({
where: { id: parsed.assignedArtistId },
select: { name: true, email: true },
});
await notifyTaskAssigned({
artistId: parsed.assignedArtistId,
taskTitle: task.title,
taskId: task.id,
contextCode,
assignedByName,
}).catch(() => {});
if (project?.slackWebhook) {
await slackNotifyTaskAssigned(project.slackWebhook, {
taskTitle: task.title,
contextCode,
artistName: artist?.name ?? artist?.email ?? "artist",
assignedByName,
projectName: project.name,
taskUrl,
}).catch(() => {});
}
}
revalidatePath(`/projects/${parsed.projectId}`);
if (parsed.shotId) revalidatePath(`/projects/${parsed.projectId}/shots/${parsed.shotId}`);
return { success: true, task };
}
const updateTaskSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
type: z.nativeEnum(TaskType).optional(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(ShotPriority).optional(),
dueDate: z.string().nullable().optional(),
estimatedHours: z.coerce.number().positive().nullable().optional(),
assignedArtistId: z.string().cuid().nullable().optional(),
sortOrder: z.number().int().optional(),
});
export async function updateTask(taskId: string, data: z.infer<typeof updateTaskSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = updateTaskSchema.parse(data);
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { name: true, slackWebhook: true } },
},
});
if (!task) throw new Error("Task not found");
const updated = await db.task.update({
where: { id: taskId },
data: {
...(parsed.title !== undefined && { title: parsed.title }),
...(parsed.description !== undefined && { description: parsed.description }),
...(parsed.type !== undefined && { type: parsed.type }),
...(parsed.status !== undefined && { status: parsed.status }),
...(parsed.priority !== undefined && { priority: parsed.priority }),
...(parsed.dueDate !== undefined && {
dueDate: parsed.dueDate ? new Date(parsed.dueDate) : null,
}),
...(parsed.estimatedHours !== undefined && { estimatedHours: parsed.estimatedHours }),
...(parsed.assignedArtistId !== undefined && { assignedArtistId: parsed.assignedArtistId }),
...(parsed.sortOrder !== undefined && { sortOrder: parsed.sortOrder }),
},
});
// Notify new assignee when assignee changes
if (
parsed.assignedArtistId !== undefined &&
parsed.assignedArtistId !== task.assignedArtistId &&
parsed.assignedArtistId !== null
) {
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode ?? null;
const assignedByName = session.user.name ?? session.user.email ?? "Someone";
const artist = await db.user.findUnique({
where: { id: parsed.assignedArtistId },
select: { name: true, email: true },
});
await notifyTaskAssigned({
artistId: parsed.assignedArtistId,
taskTitle: task.title,
taskId,
contextCode,
assignedByName,
}).catch(() => {});
if (task.project.slackWebhook) {
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${taskId}`;
await slackNotifyTaskAssigned(task.project.slackWebhook, {
taskTitle: task.title,
contextCode,
artistName: artist?.name ?? artist?.email ?? "artist",
assignedByName,
projectName: task.project.name,
taskUrl,
}).catch(() => {});
}
}
revalidatePath(`/projects/${task.projectId}`);
revalidatePath(`/tasks/${taskId}`);
return { success: true, task: updated };
}
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, slackWebhook: true } },
},
});
if (!task) throw new Error("Task not found");
const updated = await db.task.update({
where: { id: taskId },
data: { status },
});
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode ?? null;
const taskUrl = `${process.env.NEXTAUTH_URL ?? ""}/tasks/${taskId}`;
const actorName = session.user.name ?? session.user.email ?? "Someone";
// Task moved to internal review — notify supervisors/producers
if (status === "INTERNAL_REVIEW") {
await notifyTaskReadyForReview({
taskId,
taskTitle: task.title,
contextCode,
artistName: actorName,
projectId: task.projectId,
taskUrl,
}).catch(() => {});
if (task.project.slackWebhook) {
await slackNotifyTaskReadyForReview(task.project.slackWebhook, {
taskTitle: task.title,
contextCode,
artistName: actorName,
projectName: task.project.name,
taskUrl,
}).catch(() => {});
}
}
// Task approved (moved to DONE by reviewer) — notify artist
if (status === "DONE" && task.assignedArtistId && task.assignedArtistId !== session.user.id) {
await notifyTaskApproved({
artistId: task.assignedArtistId,
taskTitle: task.title,
taskId,
contextCode,
reviewerName: actorName,
}).catch(() => {});
}
// Changes requested — notify artist
if (status === "CHANGES" && task.assignedArtistId && task.assignedArtistId !== session.user.id) {
await notifyTaskChangesRequested({
artistId: task.assignedArtistId,
taskTitle: task.title,
taskId,
contextCode,
reviewerName: actorName,
}).catch(() => {});
}
// Recalculate shot status from task states
if (task.shot) {
await recalcShotStatus(task.shot.id).catch(() => {});
}
revalidatePath(`/projects/${task.projectId}`);
revalidatePath(`/tasks/${taskId}`);
return { success: true };
}
export async function deleteTask(taskId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const task = await db.task.findUnique({ where: { id: taskId } });
if (!task) throw new Error("Task not found");
const shotId = task.shotId;
await db.task.delete({ where: { id: taskId } });
// Recalculate shot status after task removal
if (shotId) {
await recalcShotStatus(shotId).catch(() => {});
}
revalidatePath(`/projects/${task.projectId}`);
return { success: true };
}
export async function getTaskById(taskId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true, projectId: true } },
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, image: true } },
_count: { select: { comments: true } },
},
},
_count: { select: { versions: true } },
},
});
}
export async function getProjectTasks(projectId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { projectId },
orderBy: [{ shotId: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
},
});
}
export async function getShotTasks(shotId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { shotId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
});
}
export async function getAssetTasks(assetId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { assetId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
});
}
export async function getMyTasks() {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.task.findMany({
where: { assignedArtistId: session.user.id },
orderBy: [{ status: "asc" }, { dueDate: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
_count: { select: { versions: true } },
},
});
}
+143
View File
@@ -0,0 +1,143 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const MANAGED_BY_ADMIN_ONLY: Role[] = [Role.ADMIN];
function requireAdmin(role: string) {
if (role !== "ADMIN") throw new Error("Admin access required");
}
// ── Create User ──────────────────────────────────────────────────────────────
const createUserSchema = z.object({
name: z.string().max(100).optional(),
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.nativeEnum(Role),
});
export async function createUser(data: z.infer<typeof createUserSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireAdmin(session.user.role as string);
const parsed = createUserSchema.parse(data);
const existing = await db.user.findUnique({ where: { email: parsed.email } });
if (existing) throw new Error("A user with that email already exists");
const passwordHash = await bcrypt.hash(parsed.password, 12);
const user = await db.user.create({
data: {
name: parsed.name || null,
email: parsed.email,
passwordHash,
role: parsed.role,
isActive: true,
mustChangePassword: true,
},
});
revalidatePath("/users");
return { success: true, userId: user.id };
}
// ── Update User ──────────────────────────────────────────────────────────────
const updateUserSchema = z.object({
userId: z.string().cuid(),
name: z.string().max(100).optional(),
role: z.nativeEnum(Role),
isActive: z.boolean(),
newPassword: z.string().min(8, "Password must be at least 8 characters").optional().or(z.literal("")),
});
export async function updateUser(data: z.infer<typeof updateUserSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireAdmin(session.user.role as string);
const parsed = updateUserSchema.parse(data);
// Prevent demoting or deactivating yourself
if (parsed.userId === session.user.id) {
if (!parsed.isActive) throw new Error("You cannot deactivate your own account");
if (parsed.role !== "ADMIN") throw new Error("You cannot change your own role");
}
const updateData: Record<string, unknown> = {
name: parsed.name || null,
role: parsed.role,
isActive: parsed.isActive,
};
if (parsed.newPassword) {
updateData.passwordHash = await bcrypt.hash(parsed.newPassword, 12);
updateData.mustChangePassword = true;
}
await db.user.update({ where: { id: parsed.userId }, data: updateData });
revalidatePath("/users");
return { success: true };
}
// ── Change Own Password ───────────────────────────────────────────────────────
const changeOwnPasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(8, "New password must be at least 8 characters"),
});
export async function changeOwnPassword(data: z.infer<typeof changeOwnPasswordSchema>) {
const session = await auth();
if (!session?.user?.id) throw new Error("Unauthorized");
const parsed = changeOwnPasswordSchema.parse(data);
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user?.passwordHash) throw new Error("No password set on this account");
const isValid = await bcrypt.compare(parsed.currentPassword, user.passwordHash);
if (!isValid) throw new Error("Current password is incorrect");
if (parsed.currentPassword === parsed.newPassword) {
throw new Error("New password must be different from the current password");
}
const passwordHash = await bcrypt.hash(parsed.newPassword, 12);
await db.user.update({
where: { id: session.user.id },
data: { passwordHash, mustChangePassword: false },
});
revalidatePath("/settings");
return { success: true };
}
// ── Delete User ───────────────────────────────────────────────────────────────
export async function deleteUser(userId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
requireAdmin(session.user.role as string);
if (userId === session.user.id) throw new Error("You cannot delete your own account");
await db.user.delete({ where: { id: userId } });
revalidatePath("/users");
return { success: true };
}
+212
View File
@@ -0,0 +1,212 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { recalcShotStatus } from "@/lib/shot-status";
import { notifyNewVersionUploaded } from "@/lib/notifications";
import { slackNotifyVersionUploaded } from "@/lib/slack";
const createVersionSchema = z.object({
taskId: z.string().cuid(),
fileUrl: z.string().min(1),
fileName: z.string(),
fileSize: z.number().optional(),
mimeType: z.string().optional(),
thumbnailUrl: z.string().min(1).optional().or(z.literal("")),
fps: z.number().default(24),
duration: z.number().optional(),
frameCount: z.number().int().optional(),
width: z.number().int().optional(),
height: z.number().int().optional(),
notes: z.string().optional(),
});
export async function createVersion(data: z.infer<typeof createVersionSchema>) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const parsed = createVersionSchema.parse(data);
// All versions must belong to a task
const task = await db.task.findUnique({
where: { id: parsed.taskId },
include: { project: true, shot: { select: { id: true, shotCode: true } } },
});
if (!task) throw new Error("Task not found");
const projectId = task.projectId;
const slackWebhook = task.project.slackWebhook ?? undefined;
const projectName = task.project.name;
const shotCode = task.shot?.shotCode;
// Mark all existing versions for this task as not latest
await db.version.updateMany({ where: { taskId: parsed.taskId }, data: { isLatest: false } });
// Move task to INTERNAL_REVIEW on upload
await db.task.update({
where: { id: parsed.taskId },
data: { status: "INTERNAL_REVIEW" },
});
// Recalculate shot status
if (task.shot) {
await recalcShotStatus(task.shot.id).catch(() => {});
}
// Get the next version number for this task
const lastVersion = await db.version.findFirst({
where: { taskId: parsed.taskId },
orderBy: { versionNumber: "desc" },
});
const versionNumber = (lastVersion?.versionNumber ?? 0) + 1;
// Create the new version
const version = await db.version.create({
data: {
versionNumber,
taskId: parsed.taskId,
artistId: session.user.id,
fileUrl: parsed.fileUrl,
fileName: parsed.fileName,
fileSize: parsed.fileSize ? BigInt(parsed.fileSize) : undefined,
mimeType: parsed.mimeType,
thumbnailUrl: parsed.thumbnailUrl || undefined,
fps: parsed.fps,
duration: parsed.duration,
frameCount: parsed.frameCount,
width: parsed.width,
height: parsed.height,
notes: parsed.notes,
isLatest: true,
},
});
// Send notifications
const user = await db.user.findUnique({ where: { id: session.user.id } });
const versionLabelStr = `v${String(versionNumber).padStart(3, "0")}`;
if (shotCode) {
await notifyNewVersionUploaded({
shotCode,
versionNumber,
projectId,
versionId: version.id,
artistName: user?.name ?? session.user.email ?? "Artist",
});
}
// Slack notification
if (slackWebhook && shotCode) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyVersionUploaded(slackWebhook, {
shotCode,
versionLabel: versionLabelStr,
artistName: user?.name ?? "Artist",
projectName,
reviewUrl: `${appUrl}/review/${version.id}`,
});
}
revalidatePath(`/projects/${projectId}`);
if (task.shot) revalidatePath(`/projects/${projectId}/shots/${task.shot.id}`);
revalidatePath(`/tasks/${parsed.taskId}`);
return { success: true, version, versionNumber };
}
/**
* Share a version with the client — marks it as client-visible,
* records who shared it, and moves the associated task to CLIENT_REVIEW.
*/
export async function shareVersionWithClient(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
throw new Error("Insufficient permissions");
}
const version = await db.version.findUnique({
where: { id: versionId },
select: { id: true, taskId: true, isClientVisible: true },
});
if (!version) throw new Error("Version not found");
await db.version.update({
where: { id: versionId },
data: {
isClientVisible: true,
sharedAt: new Date(),
sharedById: session.user.id,
},
});
// Move task to CLIENT_REVIEW if it is in INTERNAL_REVIEW; recalc shot status
if (version.taskId) {
const task = await db.task.findUnique({
where: { id: version.taskId },
select: { status: true, projectId: true, shotId: true },
});
if (task && task.status === "INTERNAL_REVIEW") {
await db.task.update({
where: { id: version.taskId },
data: { status: "CLIENT_REVIEW" },
});
if (task.shotId) {
await recalcShotStatus(task.shotId).catch(() => {});
}
revalidatePath(`/tasks/${version.taskId}`);
revalidatePath(`/projects/${task.projectId}`);
}
}
revalidatePath(`/review/${versionId}`);
return { success: true };
}
export async function getVersionById(versionId: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
return db.version.findUnique({
where: { id: versionId },
include: {
task: {
include: {
project: { include: { client: true } },
shot: true,
asset: true,
versions: {
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
artist: { select: { id: true, name: true, email: true, image: true } },
approvals: {
include: { user: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "desc" },
},
},
});
}
export async function getVersionComments(versionId: string) {
return db.comment.findMany({
where: { versionId },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: {
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
orderBy: { createdAt: "asc" },
},
annotations: {
select: { id: true, frameNumber: true, drawingData: true, color: true, isVisible: true, authorId: true },
},
},
orderBy: { frameNumber: "asc" },
});
}
+42
View File
@@ -0,0 +1,42 @@
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
import Image from 'next/image';
import { Montserrat } from 'next/font/google';
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['200', '500', '600'],
});
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (session?.user) redirect('/dashboard');
return (
<div className="min-h-screen flex items-center justify-center bg-zinc-950">
<div className="w-full max-w-md px-4">
{/* Logo / Brand */}
<div className="text-center mb-8">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-md bg-black border border-zinc-800 shadow-2xl">
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
</div>
<div className={`${montserrat.className} mt-4`}>
<span className="block text-2xl font-light tracking-wide text-white leading-none">
TWO TALES
</span>
<span className="block text-[11px] tracking-[0.28em] italic uppercase text-zinc-500 leading-none mt-1">
vfx review
</span>
</div>
</div>
{children}
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Eye, EyeOff, LogIn } from "lucide-react";
const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
const [showPassword, setShowPassword] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormValues) => {
setAuthError(null);
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (result?.error) {
setAuthError("Invalid email or password");
return;
}
router.push(callbackUrl);
router.refresh();
};
return (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-xl align-middle text-center">Sign in</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@studio.com"
autoComplete="email"
autoFocus
{...register("email")}
/>
{errors.email && (
<p className="text-xs text-red-400">{errors.email.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="current-password"
className="pr-10"
{...register("password")}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{errors.password && (
<p className="text-xs text-red-400">{errors.password.message}</p>
)}
</div>
{authError && (
<p className="text-sm text-red-400 text-center">{authError}</p>
)}
<Button type="submit" className="w-full gap-2" disabled={isSubmitting}>
<LogIn className="h-4 w-4" />
{isSubmitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { Building2, Mail, User2, Phone, ArrowLeft, ExternalLink, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ShareReviewDialog } from "@/components/clients/ShareReviewDialog";
import { ReviewSessionList } from "@/components/clients/ReviewSessionList";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Client Detail" };
async function getClient(clientId: string) {
return db.client.findUnique({
where: { id: clientId },
include: {
projects: {
orderBy: { createdAt: "desc" },
include: {
_count: { select: { shots: true } },
},
},
},
});
}
async function getReviewSessions(projectIds: string[]) {
if (projectIds.length === 0) return [];
return db.reviewSession.findMany({
where: { projectId: { in: projectIds }, isActive: true },
orderBy: { createdAt: "desc" },
include: { project: { select: { name: true } } },
});
}
export default async function ClientDetailPage({
params,
}: {
params: Promise<{ clientId: string }>;
}) {
const { clientId } = await params;
const session = await auth();
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
redirect("/dashboard");
}
const client = await getClient(clientId);
if (!client) notFound();
const projectIds = client.projects.map((p) => p.id);
const reviewSessions = await getReviewSessions(projectIds);
const PROJECT_STATUS_COLORS: Record<string, string> = {
ACTIVE: "text-emerald-400 bg-emerald-500/10",
COMPLETED: "text-zinc-400 bg-zinc-700",
ON_HOLD: "text-amber-400 bg-amber-500/10",
CANCELLED: "text-red-400 bg-red-500/10",
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Back */}
<Link
href="/clients"
className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="h-4 w-4" />
All Clients
</Link>
{/* Client header */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0">
<Building2 className="h-6 w-6 text-zinc-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">{client.company}</h1>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full mt-1 inline-block",
client.isActive
? "bg-emerald-500/10 text-emerald-400"
: "bg-zinc-700 text-zinc-500"
)}
>
{client.isActive ? "Active" : "Inactive"}
</span>
</div>
</div>
<ShareReviewDialog
clientId={client.id}
clientEmail={client.email ?? ""}
projects={client.projects}
>
<Button className="gap-2 shrink-0">
<ExternalLink className="h-4 w-4" />
Share Review Link
</Button>
</ShareReviewDialog>
</div>
<div className="mt-5 grid grid-cols-1 sm:grid-cols-3 gap-4">
{client.contactPerson && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<User2 className="h-4 w-4 text-zinc-600 shrink-0" />
<span>{client.contactPerson}</span>
</div>
)}
{client.email && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<Mail className="h-4 w-4 text-zinc-600 shrink-0" />
<a href={`mailto:${client.email}`} className="hover:text-amber-400 transition-colors">
{client.email}
</a>
</div>
)}
{client.phone && (
<div className="flex items-center gap-2 text-sm text-zinc-300">
<Phone className="h-4 w-4 text-zinc-600 shrink-0" />
<span>{client.phone}</span>
</div>
)}
</div>
{client.notes && (
<p className="mt-4 text-sm text-zinc-400 border-t border-zinc-800 pt-4">
{client.notes}
</p>
)}
</div>
{/* Projects */}
<div>
<h2 className="text-base font-semibold text-white mb-3">
Projects ({client.projects.length})
</h2>
{client.projects.length === 0 ? (
<p className="text-sm text-zinc-500">No projects assigned to this client.</p>
) : (
<div className="space-y-2">
{client.projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="flex items-center gap-4 p-4 rounded-xl border border-zinc-800 bg-zinc-900 hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
>
<div className="flex-1 min-w-0">
<p className="font-semibold text-white">{project.name}</p>
<p className="text-xs font-mono text-zinc-500">{project.code}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs text-zinc-500">
{project._count.shots} shots
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
PROJECT_STATUS_COLORS[project.status] ?? "text-zinc-400 bg-zinc-700"
)}
>
{project.status}
</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Review sessions */}
<ReviewSessionList sessions={reviewSessions} />
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Metadata } from "next";
import Link from "next/link";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { PlusCircle, Building2, Mail, User2, FolderOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { NewClientDialog } from "@/components/clients/NewClientDialog";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Clients" };
async function getClients() {
return db.client.findMany({
orderBy: { company: "asc" },
include: { _count: { select: { projects: true } } },
});
}
export default async function ClientsPage() {
const session = await auth();
if (!session || !["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
redirect("/dashboard");
}
const clients = await getClients();
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Clients</h1>
<p className="text-sm text-zinc-400 mt-0.5">
{clients.length} client{clients.length !== 1 ? "s" : ""} total
</p>
</div>
<NewClientDialog>
<Button className="gap-2">
<PlusCircle className="h-4 w-4" />
New Client
</Button>
</NewClientDialog>
</div>
{/* Grid */}
{clients.length === 0 ? (
<div className="rounded-xl border border-dashed border-zinc-700 bg-zinc-900/40 p-16 text-center">
<Building2 className="h-10 w-10 mx-auto mb-3 text-zinc-600" />
<p className="text-zinc-400 font-medium">No clients yet</p>
<p className="text-zinc-600 text-sm mt-1">
Add your first client to start sharing reviews.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{clients.map((client) => (
<Link
key={client.id}
href={`/clients/${client.id}`}
className={cn(
"rounded-xl border border-zinc-800 bg-zinc-900 p-5 space-y-4",
"hover:border-zinc-600 hover:bg-zinc-800/60 transition-all group"
)}
>
{/* Avatar + company */}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0 group-hover:bg-zinc-700 transition-colors">
<Building2 className="h-5 w-5 text-zinc-400" />
</div>
<div className="min-w-0">
<p className="font-semibold text-white truncate">{client.company}</p>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
client.isActive
? "bg-emerald-500/10 text-emerald-400"
: "bg-zinc-700 text-zinc-500"
)}
>
{client.isActive ? "Active" : "Inactive"}
</span>
</div>
</div>
{/* Details */}
<div className="space-y-1.5">
{client.contactPerson && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<User2 className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
<span className="truncate">{client.contactPerson}</span>
</div>
)}
{client.email && (
<div className="flex items-center gap-2 text-xs text-zinc-400">
<Mail className="h-3.5 w-3.5 text-zinc-600 shrink-0" />
<span className="truncate">{client.email}</span>
</div>
)}
</div>
{/* Project count */}
<div className="flex items-center gap-1.5 text-xs text-zinc-500 pt-2 border-t border-zinc-800">
<FolderOpen className="h-3.5 w-3.5" />
<span>{client._count.projects} project{client._count.projects !== 1 ? "s" : ""}</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+212
View File
@@ -0,0 +1,212 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { StatsCards } from "@/components/dashboard/StatsCards";
import { ShotQueue } from "@/components/dashboard/ShotQueue";
import { RecentActivity } from "@/components/dashboard/RecentActivity";
import { TaskWidgets } from "@/components/dashboard/TaskWidgets";
import { ScheduleWidgets } from "@/components/dashboard/ScheduleWidgets";
import type { DashboardStats } from "@/types";
export const metadata = { title: "Dashboard" };
async function getDashboardData(userId: string, role: string) {
const isArtist = role === "ARTIST";
const isClient = role === "CLIENT";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart.getTime() + 86400000);
const [awaitingReview, needsRevisions, approved, activeProjects,
tasksDueToday, tasksInReview, tasksOverdue, myTasksCount] =
await Promise.all([
db.version.count({ where: { approvalStatus: "PENDING_REVIEW" } }),
db.version.count({ where: { approvalStatus: "NEEDS_CHANGES" } }),
db.version.count({ where: { approvalStatus: "APPROVED" } }),
db.project.count({ where: { status: "ACTIVE" } }),
db.task.count({
where: {
dueDate: { gte: todayStart, lt: todayEnd },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: {
dueDate: { lt: todayStart },
status: { not: "DONE" },
...(isArtist ? { assignedArtistId: userId } : {}),
},
}),
db.task.count({
where: { assignedArtistId: userId, status: { not: "DONE" } },
}),
]);
const shotsWhere = isArtist ? { artistId: userId } : {};
const shots = await db.shot.findMany({
where: { ...shotsWhere, status: { not: "COMPLETE" } },
take: 15,
orderBy: { updatedAt: "desc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
include: { comments: { select: { id: true, isResolved: true } } },
},
},
});
// My tasks (for widget)
const myTasks = await db.task.findMany({
where: {
assignedArtistId: userId,
status: { not: "DONE" },
},
take: 8,
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
},
});
// Tasks in review (for supervisors/producers)
const reviewTasks = isArtist || isClient ? [] : await db.task.findMany({
where: { status: { in: ["INTERNAL_REVIEW", "CLIENT_REVIEW"] } },
take: 8,
orderBy: { updatedAt: "desc" },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: { select: { id: true, name: true, image: true, email: true } },
},
});
const activity = await db.notification.findMany({
where: isClient ? { userId } : {},
take: 20,
orderBy: { createdAt: "desc" },
include: { user: { select: { name: true, image: true } } },
});
// Schedule data (for producers/supervisors/admins)
const scheduledTasks =
!isArtist && !isClient
? await db.task.findMany({
where: { scheduledStartDate: { not: null }, status: { not: "DONE" } },
include: {
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
})
: [];
const scheduleArtists =
!isArtist && !isClient
? await db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: {
id: true,
name: true,
email: true,
image: true,
role: true,
},
orderBy: { name: "asc" },
})
: [];
const stats: DashboardStats = {
awaitingReview,
needsRevisions,
approved,
overdue: tasksOverdue,
activeProjects,
tasksDueToday,
tasksInReview,
tasksOverdue,
myTasksCount,
};
return { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists };
}
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) return null;
const { stats, shots, activity, myTasks, reviewTasks, scheduledTasks, scheduleArtists } =
await getDashboardData(session.user.id, session.user.role);
const isClient = session.user.role === "CLIENT";
const canSeeSchedule = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="mb-4">
<h1 className="text-3xl font-bold text-white">Dashboard</h1>
<p className="text-zinc-400 mt-1">
Welcome back, {session.user.name ?? session.user.email}
</p>
</div>
<StatsCards stats={stats} />
{!isClient && (
<TaskWidgets
myTasks={myTasks as any}
reviewTasks={reviewTasks as any}
role={session.user.role}
/>
)}
{canSeeSchedule && (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Schedule Overview
</h2>
<ScheduleWidgets
scheduledTasks={scheduledTasks as any}
artists={scheduleArtists}
/>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Shot Queue
</h2>
<div className="rounded-lg border border-border bg-card p-1">
<ShotQueue shots={shots as any} />
</div>
</div>
<div className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Recent Activity
</h2>
<div className="rounded-lg border border-border bg-card h-[400px] overflow-hidden">
<RecentActivity activities={activity as any} />
</div>
</div>
</div>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
return (
<div className="flex h-screen bg-zinc-950 overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ShotCard } from "@/components/shots/ShotCard";
import { NewShotDialog } from "@/components/shots/NewShotDialog";
import { Film, Plus } from "lucide-react";
import type { ShotWithDetails } from "@/types";
interface ProjectShotsClientProps {
projectId: string;
shots: ShotWithDetails[];
}
export function ProjectShotsClient({ projectId, shots }: ProjectShotsClientProps) {
const [showNew, setShowNew] = useState(false);
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Shots</h2>
<Button size="sm" className="gap-2" onClick={() => setShowNew(true)}>
<Plus className="h-4 w-4" />
New Shot
</Button>
</div>
{shots.length === 0 ? (
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
<Film className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">No shots yet</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{shots.map((shot) => (
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
))}
</div>
)}
<NewShotDialog
projectId={projectId}
open={showNew}
onClose={() => setShowNew(false)}
onSuccess={() => setShowNew(false)}
/>
</div>
);
}
@@ -0,0 +1,245 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { ShotCard } from "@/components/shots/ShotCard";
import { NewShotDialog } from "@/components/shots/NewShotDialog";
import { ImportShotsDialog } from "@/components/shots/ImportShotsDialog";
import { AssetCard } from "@/components/assets/AssetCard";
import { NewAssetDialog } from "@/components/assets/NewAssetDialog";
import { TaskCard } from "@/components/tasks/TaskCard";
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { cn } from "@/lib/utils";
import { Film, Package, ListTodo, LayoutDashboard, Plus, Settings, FileUp } from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ProjectSettingsTab } from "@/components/projects/ProjectSettingsTab";
type Tab = "shots" | "assets" | "tasks" | "kanban" | "settings";
interface Artist {
id: string;
name: string | null;
email: string;
}
interface Client {
id: string;
company: string;
}
interface TeamMember {
id: string;
name: string | null;
email: string;
role: string;
}
interface ProjectTabsClientProps {
projectId: string;
projectType: "STANDARD" | "EPISODIC";
projectSettings: {
id: string;
name: string;
code: string;
showId: string;
projectType: "STANDARD" | "EPISODIC";
description: string | null;
status: string;
clientId: string | null;
producerId: string | null;
supervisorId: string | null;
dueDate: Date | null;
startDate: Date | null;
slackWebhook: string | null;
slackChannel: string | null;
};
clients: Client[];
teamMembers: TeamMember[];
shots: ShotWithDetails[];
assets: any[];
tasks: any[];
artists: Artist[];
canManage: boolean;
}
export function ProjectTabsClient({
projectId,
projectType,
projectSettings,
clients,
teamMembers,
shots,
assets,
tasks,
artists,
canManage,
}: ProjectTabsClientProps) {
const [activeTab, setActiveTab] = useState<Tab>("shots");
const [showNewShot, setShowNewShot] = useState(false);
const [showImportShots, setShowImportShots] = useState(false);
const [showNewAsset, setShowNewAsset] = useState(false);
const [showNewTask, setShowNewTask] = useState(false);
const tabs: { id: Tab; label: string; icon: React.ElementType; count: number; managerOnly?: boolean }[] = [
{ id: "shots", label: "Shots", icon: Film, count: shots.length },
{ id: "assets", label: "Assets", icon: Package, count: assets.length },
{ id: "tasks", label: "All Tasks", icon: ListTodo, count: tasks.length },
{ id: "kanban", label: "Kanban", icon: LayoutDashboard, count: 0 },
{ id: "settings", label: "Settings", icon: Settings, count: 0, managerOnly: true },
];
const visibleTabs = tabs.filter((t) => !t.managerOnly || canManage);
return (
<div className="space-y-4">
{/* Tab bar */}
<div className="flex items-center justify-between border-b border-border pb-0">
<div className="flex">
{visibleTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === tab.id
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Icon className="h-4 w-4" />
{tab.label}
{tab.count > 0 && (
<span className="text-xs text-zinc-500">{tab.count}</span>
)}
</button>
);
})}
</div>
{/* Context-sensitive add button */}
{canManage && (
<div className="pb-1">
{activeTab === "shots" && (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2 h-8" onClick={() => setShowImportShots(true)}>
<FileUp className="h-3.5 w-3.5" /> Import CSV
</Button>
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewShot(true)}>
<Plus className="h-3.5 w-3.5" /> New Shot
</Button>
</div>
)}
{activeTab === "assets" && (
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewAsset(true)}>
<Plus className="h-3.5 w-3.5" /> New Asset
</Button>
)}
{(activeTab === "tasks" || activeTab === "kanban") && (
<Button size="sm" className="gap-2 h-8" onClick={() => setShowNewTask(true)}>
<Plus className="h-3.5 w-3.5" /> New Task
</Button>
)}
</div>
)}
</div>
{/* Tab content */}
{activeTab === "shots" && (
<div>
{shots.length === 0 ? (
<EmptyState icon={Film} label="No shots yet" />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{shots.map((shot) => (
<ShotCard key={shot.id} shot={shot} projectId={projectId} />
))}
</div>
)}
</div>
)}
{activeTab === "assets" && (
<div className="space-y-2">
{assets.length === 0 ? (
<EmptyState icon={Package} label="No assets yet" />
) : (
assets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
projectId={projectId}
artists={artists}
canManage={canManage}
/>
))
)}
</div>
)}
{activeTab === "tasks" && (
<div className="space-y-1">
{tasks.length === 0 ? (
<EmptyState icon={ListTodo} label="No tasks yet" />
) : (
tasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} canManage={canManage} />
))
)}
</div>
)}
{activeTab === "kanban" && (
<KanbanBoard tasks={tasks} projectId={projectId} artists={artists} />
)}
{activeTab === "settings" && canManage && (
<ProjectSettingsTab
project={projectSettings}
clients={clients}
teamMembers={teamMembers}
/>
)}
{/* Dialogs */}
<NewShotDialog
projectId={projectId}
projectType={projectType}
open={showNewShot}
onClose={() => setShowNewShot(false)}
onSuccess={() => setShowNewShot(false)}
/>
<ImportShotsDialog
projectId={projectId}
projectType={projectType}
open={showImportShots}
onClose={() => setShowImportShots(false)}
onSuccess={() => setShowImportShots(false)}
/>
<NewAssetDialog
projectId={projectId}
open={showNewAsset}
onClose={() => setShowNewAsset(false)}
onSuccess={() => setShowNewAsset(false)}
/>
<NewTaskDialog
projectId={projectId}
artists={artists}
open={showNewTask}
onClose={() => setShowNewTask(false)}
onSuccess={() => setShowNewTask(false)}
/>
</div>
);
}
function EmptyState({ icon: Icon, label }: { icon: React.ElementType; label: string }) {
return (
<div className="text-center py-10 text-muted-foreground border border-dashed border-border rounded-lg">
<Icon className="h-8 w-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">{label}</p>
</div>
);
}
@@ -0,0 +1,106 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { KanbanBoard } from "@/components/tasks/KanbanBoard";
import { ArrowLeft, LayoutDashboard } from "lucide-react";
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await db.project.findUnique({
where: { id },
select: { name: true },
});
return { title: project ? `${project.name} — Kanban` : "Kanban" };
}
export default async function KanbanPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await auth();
if (!session?.user) redirect("/login");
// Clients cannot access kanban
if (session.user.role === "CLIENT") redirect(`/projects/${id}`);
const project = await db.project.findUnique({
where: { id },
select: { id: true, name: true, code: true },
});
if (!project) notFound();
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
createdAt: true,
},
},
},
}),
db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
]);
return (
<div className="p-6 space-y-6 max-w-[1800px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link
href={`/projects/${id}`}
className="hover:text-foreground transition-colors"
>
{project.name}
</Link>
<span>/</span>
<span className="text-foreground">Kanban</span>
</div>
{/* Header */}
<div className="flex items-center gap-3">
<Link
href={`/projects/${id}`}
className="text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<LayoutDashboard className="h-5 w-5 text-amber-400" />
<div>
<h1 className="text-xl font-bold">{project.name}</h1>
<p className="text-sm text-muted-foreground">Kanban Board {tasks.length} tasks</p>
</div>
</div>
{/* Board */}
<KanbanBoard tasks={tasks} projectId={id} artists={artists} />
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { Badge } from "@/components/ui/badge";
import { ProjectTabsClient } from "./ProjectTabsClient";
import {
Film,
Layers,
CheckCircle2,
ListTodo,
} from "lucide-react";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const project = await db.project.findUnique({ where: { id }, select: { name: true } });
return { title: project?.name ?? "Project" };
}
async function getProject(id: string) {
return db.project.findUnique({
where: { id },
include: {
client: true,
producer: { select: { id: true, name: true, image: true, email: true } },
supervisor: { select: { id: true, name: true, image: true, email: true } },
shots: {
orderBy: { createdAt: "asc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
include: {
comments: { select: { id: true, isResolved: true } },
},
},
},
},
assets: {
orderBy: { assetCode: "asc" },
include: {
lead: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { tasks: true } },
tasks: {
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
},
},
tasks: {
orderBy: [{ status: "asc" }, { sortOrder: "asc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
},
},
});
}
async function getProjectArtists() {
return db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
});
}
async function getClients() {
return db.client.findMany({
where: { isActive: true },
select: { id: true, company: true },
orderBy: { company: "asc" },
});
}
async function getTeamMembers() {
return db.user.findMany({
where: { isActive: true, role: { in: ["ADMIN", "PRODUCER", "SUPERVISOR"] } },
select: { id: true, name: true, email: true, role: true },
orderBy: { name: "asc" },
});
}
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await auth();
const [project, artists, clients, teamMembers] = await Promise.all([
getProject(id),
getProjectArtists(),
getClients(),
getTeamMembers(),
]);
if (!project) notFound();
const canManage = session?.user && ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const totalShots = project.shots.length;
const approvedShots = project.shots.filter((s) => s.status === "COMPLETE").length;
const totalTasks = project.tasks.length;
const doneTasks = project.tasks.filter((t) => t.status === "DONE").length;
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">
Projects
</Link>
<span>/</span>
<span className="text-white">{project.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-sm text-muted-foreground">{project.code}</span>
{project.client && (
<span className="text-sm text-muted-foreground"> {project.client.company}</span>
)}
<Badge
className={
project.status === "ACTIVE"
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-zinc-500/10 text-zinc-400"
}
>
{project.status.replace("_", " ")}
</Badge>
</div>
<h1 className="text-3xl font-bold text-white">{project.name}</h1>
{project.description && (
<p className="text-zinc-400 mt-1">{project.description}</p>
)}
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-4 gap-3 max-w-md">
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<Layers className="h-4 w-4 text-zinc-400 mb-1" />
<span className="text-xl font-bold text-white">{totalShots}</span>
<span className="text-xs text-zinc-400">shots</span>
</div>
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<CheckCircle2 className="h-4 w-4 text-green-400 mb-1" />
<span className="text-xl font-bold text-white">{approvedShots}</span>
<span className="text-xs text-zinc-400">approved</span>
</div>
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<ListTodo className="h-4 w-4 text-amber-400 mb-1" />
<span className="text-xl font-bold text-white">{totalTasks}</span>
<span className="text-xs text-zinc-400">tasks</span>
</div>
<div className="flex flex-col items-center rounded-xl border border-zinc-800 bg-zinc-900 py-3">
<Film className="h-4 w-4 text-blue-400 mb-1" />
<span className="text-xl font-bold text-white">{doneTasks}</span>
<span className="text-xs text-zinc-400">done</span>
</div>
</div>
{/* Tabs */}
<ProjectTabsClient
projectId={id}
projectType={project.projectType}
projectSettings={{
id: project.id,
name: project.name,
code: project.code,
showId: project.showId,
projectType: project.projectType,
description: project.description,
status: project.status,
clientId: project.clientId,
producerId: project.producerId,
supervisorId: project.supervisorId,
dueDate: project.dueDate,
startDate: project.startDate,
slackWebhook: project.slackWebhook,
slackChannel: project.slackChannel,
}}
clients={clients}
teamMembers={teamMembers}
shots={project.shots as any}
assets={project.assets as any}
tasks={project.tasks as any}
artists={artists}
canManage={!!canManage}
/>
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { TaskList } from "@/components/tasks/TaskList";
import { Separator } from "@/components/ui/separator";
import { getInitials } from "@/lib/utils";
import { cn } from "@/lib/utils";
import {
Film,
ArrowLeft,
Clock,
AlertCircle,
CheckCircle2,
Settings,
ListTodo,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab";
const STATUS_CONFIG: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
WAITING: { label: "Waiting", className: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", Icon: Clock },
IN_PROGRESS: { label: "In Progress", className: "bg-blue-500/10 text-blue-400 border-blue-500/20", Icon: Film },
IN_REVIEW: { label: "In Review", className: "bg-amber-500/10 text-amber-400 border-amber-500/20", Icon: AlertCircle },
REVISIONS: { label: "Revisions", className: "bg-orange-500/10 text-orange-400 border-orange-500/20", Icon: AlertCircle },
COMPLETE: { label: "Complete", className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", Icon: CheckCircle2 },
};
const PRIORITY_CONFIG: Record<string, { label: string; dot: string }> = {
LOW: { label: "Low", dot: "bg-zinc-400" },
NORMAL: { label: "Normal", dot: "bg-blue-400" },
HIGH: { label: "High", dot: "bg-amber-400" },
CRITICAL: { label: "Critical", dot: "bg-red-500" },
};
export default function ShotDetailPage() {
const params = useParams<{ id: string; shotId: string }>();
const router = useRouter();
const [shot, setShot] = useState<ShotWithDetails | null>(null);
const [projectName, setProjectName] = useState<string>("");
const [loading, setLoading] = useState(true);
const [canApprove, setCanApprove] = useState(false);
const [tasks, setTasks] = useState<any[]>([]);
const [artists, setArtists] = useState<any[]>([]);
const [canManage, setCanManage] = useState(false);
const [activeTab, setActiveTab] = useState<"tasks" | "settings">("tasks");
const fetchShot = async () => {
try {
const res = await fetch(`/api/shots/${params.shotId}?projectId=${params.id}`);
if (res.status === 404) {
router.push("/projects");
return;
}
const data = await res.json();
setShot(data.shot);
setProjectName(data.projectName ?? "");
setCanApprove(data.canApprove ?? false);
setTasks(data.tasks ?? []);
setArtists(data.artists ?? []);
setCanManage(data.canApprove ?? false);
} catch {
router.push("/projects");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchShot();
}, [params.shotId]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Film className="h-6 w-6 animate-pulse text-muted-foreground" />
</div>
);
}
if (!shot) return null;
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
const priorityCfg = PRIORITY_CONFIG[shot.priority] ?? PRIORITY_CONFIG.NORMAL;
const { Icon: StatusIcon } = statusCfg;
return (
<div className="p-6 space-y-6 max-w-[1400px] mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground transition-colors">
Projects
</Link>
<span>/</span>
<Link href={`/projects/${params.id}`} className="hover:text-foreground transition-colors">
{projectName}
</Link>
<span>/</span>
<span className="text-foreground font-mono">{shot.shotCode}</span>
</div>
{/* Header */}
<div className="flex items-start gap-6">
{/* Thumbnail cinema scope 2.39:1 */}
<Button variant="ghost" size="icon" asChild className="-ml-2 h-8 w-8">
<Link href={`/projects/${params.id}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold font-mono">{shot.shotCode}</h1>
{shot.sequence && (
<span className="text-sm text-muted-foreground">Seq: {shot.sequence}</span>
)}
</div>
{shot.description && (
<p className="text-muted-foreground ">{shot.description}</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge className={statusCfg.className} variant="outline">
<StatusIcon className="h-3 w-3 mr-1" />
{statusCfg.label}
</Badge>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<span
className={`h-2 w-2 rounded-full ${priorityCfg.dot}`}
/>
{priorityCfg.label} Priority
</div>
<span className="text-sm text-muted-foreground">{shot.fps} fps</span>
{shot.artist && (
<div className="flex items-center gap-1.5">
<Avatar className="h-5 w-5">
<AvatarImage src={shot.artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(shot.artist.name ?? shot.artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground">
{shot.artist.name ?? shot.artist.email}
</span>
</div>
)}
</div>
</div>
</div>
<Separator />
{/* Tabs */}
<div>
<div className="flex border-b border-border mb-5">
<button
onClick={() => setActiveTab("tasks")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "tasks"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<ListTodo className="h-4 w-4" />
Tasks
</button>
{canManage && (
<button
onClick={() => setActiveTab("settings")}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px",
activeTab === "settings"
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Settings className="h-4 w-4" />
Settings
</button>
)}
</div>
{activeTab === "tasks" && (
<TaskList
tasks={tasks}
projectId={params.id}
shotId={shot.id}
artists={artists}
canManage={canManage}
onTaskCreated={fetchShot}
/>
)}
{activeTab === "settings" && canManage && (
<ShotSettingsTab shot={shot} artists={artists} onSaved={fetchShot} />
)}
</div>
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
import { Plus, Search, Loader2 } from "lucide-react";
export default function ProjectsPage() {
const [search, setSearch] = useState("");
const [showNew, setShowNew] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["projects", search],
queryFn: async () => {
const res = await fetch(`/api/projects?q=${encodeURIComponent(search)}`);
if (!res.ok) throw new Error("Failed to fetch projects");
return res.json() as Promise<{ projects: any[] }>;
},
staleTime: 30_000,
});
const { data: clientsData } = useQuery({
queryKey: ["clients"],
queryFn: async () => {
const res = await fetch("/api/clients");
if (!res.ok) return { clients: [] };
return res.json() as Promise<{ clients: { id: string; company: string }[] }>;
},
staleTime: 60_000,
});
const projects = data?.projects ?? [];
const clients = clientsData?.clients ?? [];
return (
<div className="p-8 space-y-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white">Projects</h1>
<p className="text-zinc-400 mt-1">
{projects.length} project{projects.length !== 1 ? "s" : ""}
</p>
</div>
<Button onClick={() => setShowNew(true)} className="gap-2">
<Plus className="h-4 w-4" />
New Project
</Button>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Grid */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No projects found.</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => setShowNew(true)}
>
Create your first project
</Button>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<NewProjectDialog
open={showNew}
onClose={() => setShowNew(false)}
clients={clients}
/>
</div>
);
}
@@ -0,0 +1,592 @@
"use client";
import { useState, useRef, useCallback, useMemo } from "react";
import {
DndContext,
DragStartEvent,
DragEndEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
addDays,
startOfWeek,
startOfDay,
format,
differenceInDays,
isBefore,
parseISO,
} from "date-fns";
import { scheduleTask, unscheduleTask } from "@/actions/schedule";
import { ScheduleTimeline } from "@/components/schedule/ScheduleTimeline";
import { BacklogPanel } from "@/components/schedule/BacklogPanel";
import { ScheduleFilters } from "@/components/schedule/ScheduleFilters";
import { CalendarDays, PanelRightOpen, PanelRightClose, Columns2, Rows } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
export const DAY_WIDTH = 216;
export const ROW_HEIGHT = 56;
export const HEADER_HEIGHT = 44;
export const LABEL_WIDTH = 208;
export const NUM_DAYS = 35;
export const SLOT_HEIGHT = 48; // task height = ROW_HEIGHT - 8
export interface ScheduleTask {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: string | null;
estimatedHours: number | null;
scheduledStartDate: string | null;
scheduledEndDate: string | null;
scheduleNotes: string | null;
assignedArtistId: string | null;
assignedArtist: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
shot: { id: string; shotCode: string; thumbnailUrl: string | null } | null;
asset: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
}
export interface ScheduleArtist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
export interface ActiveDragData {
type: "scheduled" | "backlog" | "resize";
taskId: string;
duration?: number;
estimatedHours?: number | null;
originalEndDate?: string;
}
interface SchedulePageClientProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
backlog: ScheduleTask[];
projects: { id: string; name: string; code: string }[];
canEdit: boolean;
currentUserId: string;
activeProject?: string;
activeArtist?: string;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function calcDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
export function SchedulePageClient({
artists,
tasks,
backlog,
projects,
canEdit,
currentUserId,
activeProject,
activeArtist,
}: SchedulePageClientProps) {
const { toast } = useToast();
const [localTasks, setLocalTasks] = useState<ScheduleTask[]>(tasks);
const [localBacklog, setLocalBacklog] = useState<ScheduleTask[]>(backlog);
const [viewStart, setViewStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 })
);
const [showBacklog, setShowBacklog] = useState(true);
const [filterProject, setFilterProject] = useState(activeProject ?? "");
const [filterArtist, setFilterArtist] = useState(activeArtist ?? "");
const [filterStatus, setFilterStatus] = useState("");
const [activeDrag, setActiveDrag] = useState<ActiveDragData | null>(null);
const [resizePreview, setResizePreview] = useState<{
taskId: string;
endDate: Date;
estimatedHours: number;
} | null>(null);
const [dayWidth, setDayWidth] = useState(DAY_WIDTH);
const [rowHeight, setRowHeight] = useState(ROW_HEIGHT);
const timelineRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 6 },
})
);
const days = useMemo(
() => Array.from({ length: NUM_DAYS }, (_, i) => addDays(viewStart, i)),
[viewStart]
);
const filteredArtists = useMemo(() => {
if (!filterArtist) return artists;
return artists.filter((a) => a.id === filterArtist);
}, [artists, filterArtist]);
const filteredScheduledTasks = useMemo(() => {
return localTasks.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localTasks, filterProject, filterArtist, filterStatus]);
const filteredBacklog = useMemo(() => {
return localBacklog.filter((t) => {
if (filterProject && t.project.id !== filterProject) return false;
if (filterArtist && t.assignedArtistId !== filterArtist) return false;
if (filterStatus && t.status !== filterStatus) return false;
return true;
});
}, [localBacklog, filterProject, filterArtist, filterStatus]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const data = event.active.data.current as ActiveDragData;
setActiveDrag(data);
},
[]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDrag(null);
const { active, delta, over } = event;
if (!active.data.current) return;
const dragData = active.data.current as ActiveDragData;
// Resize handle - only deltaX matters
if (dragData.type === "resize") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task?.scheduledStartDate || !dragData.originalEndDate) return;
const originalEnd = toDate(dragData.originalEndDate)!;
const daysDelta = Math.round(delta.x / dayWidth);
let newEnd = addDays(originalEnd, daysDelta);
const startDate = toDate(task.scheduledStartDate)!;
if (isBefore(newEnd, startDate)) newEnd = startDate;
const prevEnd = task.scheduledEndDate;
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: newEnd.toISOString() }
: t
)
);
scheduleTask({
taskId: dragData.taskId,
scheduledStartDate: task.scheduledStartDate,
scheduledEndDate: newEnd.toISOString(),
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) =>
t.id === dragData.taskId
? { ...t, scheduledEndDate: prevEnd }
: t
)
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
return;
}
// Check if dropped over the backlog panel (unschedule)
if (over?.id === "backlog-drop-zone" && dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === dragData.taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== dragData.taskId));
setLocalBacklog((prev) => [
{
...task,
scheduledStartDate: null,
scheduledEndDate: null,
},
...prev,
]);
unscheduleTask(dragData.taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) =>
prev.filter((t) => t.id !== dragData.taskId)
);
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
return;
}
// Drop on timeline - calculate position from translated rect
const translatedRect = active.rect.current.translated;
if (!translatedRect || !timelineRef.current) return;
const timelineBounds = timelineRef.current.getBoundingClientRect();
const scrollLeft = timelineRef.current.scrollLeft;
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
// Check if drop is within timeline bounds
const inTimeline =
centerX >= timelineBounds.left &&
centerX <= timelineBounds.right &&
centerY >= timelineBounds.top + HEADER_HEIGHT &&
centerY <= timelineBounds.bottom;
if (!inTimeline && dragData.type !== "backlog") return;
if (!inTimeline && dragData.type === "backlog") return;
const dayIndex = Math.max(
0,
Math.min(
Math.floor((centerX - timelineBounds.left + scrollLeft) / dayWidth),
NUM_DAYS - 1
)
);
const artistIndex = Math.max(
0,
Math.min(
Math.floor(
(centerY - timelineBounds.top - HEADER_HEIGHT) / rowHeight
),
filteredArtists.length - 1
)
);
const newArtist = filteredArtists[artistIndex];
if (!newArtist) return;
const taskId = dragData.taskId;
// Duration always derived from estimatedHours so width = hours
let duration = 1;
if (dragData.type === "scheduled") {
const task = localTasks.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
} else if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (task) duration = calcDuration(task);
}
// Pack task within the dropped day using hour offsets.
// Sum hours already scheduled for this artist on that day,
// and start the new task at that hour (stored in scheduledStartDate's time component).
const existingForArtist = localTasks.filter(
(t) =>
t.assignedArtistId === newArtist.id &&
t.id !== taskId &&
t.scheduledStartDate
);
const droppedDay = startOfDay(addDays(viewStart, dayIndex));
const hoursOnDay = existingForArtist
.filter((t) => {
const tStart = toDate(t.scheduledStartDate!);
return tStart && differenceInDays(startOfDay(tStart), droppedDay) === 0;
})
.reduce((sum, t) => sum + (t.estimatedHours ?? 8), 0);
const newStartDate = new Date(droppedDay);
newStartDate.setHours(hoursOnDay);
const newEndDate = addDays(newStartDate, duration - 1);
if (dragData.type === "backlog") {
const task = localBacklog.find((t) => t.id === taskId);
if (!task) return;
const scheduledTask: ScheduleTask = {
...task,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
};
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
setLocalTasks((prev) => [...prev, scheduledTask]);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [task, ...prev]);
toast({ title: "Failed to schedule task", variant: "destructive" });
});
} else if (dragData.type === "scheduled") {
const prevTask = localTasks.find((t) => t.id === taskId);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
assignedArtist: {
id: newArtist.id,
name: newArtist.name,
email: newArtist.email,
image: newArtist.image,
},
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: newStartDate.toISOString(),
scheduledEndDate: newEndDate.toISOString(),
assignedArtistId: newArtist.id,
}).catch(() => {
if (prevTask) {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
}
toast({ title: "Failed to move task", variant: "destructive" });
});
}
},
[localTasks, localBacklog, viewStart, filteredArtists, toast, dayWidth, rowHeight]
);
const handleResizeMouseDown = useCallback(
(taskId: string, _currentEndDate: string) =>
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prevTask = localTasks.find((t) => t.id === taskId);
if (!prevTask?.scheduledStartDate) return;
const startX = e.clientX;
const startDate = toDate(prevTask.scheduledStartDate)!;
const originalHours = prevTask.estimatedHours ?? 8;
const HOUR_WIDTH = dayWidth / 8; // px per 1 hour
let currentHours = originalHours;
const onMouseMove = (me: MouseEvent) => {
const deltaPx = me.clientX - startX;
const hoursDelta = Math.round(deltaPx / HOUR_WIDTH);
currentHours = Math.max(1, originalHours + hoursDelta);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const newEndDate = addDays(startDate, newDays - 1);
setResizePreview({
taskId,
endDate: newEndDate,
estimatedHours: currentHours,
});
};
const onMouseUp = () => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
const newDays = Math.max(1, Math.ceil(currentHours / 8));
const finalEnd = addDays(startDate, newDays - 1);
setResizePreview(null);
setLocalTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}
: t
)
);
scheduleTask({
taskId,
scheduledStartDate: prevTask.scheduledStartDate,
scheduledEndDate: finalEnd.toISOString(),
estimatedHours: currentHours,
}).catch(() => {
setLocalTasks((prev) =>
prev.map((t) => (t.id === taskId ? prevTask : t))
);
toast({ title: "Failed to resize task", variant: "destructive" });
});
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
},
[localTasks, toast, dayWidth]
);
const activeDragTask = activeDrag
? localTasks.find((t) => t.id === activeDrag.taskId) ??
localBacklog.find((t) => t.id === activeDrag.taskId)
: null;
const handleUnschedule = useCallback(
(taskId: string) => {
const task = localTasks.find((t) => t.id === taskId);
if (!task) return;
setLocalTasks((prev) => prev.filter((t) => t.id !== taskId));
setLocalBacklog((prev) => [
{ ...task, scheduledStartDate: null, scheduledEndDate: null },
...prev,
]);
unscheduleTask(taskId).catch(() => {
setLocalTasks((prev) => [...prev, task]);
setLocalBacklog((prev) => prev.filter((t) => t.id !== taskId));
toast({ title: "Failed to unschedule task", variant: "destructive" });
});
},
[localTasks, toast]
);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-full overflow-hidden bg-zinc-950">
{/* Header */}
<div className="flex items-center justify-between px-6 py-3 border-b border-zinc-800 bg-zinc-900 shrink-0">
<div className="flex items-center gap-2">
<CalendarDays className="h-5 w-5 text-amber-400" />
<h1 className="text-lg font-semibold text-white">Schedule</h1>
<span className="text-sm text-zinc-500">
{format(viewStart, "MMM d")} {" "}
{format(addDays(viewStart, NUM_DAYS - 1), "MMM d, yyyy")}
</span>
</div>
<div className="flex items-center gap-2">
{/* Zoom controls */}
<div className="flex items-center gap-3 border border-zinc-700 rounded-md px-2.5 py-1">
<div className="flex items-center gap-1.5">
<Columns2 className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={60}
max={400}
step={8}
value={dayWidth}
onChange={(e) => setDayWidth(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
<div className="flex items-center gap-1.5">
<Rows className="h-3 w-3 text-zinc-500" />
<input
type="range"
min={40}
max={120}
step={4}
value={rowHeight}
onChange={(e) => setRowHeight(Number(e.target.value))}
className="w-20 accent-amber-400 cursor-pointer"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowBacklog((b) => !b)}
className="text-zinc-400 hover:text-white gap-2"
>
{showBacklog ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
{showBacklog ? "Hide" : "Backlog"}
</Button>
</div>
</div>
{/* Filters */}
<ScheduleFilters
projects={projects}
artists={artists}
filterProject={filterProject}
filterArtist={filterArtist}
filterStatus={filterStatus}
viewStart={viewStart}
onProjectChange={setFilterProject}
onArtistChange={setFilterArtist}
onStatusChange={setFilterStatus}
onViewStartChange={setViewStart}
/>
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
<ScheduleTimeline
artists={filteredArtists}
tasks={filteredScheduledTasks}
days={days}
viewStart={viewStart}
canEdit={canEdit}
timelineRef={timelineRef}
resizePreview={resizePreview}
onResizeMouseDown={handleResizeMouseDown}
activeDragId={activeDrag?.taskId ?? null}
onUnschedule={handleUnschedule}
dayWidth={dayWidth}
rowHeight={rowHeight}
/>
{showBacklog && (
<BacklogPanel
tasks={filteredBacklog}
canEdit={canEdit}
/>
)}
</div>
{/* Drag overlay */}
<DragOverlay dropAnimation={null}>
{activeDragTask && (
<div className="rounded-md border border-amber-500/60 bg-amber-500/20 px-2 py-1.5 text-xs font-medium text-amber-200 shadow-xl opacity-90 max-w-[200px] truncate pointer-events-none">
{activeDragTask.shot?.shotCode ??
activeDragTask.asset?.assetCode ??
activeDragTask.title}
</div>
)}
</DragOverlay>
</div>
</DndContext>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { SchedulePageClient } from "./SchedulePageClient";
export const metadata = { title: "Schedule" };
export default async function SchedulePage({
searchParams,
}: {
searchParams: Promise<{ project?: string; artist?: string; status?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { project, artist, status } = await searchParams;
const [artists, scheduledTasks, backlogTasks, projects] = await Promise.all([
db.user.findMany({
where: { isActive: true, role: { not: "CLIENT" } },
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: [{ role: "asc" }, { name: "asc" }],
}),
db.task.findMany({
where: {
scheduledStartDate: { not: null },
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: { scheduledStartDate: "asc" },
}),
db.task.findMany({
where: {
scheduledStartDate: null,
status: { not: "DONE" },
...(project ? { projectId: project } : {}),
...(artist ? { assignedArtistId: artist } : {}),
},
include: {
shot: { select: { id: true, shotCode: true, thumbnailUrl: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: {
select: { id: true, name: true, email: true, image: true },
},
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }],
}),
db.project.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true, code: true },
orderBy: { name: "asc" },
}),
]);
const canEdit = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role
);
const serializeTask = (t: (typeof scheduledTasks)[number]) => ({
...t,
dueDate: t.dueDate ? t.dueDate.toISOString() : null,
scheduledStartDate: t.scheduledStartDate ? t.scheduledStartDate.toISOString() : null,
scheduledEndDate: t.scheduledEndDate ? t.scheduledEndDate.toISOString() : null,
});
return (
<SchedulePageClient
artists={artists}
tasks={scheduledTasks.map(serializeTask)}
backlog={backlogTasks.map(serializeTask)}
projects={projects}
canEdit={canEdit}
currentUserId={session.user.id}
activeProject={project}
activeArtist={artist}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { auth } from "@/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils";
import { ChangePasswordForm } from "@/components/settings/ChangePasswordForm";
export const metadata = { title: "Settings" };
export default async function SettingsPage() {
const session = await auth();
if (!session?.user) return null;
return (
<div className="p-6 space-y-6 max-w-2xl mx-auto">
<h1 className="text-2xl font-bold">Account Settings</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Your Profile</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-4">
<Avatar className="h-14 w-14">
{session.user.image && <AvatarImage src={session.user.image} />}
<AvatarFallback className="text-lg">
{getInitials(session.user.name)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold">{session.user.name ?? "—"}</p>
<p className="text-sm text-muted-foreground">{session.user.email}</p>
<p className="text-xs text-muted-foreground mt-0.5 capitalize">
{session.user.role?.toLowerCase()}
</p>
</div>
</CardContent>
</Card>
<ChangePasswordForm mustChangePassword={session.user.mustChangePassword ?? false} />
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn, getInitials } from "@/lib/utils";
import {
CalendarDays,
ListTodo,
AlertTriangle,
Eye,
Clock,
Layers,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { TaskStatus, TaskType } from "@prisma/client";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
interface Artist {
id: string;
name: string | null;
email: string;
}
interface Task {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
shot?: { id: string; shotCode: string } | null;
asset?: { id: string; assetCode: string; name: string } | null;
project: { id: string; name: string; code: string };
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
_count?: { versions: number };
versions?: { id: string; versionNumber: number; approvalStatus: string; createdAt: Date }[];
}
interface TasksPageClientProps {
tasks: Task[];
artists: Artist[];
currentUserId: string;
role: string;
counts: { today: number; overdue: number; inReview: number; total: number };
activeStatus?: string;
activeAssignee?: string;
}
const FILTER_TABS = [
{ label: "All", status: undefined, icon: ListTodo, color: "text-zinc-400" },
{ label: "Due Today", status: "today", icon: Clock, color: "text-amber-400" },
{ label: "Overdue", status: "overdue", icon: AlertTriangle, color: "text-red-400" },
{ label: "In Review", status: "INTERNAL_REVIEW", icon: Eye, color: "text-purple-400" },
];
export function TasksPageClient({
tasks,
artists,
currentUserId,
role,
counts,
activeStatus,
activeAssignee,
}: TasksPageClientProps) {
const router = useRouter();
const isArtist = role === "ARTIST";
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Client-side filter
const filtered = tasks.filter((task) => {
if (activeStatus === "today") {
return (
task.dueDate &&
new Date(task.dueDate) >= todayStart &&
new Date(task.dueDate) < new Date(todayStart.getTime() + 86400000) &&
task.status !== "DONE"
);
}
if (activeStatus === "overdue") {
return task.dueDate && new Date(task.dueDate) < todayStart && task.status !== "DONE";
}
if (activeStatus === "INTERNAL_REVIEW") {
return ["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(task.status);
}
return true;
});
const navigate = (params: { status?: string; assignee?: string }) => {
const sp = new URLSearchParams();
if (params.status) sp.set("status", params.status);
if (params.assignee) sp.set("assignee", params.assignee);
router.push(`/tasks?${sp.toString()}`);
};
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">
{isArtist ? "My Tasks" : "All Tasks"}
</h1>
<p className="text-sm text-zinc-400 mt-0.5">
{counts.total} total · {counts.overdue > 0 && (
<span className="text-red-400">{counts.overdue} overdue · </span>
)}
{counts.inReview} in review
</p>
</div>
{/* Assignee filter (non-artists) */}
{!isArtist && artists.length > 0 && (
<Select
value={activeAssignee ?? "__all__"}
onValueChange={(v) =>
navigate({ status: activeStatus, assignee: v === "__all__" ? undefined : v })
}
>
<SelectTrigger className="w-44 h-8 text-sm">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All artists</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Filter tabs */}
<div className="flex gap-1 border-b border-zinc-800 pb-0">
{FILTER_TABS.map((tab) => {
const Icon = tab.icon;
const isActive =
(!activeStatus && !tab.status) || activeStatus === tab.status;
const count =
tab.status === "today"
? counts.today
: tab.status === "overdue"
? counts.overdue
: tab.status === "INTERNAL_REVIEW"
? counts.inReview
: counts.total;
return (
<button
key={tab.label}
onClick={() =>
navigate({ status: tab.status, assignee: activeAssignee })
}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
isActive
? "border-amber-500 text-amber-400"
: "border-transparent text-zinc-500 hover:text-zinc-300"
)}
>
<Icon className={cn("h-3.5 w-3.5", isActive ? "text-amber-400" : tab.color)} />
{tab.label}
{count > 0 && (
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 font-mono">
{count}
</span>
)}
</button>
);
})}
</div>
{/* Task list */}
{filtered.length === 0 ? (
<div className="text-center py-16 border border-dashed border-zinc-800 rounded-xl">
<ListTodo className="h-8 w-8 mx-auto mb-3 text-zinc-700" />
<p className="text-zinc-500 text-sm">No tasks found</p>
</div>
) : (
<div className="space-y-1">
{filtered.map((task) => {
const cfg = TASK_STATUS_CONFIG[task.status];
const Icon = cfg.icon;
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const isOverdue =
task.dueDate &&
new Date(task.dueDate) < now &&
task.status !== "DONE";
const latestVersion = task.versions?.[0];
return (
<Link
key={task.id}
href={`/tasks/${task.id}`}
className="flex items-center gap-3 px-4 py-3 rounded-lg border border-transparent hover:border-zinc-800 hover:bg-zinc-900/60 transition-all group"
>
{/* Priority dot */}
<span
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
{/* Title + context */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
{task.title}
</span>
{contextCode && (
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
{contextCode}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-zinc-500">
<span>{TASK_TYPE_LABELS[task.type]}</span>
<span className="text-zinc-700">·</span>
<span>{task.project.name}</span>
</div>
</div>
{/* Meta */}
<div className="flex items-center gap-3 shrink-0">
{latestVersion && (
<span className="text-xs font-mono text-zinc-500">
v{latestVersion.versionNumber}
</span>
)}
{(task._count?.versions ?? 0) > 0 && (
<span className="flex items-center gap-1 text-[11px] text-zinc-500">
<Layers className="h-3 w-3" />
{task._count!.versions}
</span>
)}
{task.dueDate && (
<span
className={cn(
"flex items-center gap-1 text-[11px]",
isOverdue ? "text-red-400" : "text-zinc-500"
)}
>
<CalendarDays className="h-3 w-3" />
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
</span>
)}
{task.assignedArtist && (
<Avatar className="h-5 w-5">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
)}
<Badge
className={cn(
"text-[10px] border px-1.5 py-0 h-5 gap-1",
cfg.color
)}
>
<Icon className="h-2.5 w-2.5" />
{cfg.label}
</Badge>
</div>
</Link>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,485 @@
"use client";
import { useState, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate, formatFileSize } from "@/lib/utils";
import { updateTask, updateTaskStatus } from "@/actions/tasks";
import { useToast } from "@/components/ui/use-toast";
import { TaskStatus, TaskType } from "@prisma/client";
import { formatDistanceToNow, format } from "date-fns";
import {
ArrowLeft,
CalendarDays,
Clock,
Film,
Layers,
Play,
Upload,
ChevronDown,
CheckCircle2,
Eye,
RefreshCw,
Loader2,
AlertCircle,
User,
ExternalLink,
} from "lucide-react";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { VersionUpload } from "@/components/versions/VersionUpload";
import type { CommentWithReplies } from "@/types";
import { CommentPanel } from "@/components/comments/CommentPanel";
const PRIORITY_COLORS: Record<string, string> = {
LOW: "text-zinc-400", NORMAL: "text-blue-400", HIGH: "text-amber-400", URGENT: "text-red-400",
};
const APPROVAL_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
interface Artist {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}
interface Version {
id: string;
versionNumber: number;
fileName: string;
fileSize: bigint | null;
fps: number;
approvalStatus: string;
isLatest: boolean;
isClientVisible: boolean;
createdAt: Date;
notes: string | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
_count: { comments: number };
approvals: { id: string; user: { id: string; name: string | null; role: string } }[];
comments: CommentWithReplies[];
}
interface Task {
id: string;
title: string;
description: string | null;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
estimatedHours: number | null;
projectId: string;
assignedArtistId: string | null;
assignedArtist: Artist | null;
createdBy: { id: string; name: string | null; email: string };
project: { id: string; name: string; code: string };
shot: { id: string; shotCode: string; projectId: string } | null;
asset: { id: string; assetCode: string; name: string; projectId: string } | null;
versions: Version[];
}
interface TaskDetailClientProps {
task: Task;
artists: Artist[];
currentUserId: string;
canManage: boolean;
canUpload: boolean;
}
export function TaskDetailClient({
task,
artists,
currentUserId,
canManage,
canUpload,
}: TaskDetailClientProps) {
const router = useRouter();
const { toast } = useToast();
const [showUpload, setShowUpload] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const statusCfg = TASK_STATUS_CONFIG[task.status];
const StatusIcon = statusCfg.icon;
const latestVersion = task.versions[0];
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "DONE";
const parentLink = task.shot
? { label: task.shot.shotCode, href: `/projects/${task.project.id}` }
: task.asset
? { label: `${task.asset.assetCode}${task.asset.name}`, href: `/projects/${task.project.id}` }
: null;
const handleStatusChange = async (newStatus: TaskStatus) => {
setUpdatingStatus(true);
try {
await updateTaskStatus(task.id, newStatus);
router.refresh();
} catch {
toast({ title: "Failed to update status", variant: "destructive" });
} finally {
setUpdatingStatus(false);
}
};
const handleAssigneeChange = async (artistId: string) => {
try {
await updateTask(task.id, { assignedArtistId: artistId === "__none__" ? null : artistId });
router.refresh();
toast({ title: "Assignment updated" });
} catch {
toast({ title: "Failed to update assignment", variant: "destructive" });
}
};
return (
<div className="min-h-screen bg-background">
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500">
<Link href="/projects" className="hover:text-white transition-colors">Projects</Link>
<span>/</span>
<Link href={`/projects/${task.project.id}`} className="hover:text-white transition-colors">
{task.project.name}
</Link>
{parentLink && (
<>
<span>/</span>
<Link href={parentLink.href} className="hover:text-white transition-colors">
{parentLink.label}
</Link>
</>
)}
<span>/</span>
<span className="text-zinc-300">{task.title}</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main column */}
<div className="lg:col-span-2 space-y-5">
{/* Task header */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold text-white">{task.title}</h1>
<div className="flex items-center gap-2 text-sm text-zinc-500">
<span className="font-mono">{TASK_TYPE_LABELS[task.type]}</span>
{parentLink && (
<>
<span>·</span>
<Link href={parentLink.href} className="hover:text-zinc-300">
{parentLink.label}
</Link>
</>
)}
</div>
</div>
{canUpload && (
<Button onClick={() => setShowUpload(true)} className="gap-2 shrink-0">
<Upload className="h-4 w-4" />
Upload Version
</Button>
)}
</div>
{task.description && (
<p className="text-zinc-400 text-sm leading-relaxed">{task.description}</p>
)}
</div>
{/* Version history */}
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<Layers className="h-4 w-4" />
Versions
{task.versions.length > 0 && (
<span className="text-xs text-zinc-500 font-normal">({task.versions.length})</span>
)}
</h2>
{task.versions.length === 0 ? (
<div className="text-center py-8 border border-dashed border-border rounded-lg">
<Film className="h-8 w-8 mx-auto mb-2 text-zinc-600" />
<p className="text-sm text-muted-foreground">No versions uploaded yet</p>
{canUpload && (
<Button
variant="ghost"
size="sm"
className="mt-3 gap-2"
onClick={() => setShowUpload(true)}
>
<Upload className="h-3.5 w-3.5" />
Upload first version
</Button>
)}
</div>
) : (
<div className="space-y-2">
{task.versions.map((v) => (
<div
key={v.id}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors",
v.isLatest
? "border-amber-500/30 bg-amber-500/5"
: "border-border bg-card"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono font-medium text-sm text-white">
v{String(v.versionNumber).padStart(3, "0")}
</span>
{v.isLatest && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-amber-500/15 text-amber-400 border-amber-500/30">
Latest
</Badge>
)}
<Badge className={cn("text-xs border px-1.5 py-0 h-5", APPROVAL_STYLES[v.approvalStatus])}>
{v.approvalStatus.replace(/_/g, " ")}
</Badge>
{v.isClientVisible && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-blue-500/15 text-blue-400 border-blue-500/30">
Client Visible
</Badge>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-zinc-500">
<span>{v.fileName}</span>
{v.fileSize != null && (
<span>{formatFileSize(Number(v.fileSize))}</span>
)}
<span>{formatRelativeDate(new Date(v.createdAt))}</span>
{v._count.comments > 0 && (
<span className="flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
{v._count.comments} comments
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{v.artist && (
<Avatar className="h-6 w-6">
<AvatarImage src={v.artist.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{getInitials(v.artist.name ?? v.artist.email)}
</AvatarFallback>
</Avatar>
)}
<Link href={`/review/${v.id}`}>
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs">
<Play className="h-3 w-3" />
Review
</Button>
</Link>
</div>
</div>
))}
</div>
)}
</div>
{/* Comments on latest version */}
{latestVersion && (
<div className="space-y-3">
<h2 className="font-semibold text-zinc-300 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
Comments
</h2>
<CommentPanel
versionId={latestVersion.id}
fps={latestVersion.fps}
comments={latestVersion.comments}
/>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* Status */}
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
<h3 className="text-sm font-medium text-zinc-400">Status</h3>
{canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
disabled={updatingStatus}
className={cn(
"w-full flex items-center justify-between gap-2 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
statusCfg.color
)}
>
<span className="flex items-center gap-2">
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[]).map((s) => {
const cfg = TASK_STATUS_CONFIG[s];
const Icon = cfg.icon;
return (
<DropdownMenuItem
key={s}
onClick={() => handleStatusChange(s)}
className={cn(s === task.status && "font-medium")}
>
<Icon className="h-3.5 w-3.5 mr-2" />
{cfg.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-md border text-sm font-medium", statusCfg.color)}>
<StatusIcon className="h-3.5 w-3.5" />
{statusCfg.label}
</div>
)}
{/* Meta fields */}
<div className="space-y-3 pt-2 border-t border-border">
<div>
<p className="text-xs text-zinc-500 mb-1">Type</p>
<p className="text-sm font-medium">{TASK_TYPE_LABELS[task.type]}</p>
</div>
<div>
<p className="text-xs text-zinc-500 mb-1">Priority</p>
<p className={cn("text-sm font-medium capitalize", PRIORITY_COLORS[task.priority])}>
{task.priority}
</p>
</div>
{task.dueDate && (
<div>
<p className="text-xs text-zinc-500 mb-1">Due Date</p>
<p className={cn("text-sm flex items-center gap-1.5", isOverdue ? "text-red-400" : "text-zinc-300")}>
<CalendarDays className="h-3.5 w-3.5" />
{format(new Date(task.dueDate), "MMM d, yyyy")}
{isOverdue && <span className="text-xs">(Overdue)</span>}
</p>
</div>
)}
{task.estimatedHours && (
<div>
<p className="text-xs text-zinc-500 mb-1">Estimated</p>
<p className="text-sm flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-zinc-400" />
{task.estimatedHours}h
</p>
</div>
)}
</div>
</div>
{/* Assignee */}
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<h3 className="text-sm font-medium text-zinc-400">Assigned Artist</h3>
{canManage ? (
<Select
value={task.assignedArtistId ?? "__none__"}
onValueChange={handleAssigneeChange}
>
<SelectTrigger className="text-sm">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Unassigned</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name ?? a.email}
</SelectItem>
))}
</SelectContent>
</Select>
) : task.assignedArtist ? (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-xs">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{task.assignedArtist.name ?? task.assignedArtist.email}</p>
<p className="text-xs text-zinc-500">{task.assignedArtist.role}</p>
</div>
</div>
) : (
<p className="text-sm text-zinc-500">Unassigned</p>
)}
</div>
{/* Project context */}
<div className="rounded-lg border border-border bg-card p-4 space-y-2">
<h3 className="text-sm font-medium text-zinc-400">Project</h3>
<Link
href={`/projects/${task.project.id}`}
className="flex items-center gap-1.5 text-sm text-white hover:text-amber-400 transition-colors"
>
<span className="font-mono text-zinc-500">{task.project.code}</span>
{task.project.name}
<ExternalLink className="h-3 w-3 text-zinc-500" />
</Link>
{task.shot && (
<p className="text-xs text-zinc-500">Shot: {task.shot.shotCode}</p>
)}
{task.asset && (
<p className="text-xs text-zinc-500">Asset: {task.asset.assetCode} {task.asset.name}</p>
)}
</div>
</div>
</div>
</div>
{/* Version upload dialog */}
{showUpload && (
<VersionUpload
taskId={task.id}
projectId={task.projectId}
currentVersionNumber={latestVersion?.versionNumber ?? 0}
open={showUpload}
onClose={() => setShowUpload(false)}
onSuccess={() => {
setShowUpload(false);
router.refresh();
}}
/>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { auth } from "@/auth";
import { TaskDetailClient } from "./TaskDetailClient";
export async function generateMetadata({ params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const task = await db.task.findUnique({ where: { id: taskId }, select: { title: true } });
return { title: task?.title ?? "Task" };
}
async function getTask(taskId: string) {
return db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true, projectId: true } },
asset: { select: { id: true, assetCode: true, name: true, projectId: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true, role: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, image: true, email: true } },
_count: { select: { comments: true } },
approvals: {
orderBy: { createdAt: "desc" },
take: 1,
include: { user: { select: { id: true, name: true, role: true } } },
},
comments: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true, image: true, role: true } },
},
},
},
},
},
},
},
});
}
async function getProjectArtists(projectId: string) {
return db.user.findMany({
where: {
isActive: true,
},
select: { id: true, name: true, email: true, image: true, role: true },
orderBy: { name: "asc" },
});
}
export default async function TaskPage({ params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const session = await auth();
if (!session?.user) redirect("/login");
const task = await getTask(taskId);
if (!task) notFound();
const artists = await getProjectArtists(task.projectId);
const canManage = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
const isAssigned = task.assignedArtistId === session.user.id;
const canUpload = canManage || isAssigned;
return (
<TaskDetailClient
task={task as any}
artists={artists}
currentUserId={session.user.id}
canManage={canManage}
canUpload={canUpload}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { TasksPageClient } from "./TasksPageClient";
export const metadata = { title: "My Tasks" };
export default async function TasksPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; assignee?: string }>;
}) {
const session = await auth();
if (!session?.user) redirect("/login");
if (session.user.role === "CLIENT") redirect("/dashboard");
const { status, assignee } = await searchParams;
const isArtist = session.user.role === "ARTIST";
// Artists only see their own tasks; others can filter by assignee
const assigneeFilter = isArtist
? session.user.id
: assignee || undefined;
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tasks = await db.task.findMany({
where: {
...(assigneeFilter ? { assignedArtistId: assigneeFilter } : {}),
},
orderBy: [{ dueDate: "asc" }, { priority: "desc" }, { createdAt: "desc" }],
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
project: { select: { id: true, name: true, code: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
});
const artists = isArtist
? []
: await db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
});
// Counts for filter tabs
const today = tasks.filter(
(t) => t.dueDate && new Date(t.dueDate) >= todayStart && new Date(t.dueDate) < new Date(todayStart.getTime() + 86400000) && t.status !== "DONE"
).length;
const overdue = tasks.filter(
(t) => t.dueDate && new Date(t.dueDate) < todayStart && t.status !== "DONE"
).length;
const inReview = tasks.filter((t) =>
["INTERNAL_REVIEW", "CLIENT_REVIEW"].includes(t.status)
).length;
return (
<TasksPageClient
tasks={tasks as any}
artists={artists}
currentUserId={session.user.id}
role={session.user.role}
counts={{ today, overdue, inReview, total: tasks.length }}
activeStatus={status}
activeAssignee={assignee}
/>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Metadata } from "next";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { UsersClient } from "@/components/users/UsersClient";
import { Users } from "lucide-react";
export const metadata: Metadata = { title: "User Management" };
export default async function UsersPage() {
const session = await auth();
if (!session || session.user.role !== "ADMIN") {
redirect("/dashboard");
}
const users = await db.user.findMany({
orderBy: [{ role: "asc" }, { name: "asc" }, { email: "asc" }],
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
createdAt: true,
},
});
// Serialize dates
const serialized = users.map((u) => ({
...u,
createdAt: u.createdAt.toISOString(),
}));
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center shrink-0">
<Users className="h-5 w-5 text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-sm text-zinc-400 mt-0.5">Manage studio accounts, roles, and access</p>
</div>
</div>
<UsersClient users={serialized} currentUserId={session.user.id!} />
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
+104
View File
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { ApprovalStatus } from "@prisma/client";
import { recalcShotStatus } from "@/lib/shot-status";
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const body = await req.json();
const { versionId, status, notes } = body;
const validStatuses: ApprovalStatus[] = ["APPROVED", "REJECTED", "NEEDS_CHANGES"];
if (!versionId || !validStatuses.includes(status)) {
return NextResponse.json({ error: "versionId and valid status required" }, { status: 400 });
}
// Ensure the version belongs to this project via its task
const version = await db.version.findUnique({
where: { id: versionId },
include: {
task: {
include: { shot: true, project: true },
},
},
});
const projectId = version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
// Record approval
await db.approval.create({
data: { versionId, userId: user.id, status, notes },
});
// Update version approval status
await db.version.update({
where: { id: versionId },
data: { approvalStatus: status },
});
// Update task status based on approval decision
if (version.task) {
if (status === "APPROVED") {
await db.task.update({ where: { id: version.task.id }, data: { status: "DONE" } });
} else {
await db.task.update({ where: { id: version.task.id }, data: { status: "CHANGES" } });
}
// Recalculate derived shot status
if (version.task.shot) {
await recalcShotStatus(version.task.shot.id).catch(() => {});
}
}
// Slack notification
if (version.task?.project?.slackWebhook) {
const { slackNotifyApproval } = await import("@/lib/slack");
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
const contextCode = version.task.shot?.shotCode ?? version.task.title;
await slackNotifyApproval(version.task.project.slackWebhook, {
shotCode: contextCode,
versionLabel: `v${String(version.versionNumber).padStart(3, "0")}`,
reviewerName: user.name ?? "Client",
status,
projectName: version.task.project.name,
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
});
}
return NextResponse.json({ success: true });
}
+91
View File
@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { slackNotifyNewFeedback } from "@/lib/slack";
/** Find or create a guest user for the client reviewer based on the session email */
async function getOrCreateClientUser(email: string, label?: string | null) {
const existing = await db.user.findUnique({ where: { email } });
if (existing) return existing;
return db.user.create({
data: {
email,
name: label ?? email.split("@")[0],
role: "CLIENT",
isActive: true,
},
});
}
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const body = await req.json();
const { versionId, frameNumber, timestamp, text } = body;
if (!versionId || frameNumber == null || timestamp == null || !text?.trim()) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Ensure the version belongs to this project
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { projectId: true, shotCode: true, project: { select: { slackWebhook: true } } } },
task: { select: { projectId: true, title: true, project: { select: { slackWebhook: true } }, shot: { select: { shotCode: true } } } },
},
});
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
// Resolve commenter identity
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
const user = await getOrCreateClientUser(email, session.label);
const comment = await db.comment.create({
data: {
versionId,
authorId: user.id,
frameNumber,
timestamp,
text: text.trim(),
},
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: true,
},
});
// Slack notification
const slackWebhook =
version.shot?.project?.slackWebhook ?? version.task?.project?.slackWebhook ?? null;
const shotCode =
version.shot?.shotCode ?? version.task?.shot?.shotCode ?? version.task?.title ?? "Task";
if (slackWebhook) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
await slackNotifyNewFeedback(slackWebhook, {
shotCode,
frameNumber,
authorName: user.name ?? user.email,
commentText: text.trim(),
reviewUrl: `${appUrl}/client/${token}/review/${versionId}`,
});
}
return NextResponse.json({ comment }, { status: 201 });
}
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
/** GET /api/client/[token]/project — returns project + shots with tasks that have client-visible versions */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const project = await db.project.findUnique({
where: { id: session.projectId },
select: { id: true, name: true, code: true, description: true, status: true },
});
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
// Find shots that have at least one task with a client-visible version
const shots = await db.shot.findMany({
where: {
projectId: session.projectId,
tasks: {
some: {
versions: { some: { isClientVisible: true } },
},
},
},
orderBy: [{ sequence: "asc" }, { shotCode: "asc" }],
select: {
id: true,
shotCode: true,
sequence: true,
description: true,
status: true,
thumbnailUrl: true,
tasks: {
where: {
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
},
},
},
});
// Asset tasks with client-visible versions (no shotId)
const assetTasks = await db.task.findMany({
where: {
projectId: session.projectId,
shotId: null,
versions: { some: { isClientVisible: true } },
},
select: {
id: true,
title: true,
type: true,
status: true,
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
where: { isClientVisible: true, isLatest: true },
take: 1,
select: {
id: true,
versionNumber: true,
approvalStatus: true,
fps: true,
duration: true,
thumbnailUrl: true,
notes: true,
createdAt: true,
},
},
},
});
// Increment access count
await db.reviewSession.update({
where: { id: session.id },
data: { accessCount: { increment: 1 } },
});
return NextResponse.json({
project,
shots,
assetTasks,
sessionLabel: session.label,
});
}
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
async function validateToken(token: string) {
const session = await db.reviewSession.findUnique({ where: { token } });
if (!session || !session.isActive) return null;
if (session.expiresAt && session.expiresAt < new Date()) return null;
return session;
}
/** GET /api/client/[token]/versions/[versionId] — returns version + comments for client portal */
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string; versionId: string }> }
) {
const { token, versionId } = await params;
const session = await validateToken(token);
if (!session) {
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
}
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: {
include: {
project: { select: { id: true, name: true, code: true } },
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
createdAt: true,
},
},
},
},
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { shotCode: true } },
asset: { select: { assetCode: true, name: true } },
},
},
artist: { select: { id: true, name: true, image: true, email: true } },
approvals: {
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true } } },
},
},
});
// Resolve project: from shot or from task
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
if (!version || projectId !== session.projectId) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
// For task-only versions (no shot), require explicit client sharing
if (!version.shot && !version.isClientVisible) {
return NextResponse.json({ error: "Version not found" }, { status: 404 });
}
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
const serializedVersion = {
...version,
fileSize: version.fileSize?.toString() ?? null,
};
return NextResponse.json({ version: serializedVersion, comments });
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ clientId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { clientId } = await params;
const client = await db.client.findUnique({
where: { id: clientId },
include: {
projects: {
orderBy: { createdAt: "desc" },
include: {
_count: { select: { shots: true } },
},
},
},
});
if (!client) {
return NextResponse.json({ error: "Client not found" }, { status: 404 });
}
return NextResponse.json({ client });
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ clientId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { clientId } = await params;
const body = await req.json();
const { company, contactPerson, email, phone, notes, isActive } = body;
const client = await db.client.update({
where: { id: clientId },
data: { company, contactPerson, email, phone, notes, isActive },
});
return NextResponse.json({ client });
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const clients = await db.client.findMany({
select: {
id: true,
company: true,
contactPerson: true,
email: true,
isActive: true,
_count: { select: { projects: true } },
},
orderBy: { company: "asc" },
});
return NextResponse.json({ clients });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { company, contactPerson, email, phone, notes } = body;
if (!company || !contactPerson || !email) {
return NextResponse.json({ error: "company, contactPerson and email are required" }, { status: 400 });
}
const client = await db.client.create({
data: { company, contactPerson, email, phone, notes },
});
return NextResponse.json({ client }, { status: 201 });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ key: string[] }> }
) {
const { key } = await params;
// key is a catch-all segment array, e.g. ["videos", "uuid-filename.mp4"]
const relativePath = key.join("/");
// Sanitize: prevent path traversal
const uploadDir = path.resolve(process.env.LOCAL_UPLOAD_DIR ?? "./uploads");
const filePath = path.resolve(path.join(uploadDir, relativePath));
if (!filePath.startsWith(uploadDir)) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!fs.existsSync(filePath)) {
return new NextResponse("Not found", { status: 404 });
}
const stat = fs.statSync(filePath);
const ext = path.extname(filePath).toLowerCase();
const mimeMap: Record<string, string> = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".avi": "video/x-msvideo",
".mxf": "application/mxf",
".webm": "video/webm",
};
const contentType = mimeMap[ext] ?? "application/octet-stream";
// Support range requests so the HTML5 video player can seek
const rangeHeader = req.headers.get("range");
if (rangeHeader) {
const [startStr, endStr] = rangeHeader.replace("bytes=", "").split("-");
const start = parseInt(startStr, 10);
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const nodeStream = stream as unknown as ReadableStream;
return new NextResponse(nodeStream, {
status: 206,
headers: {
"Content-Range": `bytes ${start}-${end}/${stat.size}`,
"Accept-Ranges": "bytes",
"Content-Length": String(chunkSize),
"Content-Type": contentType,
},
});
}
const stream = fs.createReadStream(filePath) as unknown as ReadableStream;
return new NextResponse(stream, {
headers: {
"Content-Length": String(stat.size),
"Content-Type": contentType,
"Accept-Ranges": "bytes",
},
});
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const notifications = await db.notification.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 50,
});
const unreadCount = await db.notification.count({
where: { userId: session.user.id, isRead: false },
});
return NextResponse.json({ notifications, unreadCount });
}
export async function PATCH() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await db.notification.updateMany({
where: { userId: session.user.id, isRead: false },
data: { isRead: true },
});
return NextResponse.json({ success: true });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") ?? "";
const status = searchParams.get("status");
const projects = await db.project.findMany({
where: {
AND: [
q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ code: { contains: q, mode: "insensitive" } },
],
}
: {},
status ? { status: status as any } : {},
],
},
orderBy: { createdAt: "desc" },
include: {
client: { select: { id: true, company: true } },
producer: { select: { id: true, name: true, image: true } },
_count: { select: { shots: true } },
},
});
return NextResponse.json({ projects });
}
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { addDays } from "date-fns";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const projectId = req.nextUrl.searchParams.get("projectId");
const sessions = await db.reviewSession.findMany({
where: projectId ? { projectId } : undefined,
orderBy: { createdAt: "desc" },
include: {
project: { select: { id: true, name: true, code: true } },
},
});
return NextResponse.json({ sessions });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role as string)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const { projectId, label, email, expiresInDays = 30 } = body;
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
const project = await db.project.findUnique({ where: { id: projectId } });
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const reviewSession = await db.reviewSession.create({
data: {
projectId,
label: label || `Review — ${project.name}`,
email: email || null,
expiresAt: addDays(new Date(), expiresInDays),
},
});
const appUrl =
process.env.NEXT_PUBLIC_APP_URL ||
`${req.headers.get("x-forwarded-proto") ?? "https"}://${req.headers.get("host")}`;
const portalUrl = `${appUrl}/client/${reviewSession.token}`;
return NextResponse.json({ session: reviewSession, portalUrl }, { status: 201 });
}
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
await db.reviewSession.update({
where: { id },
data: { isActive: false },
});
return NextResponse.json({ success: true });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ shotId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { shotId } = await params;
const shot = await db.shot.findUnique({
where: { id: shotId },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
versions: {
orderBy: { versionNumber: "desc" },
include: {
artist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { comments: true } },
approvals: {
orderBy: { createdAt: "desc" },
include: { user: { select: { id: true, name: true, image: true } } },
},
},
},
},
});
if (!shot) {
return NextResponse.json({ error: "Shot not found" }, { status: 404 });
}
const project = await db.project.findUnique({
where: { id: shot.projectId },
select: { name: true },
});
const [tasks, artists] = await Promise.all([
db.task.findMany({
where: { shotId },
orderBy: { sortOrder: "asc" },
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
versions: {
take: 1,
orderBy: { versionNumber: "desc" },
select: { id: true, versionNumber: true, approvalStatus: true, createdAt: true },
},
},
}),
db.user.findMany({
where: { isActive: true },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
]);
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(
session.user.role as string
);
// Serialize BigInt fields (fileSize) so JSON.stringify doesn't throw
const shotSerialized = {
...shot,
versions: shot.versions.map((v) => ({
...v,
fileSize: v.fileSize != null ? v.fileSize.toString() : null,
})),
};
return NextResponse.json({
shot: shotSerialized,
projectName: project?.name ?? "",
canApprove,
tasks,
artists,
});
}
+63
View File
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { taskId } = await params;
const task = await db.task.findUnique({
where: { id: taskId },
include: {
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
createdBy: { select: { id: true, name: true, email: true } },
project: { select: { id: true, name: true, code: true } },
_count: { select: { versions: true } },
},
});
if (!task) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(task);
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { taskId } = await params;
const body = await req.json();
const task = await db.task.update({
where: { id: taskId },
data: body,
});
return NextResponse.json(task);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ taskId: string }> }
) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { taskId } = await params;
await db.task.delete({ where: { id: taskId } });
return NextResponse.json({ success: true });
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(request: Request) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const projectId = searchParams.get("projectId");
const shotId = searchParams.get("shotId");
const assetId = searchParams.get("assetId");
const tasks = await db.task.findMany({
where: {
...(projectId && { projectId }),
...(shotId && { shotId }),
...(assetId && { assetId }),
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
include: {
assignedArtist: { select: { id: true, name: true, email: true, image: true } },
_count: { select: { versions: true } },
},
});
return NextResponse.json(tasks);
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadFile } from "@/lib/storage";
export const config = { api: { bodyParser: false } };
// Max 2 GB
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.type.match(/video\//)) {
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
}
if (file.size > 2 * 1024 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 2 GB)" }, { status: 413 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const result = await uploadFile(buffer, file.name, file.type, "videos");
return NextResponse.json({ url: result.url, key: result.key });
} catch (err) {
console.error("[local-upload]", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { uploadFile } from "@/lib/storage";
export const config = { api: { bodyParser: false } };
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get("file") as File | null;
const type = (formData.get("type") as string) || "videos";
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Validate file type based on upload type
if (type === "image" && !file.type.match(/image\//)) {
return NextResponse.json({ error: "Only image files are accepted" }, { status: 400 });
}
if (type === "video" && !file.type.match(/video\//)) {
return NextResponse.json({ error: "Only video files are accepted" }, { status: 400 });
}
// Size limit: 500MB for images, 2GB for videos
const maxSize = type === "image" ? 500 * 1024 * 1024 : 2 * 1024 * 1024 * 1024;
if (file.size > maxSize) {
const maxSizeStr = type === "image" ? "500 MB" : "2 GB";
return NextResponse.json(
{ error: `File too large (max ${maxSizeStr})` },
{ status: 413 }
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const result = await uploadFile(buffer, file.name, file.type, type);
return NextResponse.json({ url: result.url, key: result.key });
} catch (err) {
console.error("[upload]", err);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const disabled = () =>
NextResponse.json(
{
error:
"UploadThing is not configured. Add UPLOADTHING_SECRET to .env or use STORAGE_PROVIDER=local.",
},
{ status: 503 }
);
// Lazily resolve the real handlers only when the secret is present.
// This prevents the UploadThing SDK from throwing at module-load time
// when the env var is missing (e.g. STORAGE_PROVIDER=local).
async function getHandlers() {
if (!process.env.UPLOADTHING_SECRET) return null;
const { createRouteHandler } = await import("uploadthing/next");
const { uploadRouter } = await import("@/lib/uploadthing");
return createRouteHandler({ router: uploadRouter });
}
export async function GET(req: NextRequest) {
const h = await getHandlers();
return h ? h.GET(req) : disabled();
}
export async function POST(req: NextRequest) {
const h = await getHandlers();
return h ? h.POST(req) : disabled();
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const annotations = await db.annotation.findMany({
where: { versionId, isVisible: true },
include: {
author: { select: { id: true, name: true, image: true } },
},
orderBy: { frameNumber: "asc" },
});
return NextResponse.json({ annotations });
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ versionId: string }> }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { versionId } = await params;
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
return NextResponse.json({ comments });
}
+426
View File
@@ -0,0 +1,426 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import {
Film,
CheckCircle2,
XCircle,
AlertCircle,
Clock,
ChevronRight,
Package,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Montserrat } from 'next/font/google';
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['200', '500', '600'],
});
interface ClientVersion {
id: string;
versionNumber: number;
approvalStatus: string;
fps: number;
duration: number | null;
thumbnailUrl: string | null;
notes: string | null;
createdAt: string;
}
interface ClientTask {
id: string;
title: string;
type: string;
status: string;
versions: ClientVersion[];
}
interface ClientShot {
id: string;
shotCode: string;
sequence: string | null;
description: string | null;
status: string;
thumbnailUrl: string | null;
tasks: ClientTask[];
}
interface AssetTask extends ClientTask {
asset?: { id: string; assetCode: string; name: string } | null;
}
interface Project {
id: string;
name: string;
code: string;
description: string | null;
status: string;
}
const APPROVAL_STYLES: Record<
string,
{ label: string; className: string; Icon: React.ElementType }
> = {
PENDING_REVIEW: {
label: 'Awaiting Review',
className: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
Icon: Clock,
},
APPROVED: {
label: 'Approved',
className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
Icon: CheckCircle2,
},
REJECTED: {
label: 'Rejected',
className: 'bg-red-500/10 text-red-400 border-red-500/20',
Icon: XCircle,
},
NEEDS_CHANGES: {
label: 'Needs Changes',
className: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
Icon: AlertCircle,
},
};
const TASK_TYPE_LABELS: Record<string, string> = {
TRACK: 'Tracking',
ROTO: 'Roto',
KEY: 'Keying',
COMP: 'Comp',
FX: 'FX',
LIGHTING: 'Lighting',
RENDER: 'Render',
ANIMATION: 'Animation',
MODEL: 'Model',
TEXTURE: 'Texture',
RIG: 'Rig',
LOOKDEV: 'Lookdev',
GENERAL: 'Task',
};
function getLatestVersion(task: ClientTask): ClientVersion | undefined {
return task.versions[0];
}
export default function ClientPortalPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const [token, setToken] = useState<string>('');
const [project, setProject] = useState<Project | null>(null);
const [shots, setShots] = useState<ClientShot[]>([]);
const [assetTasks, setAssetTasks] = useState<AssetTask[]>([]);
const [sessionLabel, setSessionLabel] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
params.then(({ token: t }) => {
setToken(t);
fetch(`/api/client/${t}/project`)
.then((r) => {
if (!r.ok) throw new Error('Invalid or expired review link');
return r.json();
})
.then((data) => {
setProject(data.project);
setShots(data.shots ?? []);
setAssetTasks(data.assetTasks ?? []);
setSessionLabel(data.sessionLabel ?? '');
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
});
}, [params]);
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (error || !project) {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center">
<Film className="h-6 w-6 text-black" />
</div>
<h1 className="text-xl font-semibold text-white">
Review link unavailable
</h1>
<p className="text-zinc-400 text-sm max-w-sm">
{error ??
'This review link has expired or is no longer active. Please request a new link from your studio contact.'}
</p>
</div>
);
}
const allTasks = [...shots.flatMap((s) => s.tasks), ...assetTasks];
const totalTasks = allTasks.length;
const approved = allTasks.filter(
(t) => getLatestVersion(t)?.approvalStatus === 'APPROVED',
).length;
const needsChanges = allTasks.filter((t) =>
['REJECTED', 'NEEDS_CHANGES'].includes(
getLatestVersion(t)?.approvalStatus ?? '',
),
).length;
const pending = totalTasks - approved - needsChanges;
const shotsBySequence = shots.reduce<Record<string, ClientShot[]>>(
(acc, shot) => {
const seq = shot.sequence ?? 'Shots';
if (!acc[seq]) acc[seq] = [];
acc[seq].push(shot);
return acc;
},
{},
);
return (
<div className="min-h-screen bg-zinc-950 text-white">
<header className="border-b border-zinc-800 bg-zinc-900">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
{/* Logo */}
<div className='flex items-center gap-3'>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
</div>
<div className={montserrat.className}>
<span className="block text-2xl font-light text-white leading-none">
TWO TALES
</span>
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
vfx review
</span>
</div>
</div>
{sessionLabel && (
<span className="text-sm text-zinc-400">{sessionLabel}</span>
)}
</div>
</header>
<div className="border-b border-zinc-800 bg-zinc-900/50">
<div className="max-w-5xl mx-auto px-6 py-8">
<p className="text-xs text-zinc-500 uppercase tracking-wider font-medium mb-1">
{project.code}
</p>
<h1 className="text-3xl font-bold text-white mb-2">{project.name}</h1>
{project.description && (
<p className="text-zinc-400 text-sm max-w-xl">
{project.description}
</p>
)}
<div className="flex flex-wrap gap-3 mt-6">
<div className="bg-zinc-800 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-white">{totalTasks}</p>
<p className="text-xs text-zinc-400 mt-0.5">Items</p>
</div>
<div className="bg-emerald-900/30 border border-emerald-800/30 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-emerald-400">{approved}</p>
<p className="text-xs text-zinc-400 mt-0.5">Approved</p>
</div>
<div className="bg-amber-900/20 border border-amber-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-amber-400">{pending}</p>
<p className="text-xs text-zinc-400 mt-0.5">Awaiting Review</p>
</div>
{needsChanges > 0 && (
<div className="bg-red-900/20 border border-red-800/20 rounded-lg px-4 py-3 text-center min-w-[80px]">
<p className="text-2xl font-bold text-red-400">
{needsChanges}
</p>
<p className="text-xs text-zinc-400 mt-0.5">Needs Changes</p>
</div>
)}
</div>
</div>
</div>
<main className="max-w-5xl mx-auto px-6 py-8 space-y-10">
{Object.entries(shotsBySequence).map(([sequence, seqShots]) => (
<div key={sequence}>
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
{sequence}
</h2>
<div className="space-y-4">
{seqShots.map((shot) => (
<div key={shot.id} className="space-y-1">
<div className="flex items-center gap-3 px-1">
{shot.thumbnailUrl && (
<div className="relative flex-shrink-0 w-40 aspect-[2.39] rounded overflow-hidden border border-zinc-800">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
/>
</div>
)}
<p className="font-mono text-sm font-semibold text-zinc-300">
{shot.shotCode}
{shot.description && (
<span className="font-sans font-normal text-zinc-500 ml-2">
{shot.description}
</span>
)}
</p>
</div>
<div className="space-y-1.5 pl-3 border-l border-zinc-800">
{shot.tasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey =
ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
{task.title}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs font-mono text-zinc-500">
v{String(ver.versionNumber).padStart(3, '0')}
</span>
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
))}
{assetTasks.length > 0 && (
<div>
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500 mb-3">
Assets
</h2>
<div className="space-y-2">
{assetTasks.map((task) => {
const ver = getLatestVersion(task);
const approvalKey = ver?.approvalStatus ?? 'PENDING_REVIEW';
const approval =
APPROVAL_STYLES[approvalKey] ??
APPROVAL_STYLES.PENDING_REVIEW;
const ApprovalIcon = approval.Icon;
return (
<Link
key={task.id}
href={ver ? `/client/${token}/review/${ver.id}` : '#'}
className={cn(
'flex items-center gap-4 p-4 rounded-xl border transition-all group',
'bg-zinc-900 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/70',
!ver && 'pointer-events-none opacity-40',
)}
>
<Package className="h-4 w-4 text-zinc-500 shrink-0" />
<div className="min-w-[90px]">
<p className="font-mono font-semibold text-white text-sm">
{task.asset?.assetCode ?? TASK_TYPE_LABELS[task.type]}
</p>
<p className="text-xs text-zinc-500">
{TASK_TYPE_LABELS[task.type] ?? task.type}
</p>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 truncate">
{task.title}
</p>
{ver?.notes && (
<p className="text-xs text-zinc-500 truncate italic mt-0.5">
&ldquo;{ver.notes}&rdquo;
</p>
)}
</div>
{ver ? (
<div className="flex items-center gap-3 shrink-0">
<span className="text-xs font-mono text-zinc-500">
v{String(ver.versionNumber).padStart(3, '0')}
</span>
<span
className={cn(
'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs font-medium',
approval.className,
)}
>
<ApprovalIcon className="h-3 w-3" />
{approval.label}
</span>
</div>
) : (
<span className="text-xs text-zinc-600 shrink-0">
No versions yet
</span>
)}
<ChevronRight className="h-4 w-4 text-zinc-600 group-hover:text-zinc-400 shrink-0 transition-colors" />
</Link>
);
})}
</div>
</div>
)}
{totalTasks === 0 && (
<div className="text-center py-16 text-zinc-500">
<Film className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No items have been shared for review yet.</p>
</div>
)}
</main>
<footer className="border-t border-zinc-800 py-6 text-center text-xs text-zinc-600">
Powered by <span className="text-zinc-400">TTDEV</span>
</footer>
</div>
);
}
@@ -0,0 +1,482 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useToast } from "@/components/ui/use-toast";
import { cn, frameToTimecode, getInitials } from "@/lib/utils";
import {
Film,
ArrowLeft,
CheckCircle2,
XCircle,
AlertCircle,
MessageSquare,
Send,
Clock,
} from "lucide-react";
import { useReviewStore } from "@/hooks/use-review-player";
interface Comment {
id: string;
frameNumber: number;
timestamp: number;
text: string;
isResolved: boolean;
createdAt: string;
author: { id: string; name: string | null; image: string | null; email: string };
replies: { id: string; text: string; createdAt: string; author: { name: string | null } }[];
}
interface Version {
id: string;
versionNumber: number;
fileUrl: string;
fps: number;
duration: number | null;
approvalStatus: string;
notes: string | null;
shot?: {
id: string;
shotCode: string;
project: { id: string; name: string; code: string };
} | null;
task?: {
id: string;
title: string;
type: string;
project: { id: string; name: string; code: string };
shot?: { shotCode: string } | null;
asset?: { assetCode: string; name: string } | null;
} | null;
}
const APPROVAL_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
export default function ClientReviewPage({
params,
}: {
params: Promise<{ token: string; versionId: string }>;
}) {
const [token, setToken] = useState("");
const [version, setVersion] = useState<Version | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const currentFrame = useReviewStore((s) => s.currentFrame);
// Comment form state
const [commentText, setCommentText] = useState("");
const [commentFrame, setCommentFrame] = useState<number | null>(null);
const [submittingComment, setSubmittingComment] = useState(false);
// Approval dialog
const [approvalDialog, setApprovalDialog] = useState<{
open: boolean;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
}>({ open: false, status: null });
const [approvalNotes, setApprovalNotes] = useState("");
const [submittingApproval, setSubmittingApproval] = useState(false);
const [currentApprovalStatus, setCurrentApprovalStatus] = useState("PENDING_REVIEW");
const playerRef = useRef<ReviewPlayerRef>(null);
const { toast } = useToast();
useEffect(() => {
params.then(({ token: t, versionId }) => {
setToken(t);
fetch(`/api/client/${t}/versions/${versionId}`)
.then((r) => {
if (!r.ok) throw new Error("Invalid or expired review link");
return r.json();
})
.then((data) => {
setVersion(data.version);
setComments(data.comments);
setCurrentApprovalStatus(data.version.approvalStatus);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
});
}, [params]);
const refreshComments = useCallback(async (t: string, vId: string) => {
const res = await fetch(`/api/client/${t}/versions/${vId}`);
if (res.ok) {
const data = await res.json();
setComments(data.comments);
}
}, []);
const handlePlayerAddComment = useCallback((frameNumber: number, _timestamp: number) => {
playerRef.current?.pause();
setCommentFrame(frameNumber);
}, []);
const handleSubmitComment = async () => {
if (!commentText.trim() || commentFrame === null || !version) return;
setSubmittingComment(true);
try {
const res = await fetch(`/api/client/${token}/comment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionId: version.id,
frameNumber: commentFrame,
timestamp: commentFrame / version.fps,
text: commentText.trim(),
}),
});
if (!res.ok) throw new Error("Failed to post comment");
setCommentText("");
setCommentFrame(null);
await refreshComments(token, version.id);
toast({ title: "Comment added" });
} catch {
toast({ title: "Failed to add comment", variant: "destructive" });
} finally {
setSubmittingComment(false);
}
};
const handleSubmitApproval = async () => {
if (!approvalDialog.status || !version) return;
setSubmittingApproval(true);
try {
const res = await fetch(`/api/client/${token}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionId: version.id,
status: approvalDialog.status,
notes: approvalNotes,
}),
});
if (!res.ok) throw new Error("Failed to submit decision");
setCurrentApprovalStatus(approvalDialog.status);
setApprovalDialog({ open: false, status: null });
setApprovalNotes("");
toast({
title:
approvalDialog.status === "APPROVED"
? "Version approved!"
: approvalDialog.status === "REJECTED"
? "Version rejected"
: "Changes requested",
});
} catch {
toast({ title: "Failed to submit decision", variant: "destructive" });
} finally {
setSubmittingApproval(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<div className="h-8 w-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (error || !version) {
return (
<div className="min-h-screen bg-zinc-950 flex flex-col items-center justify-center gap-4 text-center px-4">
<Film className="h-10 w-10 text-zinc-600" />
<h1 className="text-xl font-semibold text-white">Version unavailable</h1>
<p className="text-zinc-400 text-sm">{error}</p>
</div>
);
}
const approvalStyle = APPROVAL_STATUS_STYLES[currentApprovalStatus] ?? "";
return (
<div className="flex flex-col h-screen bg-zinc-950 text-white overflow-hidden">
{/* Header */}
<header className="flex items-center gap-3 px-4 py-2.5 border-b border-zinc-800 bg-zinc-900 shrink-0">
<Link
href={`/client/${token}`}
className="text-zinc-400 hover:text-white transition-colors flex items-center gap-1.5 text-sm"
>
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">All Shots</span>
</Link>
<div className="h-4 w-px bg-zinc-700" />
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="w-5 h-5 rounded bg-amber-500 flex items-center justify-center shrink-0">
<Film className="h-3 w-3 text-black" />
</div>
{(() => {
const project = version.shot?.project ?? version.task?.project;
const contextCode =
version.shot?.shotCode ??
version.task?.shot?.shotCode ??
version.task?.asset?.assetCode ??
null;
return (
<>
<span className="text-xs text-zinc-500 hidden sm:block">{project?.code}</span>
<span className="text-zinc-600">/</span>
{contextCode && (
<>
<span className="font-mono font-semibold">{contextCode}</span>
<span className="text-zinc-600">/</span>
</>
)}
<span className="font-mono text-sm text-zinc-300">
v{String(version.versionNumber).padStart(3, "0")}
</span>
</>
);
})()}
</div>
<span
className={cn(
"text-xs px-2.5 py-1 rounded-full border hidden sm:inline-flex items-center gap-1",
approvalStyle
)}
>
{currentApprovalStatus === "PENDING_REVIEW" ? (
<Clock className="h-3 w-3" />
) : currentApprovalStatus === "APPROVED" ? (
<CheckCircle2 className="h-3 w-3" />
) : currentApprovalStatus === "NEEDS_CHANGES" ? (
<AlertCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{currentApprovalStatus.replace(/_/g, " ")}
</span>
{/* Decision buttons */}
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="outline"
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
>
<AlertCircle className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Needs Changes</span>
</Button>
<Button
size="sm"
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
>
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Approve</span>
</Button>
</div>
</header>
{/* Main: player + comments */}
<div className="flex flex-1 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
ref={playerRef}
videoUrl={version.fileUrl}
fps={version.fps}
comments={comments as any}
versionId={version.id}
onAddComment={handlePlayerAddComment}
/>
</div>
{/* Comment panel */}
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-zinc-400" />
Notes
{comments.length > 0 && (
<span className="text-xs text-zinc-500">({comments.length})</span>
)}
</h3>
</div>
<ScrollArea className="flex-1 px-4 py-3">
{comments.length === 0 ? (
<div className="text-center py-8 text-zinc-500 text-sm">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p>No notes yet.</p>
<p className="text-xs mt-1">Pause the video and add a note at any frame.</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.id} className="space-y-2">
<div
className="rounded-lg bg-zinc-800/70 p-3 space-y-2 cursor-pointer hover:bg-zinc-700/70 transition-colors"
onClick={() => playerRef.current?.seekToFrame(comment.frameNumber)}
title={`Jump to frame ${comment.frameNumber}`}
>
<div className="flex items-center gap-2">
<Avatar className="h-5 w-5">
<AvatarFallback className="text-[9px] bg-zinc-700">
{getInitials(comment.author.name ?? comment.author.email)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 font-medium">
{comment.author.name ?? comment.author.email.split("@")[0]}
</span>
<span className="ml-auto font-mono text-xs text-amber-400/80">
{frameToTimecode(comment.frameNumber, version.fps)}
</span>
</div>
<p className="text-sm text-zinc-200 leading-relaxed">{comment.text}</p>
</div>
{comment.replies.map((reply) => (
<div key={reply.id} className="ml-4 rounded-lg bg-zinc-800/40 border border-zinc-700/50 p-2.5">
<span className="text-xs text-zinc-400 font-medium">
{reply.author.name ?? "Reply"}
</span>
<p className="text-xs text-zinc-300 mt-1">{reply.text}</p>
</div>
))}
</div>
))}
</div>
)}
</ScrollArea>
{/* Add note */}
{commentFrame !== null ? (
<div className="border-t border-zinc-800 p-3 space-y-2">
<p className="text-xs text-zinc-500 font-mono">
Frame {commentFrame} · {frameToTimecode(commentFrame, version.fps)}
</p>
<Textarea
placeholder="Add your note..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="min-h-[80px] text-sm resize-none"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmitComment();
if (e.key === "Escape") setCommentFrame(null);
}}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-xs"
onClick={() => { setCommentFrame(null); setCommentText(""); }}
>
Cancel
</Button>
<Button
size="sm"
className="flex-1 h-8 text-xs gap-1"
onClick={handleSubmitComment}
disabled={!commentText.trim() || submittingComment}
>
<Send className="h-3 w-3" />
Send
</Button>
</div>
</div>
) : (
<div className="border-t border-zinc-800 p-3">
<Button
variant="outline"
className="w-full text-sm gap-2 h-9"
onClick={() => {
playerRef.current?.pause();
setCommentFrame(currentFrame);
}}
>
<MessageSquare className="h-4 w-4" />
Add Note at Current Frame
</Button>
</div>
)}
</div>
</div>
{/* Approval dialog */}
<Dialog
open={approvalDialog.open}
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{approvalDialog.status === "APPROVED"
? "✅ Approve this version"
: approvalDialog.status === "NEEDS_CHANGES"
? "✏️ Request changes"
: "❌ Reject this version"}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-zinc-400">
{approvalDialog.status === "APPROVED"
? "Confirm you're happy with this version. You can add an optional note below."
: "Describe what needs to change so the team can action it quickly."}
</p>
<Textarea
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
placeholder={
approvalDialog.status === "APPROVED"
? "Looks great! (optional)"
: "Please describe the changes needed..."
}
className="min-h-[100px]"
autoFocus
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setApprovalDialog({ open: false, status: null })}
>
Cancel
</Button>
<Button
onClick={handleSubmitApproval}
disabled={submittingApproval}
className={cn(
approvalDialog.status === "APPROVED"
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: approvalDialog.status === "NEEDS_CHANGES"
? "bg-orange-600 hover:bg-orange-500 text-white"
: "bg-red-700 hover:bg-red-600 text-white"
)}
>
{submittingApproval
? "Submitting..."
: approvalDialog.status === "APPROVED"
? "Approve"
: approvalDialog.status === "NEEDS_CHANGES"
? "Request Changes"
: "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Dark-first CSS variables (shadcn/ui compatible) ─────────────────────── */
@layer base {
:root {
/* Zinc dark palette — always dark */
--background: 0 0% 3.9%; /* zinc-950 */
--foreground: 0 0% 98%; /* white */
--card: 0 0% 9%; /* zinc-900 */
--card-foreground: 0 0% 98%;
--popover: 0 0% 9%; /* zinc-900 */
--popover-foreground: 0 0% 98%;
/* Amber accent — brand colour */
--primary: 38 92% 50%; /* amber-500 */
--primary-foreground: 38 100% 6%;
--secondary: 0 0% 14.9%; /* zinc-800 */
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%; /* zinc-800 */
--muted-foreground: 0 0% 63.9%; /* zinc-400 */
--accent: 0 0% 14.9%; /* zinc-800 */
--accent-foreground: 0 0% 98%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%; /* zinc-800 */
--input: 0 0% 9%; /* zinc-900 */
--ring: 38 92% 50%; /* amber-500 */
--radius: 0.625rem; /* 10px */
/* Status colors */
--status-approved: 142 71% 45%;
--status-rejected: 0 72% 51%;
--status-pending: 38 92% 50%;
--status-changes: 271 76% 53%;
}
/* Force dark mode globally */
html {
color-scheme: dark;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/50;
}
}
/* ── Custom utilities ────────────────────────────────────────────────────── */
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Cinematic glow effect for review player */
.player-glow {
box-shadow: 0 0 60px rgba(0, 0, 0, 0.8), inset 0 0 120px rgba(0, 0, 0, 0.2);
}
/* Annotation canvas cursor crosshair */
.cursor-crosshair {
cursor: crosshair;
}
/* Timeline scrub cursor */
.cursor-ew-resize {
cursor: ew-resize;
}
/* Status colors */
.status-approved {
color: hsl(var(--status-approved));
}
.status-rejected {
color: hsl(var(--status-rejected));
}
.status-pending {
color: hsl(var(--status-pending));
}
.status-changes {
color: hsl(var(--status-changes));
}
.bg-status-approved {
background-color: hsl(var(--status-approved) / 0.15);
}
.bg-status-rejected {
background-color: hsl(var(--status-rejected) / 0.15);
}
.bg-status-pending {
background-color: hsl(var(--status-pending) / 0.15);
}
.bg-status-changes {
background-color: hsl(var(--status-changes) / 0.15);
}
}
/* ── Review player specific styles ──────────────────────────────────────── */
.review-player-container {
position: relative;
background: #000;
user-select: none;
}
.review-player-container video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Annotation canvas overlay */
.annotation-canvas-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.annotation-canvas-overlay.is-annotating {
pointer-events: auto;
}
/* Frame timeline */
.frame-timeline {
position: relative;
height: 48px;
background: hsl(0 0% 6%);
border-top: 1px solid hsl(0 0% 14%);
cursor: ew-resize;
overflow: hidden;
}
/* Comment badge on timeline */
.timeline-comment-marker {
position: absolute;
top: 4px;
width: 4px;
height: 4px;
border-radius: 50%;
background: hsl(213 94% 68%);
transform: translateX(-50%);
pointer-events: none;
}
/* Keyframe indicator */
.playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: hsl(0 72% 51%);
pointer-events: none;
z-index: 10;
}
/* Player controls glassmorphism */
.player-controls {
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0) 100%);
}
+32
View File
@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/layout/Providers";
import { Toaster } from "@/components/ui/toaster";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: {
template: "%s | FeedBack",
default: "FeedBack — VFX Review Platform",
},
description: "Frame-accurate review and approval for VFX and animation studios.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
export default async function RootPage() {
const session = await auth();
if (session?.user) {
redirect("/dashboard");
} else {
redirect("/login");
}
}
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer";
import { CommentPanel } from "@/components/comments/CommentPanel";
import { VersionList } from "@/components/versions/VersionList";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { submitApproval } from "@/actions/approvals";
import { useToast } from "@/components/ui/use-toast";
import { ShareWithClientButton } from "@/components/versions/ShareWithClientButton";
import {
CheckCircle2,
XCircle,
AlertCircle,
ChevronDown,
ArrowLeft,
Film,
} from "lucide-react";
interface ReviewPageClientProps {
version: {
id: string;
versionNumber: number;
fileUrl: string;
fps: number;
duration: number | null;
frameCount: number | null;
approvalStatus: string;
notes: string | null;
isClientVisible: boolean;
shot?: {
id: string;
shotCode: string;
project: { id: string; name: string; code: string };
versions: {
id: string;
versionNumber: number;
approvalStatus: string;
isLatest: boolean;
fps: number;
duration: number | null;
notes: string | null;
fileSize: number | null;
createdAt: Date;
}[];
} | null;
task?: {
id: string;
title: string;
project: { id: string; name: string; code: string };
shot?: { id: string; shotCode: string } | null;
asset?: { id: string; assetCode: string; name: string } | null;
versions: {
id: string;
versionNumber: number;
approvalStatus: string;
isLatest: boolean;
fps: number;
duration: number | null;
notes: string | null;
fileSize: number | null;
createdAt: Date;
}[];
} | null;
artist: { id: string; name: string | null; image: string | null; email: string } | null;
};
comments: any[];
annotations: any[];
canApprove: boolean;
canShare: boolean;
currentUserId: string;
}
const APPROVAL_STATUS_STYLES: Record<string, string> = {
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
};
export function ReviewPageClient({
version,
comments: initialComments,
annotations: initialAnnotations,
canApprove,
canShare,
currentUserId,
}: ReviewPageClientProps) {
const [comments, setComments] = useState(initialComments);
const [annotations, setAnnotations] = useState(initialAnnotations);
const [pendingFrame, setPendingFrame] = useState<number | null>(null);
const [approvalDialog, setApprovalDialog] = useState<{
open: boolean;
status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES" | null;
}>({ open: false, status: null });
const [approvalNotes, setApprovalNotes] = useState("");
const [isSubmittingApproval, setIsSubmittingApproval] = useState(false);
const [showVersions, setShowVersions] = useState(false);
const playerRef = useRef<ReviewPlayerRef>(null);
const { toast } = useToast();
const router = useRouter();
const handleAddComment = useCallback(() => {
// Pause player first
playerRef.current?.pause();
const frame = playerRef.current ? undefined : undefined;
// Frame is tracked in ReviewPlayer store — CommentPanel reads from there
setPendingFrame(Date.now()); // trigger effect with timestamp trick
}, []);
const handleCommentsChange = useCallback(async () => {
try {
const res = await fetch(`/api/versions/${version.id}/comments`);
if (res.ok) {
const data = await res.json();
setComments(data.comments);
}
} catch {
// silently ignore
}
}, [version.id]);
const handleAnnotationSaved = useCallback(async () => {
// Refresh both annotations (for timeline markers) and comments (for the auto-comment)
try {
const [annRes, cmtRes] = await Promise.all([
fetch(`/api/versions/${version.id}/annotations`),
fetch(`/api/versions/${version.id}/comments`),
]);
if (annRes.ok) setAnnotations((await annRes.json()).annotations);
if (cmtRes.ok) setComments((await cmtRes.json()).comments);
} catch {
// silently ignore
}
}, [version.id]);
const handleApprovalSubmit = async () => {
if (!approvalDialog.status) return;
setIsSubmittingApproval(true);
try {
await submitApproval({
versionId: version.id,
status: approvalDialog.status,
notes: approvalNotes,
});
toast({
title:
approvalDialog.status === "APPROVED"
? "Version approved!"
: approvalDialog.status === "REJECTED"
? "Version rejected"
: "Changes requested",
});
setApprovalDialog({ open: false, status: null });
setApprovalNotes("");
router.refresh();
} catch (err) {
toast({
title: "Failed to submit approval",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmittingApproval(false);
}
};
const openApproval = (status: "APPROVED" | "REJECTED" | "NEEDS_CHANGES") => {
setApprovalDialog({ open: true, status });
};
// Resolve context: shot or task
const project = version.shot?.project ?? version.task?.project;
const contextCode =
version.shot?.shotCode ??
version.task?.shot?.shotCode ??
version.task?.asset?.assetCode ??
null;
const backHref = version.shot
? `/projects/${project?.id}/shots/${version.shot.id}`
: version.task
? `/tasks/${version.task.id}`
: `/dashboard`;
const versionList = version.shot?.versions ?? version.task?.versions ?? [];
return (
<div className="flex flex-col h-screen bg-background overflow-hidden">
{/* Top bar */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border bg-card shrink-0">
<Link
href={backHref}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-xs text-muted-foreground hidden sm:block">
{project?.code}
</span>
<span className="text-muted-foreground">/</span>
{contextCode && (
<>
<span className="font-mono text-sm font-semibold">{contextCode}</span>
<span className="text-muted-foreground">/</span>
</>
)}
{/* Version selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 gap-1 font-mono text-sm">
<Film className="h-3.5 w-3.5" />
v{String(version.versionNumber).padStart(3, "0")}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{versionList.map((v) => (
<DropdownMenuItem key={v.id} asChild>
<Link href={`/review/${v.id}`}>
v{String(v.versionNumber).padStart(3, "0")}
{v.isLatest && " (latest)"}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Approval status */}
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border hidden sm:inline-flex items-center gap-1",
APPROVAL_STATUS_STYLES[version.approvalStatus] ?? ""
)}
>
{version.approvalStatus.replace("_", " ")}
</span>
{/* Share with Client */}
{canShare && (
<ShareWithClientButton
versionId={version.id}
isAlreadyShared={version.isClientVisible}
onShared={() => {/* router.refresh() handled inside component */}}
/>
)}
{/* Approval actions */}
{canApprove && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
onClick={() => openApproval("NEEDS_CHANGES")}
>
<AlertCircle className="h-3 w-3" />
<span className="hidden sm:inline">Needs Changes</span>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 text-red-400 border-red-500/30 hover:bg-red-500/10"
onClick={() => openApproval("REJECTED")}
>
<XCircle className="h-3 w-3" />
<span className="hidden sm:inline">Reject</span>
</Button>
<Button
size="sm"
className="h-7 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
onClick={() => openApproval("APPROVED")}
>
<CheckCircle2 className="h-3 w-3" />
<span className="hidden sm:inline">Approve</span>
</Button>
</div>
)}
</div>
{/* Main content — player + comments */}
<div className="flex flex-1 overflow-hidden">
{/* Player — 70% */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
ref={playerRef}
videoUrl={version.fileUrl}
fps={version.fps}
comments={comments}
annotations={annotations}
versionId={version.id}
onAddComment={handleAddComment}
onAnnotationSaved={handleAnnotationSaved}
/>
</div>
{/* Comment panel — 30%, min 280px */}
<div className="w-80 xl:w-96 shrink-0 flex flex-col border-l border-border">
<CommentPanel
versionId={version.id}
fps={version.fps}
comments={comments}
onCommentsChange={handleCommentsChange}
pendingFrame={pendingFrame}
onPendingFrameCleared={() => setPendingFrame(null)}
onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)}
/>
</div>
</div>
{/* Approval Dialog */}
<Dialog
open={approvalDialog.open}
onOpenChange={(o) => !o && setApprovalDialog({ open: false, status: null })}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{approvalDialog.status === "APPROVED"
? "Approve Version"
: approvalDialog.status === "REJECTED"
? "Reject Version"
: "Request Changes"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Notes (optional)</Label>
<Textarea
value={approvalNotes}
onChange={(e) => setApprovalNotes(e.target.value)}
placeholder={
approvalDialog.status === "APPROVED"
? "Any final comments..."
: "Describe the required changes..."
}
className="min-h-[100px]"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setApprovalDialog({ open: false, status: null })}
>
Cancel
</Button>
<Button
onClick={handleApprovalSubmit}
disabled={isSubmittingApproval}
className={
approvalDialog.status === "APPROVED"
? "bg-emerald-600 hover:bg-emerald-500 text-white"
: approvalDialog.status === "REJECTED"
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-orange-600 hover:bg-orange-500 text-white"
}
>
{isSubmittingApproval ? "Submitting..." : "Confirm"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { ReviewPageClient } from "./ReviewPageClient";
export async function generateMetadata({ params }: { params: Promise<{ versionId: string }> }) {
const { versionId } = await params;
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: { select: { shotCode: true } },
task: { select: { title: true } },
},
});
if (!version) return { title: "Not Found" };
const label = version.shot?.shotCode ?? version.task?.title ?? "Version";
return { title: `Review — ${label} v${version.versionNumber}` };
}
async function getReviewData(versionId: string) {
const version = await db.version.findUnique({
where: { id: versionId },
include: {
shot: {
include: {
project: {
select: { id: true, name: true, code: true },
},
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
notes: true,
fileSize: true,
createdAt: true,
},
},
},
},
task: {
include: {
project: { select: { id: true, name: true, code: true } },
shot: { select: { id: true, shotCode: true } },
asset: { select: { id: true, assetCode: true, name: true } },
versions: {
orderBy: { versionNumber: "desc" },
select: {
id: true,
versionNumber: true,
approvalStatus: true,
isLatest: true,
fps: true,
duration: true,
notes: true,
fileSize: true,
createdAt: true,
},
},
},
},
artist: {
select: { id: true, name: true, image: true, email: true },
},
},
});
if (!version) return null;
const comments = await db.comment.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
author: { select: { id: true, name: true, image: true, email: true } },
},
},
},
});
const annotations = await db.annotation.findMany({
where: { versionId },
orderBy: { frameNumber: "asc" },
});
return { version, comments, annotations };
}
export default async function ReviewPage({
params,
}: {
params: Promise<{ versionId: string }>;
}) {
const { versionId } = await params;
const session = await auth();
if (!session?.user) {
// Already handled by middleware but be safe
return null;
}
const data = await getReviewData(versionId);
if (!data) notFound();
const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR", "CLIENT"].includes(
session.user.role
);
const canShare = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session.user.role);
return (
<ReviewPageClient
version={data.version as any}
comments={data.comments as any}
annotations={data.annotations as any}
canApprove={canApprove}
canShare={canShare}
currentUserId={session.user.id}
/>
);
}
+66
View File
@@ -0,0 +1,66 @@
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
import { Role } from "@prisma/client";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db) as any,
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role: Role }).role;
token.mustChangePassword = (user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.role = token.role as Role;
session.user.mustChangePassword = token.mustChangePassword as boolean;
}
return session;
},
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await db.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash || !user.isActive) return null;
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
role: user.role,
mustChangePassword: user.mustChangePassword,
};
},
}),
],
});
+260
View File
@@ -0,0 +1,260 @@
root@ubuntu-4gb-nbg1-3:/opt/vfxreview# docker inspect vfxreview
[
{
"Id": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
"Created": "2026-05-15T11:13:46.919887274Z",
"Path": "docker-entrypoint.sh",
"Args": [
"node",
"server.js"
],
"State": {
"Status": "restarting",
"Running": true,
"Paused": false,
"Restarting": true,
"OOMKilled": false,
"Dead": false,
"Pid": 0,
"ExitCode": 255,
"Error": "",
"StartedAt": "2026-05-15T11:17:31.349008071Z",
"FinishedAt": "2026-05-15T11:17:31.553910724Z"
},
"Image": "sha256:69368bb47b96170569bd23be2343dded31696127c8d1e0c1ddff6fbc6b9d4fb2",
"ResolvConfPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/resolv.conf",
"HostnamePath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hostname",
"HostsPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/hosts",
"LogPath": "/var/lib/docker/containers/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320/c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320-json.log",
"Name": "/vfxreview",
"RestartCount": 13,
"Driver": "overlay2",
"Platform": "linux",
"MountLabel": "",
"ProcessLabel": "",
"AppArmorProfile": "docker-default",
"ExecIDs": null,
"HostConfig": {
"Binds": [
"/opt/vfxreview/uploads:/uploads:rw"
],
"ContainerIDFile": "",
"LogConfig": {
"Type": "json-file",
"Config": {}
},
"NetworkMode": "vfxreview_app-net",
"PortBindings": {},
"RestartPolicy": {
"Name": "unless-stopped",
"MaximumRetryCount": 0
},
"AutoRemove": false,
"VolumeDriver": "",
"VolumesFrom": [],
"ConsoleSize": [
0,
0
],
"CapAdd": null,
"CapDrop": null,
"CgroupnsMode": "private",
"Dns": null,
"DnsOptions": null,
"DnsSearch": null,
"ExtraHosts": null,
"GroupAdd": null,
"IpcMode": "private",
"Cgroup": "",
"Links": null,
"OomScoreAdj": 0,
"PidMode": "",
"Privileged": false,
"PublishAllPorts": false,
"ReadonlyRootfs": false,
"SecurityOpt": null,
"UTSMode": "",
"UsernsMode": "",
"ShmSize": 67108864,
"Runtime": "runc",
"Isolation": "",
"CpuShares": 0,
"Memory": 0,
"NanoCpus": 0,
"CgroupParent": "",
"BlkioWeight": 0,
"BlkioWeightDevice": null,
"BlkioDeviceReadBps": null,
"BlkioDeviceWriteBps": null,
"BlkioDeviceReadIOps": null,
"BlkioDeviceWriteIOps": null,
"CpuPeriod": 0,
"CpuQuota": 0,
"CpuRealtimePeriod": 0,
"CpuRealtimeRuntime": 0,
"CpusetCpus": "",
"CpusetMems": "",
"Devices": null,
"DeviceCgroupRules": null,
"DeviceRequests": null,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
"OomKillDisable": null,
"PidsLimit": null,
"Ulimits": null,
"CpuCount": 0,
"CpuPercent": 0,
"IOMaximumIOps": 0,
"IOMaximumBandwidth": 0,
"MaskedPaths": [
"/proc/acpi",
"/proc/asound",
"/proc/interrupts",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/sched_debug",
"/proc/scsi",
"/proc/timer_list",
"/proc/timer_stats",
"/sys/devices/virtual/powercap",
"/sys/firmware"
],
"ReadonlyPaths": [
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger"
]
},
"GraphDriver": {
"Data": {
"ID": "c657a3a390f6eed56ee8601cbec05f3871ef09886c4eeed377505d66e7329320",
"LowerDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c-init/diff:/var/lib/docker/overlay2/99da5f835f83c7550d6a8f82f03fc4f7147c86a02f934cc8688fc5444110352c/diff:/var/lib/docker/overlay2/a824d9563ca0af7794e667c9b4799f57c6a76dcff84b0f6375018dc90c4f50ce/diff:/var/lib/docker/overlay2/6c9a5c50a54b3111494f6780dbb640559b6db9310b92826e875fd4d3048bc590/diff:/var/lib/docker/overlay2/ba53a31b5ba6e15b84be1424c9fb2053a16dbba42a2056b39cc0c0ca68a23ea3/diff:/var/lib/docker/overlay2/5a6252acf39dbbb2daa9a95050adbd5d61f7079c3ca281544a72d7942bdb86fa/diff:/var/lib/docker/overlay2/2fec287c58bf77e6f80c042a360d9814b77a747b1c6436ba68b4dcee309b8bb4/diff:/var/lib/docker/overlay2/100154f217c53a8e26927fa82f3fe56074e192506607c283a0cc3dfd7d4261a7/diff:/var/lib/docker/overlay2/c8d0b59c20926f8c6337ab778303dd37701740dcab6494a8ef7547eccfd266ed/diff:/var/lib/docker/overlay2/adb4f687409d280798957b268c1eef0fcc93ab6ecc24f291ba2ff4afaf5ef9c1/diff:/var/lib/docker/overlay2/0567d27adf4684c0b9d2be40dd32e2be2a81e43d2b118c8ac10697f64ce2b41c/diff:/var/lib/docker/overlay2/509a8569764702e18a334b6c7dd49e7dfdf237b821bcd34fc9102c11378e6c2b/diff:/var/lib/docker/overlay2/66cb5fe679daa03d24693ef170278322206904e36cad21c55a582175c7ea2298/diff",
"MergedDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/merged",
"UpperDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/diff",
"WorkDir": "/var/lib/docker/overlay2/a861d88d6384dde3b397daa7330e21b1c6ae42810f5e824cfb9f3e26590bef8c/work"
},
"Name": "overlay2"
},
"Mounts": [
{
"Type": "bind",
"Source": "/opt/vfxreview/uploads",
"Destination": "/uploads",
"Mode": "rw",
"RW": true,
"Propagation": "rprivate"
}
],
"Config": {
"Hostname": "c657a3a390f6",
"Domainname": "",
"User": "nextjs",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"ExposedPorts": {
"3000/tcp": {}
},
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"DATABASE_URL=postgresql://postgres:postgres@postgres:5432/feedback",
"NEXTAUTH_SECRET=your-secret-here-change-in-production",
"NEXTAUTH_URL=http://localhost:3000",
"UPLOADTHING_SECRET=eyJhcGlLZXkiOiJza19saXZlXzM0YmMzMTQ0NmVkZTJlMDQ3NmUwYmMzY2IyYzJkNTAyOTM1ODk0ZmM0YWRiNTQ1ODIxODhhM2VjNmU5OTE2NGMiLCJhcHBJZCI6ImVrN20xbWg2cXUiLCJyZWdpb25zIjpbInNlYTEiXX0=",
"UPLOADTHING_APP_ID=",
"STORAGE_PROVIDER=local",
"AWS_ACCESS_KEY_ID=",
"AWS_SECRET_ACCESS_KEY=",
"AWS_REGION=us-east-1",
"AWS_BUCKET_NAME=",
"R2_ACCESS_KEY_ID=",
"R2_SECRET_ACCESS_KEY=",
"R2_ACCOUNT_ID=",
"R2_BUCKET_NAME=",
"R2_PUBLIC_URL=",
"B2_APPLICATION_KEY_ID=",
"B2_APPLICATION_KEY=",
"B2_BUCKET_NAME=",
"B2_ENDPOINT=",
"MINIO_ENDPOINT=http://localhost:9000",
"MINIO_ACCESS_KEY=",
"MINIO_SECRET_KEY=",
"MINIO_BUCKET_NAME=vfx-review",
"LOCAL_UPLOAD_DIR=./uploads",
"EMAIL_FROM=noreply@yourcompany.com",
"EMAIL_SERVER_HOST=smtp.gmail.com",
"EMAIL_SERVER_PORT=587",
"EMAIL_SERVER_USER=",
"EMAIL_SERVER_PASSWORD=",
"SLACK_DEFAULT_WEBHOOK=",
"NEXT_PUBLIC_APP_URL=http://localhost:3000",
"NEXT_PUBLIC_APP_NAME=VFX Review",
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_VERSION=20.20.2",
"YARN_VERSION=1.22.22",
"NODE_ENV=production",
"NEXT_TELEMETRY_DISABLED=1",
"PORT=3000",
"HOSTNAME=0.0.0.0"
],
"Cmd": [
"node",
"server.js"
],
"Image": "vfxreview:1.0.0",
"Volumes": {
"/uploads": {}
},
"WorkingDir": "/app",
"Entrypoint": [
"docker-entrypoint.sh"
],
"Labels": {
"com.docker.compose.config-hash": "be61c44b05bc31b228b3b1ab25372b6e4e6b8358b1bc7c96a2b131e642a81358",
"com.docker.compose.container-number": "1",
"com.docker.compose.oneoff": "False",
"com.docker.compose.project": "vfxreview",
"com.docker.compose.project.config_files": "docker-compose.yml",
"com.docker.compose.project.working_dir": "/opt/vfxreview",
"com.docker.compose.service": "vfxreview",
"com.docker.compose.version": "1.29.2"
}
},
"NetworkSettings": {
"SandboxID": "",
"SandboxKey": "",
"Ports": {},
"Networks": {
"vfxreview_app-net": {
"IPAMConfig": null,
"Links": null,
"Aliases": [
"vfxreview",
"c657a3a390f6"
],
"DriverOpts": null,
"GwPriority": 0,
"NetworkID": "e3f79c0c30cca1c12a74793b70818cc7573e9ec6ffa5ee393d3a81b26bae33ee",
"EndpointID": "",
"Gateway": "",
"IPAddress": "",
"MacAddress": "",
"IPPrefixLen": 0,
"IPv6Gateway": "",
"GlobalIPv6Address": "",
"GlobalIPv6PrefixLen": 0,
"DNSNames": [
"vfxreview",
"c657a3a390f6"
]
}
}
}
}
]
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+378
View File
@@ -0,0 +1,378 @@
"use client";
import {
useRef,
useEffect,
useState,
useCallback,
MouseEvent,
} from "react";
import { useReviewStore } from "@/hooks/use-review-player";
import { saveAnnotation } from "@/actions/annotations";
import { addComment } from "@/actions/comments";
import { useToast } from "@/components/ui/use-toast";
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
import { v4 as uuidv4 } from "uuid";
interface AnnotationCanvasProps {
versionId: string;
frameNumber: number;
fps: number;
isAnnotating: boolean;
showAnnotations: boolean;
existingAnnotations?: unknown[];
onAnnotationSaved?: (frameNumber: number) => void;
}
type DrawingState = {
isDrawing: boolean;
currentShape: AnnotationShape | null;
shapes: AnnotationShape[];
};
export function AnnotationCanvas({
versionId,
frameNumber,
fps,
isAnnotating,
showAnnotations,
existingAnnotations = [],
onAnnotationSaved,
}: AnnotationCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
const { toast } = useToast();
const [drawingState, setDrawingState] = useState<DrawingState>({
isDrawing: false,
currentShape: null,
shapes: [],
});
const drawingStateRef = useRef(drawingState);
drawingStateRef.current = drawingState;
// Per-frame saved shapes — persists across frame navigation so annotations
// remain visible without depending on a parent API refetch.
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, AnnotationShape[]>>(() => {
const map: Record<number, AnnotationShape[]> = {};
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
const shapes = a.drawingData?.shapes ?? [];
map[a.frameNumber] = [...(map[a.frameNumber] ?? []), ...shapes];
}
return map;
});
// Ref so redraw closure can read latest without being recreated
const savedShapesByFrameRef = useRef(savedShapesByFrame);
savedShapesByFrameRef.current = savedShapesByFrame;
// Track frames that already have an annotation comment this session,
// initialized from existing annotations so we don't double-comment on refresh.
const [annotationCommentedFrames, setAnnotationCommentedFrames] = useState<Set<number>>(() => {
const frames = new Set<number>();
for (const a of (existingAnnotations as { frameNumber: number }[])) {
frames.add(a.frameNumber);
}
return frames;
});
// Sync savedShapesByFrame when the existingAnnotations prop changes (e.g. after parent API refresh).
// MERGE server shapes with locally-drawn shapes (deduplicate by shape id) so locally-drawn
// annotations are never wiped by a new array reference from the parent.
const prevExistingLengthRef = useRef(existingAnnotations.length);
const prevExistingRef = useRef(existingAnnotations);
useEffect(() => {
if (
prevExistingRef.current === existingAnnotations ||
prevExistingLengthRef.current === existingAnnotations.length
) {
prevExistingRef.current = existingAnnotations;
prevExistingLengthRef.current = existingAnnotations.length;
return;
}
prevExistingRef.current = existingAnnotations;
prevExistingLengthRef.current = existingAnnotations.length;
setSavedShapesByFrame((prev) => {
// Build map from server data
const serverMap: Record<number, AnnotationShape[]> = {};
const serverShapeIds = new Set<string>();
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
const shapes = a.drawingData?.shapes ?? [];
serverMap[a.frameNumber] = [...(serverMap[a.frameNumber] ?? []), ...shapes];
for (const s of shapes) serverShapeIds.add(s.id);
}
// Merge: keep local shapes not yet in server data (pending save race)
const merged = { ...serverMap };
for (const [frame, shapes] of Object.entries(prev)) {
for (const s of shapes) {
if (!serverShapeIds.has(s.id)) {
const f = Number(frame);
merged[f] = [...(merged[f] ?? []), s];
}
}
}
return merged;
});
// Also update frames that now have server-side annotation comments
setAnnotationCommentedFrames((prev) => {
const next = new Set(prev);
for (const a of (existingAnnotations as { frameNumber: number }[])) {
next.add(a.frameNumber);
}
return next;
});
}, [existingAnnotations]);
// ── Coordinate normalization ─────────────────────────────────────────────
const toNormalized = (e: MouseEvent): AnnotationPoint => {
const canvas = canvasRef.current!;
const rect = canvas.getBoundingClientRect();
return {
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
};
};
const toPixel = (p: AnnotationPoint, w: number, h: number) => ({
x: p.x * w,
y: p.y * h,
});
// ── Rendering ─────────────────────────────────────────────────────────────
const renderShape = useCallback(
(
ctx: CanvasRenderingContext2D,
shape: AnnotationShape,
w: number,
h: number
) => {
if (shape.points.length === 0) return;
ctx.strokeStyle = shape.color;
ctx.lineWidth = shape.strokeWidth;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha = 0.85;
const pts = shape.points.map((p) => toPixel(p, w, h));
switch (shape.tool) {
case "freehand": {
if (pts.length < 2) return;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
break;
}
case "rectangle": {
if (pts.length < 2) return;
const x = Math.min(pts[0].x, pts[pts.length - 1].x);
const y = Math.min(pts[0].y, pts[pts.length - 1].y);
const width = Math.abs(pts[pts.length - 1].x - pts[0].x);
const height = Math.abs(pts[pts.length - 1].y - pts[0].y);
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.stroke();
break;
}
case "circle": {
if (pts.length < 2) return;
const cx = pts[0].x;
const cy = pts[0].y;
const dx = pts[pts.length - 1].x - cx;
const dy = pts[pts.length - 1].y - cy;
const radius = Math.sqrt(dx * dx + dy * dy);
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
break;
}
case "arrow": {
if (pts.length < 2) return;
const start = pts[0];
const end = pts[pts.length - 1];
const angle = Math.atan2(end.y - start.y, end.x - start.x);
const arrowLen = Math.max(10, shape.strokeWidth * 5);
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
// Arrowhead
ctx.fillStyle = shape.color;
ctx.globalAlpha = 0.85;
ctx.beginPath();
ctx.moveTo(end.x, end.y);
ctx.lineTo(
end.x - arrowLen * Math.cos(angle - Math.PI / 7),
end.y - arrowLen * Math.sin(angle - Math.PI / 7)
);
ctx.lineTo(
end.x - arrowLen * Math.cos(angle + Math.PI / 7),
end.y - arrowLen * Math.sin(angle + Math.PI / 7)
);
ctx.closePath();
ctx.fill();
break;
}
}
ctx.globalAlpha = 1;
},
[]
);
const redraw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
if (!showAnnotations) return;
// Draw saved shapes for this frame (seeded from DB + newly saved this session)
(savedShapesByFrameRef.current[frameNumber] ?? []).forEach((shape) => {
renderShape(ctx, shape, w, h);
});
// Draw current (in-progress) shape
const current = drawingStateRef.current.currentShape;
if (current && drawingStateRef.current.isDrawing) {
renderShape(ctx, current, w, h);
}
}, [frameNumber, showAnnotations, renderShape]);
// Resize observer — stable, only recreated when redraw changes (which is rare now)
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new ResizeObserver(() => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
redraw();
});
observer.observe(canvas);
return () => observer.disconnect();
}, [redraw]);
// Redraw whenever frame, saved shapes map, in-progress shape, or visibility changes
useEffect(() => {
redraw();
}, [redraw, savedShapesByFrame, drawingState.currentShape, showAnnotations]);
// ── Mouse events ─────────────────────────────────────────────────────────
const handleMouseDown = useCallback(
(e: MouseEvent<HTMLCanvasElement>) => {
if (!isAnnotating) return;
const point = toNormalized(e);
const newShape: AnnotationShape = {
id: uuidv4(),
tool: selectedTool,
points: [point],
color: selectedColor,
strokeWidth,
frameNumber,
};
setDrawingState((prev) => ({
...prev,
isDrawing: true,
currentShape: newShape,
}));
},
[isAnnotating, selectedTool, selectedColor, strokeWidth, frameNumber]
);
const handleMouseMove = useCallback(
(e: MouseEvent<HTMLCanvasElement>) => {
if (!drawingStateRef.current.isDrawing || !drawingStateRef.current.currentShape) return;
const point = toNormalized(e);
setDrawingState((prev) => ({
...prev,
currentShape: prev.currentShape
? { ...prev.currentShape, points: [...prev.currentShape.points, point] }
: null,
}));
redraw();
},
[redraw]
);
const handleMouseUp = useCallback(async () => {
const state = drawingStateRef.current;
if (!state.isDrawing || !state.currentShape) return;
const finishedShape = state.currentShape;
// Clear the in-progress drawing state
setDrawingState((prev) => ({
...prev,
isDrawing: false,
currentShape: null,
shapes: [],
}));
// Immediately persist shape into the per-frame cache so it survives frame navigation
setSavedShapesByFrame((prev) => ({
...prev,
[frameNumber]: [...(prev[frameNumber] ?? []), finishedShape],
}));
// Save to DB
try {
const canvas = canvasRef.current;
const drawingData: AnnotationDrawingData = {
shapes: [finishedShape],
canvasWidth: canvas?.offsetWidth ?? 1920,
canvasHeight: canvas?.offsetHeight ?? 1080,
version: "1.0",
};
await saveAnnotation({
versionId,
frameNumber,
drawingData,
color: selectedColor,
});
// Create a companion comment ONCE per frame so the annotation appears in the panel.
// Additional strokes on the same frame update the existing comment count silently.
if (!annotationCommentedFrames.has(frameNumber)) {
await addComment({
versionId,
frameNumber,
timestamp: frameNumber / fps,
text: `✏️ Annotation at frame ${frameNumber}`,
});
setAnnotationCommentedFrames((prev) => new Set([...prev, frameNumber]));
}
onAnnotationSaved?.(frameNumber);
} catch {
toast({
title: "Failed to save annotation",
variant: "destructive",
});
}
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
return (
<canvas
ref={canvasRef}
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
style={{
cursor: isAnnotating ? "crosshair" : "default",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useReviewStore } from "@/hooks/use-review-player";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AnnotationTool } from "@/types";
import {
Pencil,
ArrowUpRight,
Square,
Circle,
Minus,
Plus,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const TOOLS: { tool: AnnotationTool; icon: React.ElementType; label: string }[] = [
{ tool: "freehand", icon: Pencil, label: "Freehand" },
{ tool: "arrow", icon: ArrowUpRight, label: "Arrow" },
{ tool: "rectangle", icon: Square, label: "Rectangle" },
{ tool: "circle", icon: Circle, label: "Circle" },
];
const COLORS = [
{ value: "#ef4444", label: "Red" },
{ value: "#f59e0b", label: "Amber" },
{ value: "#22c55e", label: "Green" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#a855f7", label: "Purple" },
{ value: "#ffffff", label: "White" },
{ value: "#000000", label: "Black" },
];
export function AnnotationTools() {
const {
isAnnotating,
selectedTool,
selectedColor,
strokeWidth,
setSelectedTool,
setSelectedColor,
setStrokeWidth,
setAnnotating,
} = useReviewStore();
if (!isAnnotating) return null;
return (
<div className="flex items-center gap-3 px-3 py-2 bg-zinc-900/95 border-b border-white/5">
{/* Tool selector */}
<div className="flex items-center gap-1">
{TOOLS.map(({ tool, icon: Icon, label }) => (
<Tooltip key={tool}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
selectedTool === tool &&
"bg-primary/20 text-primary hover:bg-primary/30"
)}
onClick={() => setSelectedTool(tool)}
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
))}
</div>
<div className="h-5 w-px bg-white/10" />
{/* Color picker */}
<div className="flex items-center gap-1.5">
{COLORS.map((c) => (
<Tooltip key={c.value}>
<TooltipTrigger asChild>
<button
className={cn(
"h-5 w-5 rounded-full border-2 transition-transform hover:scale-110",
selectedColor === c.value
? "border-white scale-110"
: "border-transparent"
)}
style={{ backgroundColor: c.value }}
onClick={() => setSelectedColor(c.value)}
title={c.label}
/>
</TooltipTrigger>
<TooltipContent>{c.label}</TooltipContent>
</Tooltip>
))}
</div>
<div className="h-5 w-px bg-white/10" />
{/* Stroke width */}
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white h-6 w-6"
onClick={() => setStrokeWidth(Math.max(1, strokeWidth - 1))}
>
<Minus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Thinner</TooltipContent>
</Tooltip>
<div
className="rounded-full bg-current"
style={{
width: Math.min(16, strokeWidth * 3),
height: Math.min(16, strokeWidth * 3),
backgroundColor: selectedColor,
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white h-6 w-6"
onClick={() => setStrokeWidth(Math.min(8, strokeWidth + 1))}
>
<Plus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Thicker</TooltipContent>
</Tooltip>
<span className="text-xs text-zinc-500 font-mono w-4">{strokeWidth}</span>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { TaskList } from "@/components/tasks/TaskList";
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
import { cn } from "@/lib/utils";
import { getInitials } from "@/lib/utils";
import {
ChevronDown,
ChevronRight,
Package,
CalendarDays,
CheckCircle2,
} from "lucide-react";
import { ShotStatus } from "@prisma/client";
import { formatDistanceToNow } from "date-fns";
const STATUS_CONFIG: Record<
ShotStatus,
{ label: string; color: string }
> = {
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" },
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
};
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500", NORMAL: "bg-blue-500", HIGH: "bg-amber-500", URGENT: "bg-red-500",
};
interface Artist {
id: string;
name: string | null;
email: string;
}
interface AssetCardProps {
asset: {
id: string;
assetCode: string;
name: string;
description: string | null;
status: ShotStatus;
priority: string;
dueDate: Date | null;
lead?: { id: string; name: string | null; email: string; image: string | null } | null;
tasks: any[];
_count?: { tasks: number };
};
projectId: string;
artists: Artist[];
canManage?: boolean;
}
export function AssetCard({ asset, projectId, artists, canManage = false }: AssetCardProps) {
const [expanded, setExpanded] = useState(false);
const statusCfg = STATUS_CONFIG[asset.status] ?? STATUS_CONFIG.WAITING;
const doneTasks = asset.tasks.filter((t: any) => t.status === "DONE").length;
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-zinc-800/50 transition-colors text-left"
>
<div className={cn("w-1.5 h-1.5 rounded-full shrink-0", PRIORITY_DOT[asset.priority] ?? "bg-zinc-500")} />
<Package className="h-4 w-4 text-zinc-400 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-medium text-zinc-300">{asset.assetCode}</span>
<span className="text-sm text-white">{asset.name}</span>
</div>
{asset.description && (
<p className="text-xs text-zinc-500 truncate">{asset.description}</p>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
{asset.tasks.length > 0 && (
<span className="text-xs text-zinc-500">
<CheckCircle2 className="h-3 w-3 inline mr-1" />
{doneTasks}/{asset.tasks.length}
</span>
)}
<Badge className={cn("text-xs border px-1.5 py-0 h-5", statusCfg.color)}>
{statusCfg.label}
</Badge>
{asset.dueDate && (
<span className="text-xs text-zinc-500 hidden sm:flex items-center gap-1">
<CalendarDays className="h-3 w-3" />
{formatDistanceToNow(new Date(asset.dueDate), { addSuffix: true })}
</span>
)}
{asset.lead && (
<Avatar className="h-6 w-6">
<AvatarImage src={asset.lead.image ?? undefined} />
<AvatarFallback className="text-[10px]">
{getInitials(asset.lead.name ?? asset.lead.email)}
</AvatarFallback>
</Avatar>
)}
{expanded ? (
<ChevronDown className="h-4 w-4 text-zinc-500" />
) : (
<ChevronRight className="h-4 w-4 text-zinc-500" />
)}
</div>
</button>
{/* Expanded task list */}
{expanded && (
<div className="px-4 pb-4 pt-2 border-t border-border bg-zinc-900/50">
<TaskList
tasks={asset.tasks}
projectId={projectId}
assetId={asset.id}
artists={artists}
canManage={canManage}
/>
</div>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createAsset } from "@/actions/assets";
import { useToast } from "@/components/ui/use-toast";
import { ShotPriority } from "@prisma/client";
const assetSchema = z.object({
assetCode: z
.string()
.min(1, "Asset code is required")
.max(30)
.regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore only"),
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
priority: z.nativeEnum(ShotPriority),
dueDate: z.string().optional(),
});
type AssetFormValues = z.infer<typeof assetSchema>;
interface Artist {
id: string;
name: string | null;
email: string;
}
interface NewAssetDialogProps {
projectId: string;
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export function NewAssetDialog({ projectId, open, onClose, onSuccess }: NewAssetDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors },
} = useForm<AssetFormValues>({
resolver: zodResolver(assetSchema),
defaultValues: { priority: "NORMAL" },
});
const onSubmit = async (data: AssetFormValues) => {
setIsSubmitting(true);
try {
await createAsset({ projectId, status: "WAITING", ...data });
toast({ title: "Asset created" });
reset();
router.refresh();
onSuccess?.();
onClose();
} catch (err) {
toast({
title: "Failed to create asset",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New Asset</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="assetCode">Asset Code *</Label>
<Input
id="assetCode"
placeholder="CAR_01"
className="uppercase"
{...register("assetCode")}
/>
{errors.assetCode && (
<p className="text-xs text-destructive">{errors.assetCode.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label>Priority</Label>
<Select
defaultValue="NORMAL"
onValueChange={(v) => setValue("priority", v as ShotPriority)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOW">Low</SelectItem>
<SelectItem value="NORMAL">Normal</SelectItem>
<SelectItem value="HIGH">High</SelectItem>
<SelectItem value="URGENT">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="name">Asset Name *</Label>
<Input id="name" placeholder="Hero Car" {...register("name")} />
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" {...register("dueDate")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="What is this asset?"
rows={2}
{...register("description")}
/>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating…" : "Create Asset"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
const schema = z.object({
company: z.string().min(1, "Company name is required"),
contactPerson: z.string().min(1, "Contact person is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
notes: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
interface NewClientDialogProps {
children: React.ReactNode;
}
export function NewClientDialog({ children }: NewClientDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
const { toast } = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { company: "", contactPerson: "", email: "", phone: "", notes: "" },
});
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
const res = await fetch("/api/clients", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(error.error ?? "Failed to create client");
}
const data = await res.json();
toast({ title: `Client "${data.company}" created` });
setOpen(false);
reset();
router.refresh();
} catch (e) {
toast({
title: "Failed to create client",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New Client</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="company">Company *</Label>
<Input id="company" placeholder="Acme Productions" {...register("company")} />
{errors.company && <p className="text-xs text-red-400">{errors.company.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="contactPerson">Contact Person *</Label>
<Input id="contactPerson" placeholder="Jane Smith" {...register("contactPerson")} />
{errors.contactPerson && <p className="text-xs text-red-400">{errors.contactPerson.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Email *</Label>
<Input id="email" type="email" placeholder="jane@acme.com" {...register("email")} />
{errors.email && <p className="text-xs text-red-400">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="phone">Phone</Label>
<Input id="phone" type="tel" placeholder="+1 (555) 000-0000" {...register("phone")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="notes">Notes</Label>
<Textarea id="notes" placeholder="Any additional information..." className="resize-none" rows={3} {...register("notes")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Create Client"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { ExternalLink, Copy, Check, Trash2, Clock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn, formatRelativeDate } from "@/lib/utils";
interface ReviewSession {
id: string;
token: string;
label: string | null;
email: string | null;
expiresAt: Date | string | null;
accessCount: number;
isActive: boolean;
project: { name: string };
}
interface ReviewSessionListProps {
sessions: ReviewSession[];
}
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";
export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { toast } = useToast();
const router = useRouter();
const handleCopy = async (token: string, id: string) => {
const url = `${APP_URL}/client/${token}`;
await navigator.clipboard.writeText(url);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleDeactivate = async (id: string) => {
setDeletingId(id);
try {
const res = await fetch(`/api/review-sessions?id=${id}`, { method: "DELETE" });
if (!res.ok) throw new Error();
toast({ title: "Review link deactivated" });
router.refresh();
} catch {
toast({ title: "Failed to deactivate link", variant: "destructive" });
} finally {
setDeletingId(null);
}
};
if (sessions.length === 0) return null;
return (
<div>
<h2 className="text-base font-semibold text-white mb-3">
Active Review Links ({sessions.length})
</h2>
<div className="space-y-2">
{sessions.map((session) => {
const portalUrl = `${APP_URL}/client/${session.token}`;
const expired =
session.expiresAt && new Date(session.expiresAt) < new Date();
return (
<div
key={session.id}
className={cn(
"flex items-center gap-4 p-4 rounded-xl border transition-all",
expired
? "border-zinc-800 bg-zinc-900/50 opacity-60"
: "border-zinc-800 bg-zinc-900"
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-white text-sm">{session.label || "Untitled Review"}</p>
{expired && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
Expired
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-zinc-500">{session.project.name}</span>
{session.email && (
<>
<span className="text-zinc-700">·</span>
<span className="text-xs text-zinc-500">{session.email}</span>
</>
)}
{session.expiresAt && (
<>
<span className="text-zinc-700">·</span>
<span className="text-xs text-zinc-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{expired ? "Expired" : `Expires ${formatRelativeDate(session.expiresAt)}`}
</span>
</>
)}
</div>
<p className="font-mono text-xs text-zinc-600 truncate mt-1">{portalUrl}</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-zinc-500 mr-2">
{session.accessCount} view{session.accessCount !== 1 ? "s" : ""}
</span>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 gap-1.5"
onClick={() => handleCopy(session.token, session.id)}
>
{copiedId === session.id ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedId === session.id ? "Copied" : "Copy"}
</Button>
<a href={portalUrl} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline" className="h-7 px-2">
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</a>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-red-400 hover:bg-red-500/10 border-red-500/20"
onClick={() => handleDeactivate(session.id)}
disabled={deletingId === session.id}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { Copy, Check, ExternalLink } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const schema = z.object({
projectId: z.string().min(1, "Select a project"),
label: z.string().min(1, "Label is required"),
email: z.string().email("Invalid email"),
expiresInDays: z.number().int().positive().default(30),
});
type FormValues = z.infer<typeof schema>;
interface Project {
id: string;
name: string;
code: string;
}
interface ShareReviewDialogProps {
children: React.ReactNode;
clientId: string;
clientEmail: string;
projects: Project[];
}
export function ShareReviewDialog({
children,
clientEmail,
projects,
}: ShareReviewDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [portalUrl, setPortalUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const router = useRouter();
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
projectId: projects[0]?.id ?? "",
label: "Review Round 1",
email: clientEmail,
expiresInDays: 30,
},
});
const handleCopy = async () => {
if (!portalUrl) return;
await navigator.clipboard.writeText(portalUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleReset = () => {
setPortalUrl(null);
reset({
projectId: projects[0]?.id ?? "",
label: "Review Round 1",
email: clientEmail,
expiresInDays: 30,
});
};
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
const res = await fetch("/api/review-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? "Failed to create review link");
}
const data = await res.json();
setPortalUrl(data.portalUrl);
router.refresh();
} catch (e) {
toast({
title: "Failed to create review link",
description: e instanceof Error ? e.message : undefined,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) { setPortalUrl(null); }
setOpen(o);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Share Review Link</DialogTitle>
</DialogHeader>
{portalUrl ? (
<div className="space-y-4">
<p className="text-sm text-zinc-400">
Your review link is ready. Copy it and share it with your client.
</p>
<div className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 border border-zinc-700">
<ExternalLink className="h-4 w-4 text-zinc-500 shrink-0" />
<span className="flex-1 text-sm font-mono text-zinc-300 truncate">{portalUrl}</span>
<Button
size="sm"
variant="outline"
className="h-7 px-2.5 gap-1.5 shrink-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleReset}>
Create Another
</Button>
<Button onClick={() => setOpen(false)}>Done</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="projectId">Project *</Label>
<Select
defaultValue={watch("projectId")}
onValueChange={(v) => setValue("projectId", v)}
>
<SelectTrigger id="projectId">
<SelectValue placeholder="Select project" />
</SelectTrigger>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} <span className="text-zinc-500 text-xs ml-1">({p.code})</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.projectId && <p className="text-xs text-red-400">{errors.projectId.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="label">Label *</Label>
<Input id="label" placeholder="e.g. Review Round 1" {...register("label")} />
{errors.label && <p className="text-xs text-red-400">{errors.label.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="email">Client Email *</Label>
<Input id="email" type="email" {...register("email")} />
{errors.email && <p className="text-xs text-red-400">{errors.email.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="expiresInDays">Expires In (days)</Label>
<Select
defaultValue={String(watch("expiresInDays"))}
onValueChange={(v) => setValue("expiresInDays", Number(v))}
>
<SelectTrigger id="expiresInDays">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[7, 14, 30, 60, 90].map((d) => (
<SelectItem key={d} value={String(d)}>
{d} days
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={loading || projects.length === 0}>
{loading ? "Generating..." : "Create Link"}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
+394
View File
@@ -0,0 +1,394 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useReviewStore } from "@/hooks/use-review-player";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { getInitials, formatRelativeDate } from "@/lib/utils";
import { frameToTimecode } from "@/lib/frame-utils";
import {
CheckCircle2,
Circle,
MessageSquare,
Send,
ChevronDown,
ChevronUp,
Filter,
} from "lucide-react";
import { addComment, addReply, resolveComment } from "@/actions/comments";
import { useToast } from "@/components/ui/use-toast";
import type { CommentWithReplies } from "@/types";
interface CommentPanelProps {
versionId: string;
fps: number;
comments: CommentWithReplies[];
onCommentsChange?: () => void;
pendingFrame?: number | null;
onPendingFrameCleared?: () => void;
onSeekToFrame?: (frame: number) => void;
}
export function CommentPanel({
versionId,
fps,
comments,
onCommentsChange,
pendingFrame,
onPendingFrameCleared,
onSeekToFrame,
}: CommentPanelProps) {
const { currentFrame, setCurrentFrame } = useReviewStore();
const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all");
const [isSubmitting, setIsSubmitting] = useState(false);
const [commentText, setCommentText] = useState("");
const [activeCommentFrame, setActiveCommentFrame] = useState<number | null>(null);
const { toast } = useToast();
const inputRef = useRef<HTMLTextAreaElement>(null);
// Handle pending frame from "Add Comment" button click
useEffect(() => {
if (pendingFrame !== null && pendingFrame !== undefined) {
setActiveCommentFrame(pendingFrame);
setTimeout(() => inputRef.current?.focus(), 50);
onPendingFrameCleared?.();
}
}, [pendingFrame, onPendingFrameCleared]);
const filteredComments = comments.filter((c) => {
if (filterResolved === "unresolved") return !c.isResolved;
if (filterResolved === "resolved") return c.isResolved;
return true;
});
const handleSubmitComment = async () => {
if (!commentText.trim() || activeCommentFrame === null) return;
setIsSubmitting(true);
try {
const timestamp = activeCommentFrame / fps;
await addComment({
versionId,
frameNumber: activeCommentFrame,
timestamp,
text: commentText.trim(),
});
setCommentText("");
setActiveCommentFrame(null);
onCommentsChange?.();
toast({ title: "Comment added", variant: "default" });
} catch (err) {
toast({ title: "Failed to add comment", variant: "destructive" });
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmitComment();
}
if (e.key === "Escape") {
setActiveCommentFrame(null);
setCommentText("");
}
};
return (
<div className="flex flex-col h-full bg-card border-l border-border">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
Comments
{comments.length > 0 && (
<span className="ml-1.5 text-xs text-muted-foreground">
({comments.length})
</span>
)}
</span>
</div>
<div className="flex gap-1">
{(["all", "unresolved", "resolved"] as const).map((f) => (
<button
key={f}
onClick={() => setFilterResolved(f)}
className={cn(
"px-2 py-0.5 rounded text-xs transition-colors",
filterResolved === f
? "bg-primary/20 text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{f === "all" ? "All" : f === "unresolved" ? "Open" : "Resolved"}
</button>
))}
</div>
</div>
{/* Comment Input */}
{activeCommentFrame !== null ? (
<div className="shrink-0 border-b border-border p-3 bg-primary/5">
<div className="flex items-center gap-2 mb-2">
<Badge variant="pending" className="text-xs font-mono">
Frame {activeCommentFrame}
</Badge>
<span className="text-xs text-muted-foreground">
{frameToTimecode(activeCommentFrame, fps)}
</span>
</div>
<Textarea
ref={inputRef}
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add your feedback... (Ctrl+Enter to submit)"
className="min-h-[80px] text-sm bg-background/50"
autoFocus
/>
<div className="flex items-center justify-between mt-2">
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => { setActiveCommentFrame(null); setCommentText(""); }}
>
Cancel (Esc)
</button>
<Button
size="sm"
onClick={handleSubmitComment}
disabled={isSubmitting || !commentText.trim()}
>
<Send className="h-3 w-3 mr-1" />
{isSubmitting ? "Posting..." : "Post"}
</Button>
</div>
</div>
) : (
<div className="shrink-0 px-3 py-2 border-b border-border">
<Button
variant="outline"
size="sm"
className="w-full text-xs"
onClick={() => setActiveCommentFrame(currentFrame)}
>
<MessageSquare className="h-3 w-3 mr-2" />
Comment at frame {currentFrame}
</Button>
</div>
)}
{/* Comment List */}
<ScrollArea className="flex-1">
<div className="p-3 space-y-3">
{filteredComments.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<MessageSquare className="h-8 w-8 mb-3 opacity-30" />
<p className="text-sm">No comments yet</p>
<p className="text-xs mt-1">
Pause the video and click &quot;Add Comment&quot;
</p>
</div>
) : (
filteredComments.map((comment) => (
<CommentThread
key={comment.id}
comment={comment}
fps={fps}
isActive={comment.frameNumber === currentFrame}
onJumpToFrame={(frame) => {
setCurrentFrame(frame);
onSeekToFrame?.(frame);
}}
onResolved={onCommentsChange}
/>
))
)}
</div>
</ScrollArea>
</div>
);
}
// ── Individual Comment Thread ─────────────────────────────────────────────────
interface CommentThreadProps {
comment: CommentWithReplies;
fps: number;
isActive: boolean;
onJumpToFrame: (frame: number) => void;
onResolved?: () => void;
}
function CommentThread({
comment,
fps,
isActive,
onJumpToFrame,
onResolved,
}: CommentThreadProps) {
const [showReplies, setShowReplies] = useState(false);
const [replyText, setReplyText] = useState("");
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const { toast } = useToast();
const handleReply = async () => {
if (!replyText.trim()) return;
setIsSubmittingReply(true);
try {
await addReply(comment.id, replyText.trim());
setReplyText("");
onResolved?.();
} catch {
toast({ title: "Failed to post reply", variant: "destructive" });
} finally {
setIsSubmittingReply(false);
}
};
const handleToggleResolve = async () => {
try {
await resolveComment(comment.id, !comment.isResolved);
onResolved?.();
} catch {
toast({ title: "Failed to update comment", variant: "destructive" });
}
};
return (
<div
className={cn(
"rounded-lg border border-border/60 overflow-hidden transition-all",
isActive && "border-primary/40 bg-primary/5",
comment.isResolved && "opacity-60"
)}
>
{/* Comment body */}
<div className="p-3">
<div className="flex items-start gap-2">
<Avatar className="h-6 w-6 shrink-0 mt-0.5">
{comment.author?.image && (
<AvatarImage src={comment.author.image} alt={comment.author.name ?? ""} />
)}
<AvatarFallback className="text-[9px]">
{getInitials(comment.author?.name ?? null)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
</span>
<button
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
onClick={() => onJumpToFrame(comment.frameNumber)}
>
F{String(comment.frameNumber).padStart(4, "0")}
</button>
<span className="text-xs text-muted-foreground ml-auto">
{formatRelativeDate(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3 mt-2 ml-8">
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={handleToggleResolve}
>
{comment.isResolved ? (
<>
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
<span className="text-emerald-500">Resolved</span>
</>
) : (
<>
<Circle className="h-3 w-3" />
<span>Resolve</span>
</>
)}
</button>
<button
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowReplies(!showReplies)}
>
{comment.replies.length > 0 && (
<>
{showReplies ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
</>
)}
{comment.replies.length === 0 && "Reply"}
</button>
</div>
</div>
{/* Replies */}
{showReplies && (
<div className="border-t border-border/50 bg-background/30">
{comment.replies.map((reply) => (
<div key={reply.id} className="flex gap-2 px-3 py-2.5 border-b border-border/30 last:border-0">
<Avatar className="h-5 w-5 shrink-0 mt-0.5">
{reply.author?.image && (
<AvatarImage src={reply.author.image} />
)}
<AvatarFallback className="text-[8px]">
{getInitials(reply.author?.name ?? null)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">
{reply.author?.name ?? reply.author?.email ?? "Deleted User"}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeDate(reply.createdAt)}
</span>
</div>
<p className="text-xs mt-0.5 text-foreground/80 whitespace-pre-wrap">
{reply.text}
</p>
</div>
</div>
))}
{/* Reply input */}
<div className="p-2">
<div className="flex gap-2">
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleReply();
}
}}
placeholder="Reply..."
className="min-h-[48px] text-xs py-2 bg-background/50"
/>
<Button
size="icon-sm"
onClick={handleReply}
disabled={isSubmittingReply || !replyText.trim()}
className="self-end"
>
<Send className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Film,
MessageSquare,
CheckCircle2,
XCircle,
Upload,
Bell,
AtSign,
} from "lucide-react";
interface ActivityItem {
id: string;
type: string;
title: string;
message: string;
createdAt: Date;
user?: {
name: string | null;
image: string | null;
} | null;
}
interface RecentActivityProps {
activities: ActivityItem[];
}
const ACTIVITY_ICONS: Record<string, { icon: React.ElementType; color: string }> = {
VERSION_UPLOADED: { icon: Upload, color: "text-blue-400 bg-blue-500/10" },
FEEDBACK_ADDED: { icon: MessageSquare, color: "text-amber-400 bg-amber-500/10" },
SHOT_APPROVED: { icon: CheckCircle2, color: "text-emerald-400 bg-emerald-500/10" },
SHOT_REJECTED: { icon: XCircle, color: "text-red-400 bg-red-500/10" },
COMMENT_REPLY: { icon: MessageSquare, color: "text-purple-400 bg-purple-500/10" },
MENTION: { icon: AtSign, color: "text-primary bg-primary/10" },
REVISION_REQUESTED: { icon: Film, color: "text-orange-400 bg-orange-500/10" },
};
export function RecentActivity({ activities }: RecentActivityProps) {
if (activities.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-10 text-zinc-500">
<Bell className="h-8 w-8 mb-3 opacity-30" />
<p className="text-sm">No recent activity</p>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="space-y-1 p-1">
{activities.map((item) => {
const cfg = ACTIVITY_ICONS[item.type] ?? { icon: Bell, color: "text-muted-foreground bg-muted/20" };
const Icon = cfg.icon;
return (
<div
key={item.id}
className="flex gap-3 px-2 py-2.5 rounded-lg hover:bg-secondary/30 transition-colors"
>
<div className={cn("p-1.5 rounded-md shrink-0 mt-0.5", cfg.color.split(" ")[1])}>
<Icon className={cn("h-3 w-3", cfg.color.split(" ")[0])} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.title}</p>
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
{item.message}
</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{item.user && (
<Avatar className="h-5 w-5">
{item.user.image && <AvatarImage src={item.user.image} />}
<AvatarFallback className="text-[8px]">
{getInitials(item.user.name)}
</AvatarFallback>
</Avatar>
)}
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelativeDate(item.createdAt)}
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
);
}
+319
View File
@@ -0,0 +1,319 @@
"use client";
import Link from "next/link";
import { format, addDays, startOfWeek, differenceInDays, parseISO } from "date-fns";
import { CalendarDays, AlertTriangle, Clock, Users, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { TaskStatus, TaskType } from "@prisma/client";
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
interface ScheduleTask {
id: string;
title: string;
type: string;
status: string;
priority: string;
dueDate: string | null;
estimatedHours: number | null;
scheduledStartDate: string | null;
scheduledEndDate: string | null;
assignedArtistId: string | null;
assignedArtist: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
shot: { shotCode: string } | null;
asset: { assetCode: string } | null;
project: { id: string; name: string; code: string };
}
interface ScheduleWidgetsProps {
scheduledTasks: ScheduleTask[];
artists: {
id: string;
name: string | null;
email: string;
image: string | null;
role: string;
}[];
}
function calcArtistLoad(
tasks: ScheduleTask[],
artistId: string,
weekStart: Date
): number {
const weekEnd = addDays(weekStart, 6);
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
let totalHours = 0;
for (const task of artistTasks) {
const start = toDate(task.scheduledStartDate);
const end = toDate(task.scheduledEndDate) ?? start;
if (!start || !end) continue;
// Check overlap with week
if (start > weekEnd || end < weekStart) continue;
const dur = Math.max(1, differenceInDays(end, start) + 1);
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
// Count days in this week
let daysInWeek = 0;
for (let i = 0; i < 7; i++) {
const day = addDays(weekStart, i);
if (day >= start && day <= end) daysInWeek++;
}
totalHours += hoursPerDay * daysInWeek;
}
return totalHours;
}
export function ScheduleWidgets({
scheduledTasks,
artists,
}: ScheduleWidgetsProps) {
const today = new Date();
const weekStart = startOfWeek(today, { weekStartsOn: 1 });
const weekEnd = addDays(weekStart, 6);
// Tasks due this week
const dueThisWeek = scheduledTasks.filter((t) => {
const due = toDate(t.dueDate);
return due && due >= today && due <= weekEnd && t.status !== "DONE";
});
// Overloaded artists (> 40h this week = > 8h/day avg)
const artistLoads = artists.map((a) => ({
artist: a,
hours: calcArtistLoad(scheduledTasks, a.id, weekStart),
}));
const overloaded = artistLoads.filter((al) => al.hours > 40);
// Upcoming reviews
const upcomingReviews = scheduledTasks.filter(
(t) =>
t.status === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"
);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Due This Week */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-amber-400" />
<span className="text-sm font-medium text-zinc-200">
Due This Week
</span>
</div>
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
{dueThisWeek.length}
</span>
</div>
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
{dueThisWeek.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-zinc-600">
No tasks due this week
</div>
) : (
dueThisWeek.slice(0, 6).map((task) => {
const code = task.shot?.shotCode ?? task.asset?.assetCode;
const cfg = TASK_STATUS_CONFIG[task.status as TaskStatus];
const StatusIcon = cfg.icon;
return (
<Link
key={task.id}
href={`/tasks/${task.id}`}
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-zinc-800/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{code && (
<span className="text-[10px] font-mono text-zinc-500">
{code}
</span>
)}
<span className="text-xs text-zinc-300 truncate">
{task.title}
</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
className={cn(
"text-[10px]",
cfg.color.split(" ").find((c) => c.startsWith("text-"))
)}
>
{cfg.label}
</span>
{task.dueDate && (
<span className="text-[10px] text-zinc-600">
{format(toDate(task.dueDate)!, "EEE, MMM d")}
</span>
)}
</div>
</div>
{task.assignedArtist && (
<Avatar className="h-5 w-5 shrink-0">
<AvatarImage src={task.assignedArtist.image ?? undefined} />
<AvatarFallback className="text-[8px] bg-zinc-700">
{getInitials(
task.assignedArtist.name ?? task.assignedArtist.email
)}
</AvatarFallback>
</Avatar>
)}
</Link>
);
})
)}
</div>
</div>
{/* Artist Utilization */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-zinc-200">
This Week
</span>
</div>
<Link
href="/schedule"
className="text-[10px] text-zinc-500 hover:text-amber-400 flex items-center gap-0.5 transition-colors"
>
Schedule <ExternalLink className="h-2.5 w-2.5" />
</Link>
</div>
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
{artistLoads.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-zinc-600">
No scheduled work this week
</div>
) : (
artistLoads
.filter((al) => al.hours > 0)
.sort((a, b) => b.hours - a.hours)
.slice(0, 6)
.map(({ artist, hours }) => {
const pct = Math.min(100, (hours / 40) * 100);
const isOver = hours > 40;
return (
<div key={artist.id} className="px-4 py-2.5">
<div className="flex items-center gap-2 mb-1.5">
<Avatar className="h-5 w-5 shrink-0">
<AvatarImage src={artist.image ?? undefined} />
<AvatarFallback className="text-[8px] bg-zinc-700">
{getInitials(artist.name ?? artist.email)}
</AvatarFallback>
</Avatar>
<span className="text-xs text-zinc-300 flex-1 truncate">
{artist.name ?? artist.email.split("@")[0]}
</span>
<span
className={cn(
"text-[10px] font-medium",
isOver ? "text-orange-400" : "text-zinc-500"
)}
>
{Math.round(hours)}h
{isOver && " ⚠"}
</span>
</div>
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
isOver ? "bg-orange-500" : "bg-blue-500"
)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})
)}
</div>
</div>
{/* Upcoming Reviews */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-purple-400" />
<span className="text-sm font-medium text-zinc-200">
In Review
</span>
</div>
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
{upcomingReviews.length}
</span>
</div>
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
{upcomingReviews.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-zinc-600">
Nothing in review
</div>
) : (
upcomingReviews.slice(0, 6).map((task) => {
const code = task.shot?.shotCode ?? task.asset?.assetCode;
const isClient = task.status === "CLIENT_REVIEW";
return (
<Link
key={task.id}
href={`/tasks/${task.id}`}
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-zinc-800/50 transition-colors"
>
<div
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
isClient ? "bg-amber-400" : "bg-purple-400"
)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{code && (
<span className="text-[10px] font-mono text-zinc-500">
{code}
</span>
)}
<span className="text-xs text-zinc-300 truncate">
{task.title}
</span>
</div>
<span
className={cn(
"text-[10px]",
isClient ? "text-amber-500" : "text-purple-500"
)}
>
{isClient ? "Client Review" : "Internal Review"}
</span>
</div>
</Link>
);
})
)}
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
import {
ArrowUpRight,
Clock,
CheckCircle2,
AlertCircle,
Film,
MessageSquare,
} from "lucide-react";
import type { ShotWithDetails } from "@/types";
interface ShotQueueProps {
shots: ShotWithDetails[];
projectId?: string;
title?: string;
}
const STATUS_STYLES: Record<string, string> = {
WAITING: "text-zinc-400",
IN_PROGRESS: "text-blue-400",
IN_REVIEW: "text-purple-400",
REVISIONS: "text-orange-400",
COMPLETE: "text-emerald-400",
};
const STATUS_ICONS: Record<string, React.ElementType> = {
WAITING: Clock,
IN_PROGRESS: Film,
IN_REVIEW: AlertCircle,
REVISIONS: AlertCircle,
COMPLETE: CheckCircle2,
};
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
export function ShotQueue({ shots, projectId, title = "Shot Queue" }: ShotQueueProps) {
if (shots.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
<Film className="h-8 w-8 mb-3 opacity-30" />
<p className="text-sm">No shots yet</p>
</div>
);
}
return (
<div className="space-y-1">
{shots.map((shot) => {
const StatusIcon = STATUS_ICONS[shot.status] ?? Clock;
const latestVersion = shot.versions?.[0];
const openComments = shot.versions
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
const href = projectId
? `/projects/${projectId}/shots/${shot.id}`
: `/projects/${(shot as any).projectId ?? "#"}/shots/${shot.id}`;
return (
<div
key={shot.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors group"
>
{/* Priority dot */}
<div
className={cn(
"w-2 h-2 rounded-full shrink-0",
PRIORITY_DOT[shot.priority] ?? "bg-zinc-500"
)}
title={shot.priority}
/>
{/* Shot code */}
<span className="font-mono text-xs text-muted-foreground w-32 shrink-0">
{shot.shotCode}
</span>
{/* Description / name */}
<span className="flex-1 text-sm truncate text-foreground/90">
{shot.description ?? shot.shotCode}
</span>
{/* Open comments */}
{openComments > 0 && (
<span className="flex items-center gap-1 text-xs text-amber-400 shrink-0">
<MessageSquare className="h-3 w-3" />
{openComments}
</span>
)}
{/* Status */}
<span
className={cn(
"flex items-center gap-1 text-xs shrink-0",
STATUS_STYLES[shot.status] ?? "text-muted-foreground"
)}
>
<StatusIcon className="h-3 w-3" />
<span className="hidden sm:inline">
{shot.status.replace("_", " ")}
</span>
</span>
{/* Artist */}
{shot.artist && (
<Avatar className="h-5 w-5 shrink-0">
{shot.artist.image && <img src={shot.artist.image} alt="" />}
<AvatarFallback className="text-[9px]">
{getInitials(shot.artist.name)}
</AvatarFallback>
</Avatar>
)}
{/* Open link */}
<Link href={href}>
<Button
variant="ghost"
size="icon-sm"
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</Link>
</div>
);
})}
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import {
Clock,
AlertCircle,
CheckCircle2,
XCircle,
TrendingUp,
} from "lucide-react";
import type { DashboardStats } from "@/types";
interface StatsCardsProps {
stats: DashboardStats;
}
export function StatsCards({ stats }: StatsCardsProps) {
const cards = [
{
label: "Awaiting Review",
value: stats.awaitingReview,
icon: Clock,
color: "text-amber-400",
bg: "bg-amber-500/10",
ring: "ring-amber-500/20",
},
{
label: "Needs Revisions",
value: stats.needsRevisions,
icon: AlertCircle,
color: "text-orange-400",
bg: "bg-orange-500/10",
ring: "ring-orange-500/20",
},
{
label: "Approved",
value: stats.approved,
icon: CheckCircle2,
color: "text-emerald-400",
bg: "bg-emerald-500/10",
ring: "ring-emerald-500/20",
},
{
label: "Overdue Tasks",
value: stats.tasksOverdue ?? stats.overdue,
icon: XCircle,
color: "text-red-400",
bg: "bg-red-500/10",
ring: "ring-red-500/20",
},
{
label: "Active Projects",
value: stats.activeProjects,
icon: TrendingUp,
color: "text-blue-400",
bg: "bg-blue-500/10",
ring: "ring-blue-500/20",
},
];
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{cards.map((card) => {
const Icon = card.icon;
return (
<Card key={card.label}>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className={cn("p-2 rounded-md", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
<p className="text-3xl font-bold text-white">
{card.value}
</p>
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
</CardContent>
</Card>
);
})}
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, getInitials } from "@/lib/utils";
import {
CalendarDays,
Eye,
ListTodo,
AlertCircle,
CheckCircle2,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
import { TaskStatus, TaskType } from "@prisma/client";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
interface TaskRow {
id: string;
title: string;
type: TaskType;
status: TaskStatus;
priority: string;
dueDate: Date | null;
shot?: { shotCode: string } | null;
asset?: { assetCode: string } | null;
project: { id: string; name: string; code: string };
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
}
function TaskRow({ task }: { task: TaskRow }) {
const cfg = TASK_STATUS_CONFIG[task.status];
const Icon = cfg.icon;
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const isOverdue =
task.dueDate &&
new Date(task.dueDate) < new Date() &&
task.status !== "DONE";
return (
<Link
href={`/tasks/${task.id}`}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800/60 transition-colors group"
>
<span
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
{task.title}
</span>
{contextCode && (
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
{contextCode}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-zinc-500">{TASK_TYPE_LABELS[task.type]}</span>
<span className="text-[11px] text-zinc-700">·</span>
<span className="text-[11px] text-zinc-500">{task.project.code}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{task.assignedArtist && (
<Avatar className="h-5 w-5">
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
</AvatarFallback>
</Avatar>
)}
{task.dueDate && (
<span
className={cn(
"flex items-center gap-1 text-[10px]",
isOverdue ? "text-red-400" : "text-zinc-500"
)}
>
<CalendarDays className="h-3 w-3" />
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
</span>
)}
<Badge
className={cn("text-[10px] border px-1.5 py-0 h-4 gap-1 shrink-0", cfg.color)}
>
<Icon className="h-2.5 w-2.5" />
{cfg.label}
</Badge>
</div>
</Link>
);
}
interface TaskWidgetsProps {
myTasks: TaskRow[];
reviewTasks: TaskRow[];
role: string;
}
export function TaskWidgets({ myTasks, reviewTasks, role }: TaskWidgetsProps) {
const isArtist = role === "ARTIST";
const showReview = !isArtist && reviewTasks.length > 0;
if (myTasks.length === 0 && reviewTasks.length === 0) return null;
return (
<div className={cn("grid gap-4", showReview ? "grid-cols-1 xl:grid-cols-2" : "grid-cols-1")}>
{/* My Tasks */}
{myTasks.length > 0 && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60">
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-amber-400" />
<span className="text-sm font-semibold text-zinc-200">My Tasks</span>
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-2 py-0.5 font-mono">
{myTasks.length}
</span>
</div>
<Link
href="/tasks"
className="text-xs text-zinc-500 hover:text-amber-400 transition-colors"
>
View all
</Link>
</div>
<div className="divide-y divide-zinc-800/50">
{myTasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</div>
)}
{/* Tasks In Review (supervisors/producers) */}
{showReview && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60">
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-purple-400" />
<span className="text-sm font-semibold text-zinc-200">Needs Review</span>
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-2 py-0.5 font-mono">
{reviewTasks.length}
</span>
</div>
<Link
href="/tasks?status=INTERNAL_REVIEW"
className="text-xs text-zinc-500 hover:text-amber-400 transition-colors"
>
View all
</Link>
</div>
<div className="divide-y divide-zinc-800/50">
{reviewTasks.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</div>
</div>
)}
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useSession, signOut } from "next-auth/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationBell } from "@/components/notifications/NotificationBell";
import { getInitials } from "@/lib/utils";
import { LogOut, User, Settings } from "lucide-react";
import Link from "next/link";
interface HeaderProps {
title?: string;
breadcrumbs?: { label: string; href?: string }[];
}
export function Header({ title, breadcrumbs }: HeaderProps) {
const { data: session } = useSession();
const user = session?.user;
return (
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-6">
{/* Left: Title / Breadcrumbs */}
<div className="flex items-center gap-2 min-w-0">
{breadcrumbs ? (
<nav className="flex items-center gap-1.5 text-sm">
{breadcrumbs.map((crumb, i) => (
<span key={i} className="flex items-center gap-1.5">
{i > 0 && <span className="text-muted-foreground">/</span>}
{crumb.href ? (
<Link
href={crumb.href}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{crumb.label}
</Link>
) : (
<span className="text-foreground font-medium truncate">
{crumb.label}
</span>
)}
</span>
))}
</nav>
) : (
title && (
<h1 className="text-sm font-semibold truncate">{title}</h1>
)
)}
</div>
{/* Right: Notifications + User menu */}
<div className="flex items-center gap-3">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-secondary transition-colors">
<Avatar className="h-7 w-7">
{user?.image && <AvatarImage src={user.image} alt={user.name ?? ""} />}
<AvatarFallback className="text-xs">
{getInitials(user?.name)}
</AvatarFallback>
</Avatar>
<div className="hidden sm:block text-left">
<p className="text-xs font-medium leading-none">{user?.name ?? user?.email}</p>
<p className="text-xs text-muted-foreground capitalize">
{user?.role?.toLowerCase()}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>
<p className="font-medium truncate">{user?.name}</p>
<p className="text-xs text-muted-foreground font-normal truncate">
{user?.email}
</p>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
<Settings className="h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive cursor-pointer"
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4 mr-2" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { SessionProvider } from "next-auth/react";
import { useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30s
refetchOnWindowFocus: false,
},
},
})
);
return (
<SessionProvider>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={200}>
{children}
<Toaster />
</TooltipProvider>
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</SessionProvider>
);
}
+129
View File
@@ -0,0 +1,129 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Josefin_Sans } from 'next/font/google';
import { Montserrat } from 'next/font/google';
import {
LayoutDashboard,
FolderOpen,
Users,
UserCog,
Settings,
ChevronLeft,
ChevronRight,
ListTodo,
CalendarRange,
} from 'lucide-react';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { Button } from '@/components/ui/button';
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ href: '/projects', label: 'Projects', icon: FolderOpen },
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
{ href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true },
{ href: '/settings', label: 'Settings', icon: Settings },
];
const josefin = Josefin_Sans({
subsets: ['latin'],
weight: ['300', '400'],
});
const montserrat = Montserrat({
subsets: ['latin'],
weight: ['200', '500', '600'],
});
export function Sidebar() {
const pathname = usePathname();
const { data: session } = useSession();
const [collapsed, setCollapsed] = useState(false);
const isAdmin = ['ADMIN', 'PRODUCER'].includes(session?.user?.role ?? '');
return (
<aside
className={cn(
'flex flex-col border-r border-zinc-800 bg-zinc-900 transition-all duration-200',
collapsed ? 'w-16' : 'w-60',
)}
>
{/* Logo */}
<div
className={cn(
'flex items-center gap-3 px-4 py-5 border-b border-zinc-800',
collapsed && 'justify-center px-0',
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
</div>
{!collapsed && (
<div className={montserrat.className}>
<span className="block text-2xl font-light text-white leading-none">
TWO TALES
</span>
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
vfx review
</span>
</div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-4 space-y-1">
{navItems.map((item) => {
if (item.adminOnly && !isAdmin) return null;
if ((item as any).adminStrictOnly && session?.user?.role !== 'ADMIN') return null;
if ((item as any).hideForClient && session?.user?.role === 'CLIENT')
return null;
const Icon = item.icon;
const isActive =
pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
collapsed && 'justify-center px-0 w-full',
isActive
? 'bg-amber-500/10 text-amber-400'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800',
)}
title={collapsed ? item.label : undefined}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
{/* Collapse toggle */}
<div className="border-t border-zinc-800 p-2">
<Button
variant="ghost"
size="icon-sm"
className="w-full"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
</aside>
);
}
@@ -0,0 +1,165 @@
"use client";
import { useState, useEffect } from "react";
import { Bell, Check, CheckCheck } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { formatRelativeDate } from "@/lib/utils";
import { cn } from "@/lib/utils";
import Link from "next/link";
interface Notification {
id: string;
type: string;
title: string;
message: string;
data: Record<string, string> | null;
isRead: boolean;
createdAt: string;
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isLoading, setIsLoading] = useState(false);
const unreadCount = notifications.filter((n) => !n.isRead).length;
const fetchNotifications = async () => {
setIsLoading(true);
try {
const res = await fetch("/api/notifications");
if (res.ok) {
const data = await res.json();
setNotifications(data.notifications ?? []);
}
} catch {
// silent
} finally {
setIsLoading(false);
}
};
const markAllRead = async () => {
try {
await fetch("/api/notifications", { method: "PATCH" });
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
} catch {
// silent
}
};
const getNotificationHref = (n: Notification): string | null => {
const data = n.data as Record<string, string> | null;
if (data?.versionId) return `/review/${data.versionId}`;
return null;
};
const getNotificationIcon = (type: string): string => {
const icons: Record<string, string> = {
VERSION_UPLOADED: "🎬",
FEEDBACK_ADDED: "💬",
SHOT_APPROVED: "✅",
SHOT_REJECTED: "❌",
COMMENT_REPLY: "↩️",
REVISION_REQUESTED: "⚠️",
};
return icons[type] ?? "🔔";
};
return (
<DropdownMenu onOpenChange={(open) => open && fetchNotifications()}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" className="relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<DropdownMenuLabel className="p-0 text-sm font-semibold">
Notifications
</DropdownMenuLabel>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto py-0 text-xs text-muted-foreground hover:text-foreground"
onClick={markAllRead}
>
<CheckCheck className="h-3 w-3 mr-1" />
Mark all read
</Button>
)}
</div>
<ScrollArea className="max-h-80">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Bell className="h-8 w-8 mb-2 opacity-30" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div>
{notifications.map((n) => {
const href = getNotificationHref(n);
const content = (
<>
<span className="text-base mt-0.5 shrink-0">
{getNotificationIcon(n.type)}
</span>
<div className="flex-1 min-w-0">
<p className={cn("text-xs leading-relaxed", !n.isRead && "font-medium")}>
{n.message}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatRelativeDate(new Date(n.createdAt))}
</p>
</div>
{!n.isRead && (
<div className="h-2 w-2 rounded-full bg-primary shrink-0 mt-1.5" />
)}
</>
);
const className = cn(
"flex gap-3 px-4 py-3 hover:bg-secondary/50 transition-colors cursor-pointer border-b border-border/50 last:border-0",
!n.isRead && "bg-primary/5"
);
if (href) {
return (
<Link key={n.id} href={href} className={className}>
{content}
</Link>
);
}
return (
<div key={n.id} className={className}>
{content}
</div>
);
})}
</div>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useRef, useEffect, useCallback, RefObject } from "react";
import { useReviewStore } from "@/hooks/use-review-player";
import { frameToPosition } from "@/lib/frame-utils";
import type { CommentWithReplies } from "@/types";
interface FrameTimelineProps {
fps: number;
comments: CommentWithReplies[];
annotations?: { frameNumber: number }[];
videoRef: RefObject<HTMLVideoElement | null>;
onSeek: (frame: number) => void;
}
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDragging = useRef(false);
const { currentFrame, totalFrames } = useReviewStore();
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = "hsl(0 0% 6%)";
ctx.fillRect(0, 0, W, H);
// Frame ruler ticks
if (totalFrames > 0) {
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40)));
ctx.strokeStyle = "hsl(0 0% 20%)";
ctx.lineWidth = 1;
ctx.fillStyle = "hsl(0 0% 35%)";
ctx.font = "9px monospace";
ctx.textAlign = "center";
for (let f = 0; f <= totalFrames; f += tickInterval) {
const x = Math.round((f / totalFrames) * W);
const isMajor = f % (tickInterval * 5) === 0 || tickInterval > 20;
ctx.strokeStyle = isMajor ? "hsl(0 0% 22%)" : "hsl(0 0% 16%)";
ctx.beginPath();
ctx.moveTo(x, isMajor ? 0 : H / 2);
ctx.lineTo(x, H);
ctx.stroke();
if (isMajor && f > 0) {
ctx.fillStyle = "hsl(0 0% 35%)";
ctx.fillText(String(f), x, 11);
}
}
}
// Annotation markers — amber diamonds at top
const seenAnnotationFrames = new Set<number>();
annotations.forEach((ann) => {
if (totalFrames === 0) return;
if (seenAnnotationFrames.has(ann.frameNumber)) return;
seenAnnotationFrames.add(ann.frameNumber);
const x = Math.round(frameToPosition(ann.frameNumber, totalFrames) * W);
// Amber diamond
ctx.fillStyle = "hsl(38 92% 50%)";
ctx.beginPath();
ctx.moveTo(x, 2);
ctx.lineTo(x + 4, 7);
ctx.lineTo(x, 12);
ctx.lineTo(x - 4, 7);
ctx.closePath();
ctx.fill();
});
// Comment markers — blue triangles pointing down from top
comments.forEach((comment) => {
if (totalFrames === 0) return;
const x = Math.round(frameToPosition(comment.frameNumber, totalFrames) * W);
if (comment.isResolved) {
ctx.fillStyle = "hsl(142 71% 45% / 0.7)";
} else {
ctx.fillStyle = "hsl(213 94% 68%)";
}
// Triangle marker pointing down from top
ctx.beginPath();
ctx.moveTo(x - 4, 0);
ctx.lineTo(x + 4, 0);
ctx.lineTo(x, 8);
ctx.closePath();
ctx.fill();
});
// Playhead
if (totalFrames > 0) {
const playheadX = Math.round(
frameToPosition(currentFrame, totalFrames) * W
);
// Red line
ctx.strokeStyle = "hsl(0 72% 51%)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX, H);
ctx.stroke();
// Red diamond at top
ctx.fillStyle = "hsl(0 72% 51%)";
ctx.beginPath();
ctx.moveTo(playheadX, H - 4);
ctx.lineTo(playheadX - 5, H - 10);
ctx.lineTo(playheadX, H - 16);
ctx.lineTo(playheadX + 5, H - 10);
ctx.closePath();
ctx.fill();
}
}, [currentFrame, totalFrames, comments, annotations]);
// Resize observer to match canvas to container
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new ResizeObserver(() => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
draw();
});
observer.observe(canvas);
return () => observer.disconnect();
}, [draw]);
useEffect(() => {
draw();
}, [draw]);
// Seek on click/drag
const getFrameFromEvent = useCallback(
(e: React.MouseEvent | MouseEvent): number => {
const canvas = canvasRef.current;
if (!canvas || totalFrames === 0) return 0;
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
return Math.round((x / rect.width) * totalFrames);
},
[totalFrames]
);
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
onSeek(getFrameFromEvent(e));
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging.current) return;
onSeek(getFrameFromEvent(e));
},
[getFrameFromEvent, onSeek]
);
const handleMouseUp = useCallback(() => {
isDragging.current = false;
}, []);
useEffect(() => {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
return (
<canvas
ref={canvasRef}
className="frame-timeline w-full cursor-ew-resize"
style={{ height: 48 }}
onMouseDown={handleMouseDown}
/>
);
}
+284
View File
@@ -0,0 +1,284 @@
"use client";
import { RefObject } from "react";
import {
Play,
Pause,
SkipBack,
SkipForward,
ChevronFirst,
ChevronLast,
Maximize2,
MessageSquarePlus,
Pencil,
Eye,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useReviewStore } from "@/hooks/use-review-player";
import { frameToTimecode } from "@/lib/frame-utils";
import { cn } from "@/lib/utils";
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
interface PlaybackControlsProps {
videoRef: RefObject<HTMLVideoElement | null>;
fps: number;
isReversing: boolean;
onStepBackward: () => void;
onStepForward: () => void;
onReverse: () => void;
onTogglePlay: () => void;
onToggleFullscreen: () => void;
onAddComment: () => void;
}
export function PlaybackControls({
videoRef,
fps,
isReversing,
onStepBackward,
onStepForward,
onReverse,
onTogglePlay,
onToggleFullscreen,
onAddComment,
}: PlaybackControlsProps) {
const {
isPlaying,
currentFrame,
currentTime,
totalFrames,
playbackRate,
isAnnotating,
showAnnotations,
setPlaybackRate,
setAnnotating,
setShowAnnotations,
} = useReviewStore();
const handleRateChange = (val: string) => {
const rate = parseFloat(val);
if (videoRef.current) videoRef.current.playbackRate = rate;
setPlaybackRate(rate);
};
const timecode = frameToTimecode(currentFrame, fps);
return (
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5">
{/* Frame info */}
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0">
<span className="hidden sm:block text-zinc-500">
{timecode}
</span>
<span className="text-white font-semibold">
F{String(currentFrame).padStart(4, "0")}
</span>
<span className="text-zinc-600 hidden md:block">
/ {totalFrames}
</span>
</div>
{/* Divider */}
<div className="h-5 w-px bg-white/10 mx-1" />
{/* Transport Controls */}
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => {
if (videoRef.current) videoRef.current.currentTime = 0;
}}
>
<ChevronFirst className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Go to start</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
isReversing && "text-amber-400 bg-amber-400/10"
)}
onClick={onReverse}
>
<SkipBack className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Reverse (J)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onStepBackward}
>
<SkipBack className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Step back ()</TooltipContent>
</Tooltip>
{/* Play/Pause — slightly larger */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-white hover:text-white hover:bg-white/10 h-9 w-9"
onClick={onTogglePlay}
>
{isPlaying ? (
<Pause className="h-5 w-5 fill-current" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Play / Pause (K or Space)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onStepForward}
>
<SkipForward className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Step forward ()</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => {
if (videoRef.current)
videoRef.current.currentTime = videoRef.current.duration;
}}
>
<ChevronLast className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Go to end</TooltipContent>
</Tooltip>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right Controls */}
<div className="flex items-center gap-2">
{/* Playback speed */}
<Select value={String(playbackRate)} onValueChange={handleRateChange}>
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLAYBACK_RATES.map((r) => (
<SelectItem key={r} value={String(r)} className="text-xs">
{r}x
</SelectItem>
))}
</SelectContent>
</Select>
{/* Annotation toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className={cn(
"text-zinc-400 hover:text-white",
isAnnotating && "text-amber-400 bg-amber-400/10"
)}
onClick={() => setAnnotating(!isAnnotating)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{isAnnotating ? "Stop drawing" : "Draw annotation"}
</TooltipContent>
</Tooltip>
{/* Show/hide annotations */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={() => setShowAnnotations(!showAnnotations)}
>
{showAnnotations ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{showAnnotations ? "Hide annotations" : "Show annotations"}
</TooltipContent>
</Tooltip>
{/* Add Comment */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-primary"
onClick={onAddComment}
>
<MessageSquarePlus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add comment at frame {currentFrame}</TooltipContent>
</Tooltip>
{/* Fullscreen */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-zinc-400 hover:text-white"
onClick={onToggleFullscreen}
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Fullscreen (F)</TooltipContent>
</Tooltip>
</div>
</div>
);
}
+313
View File
@@ -0,0 +1,313 @@
"use client";
import {
useRef,
useEffect,
useState,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useReviewStore } from "@/hooks/use-review-player";
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
import { FrameTimeline } from "./FrameTimeline";
import { PlaybackControls } from "./PlaybackControls";
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
import { cn } from "@/lib/utils";
import type { CommentWithReplies } from "@/types";
export interface ReviewPlayerRef {
seekToFrame: (frame: number) => void;
play: () => void;
pause: () => void;
}
interface ReviewPlayerProps {
videoUrl: string;
versionId: string;
fps?: number;
comments?: CommentWithReplies[];
annotations?: unknown[];
className?: string;
onAddComment?: (frameNumber: number, timestamp: number) => void;
onAnnotationSaved?: (frameNumber: number) => void;
}
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
function ReviewPlayer(
{
videoUrl,
versionId,
fps = 24,
comments = [],
annotations,
className,
onAddComment,
onAnnotationSaved,
},
ref
) {
// Stabilise the annotations reference so AnnotationCanvas's sync effect
// only fires when the actual content changes, not on every parent re-render.
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isReversing, setIsReversing] = useState(false);
const {
isPlaying,
currentFrame,
isAnnotating,
showAnnotations,
setPlaying,
setCurrentFrame,
setCurrentTime,
setDuration,
setFps,
setTotalFrames,
} = useReviewStore();
// Initialize player config from props
useEffect(() => {
setFps(fps);
}, [fps, setFps]);
// ── Playback state sync ──────────────────────────────────────────────────
const handleTimeUpdate = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const frame = timeToFrame(video.currentTime, fps);
setCurrentFrame(frame);
setCurrentTime(video.currentTime);
}, [fps, setCurrentFrame, setCurrentTime]);
const handleLoadedMetadata = useCallback(() => {
const video = videoRef.current;
if (!video) return;
const frames = durationToFrameCount(video.duration, fps);
setDuration(video.duration);
setTotalFrames(frames);
}, [fps, setDuration, setTotalFrames]);
const handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
// ── Exposed ref API ──────────────────────────────────────────────────────
useImperativeHandle(ref, () => ({
seekToFrame: (frame: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = frameToTime(frame, fps);
if (!video.paused) video.pause();
},
play: () => videoRef.current?.play(),
pause: () => videoRef.current?.pause(),
}));
// ── Frame step functions ─────────────────────────────────────────────────
const stepFrame = useCallback(
(delta: number) => {
const video = videoRef.current;
if (!video) return;
video.pause();
stopReverse();
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps));
video.currentTime = newTime;
},
[fps]
);
const stopReverse = useCallback(() => {
if (reverseIntervalRef.current) {
clearInterval(reverseIntervalRef.current);
reverseIntervalRef.current = null;
}
setIsReversing(false);
}, []);
const startReverse = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
setIsReversing(true);
reverseIntervalRef.current = setInterval(() => {
if (!videoRef.current || videoRef.current.currentTime <= 0) {
stopReverse();
return;
}
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / fps);
}, 1000 / fps);
}, [fps, stopReverse]);
const togglePlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
stopReverse();
if (video.paused) {
video.play();
} else {
video.pause();
}
}, [stopReverse]);
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
)
return;
switch (e.key.toLowerCase()) {
case "j":
e.preventDefault();
if (isReversing) {
stopReverse();
} else {
startReverse();
}
break;
case "k":
e.preventDefault();
if (isReversing) {
stopReverse();
} else {
togglePlayback();
}
break;
case "l":
e.preventDefault();
stopReverse();
if (videoRef.current?.paused) videoRef.current.play();
break;
case "arrowleft":
e.preventDefault();
stepFrame(-1);
break;
case "arrowright":
e.preventDefault();
stepFrame(1);
break;
case " ":
e.preventDefault();
stopReverse();
togglePlayback();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
stopReverse();
};
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]);
// ── Fullscreen ───────────────────────────────────────────────────────────
const toggleFullscreen = () => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
el.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
};
const handleSeek = useCallback(
(frame: number) => {
const video = videoRef.current;
if (!video) return;
stopReverse();
video.currentTime = frameToTime(frame, fps);
video.pause();
},
[fps, stopReverse]
);
const handleAddComment = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
onAddComment?.(currentFrame, video.currentTime);
}, [currentFrame, onAddComment]);
return (
<div
ref={containerRef}
className={cn(
"review-player-container flex flex-col bg-black select-none h-full",
className
)}
>
{/* Video */}
<div className="relative flex-1 min-h-0 overflow-hidden">
<video
ref={videoRef}
src={videoUrl}
className="block w-full h-full object-contain"
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onPlay={handlePlay}
onPause={handlePause}
preload="metadata"
playsInline
/>
{/* Annotation canvas overlay */}
<AnnotationCanvas
versionId={versionId}
frameNumber={currentFrame}
fps={fps}
isAnnotating={isAnnotating && !isPlaying}
showAnnotations={showAnnotations}
existingAnnotations={stableAnnotations}
onAnnotationSaved={onAnnotationSaved}
/>
{/* JKL hint overlay — shown briefly when reversing */}
{isReversing && (
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
REVERSE
</div>
)}
</div>
{/* Annotation Tools (visible when annotation mode is active) */}
<AnnotationTools />
{/* Frame Timeline */}
<FrameTimeline
fps={fps}
comments={comments}
annotations={annotations as { frameNumber: number }[]}
videoRef={videoRef}
onSeek={handleSeek}
/>
{/* Playback Controls */}
<PlaybackControls
videoRef={videoRef}
fps={fps}
isReversing={isReversing}
onStepBackward={() => stepFrame(-1)}
onStepForward={() => stepFrame(1)}
onReverse={isReversing ? stopReverse : startReverse}
onTogglePlay={togglePlayback}
onToggleFullscreen={toggleFullscreen}
onAddComment={handleAddComment}
/>
</div>
);
}
);
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { createProject } from "@/actions/projects";
import { useToast } from "@/components/ui/use-toast";
const projectSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
code: z.string().min(2, "Project code required").regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
showId: z.string().min(1, "Show ID required").max(10, "Max 10 chars").regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
projectType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
description: z.string().optional(),
clientId: z.string().optional(),
deadline: z.string().optional(),
});
type ProjectFormValues = z.infer<typeof projectSchema>;
interface NewProjectDialogProps {
open: boolean;
onClose: () => void;
clients?: { id: string; company: string }[];
onSuccess?: (projectId: string) => void;
}
export function NewProjectDialog({
open,
onClose,
clients = [],
onSuccess,
}: NewProjectDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors },
} = useForm<ProjectFormValues>({
resolver: zodResolver(projectSchema),
defaultValues: { projectType: "STANDARD" },
});
const onSubmit = async (data: ProjectFormValues) => {
setIsSubmitting(true);
try {
const result = await createProject({
name: data.name,
code: data.code.toUpperCase(),
showId: data.showId.toUpperCase(),
projectType: data.projectType,
description: data.description,
clientId: data.clientId || undefined,
deadline: data.deadline ? new Date(data.deadline) : undefined,
});
toast({ title: "Project created" });
reset();
router.push(`/projects/${result.project.id}`);
router.refresh();
onSuccess?.(result.project.id);
onClose();
} catch (err) {
toast({
title: "Failed to create project",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New Project</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="name">Project Name *</Label>
<Input id="name" placeholder="Stellar Montage" {...register("name")} />
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="code">Code *</Label>
<Input
id="code"
placeholder="NOVA-25"
className="uppercase"
{...register("code")}
/>
{errors.code && (
<p className="text-xs text-destructive">{errors.code.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="showId">Show ID *</Label>
<Input
id="showId"
placeholder="PRJX"
className="uppercase"
maxLength={10}
{...register("showId")}
/>
{errors.showId && (
<p className="text-xs text-destructive">{errors.showId.message}</p>
)}
<p className="text-xs text-muted-foreground">Used in shot codes (e.g. PRJX_SC010_0010)</p>
</div>
<div className="space-y-1.5">
<Label>Project Type *</Label>
<Select
defaultValue="STANDARD"
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STANDARD">Standard</SelectItem>
<SelectItem value="EPISODIC">Episodic</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Brief description of this project..."
{...register("description")}
className="min-h-[70px]"
/>
</div>
{clients.length > 0 && (
<div className="space-y-1.5">
<Label>Client</Label>
<Select onValueChange={(v) => setValue("clientId", v)}>
<SelectTrigger>
<SelectValue placeholder="Select client (optional)" />
</SelectTrigger>
<SelectContent>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.company}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="deadline">Deadline</Label>
<Input id="deadline" type="date" {...register("deadline")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Project"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
import {
Film,
Layers,
CheckCircle2,
Clock,
Users,
ArrowUpRight,
} from "lucide-react";
interface ProjectCardProps {
project: {
id: string;
name: string;
code: string;
status: string;
description?: string | null;
deadline?: Date | null;
createdAt: Date;
client?: { company: string } | null;
producer?: { name: string | null; image: string | null } | null;
_count?: { shots: number };
shotStats?: {
total: number;
approved: number;
inProgress: number;
};
};
}
const STATUS_STYLES: Record<string, string> = {
ACTIVE: "bg-green-900/60 text-green-300",
ON_HOLD: "bg-yellow-900/60 text-yellow-300",
COMPLETED: "bg-blue-900/60 text-blue-300",
ARCHIVED: "bg-zinc-800 text-zinc-500",
};
export function ProjectCard({ project }: ProjectCardProps) {
const total = project.shotStats?.total ?? project._count?.shots ?? 0;
const approved = project.shotStats?.approved ?? 0;
const progress = total > 0 ? Math.round((approved / total) * 100) : 0;
return (
<Card className="group hover:border-zinc-700 transition-all flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className="font-mono text-xs text-zinc-500">{project.code}</span>
{project.client && (
<span className="text-xs text-zinc-600"> {project.client.company}</span>
)}
</div>
<h3 className="font-semibold text-base leading-tight text-white">{project.name}</h3>
</div>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border-0 font-medium shrink-0",
STATUS_STYLES[project.status] ?? STATUS_STYLES.ACTIVE
)}
>
{project.status.replace("_", " ")}
</span>
</div>
{project.description && (
<p className="text-sm text-zinc-400 line-clamp-2 mt-1">
{project.description}
</p>
)}
</CardHeader>
<CardContent className="flex-1 pb-3 space-y-3">
{/* Progress */}
{total > 0 && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Shot progress</span>
<span className="text-foreground font-medium">{approved}/{total} approved</span>
</div>
<Progress value={progress} className="h-1.5" />
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
<Layers className="h-3.5 w-3.5 text-zinc-400 mb-1" />
<span className="font-semibold text-white">{total}</span>
<span className="text-zinc-400">shots</span>
</div>
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 mb-1" />
<span className="font-semibold text-white">{approved}</span>
<span className="text-zinc-400">done</span>
</div>
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
<Film className="h-3.5 w-3.5 text-blue-400 mb-1" />
<span className="font-semibold text-white">
{project.shotStats?.inProgress ?? 0}
</span>
<span className="text-zinc-400">active</span>
</div>
</div>
</CardContent>
<CardFooter className="pt-2 border-t border-zinc-800 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-zinc-400">
{project.producer ? (
<>
<Avatar className="h-5 w-5">
{project.producer.image && <AvatarImage src={project.producer.image} />}
<AvatarFallback className="text-[9px]">
{getInitials(project.producer.name)}
</AvatarFallback>
</Avatar>
<span>{project.producer.name ?? "Producer"}</span>
</>
) : (
<>
<Clock className="h-3 w-3" />
<span>{formatRelativeDate(project.createdAt)}</span>
</>
)}
{project.deadline && (
<span className="text-amber-400 ml-2">
Due {formatRelativeDate(project.deadline)}
</span>
)}
</div>
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1">
<Link href={`/projects/${project.id}`}>
Open
<ArrowUpRight className="h-3 w-3" />
</Link>
</Button>
</CardFooter>
</Card>
);
}
+349
View File
@@ -0,0 +1,349 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { updateProject } from "@/actions/projects";
import { useToast } from "@/components/ui/use-toast";
import { Settings, Building2, Users, Webhook } from "lucide-react";
const settingsSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
code: z.string().min(1, "Code is required").max(20).regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
showId: z.string().min(1, "Show ID is required").max(10).regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
projectType: z.enum(["STANDARD", "EPISODIC"]),
description: z.string().optional(),
status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]),
clientId: z.string().optional(),
producerId: z.string().optional(),
supervisorId: z.string().optional(),
dueDate: z.string().optional(),
startDate: z.string().optional(),
slackWebhook: z.string().optional(),
slackChannel: z.string().optional(),
});
type SettingsFormValues = z.infer<typeof settingsSchema>;
interface Client {
id: string;
company: string;
}
interface TeamMember {
id: string;
name: string | null;
email: string;
role: string;
}
interface ProjectSettingsTabProps {
project: {
id: string;
name: string;
code: string;
showId: string;
projectType: "STANDARD" | "EPISODIC";
description: string | null;
status: string;
clientId: string | null;
producerId: string | null;
supervisorId: string | null;
dueDate: Date | null;
startDate: Date | null;
slackWebhook: string | null;
slackChannel: string | null;
};
clients: Client[];
teamMembers: TeamMember[];
}
const NONE = "__none__";
function toDateInput(d: Date | null | undefined) {
if (!d) return "";
return new Date(d).toISOString().substring(0, 10);
}
export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSettingsTabProps) {
const { toast } = useToast();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const producers = teamMembers.filter((m) => ["ADMIN", "PRODUCER"].includes(m.role));
const supervisors = teamMembers.filter((m) => ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(m.role));
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isDirty },
} = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
name: project.name,
code: project.code,
showId: project.showId,
projectType: project.projectType,
description: project.description ?? "",
status: project.status as SettingsFormValues["status"],
clientId: project.clientId ?? NONE,
producerId: project.producerId ?? NONE,
supervisorId: project.supervisorId ?? NONE,
dueDate: toDateInput(project.dueDate),
startDate: toDateInput(project.startDate),
slackWebhook: project.slackWebhook ?? "",
slackChannel: project.slackChannel ?? "",
},
});
const onSubmit = async (data: SettingsFormValues) => {
setIsSubmitting(true);
try {
await updateProject({
id: project.id,
name: data.name,
code: data.code,
showId: data.showId,
projectType: data.projectType,
description: data.description || null,
status: data.status as any,
clientId: data.clientId === NONE ? null : data.clientId || null,
producerId: data.producerId === NONE ? null : data.producerId || null,
supervisorId: data.supervisorId === NONE ? null : data.supervisorId || null,
dueDate: data.dueDate || null,
startDate: data.startDate || null,
slackWebhook: data.slackWebhook || null,
slackChannel: data.slackChannel || null,
});
toast({ title: "Project settings saved" });
router.refresh();
} catch (err) {
toast({
title: "Failed to save settings",
description: (err as Error).message,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 max-w-2xl">
{/* ── General ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">General</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="name">Project Name</Label>
<Input id="name" {...register("name")} />
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
</div>
<div className="space-y-1.5">
<Label htmlFor="code">Project Code</Label>
<Input id="code" className="uppercase font-mono" {...register("code")} />
{errors.code && <p className="text-xs text-destructive">{errors.code.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Status</Label>
<Select
defaultValue={project.status}
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"], { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="ON_HOLD">On Hold</SelectItem>
<SelectItem value="COMPLETED">Completed</SelectItem>
<SelectItem value="ARCHIVED">Archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 col-span-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" className="min-h-[80px]" {...register("description")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="startDate">Start Date</Label>
<Input id="startDate" type="date" {...register("startDate")} />
</div>
<div className="space-y-1.5">
<Label htmlFor="dueDate">Due Date</Label>
<Input id="dueDate" type="date" {...register("dueDate")} />
</div>
</div>
</section>
{/* ── Shot Naming ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Shot Naming</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="showId">Show ID</Label>
<Input id="showId" className="uppercase font-mono" maxLength={10} {...register("showId")} />
{errors.showId && <p className="text-xs text-destructive">{errors.showId.message}</p>}
<p className="text-xs text-muted-foreground">Used in shot codes, e.g. SHOWID_SC010_0010</p>
</div>
<div className="space-y-1.5">
<Label>Project Type</Label>
<Select
defaultValue={project.projectType}
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC", { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STANDARD">Standard</SelectItem>
<SelectItem value="EPISODIC">Episodic</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Episodic adds an episode segment to shot codes</p>
</div>
</div>
</section>
{/* ── Client & Team ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Client & Team</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label>Client</Label>
<Select
defaultValue={project.clientId ?? NONE}
onValueChange={(v) => setValue("clientId", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No client assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>No client</SelectItem>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.company}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Producer</Label>
<Select
defaultValue={project.producerId ?? NONE}
onValueChange={(v) => setValue("producerId", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Not assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Not assigned</SelectItem>
{producers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name ?? m.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Supervisor</Label>
<Select
defaultValue={project.supervisorId ?? NONE}
onValueChange={(v) => setValue("supervisorId", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Not assigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Not assigned</SelectItem>
{supervisors.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name ?? m.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>
{/* ── Integrations ── */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Webhook className="h-4 w-4 text-zinc-400" />
<h2 className="text-sm font-semibold text-zinc-200">Slack Integration</h2>
</div>
<Separator />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="slackWebhook">Webhook URL</Label>
<Input
id="slackWebhook"
type="url"
placeholder="https://hooks.slack.com/services/..."
{...register("slackWebhook")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="slackChannel">Channel</Label>
<Input
id="slackChannel"
placeholder="#vfx-pipeline"
{...register("slackChannel")}
/>
</div>
</div>
</section>
{/* Save */}
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</form>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useDroppable, useDraggable } from "@dnd-kit/core";
import { format, parseISO, formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
import {
CalendarDays,
Clock,
GripVertical,
Layers,
ChevronDown,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { TaskStatus, TaskType } from "@prisma/client";
import {
ScheduleTask,
ActiveDragData,
} from "@/app/(dashboard)/schedule/SchedulePageClient";
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
const PRIORITY_DOT: Record<string, string> = {
LOW: "bg-zinc-500",
NORMAL: "bg-blue-500",
HIGH: "bg-amber-500",
URGENT: "bg-red-500",
};
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function BacklogItem({
task,
canEdit,
}: {
task: ScheduleTask;
canEdit: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: task.id,
disabled: !canEdit,
data: {
type: "backlog",
taskId: task.id,
estimatedHours: task.estimatedHours,
} as ActiveDragData,
});
const cfg = TASK_STATUS_CONFIG[task.status];
const StatusIcon = cfg.icon;
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const dueDate = toDate(task.dueDate);
const isOverdue =
dueDate && dueDate < new Date() && task.status !== "DONE";
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 50,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group flex items-start gap-2 px-3 py-2.5 rounded-lg border transition-colors",
"border-zinc-800 bg-zinc-900 hover:border-zinc-700 hover:bg-zinc-800/80",
isDragging && "opacity-30 border-amber-500/40",
canEdit && "cursor-grab active:cursor-grabbing"
)}
{...listeners}
{...attributes}
>
{/* Grip */}
{canEdit && (
<GripVertical className="h-3.5 w-3.5 text-zinc-600 shrink-0 mt-0.5 group-hover:text-zinc-400" />
)}
{/* Priority dot */}
<div
className={cn(
"w-1.5 h-1.5 rounded-full shrink-0 mt-1.5",
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
)}
/>
{/* Content */}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5">
{contextCode && (
<span className="text-[10px] font-mono bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded shrink-0">
{contextCode}
</span>
)}
<span className="text-xs font-medium text-zinc-200 truncate">
{task.title}
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] text-zinc-500">
{TASK_TYPE_LABELS[task.type as TaskType]} · {task.project.code}
</span>
{task.estimatedHours && (
<span className="flex items-center gap-0.5 text-[10px] text-zinc-600">
<Clock className="h-2.5 w-2.5" />
{task.estimatedHours}h
</span>
)}
</div>
<div className="flex items-center gap-2">
<Badge
className={cn(
"text-[9px] border px-1 py-0 h-3.5 gap-0.5 shrink-0",
cfg.color
)}
>
<StatusIcon className="h-2 w-2" />
{cfg.label}
</Badge>
{dueDate && (
<span
className={cn(
"flex items-center gap-0.5 text-[10px]",
isOverdue ? "text-red-400" : "text-zinc-600"
)}
>
<CalendarDays className="h-2.5 w-2.5" />
{formatDistanceToNow(dueDate, { addSuffix: true })}
</span>
)}
</div>
</div>
</div>
);
}
interface BacklogPanelProps {
tasks: ScheduleTask[];
canEdit: boolean;
}
export function BacklogPanel({ tasks, canEdit }: BacklogPanelProps) {
const { setNodeRef, isOver } = useDroppable({
id: "backlog-drop-zone",
});
return (
<div
className={cn(
"flex flex-col border-l border-zinc-800 bg-zinc-900 transition-colors",
"w-72 shrink-0"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-zinc-800 shrink-0">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-zinc-400" />
<span className="text-sm font-medium text-zinc-200">Backlog</span>
<span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded-full">
{tasks.length}
</span>
</div>
{canEdit && (
<span className="text-[10px] text-zinc-600">
Drag onto timeline
</span>
)}
</div>
{/* Drop zone + list */}
<div
ref={setNodeRef}
className={cn(
"flex-1 transition-colors",
isOver && "bg-amber-500/5 ring-1 ring-inset ring-amber-500/20"
)}
>
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-600">
<Layers className="h-8 w-8 mb-2 opacity-30" />
<p className="text-xs">All tasks scheduled</p>
{canEdit && (
<p className="text-[10px] mt-1 text-zinc-700">
Drop here to unschedule
</p>
)}
</div>
) : (
<ScrollArea className="h-full">
<div className="p-2 space-y-1.5">
{canEdit && isOver && (
<div className="flex items-center justify-center py-3 rounded-lg border border-dashed border-amber-500/40 text-amber-400 text-xs">
Drop to unschedule
</div>
)}
{tasks.map((task) => (
<BacklogItem key={task.id} task={task} canEdit={canEdit} />
))}
</div>
</ScrollArea>
)}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
"use client";
import { addWeeks, subWeeks, addDays, format } from "date-fns";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react";
import { ScheduleArtist } from "@/app/(dashboard)/schedule/SchedulePageClient";
const STATUS_OPTIONS = [
{ value: "TODO", label: "To Do" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "INTERNAL_REVIEW", label: "Internal Review" },
{ value: "CLIENT_REVIEW", label: "Client Review" },
{ value: "CHANGES", label: "Changes" },
];
interface ScheduleFiltersProps {
projects: { id: string; name: string; code: string }[];
artists: ScheduleArtist[];
filterProject: string;
filterArtist: string;
filterStatus: string;
viewStart: Date;
onProjectChange: (v: string) => void;
onArtistChange: (v: string) => void;
onStatusChange: (v: string) => void;
onViewStartChange: (d: Date) => void;
}
export function ScheduleFilters({
projects,
artists,
filterProject,
filterArtist,
filterStatus,
viewStart,
onProjectChange,
onArtistChange,
onStatusChange,
onViewStartChange,
}: ScheduleFiltersProps) {
const goPrev = () => onViewStartChange(subWeeks(viewStart, 1));
const goNext = () => onViewStartChange(addWeeks(viewStart, 1));
const goToday = () => {
const today = new Date();
const monday = addDays(today, -((today.getDay() + 6) % 7));
onViewStartChange(monday);
};
return (
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-950 shrink-0 flex-wrap">
{/* Week navigation */}
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white"
onClick={goPrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-zinc-400 hover:text-white gap-1.5"
onClick={goToday}
>
<CalendarDays className="h-3 w-3" />
Today
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-zinc-400 hover:text-white"
onClick={goNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-zinc-500 ml-1">
{format(viewStart, "MMM d")} {format(addDays(viewStart, 34), "MMM d, yyyy")}
</span>
</div>
<div className="h-4 w-px bg-zinc-800" />
{/* Project filter */}
<Select
value={filterProject || "__all__"}
onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="__all__" className="text-xs">
All projects
</SelectItem>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-xs">
[{p.code}] {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Artist filter */}
<Select
value={filterArtist || "__all__"}
onValueChange={(v) => onArtistChange(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
<SelectValue placeholder="All artists" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="__all__" className="text-xs">
All artists
</SelectItem>
{artists.map((a) => (
<SelectItem key={a.id} value={a.id} className="text-xs">
{a.name ?? a.email.split("@")[0]}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Status filter */}
<Select
value={filterStatus || "__all__"}
onValueChange={(v) => onStatusChange(v === "__all__" ? "" : v)}
>
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent className="bg-zinc-900 border-zinc-700">
<SelectItem value="__all__" className="text-xs">
All statuses
</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value} className="text-xs">
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
{(filterProject || filterArtist || filterStatus) && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-zinc-500 hover:text-white"
onClick={() => {
onProjectChange("");
onArtistChange("");
onStatusChange("");
}}
>
Clear filters
</Button>
)}
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
"use client";
import { useDraggable } from "@dnd-kit/core";
import {
format,
isAfter,
parseISO,
} from "date-fns";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { CalendarDays, Clock, ExternalLink, CalendarOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { TaskStatus, TaskType } from "@prisma/client";
import {
ScheduleTask,
ActiveDragData,
} from "@/app/(dashboard)/schedule/SchedulePageClient";
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
const STATUS_STYLES: Record<
TaskStatus,
{ bg: string; border: string; text: string; dot: string }
> = {
TODO: {
bg: "bg-zinc-800/80",
border: "border-zinc-600/60",
text: "text-zinc-300",
dot: "bg-zinc-500",
},
IN_PROGRESS: {
bg: "bg-blue-900/50",
border: "border-blue-600/60",
text: "text-blue-200",
dot: "bg-blue-400",
},
INTERNAL_REVIEW: {
bg: "bg-purple-900/50",
border: "border-purple-600/60",
text: "text-purple-200",
dot: "bg-purple-400",
},
CLIENT_REVIEW: {
bg: "bg-amber-900/50",
border: "border-amber-600/60",
text: "text-amber-200",
dot: "bg-amber-400",
},
CHANGES: {
bg: "bg-orange-900/50",
border: "border-orange-600/60",
text: "text-orange-200",
dot: "bg-orange-400",
},
DONE: {
bg: "bg-emerald-900/50",
border: "border-emerald-600/60",
text: "text-emerald-200",
dot: "bg-emerald-400",
},
};
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
interface ScheduleTaskBlockProps {
task: ScheduleTask;
dayIndex: number;
duration: number;
artistIndex: number;
viewStart: Date;
days: Date[];
canEdit: boolean;
isDragging: boolean;
laneTop: number;
taskHeight: number;
onResizeMouseDown: (
taskId: string,
currentEndDate: string
) => (e: React.MouseEvent) => void;
onUnschedule: (taskId: string) => void;
dayWidth: number;
}
export function ScheduleTaskBlock({
task,
dayIndex,
duration,
canEdit,
isDragging,
laneTop,
taskHeight,
onResizeMouseDown,
onUnschedule,
dayWidth,
}: ScheduleTaskBlockProps) {
const router = useRouter();
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: task.id,
disabled: !canEdit,
data: {
type: "scheduled",
taskId: task.id,
duration,
} as ActiveDragData,
});
const taskWidth = Math.max(20, (task.estimatedHours ?? 8) / 8 * dayWidth - 4);
const style = {
position: "absolute" as const,
left: Math.max(0, dayIndex) * dayWidth + 2,
width: taskWidth,
top: laneTop,
height: taskHeight,
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
zIndex: isDragging ? 10 : 1,
};
const statusStyle = STATUS_STYLES[task.status];
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
const thumbnail = task.shot?.thumbnailUrl ?? null;
const isOverdue =
task.dueDate &&
task.scheduledEndDate &&
isAfter(
toDate(task.scheduledEndDate)!,
toDate(task.dueDate)!
);
const dueDate = toDate(task.dueDate);
const tooNarrow = (task.estimatedHours ?? 8) < 3; // < 3h = too narrow for labels
const tooShort = taskHeight < 16;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<div
ref={setNodeRef}
style={style}
className={cn(
"absolute rounded-md border flex items-center overflow-hidden select-none group",
"transition-opacity duration-100",
statusStyle.bg,
statusStyle.border,
isDragging && "opacity-30",
canEdit && "cursor-grab active:cursor-grabbing",
isOverdue && "ring-1 ring-red-500/50"
)}
{...listeners}
{...attributes}
>
{/* Status dot */}
<div
className={cn(
"shrink-0 w-1 self-stretch rounded-l-md",
statusStyle.dot
)}
/>
{/* Thumbnail */}
{thumbnail && !tooNarrow && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={thumbnail}
alt=""
className="shrink-0 h-full object-cover"
style={{ width: taskHeight * 2.39, minWidth: 0 }}
/>
)}
{/* Content */}
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden px-1">
{!tooShort && (
<span
className={cn(
"text-[10px] font-mono font-semibold truncate shrink-0 max-w-[50%]",
statusStyle.text
)}
>
{contextName}
</span>
)}
{!tooNarrow && !tooShort && (
<span className="text-[9px] text-zinc-400 truncate">
{TASK_TYPE_LABELS[task.type as TaskType]}
</span>
)}
{!tooNarrow && !tooShort && task.estimatedHours && (
<span className="text-[9px] text-zinc-500 shrink-0 ml-auto">
{task.estimatedHours}h
</span>
)}
</div>
{/* Overdue indicator */}
{isOverdue && (
<div className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-400 mr-1" />
)}
{/* Resize handle */}
{canEdit && task.scheduledEndDate && (
<div
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={onResizeMouseDown(task.id, task.scheduledEndDate)}
onClick={(e) => e.stopPropagation()}
>
<div className="w-0.5 h-3/4 rounded-full bg-current opacity-50" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-zinc-900 border-zinc-700 text-zinc-100 max-w-[240px]"
>
<div className="space-y-1">
<div className="font-medium text-xs">
{contextCode && (
<span className="text-amber-400 mr-1">{contextCode}</span>
)}
{task.title}
</div>
<div className="text-[11px] text-zinc-400">
{TASK_TYPE_LABELS[task.type as TaskType]} ·{" "}
{task.project.code}
</div>
{task.scheduledStartDate && task.scheduledEndDate && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
<CalendarDays className="h-3 w-3" />
{format(toDate(task.scheduledStartDate)!, "MMM d")} {" "}
{format(toDate(task.scheduledEndDate)!, "MMM d")}
</div>
)}
{task.estimatedHours && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
<Clock className="h-3 w-3" />
{task.estimatedHours}h estimated
</div>
)}
{dueDate && (
<div
className={cn(
"flex items-center gap-1 text-[11px]",
isOverdue ? "text-red-400" : "text-zinc-400"
)}
>
<CalendarDays className="h-3 w-3" />
Due {format(dueDate, "MMM d")}
{isOverdue && " — Late!"}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
<ContextMenuContent className="bg-zinc-900 border-zinc-700 text-zinc-100 w-48">
<ContextMenuItem
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-white"
onSelect={() => router.push(`/tasks/${task.id}`)}
>
<ExternalLink className="h-3.5 w-3.5 text-zinc-400" />
View Task
</ContextMenuItem>
{canEdit && (
<>
<ContextMenuSeparator className="bg-zinc-800" />
<ContextMenuItem
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-red-400 text-red-400"
onSelect={() => onUnschedule(task.id)}
>
<CalendarOff className="h-3.5 w-3.5" />
Unschedule
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
+385
View File
@@ -0,0 +1,385 @@
"use client";
import { useRef, RefObject } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
format,
differenceInDays,
startOfDay,
isToday,
isWeekend,
isBefore,
parseISO,
addDays,
} from "date-fns";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials } from "@/lib/utils";
import { ScheduleTaskBlock } from "./ScheduleTaskBlock";
import {
ScheduleTask,
ScheduleArtist,
DAY_WIDTH,
ROW_HEIGHT,
HEADER_HEIGHT,
} from "@/app/(dashboard)/schedule/SchedulePageClient";
import { AlertTriangle } from "lucide-react";
interface ResizePreview {
taskId: string;
endDate: Date;
estimatedHours: number;
}
interface ScheduleTimelineProps {
artists: ScheduleArtist[];
tasks: ScheduleTask[];
days: Date[];
viewStart: Date;
canEdit: boolean;
timelineRef: RefObject<HTMLDivElement | null>;
resizePreview: ResizePreview | null;
onResizeMouseDown: (
taskId: string,
currentEndDate: string
) => (e: React.MouseEvent) => void;
activeDragId: string | null;
onUnschedule: (taskId: string) => void;
dayWidth: number;
rowHeight: number;
}
function toDate(val: string | null | undefined): Date | null {
if (!val) return null;
try {
return parseISO(val);
} catch {
return new Date(val);
}
}
function getDayIndex(date: Date | null, viewStart: Date): number {
if (!date) return -1;
return differenceInDays(date, viewStart);
}
/** Greedy lane assignment based on hour-ranges so task width = hours.
* Two tasks for the same artist conflict when their [startHour, endHour]
* ranges overlap. Each task occupies exactly one lane (fixed height).
*/
function computeLanes(
tasks: ScheduleTask[],
viewStart: Date
): Map<string, number> {
// Build hour ranges: startHour = dayIndex * 8, endHour = startHour + estimatedHours
const ranges = tasks
.filter((t) => t.scheduledStartDate)
.map((t) => {
const dayIdx = Math.max(
0,
differenceInDays(toDate(t.scheduledStartDate)!, viewStart)
);
const startHour = dayIdx * 8;
const endHour = startHour + (t.estimatedHours ?? 8);
return { id: t.id, startHour, endHour };
})
.sort((a, b) => a.startHour - b.startHour);
const laneEndHours: number[] = [];
const result = new Map<string, number>();
for (const task of ranges) {
let lane = laneEndHours.findIndex((end) => task.startHour >= end);
if (lane === -1) lane = laneEndHours.length;
laneEndHours[lane] = task.endHour;
result.set(task.id, lane);
}
return result;
}
function getTaskDuration(task: ScheduleTask): number {
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
}
// Calculate daily load (hours) for each artist
function calcDailyLoad(
tasks: ScheduleTask[],
artistId: string,
days: Date[]
): Record<string, number> {
const result: Record<string, number> = {};
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
for (const day of days) {
const dayStr = format(day, "yyyy-MM-dd");
let totalHours = 0;
for (const task of artistTasks) {
const start = toDate(task.scheduledStartDate);
const end = toDate(task.scheduledEndDate) ?? start;
if (!start || !end) continue;
const dayStart = new Date(day);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day);
dayEnd.setHours(23, 59, 59, 999);
if (start <= dayEnd && end >= dayStart) {
const dur = Math.max(1, differenceInDays(end, start) + 1);
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
totalHours += hoursPerDay;
}
}
result[dayStr] = totalHours;
}
return result;
}
function ArtistLabel({
artist,
isOverloaded,
rowHeight,
}: {
artist: ScheduleArtist;
isOverloaded: boolean;
rowHeight: number;
}) {
return (
<div
className="flex items-center gap-2.5 px-3 border-b border-zinc-800 bg-zinc-900"
style={{ height: rowHeight }}
>
<Avatar className="h-7 w-7 shrink-0">
<AvatarImage src={artist.image ?? undefined} />
<AvatarFallback className="text-[10px] bg-zinc-700 text-zinc-300">
{getInitials(artist.name ?? artist.email)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-zinc-200 truncate">
{artist.name ?? artist.email.split("@")[0]}
</span>
{isOverloaded && (
<AlertTriangle className="h-3 w-3 text-orange-400 shrink-0" />
)}
</div>
<span className="text-[10px] text-zinc-500 capitalize">
{artist.role.toLowerCase()}
</span>
</div>
</div>
);
}
function TimelineDropZone({ id }: { id: string }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={cn(
"absolute inset-0 transition-colors pointer-events-none",
isOver && "bg-amber-500/5"
)}
/>
);
}
export function ScheduleTimeline({
artists,
tasks,
days,
viewStart,
canEdit,
timelineRef,
resizePreview,
onResizeMouseDown,
activeDragId,
onUnschedule,
dayWidth,
rowHeight,
}: ScheduleTimelineProps) {
const totalWidth = days.length * dayWidth;
return (
<div className="flex flex-1 overflow-hidden">
{/* Sticky left: artist labels */}
<div
className="flex-shrink-0 bg-zinc-900 border-r border-zinc-800 z-10"
style={{ width: 208 }}
>
{/* Header spacer */}
<div
className="border-b border-zinc-800 bg-zinc-900"
style={{ height: HEADER_HEIGHT }}
/>
{artists.map((artist) => {
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
const maxLoad = Math.max(...Object.values(dailyLoad), 0);
const isOverloaded = maxLoad > 8;
return (
<ArtistLabel
key={artist.id}
artist={artist}
isOverloaded={isOverloaded}
rowHeight={rowHeight}
/>
);
})}
</div>
{/* Scrollable timeline */}
<div
ref={timelineRef}
className="flex-1 overflow-x-auto overflow-y-hidden relative"
style={{ scrollbarColor: "#3f3f46 transparent" }}
>
{/* Date header - sticky top */}
<div
className="flex sticky top-0 z-20 bg-zinc-900 border-b border-zinc-800"
style={{ width: totalWidth, height: HEADER_HEIGHT }}
>
{days.map((day, i) => {
const isT = isToday(day);
const isWE = isWeekend(day);
return (
<div
key={i}
className={cn(
"flex flex-col items-center justify-center border-r text-center shrink-0 select-none",
isWE ? "border-zinc-800" : "border-zinc-800/60",
isT ? "bg-amber-500/10" : isWE ? "bg-zinc-900/80" : ""
)}
style={{ width: dayWidth }}
>
<span
className={cn(
"text-[9px] font-medium uppercase tracking-wider",
isT ? "text-amber-400" : "text-zinc-600"
)}
>
{format(day, "EEE")}
</span>
<span
className={cn(
"text-xs font-semibold",
isT ? "text-amber-300" : isWE ? "text-zinc-600" : "text-zinc-400"
)}
>
{format(day, "d")}
</span>
{format(day, "d") === "1" || i === 0 ? (
<span className="text-[9px] text-zinc-600 absolute bottom-0.5">
{format(day, "MMM")}
</span>
) : null}
</div>
);
})}
</div>
{/* Artist rows */}
<div style={{ width: totalWidth }}>
{artists.map((artist, artistIndex) => {
const artistTasks = tasks.filter(
(t) => t.assignedArtistId === artist.id
);
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
// Apply resize preview so width animates live
const tasksWithPreview = artistTasks.map((t) =>
resizePreview?.taskId === t.id
? {
...t,
scheduledEndDate: resizePreview.endDate.toISOString(),
estimatedHours: resizePreview.estimatedHours,
}
: t
);
return (
<div
key={artist.id}
className="relative border-b border-zinc-800/60"
style={{ height: rowHeight }}
>
{/* Drop zone overlay */}
<TimelineDropZone id={`timeline-row-${artist.id}`} />
{/* Day grid lines + overload indicators */}
{days.map((day, dayIndex) => {
const dayStr = format(day, "yyyy-MM-dd");
const load = dailyLoad[dayStr] ?? 0;
const isT = isToday(day);
const isWE = isWeekend(day);
const isOverloaded = load > 8;
return (
<div
key={dayIndex}
className={cn(
"absolute top-0 bottom-0 border-r",
isWE ? "border-zinc-800 bg-zinc-900/30" : "border-zinc-800/40",
isT && "bg-amber-500/5",
isOverloaded && "bg-orange-500/10"
)}
style={{
left: dayIndex * dayWidth,
width: dayWidth,
}}
/>
);
})}
{/* Task blocks */}
{tasksWithPreview.map((task) => {
const startDate = toDate(task.scheduledStartDate);
if (!startDate) return null;
// dayIndex may be fractional: integer part = day column,
// fractional part = intra-day hour offset stored in the time component.
const dayIdx = getDayIndex(startOfDay(startDate), viewStart);
const hourOffset = startDate.getHours(); // hours into the 8h workday
const dayIndex = dayIdx + hourOffset / 8;
const duration = getTaskDuration(task);
// Skip tasks outside view
if (dayIndex + duration < 0 || dayIndex >= days.length)
return null;
const taskHeight = rowHeight - 8;
const laneTop = 4;
return (
<ScheduleTaskBlock
key={task.id}
task={task}
dayIndex={dayIndex}
duration={duration}
artistIndex={artistIndex}
viewStart={viewStart}
days={days}
canEdit={canEdit}
isDragging={activeDragId === task.id}
onResizeMouseDown={onResizeMouseDown}
laneTop={laneTop}
taskHeight={taskHeight}
onUnschedule={onUnschedule}
dayWidth={dayWidth}
/>
);
})}
</div>
);
})}
{/* Empty state */}
{artists.length === 0 && (
<div className="flex items-center justify-center py-16 text-zinc-600 text-sm">
No artists to display
</div>
)}
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertTriangle, Eye, EyeOff, KeyRound } from 'lucide-react';
import { changeOwnPassword } from '@/actions/users';
interface Props {
mustChangePassword: boolean;
}
export function ChangePasswordForm({ mustChangePassword }: Props) {
const router = useRouter();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrent, setShowCurrent] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (newPassword.length < 8) {
setError('New password must be at least 8 characters.');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match.');
return;
}
setLoading(true);
try {
await changeOwnPassword({ currentPassword, newPassword });
setSuccess(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
// Reload so the session banner clears
router.refresh();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to change password.');
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
{mustChangePassword && (
<div className="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0 text-amber-400" />
<span>
You must set a new password before continuing. Your account was created with a
temporary password.
</span>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="h-4 w-4" />
Change Password
</CardTitle>
</CardHeader>
<CardContent>
{success ? (
<p className="text-sm text-emerald-400">
Password updated successfully.
</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm">
<div className="space-y-1.5">
<Label htmlFor="currentPassword">Current password</Label>
<div className="relative">
<Input
id="currentPassword"
type={showCurrent ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowCurrent((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
tabIndex={-1}
>
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="newPassword">New password</Label>
<div className="relative">
<Input
id="newPassword"
type={showNew ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowNew((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
tabIndex={-1}
>
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-zinc-500">At least 8 characters</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirmPassword">Confirm new password</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirm ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
className="pr-10"
/>
<button
type="button"
onClick={() => setShowConfirm((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
tabIndex={-1}
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
<Button type="submit" disabled={loading}>
{loading ? 'Saving…' : 'Update password'}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { useState, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { importShotsFromCsv } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, AlertCircle, CheckCircle2, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface ParsedRow {
scene: string;
episode?: string;
description?: string;
priority?: string;
fps?: number;
frameStart?: number;
frameEnd?: number;
}
interface ImportShotsDialogProps {
projectId: string;
projectType?: "STANDARD" | "EPISODIC";
open: boolean;
onClose: () => void;
onSuccess?: () => void;
}
const TEMPLATE_STANDARD = `scene,description,priority,fps,frameStart,frameEnd
010,Opening wide shot,NORMAL,24,1001,1100
020,Close up reaction,NORMAL,24,,
030,Action sequence,HIGH,24,1001,1250`;
const TEMPLATE_EPISODIC = `scene,episode,description,priority,fps,frameStart,frameEnd
010,EP01,Opening wide shot,NORMAL,24,1001,1100
020,EP01,Close up reaction,NORMAL,24,,
010,EP02,Act two opener,HIGH,24,1001,1200`;
function parseCsv(raw: string): { rows: ParsedRow[]; parseErrors: string[] } {
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length < 2) return { rows: [], parseErrors: ["Need at least a header row and one data row"] };
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const sceneIdx = headers.indexOf("scene");
if (sceneIdx === -1) return { rows: [], parseErrors: ['Missing required "scene" column'] };
const idx = (name: string) => {
const i = headers.indexOf(name);
return i === -1 ? null : i;
};
const rows: ParsedRow[] = [];
const parseErrors: string[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(",").map((c) => c.trim());
const get = (name: string) => {
const j = idx(name);
return j !== null ? cells[j] ?? "" : "";
};
const scene = get("scene");
if (!scene) { parseErrors.push(`Row ${i + 1}: empty scene — skipped`); continue; }
const fpsRaw = get("fps");
const fsRaw = get("framestart") || get("frameStart");
const feRaw = get("frameend") || get("frameEnd");
rows.push({
scene,
episode: get("episode") || undefined,
description: get("description") || undefined,
priority: get("priority") || undefined,
fps: fpsRaw ? Number(fpsRaw) : undefined,
frameStart: fsRaw ? Number(fsRaw) : undefined,
frameEnd: feRaw ? Number(feRaw) : undefined,
});
}
return { rows, parseErrors };
}
export function ImportShotsDialog({
projectId,
projectType = "STANDARD",
open,
onClose,
onSuccess,
}: ImportShotsDialogProps) {
const { toast } = useToast();
const [step, setStep] = useState<"input" | "preview">("input");
const [csvText, setCsvText] = useState("");
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<{ created: string[]; errors: string[] } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setCsvText(ev.target?.result as string ?? "");
reader.readAsText(file);
};
const handleParse = () => {
const { rows, parseErrors: errs } = parseCsv(csvText);
setParsedRows(rows);
setParseErrors(errs);
if (rows.length > 0) setStep("preview");
};
const handleImport = async () => {
setIsImporting(true);
try {
const result = await importShotsFromCsv(projectId, parsedRows);
setImportResult(result);
if (result.created.length > 0) {
toast({ title: `${result.created.length} shot${result.created.length === 1 ? "" : "s"} created` });
onSuccess?.();
}
} catch (e) {
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setIsImporting(false);
}
};
const handleClose = () => {
setStep("input");
setCsvText("");
setParsedRows([]);
setParseErrors([]);
setImportResult(null);
onClose();
};
const template = projectType === "EPISODIC" ? TEMPLATE_EPISODIC : TEMPLATE_STANDARD;
return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Import Shots from CSV</DialogTitle>
</DialogHeader>
{importResult ? (
/* Result screen */
<div className="space-y-4 py-2">
{importResult.created.length > 0 && (
<div className="space-y-1.5">
<p className="flex items-center gap-2 text-sm font-medium text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
{importResult.created.length} shot{importResult.created.length === 1 ? "" : "s"} created
</p>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs font-mono text-zinc-300 max-h-40 overflow-y-auto">
{importResult.created.map((code) => <div key={code}>{code}</div>)}
</div>
</div>
)}
{importResult.errors.length > 0 && (
<div className="space-y-1.5">
<p className="flex items-center gap-2 text-sm font-medium text-amber-400">
<AlertCircle className="h-4 w-4" />
{importResult.errors.length} warning{importResult.errors.length === 1 ? "" : "s"}
</p>
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs text-zinc-400 max-h-32 overflow-y-auto space-y-0.5">
{importResult.errors.map((e, i) => <div key={i}>{e}</div>)}
</div>
</div>
)}
<DialogFooter>
<Button onClick={handleClose}>Done</Button>
</DialogFooter>
</div>
) : step === "input" ? (
/* CSV input step */
<div className="space-y-4 py-2">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Paste CSV or upload a file. Required column: <code className="text-xs bg-zinc-800 px-1 rounded">scene</code>.
{projectType === "EPISODIC" && (
<> Also required: <code className="text-xs bg-zinc-800 px-1 rounded">episode</code>.</>
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5 shrink-0"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5" />
Upload file
</Button>
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileUpload} />
</div>
<Textarea
value={csvText}
onChange={(e) => setCsvText(e.target.value)}
className="font-mono text-xs min-h-[180px]"
placeholder={template}
spellCheck={false}
/>
{parseErrors.length > 0 && (
<div className="text-xs text-amber-400 space-y-0.5">
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
</div>
)}
<details className="text-xs text-zinc-500 cursor-pointer">
<summary className="hover:text-zinc-300 transition-colors">CSV format & example</summary>
<pre className="mt-2 bg-zinc-900 border border-zinc-800 rounded p-3 text-zinc-400 whitespace-pre-wrap">{template}</pre>
</details>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
<Button onClick={handleParse} disabled={!csvText.trim()} className="gap-1.5">
Parse
<ChevronRight className="h-4 w-4" />
</Button>
</DialogFooter>
</div>
) : (
/* Preview step */
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
{parsedRows.length} shot{parsedRows.length === 1 ? "" : "s"} ready to import. Review and confirm.
</p>
<div className="rounded-lg border border-zinc-800 overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-zinc-900 text-zinc-400">
<tr>
<th className="px-3 py-2 text-left font-medium">Scene</th>
{projectType === "EPISODIC" && <th className="px-3 py-2 text-left font-medium">Episode</th>}
<th className="px-3 py-2 text-left font-medium">Description</th>
<th className="px-3 py-2 text-left font-medium">Priority</th>
<th className="px-3 py-2 text-left font-medium">FPS</th>
<th className="px-3 py-2 text-left font-medium">Frames</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{parsedRows.map((row, i) => (
<tr key={i} className={cn("bg-zinc-950", i % 2 === 0 && "bg-zinc-900/30")}>
<td className="px-3 py-2 font-mono text-zinc-200">{row.scene}</td>
{projectType === "EPISODIC" && <td className="px-3 py-2 font-mono text-zinc-400">{row.episode ?? "—"}</td>}
<td className="px-3 py-2 text-zinc-400 truncate max-w-[160px]">{row.description ?? "—"}</td>
<td className="px-3 py-2 text-zinc-400">{row.priority ?? "NORMAL"}</td>
<td className="px-3 py-2 text-zinc-400">{row.fps ?? 24}</td>
<td className="px-3 py-2 text-zinc-400 font-mono">
{row.frameStart || row.frameEnd ? `${row.frameStart ?? "?"}${row.frameEnd ?? "?"}` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{parseErrors.length > 0 && (
<div className="text-xs text-amber-400 space-y-0.5">
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep("input")}>Back</Button>
<Button onClick={handleImport} disabled={isImporting || parsedRows.length === 0}>
{isImporting ? "Importing…" : `Import ${parsedRows.length} Shot${parsedRows.length === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More