浏览代码

feat: admin invite members, auto-fill email, welcome message, logout fix

- Admin can invite any user to any project via /users page (Invitations tab)
- Auto-fill email from invite token on login/register pages
- Registration shows welcome message with user name
- Fix broken logout button in sidebar
- Invite page shows graceful message for expired/used invitations
- Admin bypass on all project/asset/comment endpoints
- Smooth timeline drag with per-frame timecode using rVFC
- Annotation persistence until Save/Undo
- Transcode worker with DB-as-queue pattern
- Two-step comment resolve workflow
- HLS adaptive streaming support
- SiteSetting for registration toggle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 月之前
父节点
当前提交
5945cfa96b

+ 4 - 1
Dockerfile.api

@@ -17,8 +17,11 @@ COPY packages/api/src ./src
 # Build TypeScript
 RUN npx tsc -p tsconfig.json
 
+# Copy the workers JS into dist (NOT compiled — pure JS, runs in forked child)
+COPY packages/api/src/workers ./dist/workers
+
 EXPOSE 3001
 
 ENV NODE_ENV=production
 
-CMD ["node", "dist/index.js"]
+CMD ["node", "dist/index.js"]

+ 429 - 0
README.md

@@ -0,0 +1,429 @@
+# VidReview — Video Review & Collaboration Platform
+
+Self-hosted platform for distributed teams to review, annotate, and approve video content. Upload videos, add timestamped comments and drawings, track review status, and collaborate through project invitations.
+
+---
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                        VidReview Stack                           │
+├──────────────┬──────────────┬──────────────┬───────────────────┤
+│   Browser    │  Frontend    │    API       │     Worker        │
+│  (Next.js)   │  (Next.js)   │  (Express)   │  (Node.js/FFmpeg) │
+│   :3000      │   :3000      │   :3001      │   background      │
+│              │  (SSR/SSG)   │              │                   │
+└──────┬───────┴──────┬───────┴──────┬───────┴────────┬──────────┘
+       │              │              │                │
+       │  HTTP REST   │              │   DB polling   │
+       └──────────────┴──────────────┴────────────────┘
+                              │
+                    ┌─────────┴─────────┐
+                    │    PostgreSQL 16   │
+                    │   (persisted vol) │
+                    └───────────────────┘
+```
+
+### Components
+
+| Service | Image | Port | Role |
+|---|---|---|---|
+| **Frontend** | `vidreview-frontend` | `3000` | Next.js 15 — UI, auth, video player |
+| **API** | `vidreview-api` | `3001` | Express.js — REST API, JWT auth, file uploads |
+| **Worker** | `vidreview-worker` | — | Background transcode jobs via FFmpeg |
+| **Database** | `postgres:16-alpine` | `5432` | PostgreSQL — schema, sessions, jobs |
+
+### Technology Stack
+
+| Layer | Technology |
+|---|---|
+| Frontend | Next.js 15, React 19, TypeScript, Tailwind CSS, HLS.js |
+| API | Express.js 4, TypeScript, Prisma ORM |
+| Auth | JWT (RS/HS), httpOnly cookies, bcrypt |
+| Video | FFmpeg, HLS (`.m3u8`), adaptive streaming |
+| Database | PostgreSQL 16 |
+| Container | Docker Compose |
+
+---
+
+## Project Structure
+
+```
+vidreview/
+├── docker-compose.yml          # All services definition
+├── Dockerfile.api              # Multi-stage build for API + worker
+├── Dockerfile.frontend         # Multi-stage build for Next.js
+├── .env                        # Environment variables
+├── .env.example                # Template for .env
+├── package.json                # Workspace root (api + src)
+├── packages/
+│   ├── api/
+│   │   ├── prisma/schema.prisma   # Database schema
+│   │   ├── src/
+│   │   │   ├── index.ts           # Express app entry (port 3001)
+│   │   │   ├── lib/
+│   │   │   │   ├── auth.ts        # JWT middleware, cookie parsing
+│   │   │   │   └── prisma.ts      # PrismaClient singleton
+│   │   │   ├── routes/
+│   │   │   │   ├── auth.ts        # /api/auth/*
+│   │   │   │   ├── projects.ts    # /api/projects/*
+│   │   │   │   ├── assets.ts      # /api/assets/*
+│   │   │   │   ├── comments.ts    # /api/comments/*
+│   │   │   │   ├── invitations.ts # /api/invitations/*
+│   │   │   │   └── users.ts       # /api/users/*
+│   │   │   ├── services/
+│   │   │   │   └── ffmpeg.ts     # FFmpeg helper types
+│   │   │   └── worker/
+│   │   │       ├── index.js       # DB-as-queue poll loop
+│   │   │       └── dispatcher.ts  # Job dispatcher stub
+│   │   └── workers/
+│   │       └── transcode.js       # FFmpeg transcode child process
+└── src/                        # Next.js frontend workspace
+    ├── app/
+    │   ├── layout.tsx              # RootLayout + AuthProvider
+    │   ├── page.tsx                # Redirect → /login
+    │   ├── globals.css             # Design system: CSS vars, dark theme
+    │   ├── (auth)/
+    │   │   ├── login/page.tsx      # Login form + demo credentials hint
+    │   │   └── register/page.tsx   # Register + invite token acceptance
+    │   ├── (dashboard)/
+    │   │   ├── layout.tsx          # Sidebar: Projects, Users, Settings, avatar
+    │   │   ├── projects/page.tsx   # Project listing + creation
+    │   │   ├── projects/[projectId]/
+    │   │   │   └── page.tsx        # Videos / Transcode Tasks / Members tabs
+    │   │   ├── users/page.tsx      # Admin user management
+    │   │   └── settings/page.tsx   # Profile settings
+    │   ├── review/[assetId]/page.tsx   # Video player + annotation canvas
+    │   └── invite/[token]/page.tsx      # Public invite acceptance
+    ├── components/
+    │   ├── ui/                     # Reusable: Avatar, Button, Input, Modal
+    │   ├── video-player/
+    │   │   ├── VideoPlayer.tsx     # HLS.js, keyboard shortcuts, fullscreen
+    │   │   ├── AnnotationCanvas.tsx # Pen/arrow/rect/ellipse, 7 colors
+    │   │   └── Timeline.tsx        # Seek bar, comment ticks, frame accuracy
+    │   ├── comments/
+    │   │   └── CommentPanel.tsx    # Timestamped + general comments, replies, resolve
+    │   └── transcode/
+    │       └── TranscodeTasksPanel.tsx  # Status list, cancel/requeue, progress
+    └── lib/
+        ├── api.ts              # Typed fetch helpers + shared interfaces
+        └── auth-context.tsx    # AuthProvider / useAuth hook
+```
+
+---
+
+## Database Schema
+
+```
+User ──────────── ProjectMember ──────────── Project
+  │                                           │
+  │ (author of comments)                      │ (has many)
+  ▼                                           ▼
+Comment ─────────────────────────────────── Asset
+  │                                              │
+  │ (timestamped, JSON annotations             │ (HLS output, thumbnail)
+  │  on video frames)                           ▼
+  │                                         Invitation
+  │                                      (7-day expiry, hex token)
+```
+
+### Models
+
+| Model | Description |
+|---|---|
+| `User` | Global user account (email, password, globalRole: ADMIN/MEMBER) |
+| `Project` | Workspace container (name, description, ownerId) |
+| `ProjectMember` | User ↔ Project join (role: ADMIN/EDITOR/REVIEWER/VIEWER) |
+| `Asset` | Uploaded video (filePath, hlsPath, transcodeStatus, duration, codec) |
+| `Comment` | Review comment (timestamp, JSON annotations, threaded replies, resolved) |
+| `Invitation` | Pending invite (email, token, expiresAt, role) |
+
+---
+
+## API Reference
+
+Base URL: `http://localhost:3001/api`
+
+### Authentication
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `POST` | `/auth/register` | Create account. Body: `{ email, name, password, inviteToken? }` |
+| `POST` | `/auth/login` | Login. Returns JWT cookie. Body: `{ email, password }` |
+| `POST` | `/auth/logout` | Clear JWT cookie |
+| `GET` | `/auth/me` | Current user info from JWT |
+
+### Projects
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/projects` | List user's projects |
+| `POST` | `/projects` | Create project. Body: `{ name, description? }` |
+| `GET` | `/projects/:id` | Project detail with members + assets |
+| `PUT` | `/projects/:id` | Update project |
+| `DELETE` | `/projects/:id` | Delete project + cascade assets |
+| `POST` | `/projects/:id/members` | Add member. Body: `{ userId, role }` |
+| `DELETE` | `/projects/:id/members/:userId` | Remove member |
+| `PUT` | `/projects/:id/members/:userId` | Change member role |
+
+### Assets
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/assets?projectId=` | List assets by project |
+| `GET` | `/assets/:id` | Asset detail with comments |
+| `GET` | `/assets/:id/status` | SSE stream of transcode progress |
+| `POST` | `/assets/upload` | Upload video (multipart). Body: `{ title, projectId }` |
+| `PUT` | `/assets/:id/status` | Update review status (PENDING_REVIEW/CHANGES_REQUESTED/APPROVED/REJECTED) |
+| `POST` | `/assets/:id/transcode/cancel` | Cancel active transcode → re-queue |
+| `DELETE` | `/assets/:id` | Delete asset + HLS directory |
+
+### Comments
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/assets/:assetId/comments` | List comments for asset |
+| `POST` | `/assets/:assetId/comments` | Create comment. Body: `{ content, timestamp?, annotations? }` |
+| `PUT` | `/comments/:id/resolve` | Toggle resolved |
+| `PUT` | `/comments/:id/annotations` | Update annotations |
+| `DELETE` | `/comments/:id` | Delete comment |
+
+### Invitations
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/invitations/:token` | Verify invite token (public) |
+| `POST` | `/invitations/:token/accept` | Accept invite (auth required) |
+| `GET` | `/invitations/project/:projectId` | List pending invites |
+| `POST` | `/invitations/project/:projectId` | Create invite. Body: `{ email, role }` |
+| `DELETE` | `/invitations/:id` | Revoke pending invite |
+| `POST` | `/invitations/project/:projectId/resend` | Resend existing invite |
+
+### Users (Admin)
+
+| Method | Endpoint | Description |
+|---|---|---|
+| `GET` | `/users` | List all users (ADMIN only) |
+| `PUT` | `/users/:id/role` | Change global role (ADMIN/MEMBER) |
+| `PUT` | `/users/:id/active` | Enable/disable user |
+| `DELETE` | `/users/:id` | Delete user |
+
+---
+
+## Video Transcode Pipeline
+
+```
+Upload (multer)
+    │
+    ▼
+PENDING ──────► UPLOADING (multer writes file)
+    │               │
+    │               ▼
+    │          PROCESSING (worker claims job)
+    │               │
+    │   ┌───────────┼───────────┐
+    │   ▼           ▼           ▼
+    │ ffprobe   thumbnail    HLS encode
+    │ (metadata)  (JPEG)      (720p H.264/AAC)
+    │                               │
+    │                               ▼
+    └────────── COMPLETED ◄────�
+        or
+    UNSUPPORTED_CODEC (auto-detected → re-encode)
+        or
+    FAILED (error logged in transcodeError)
+```
+
+**HLS output**: 720p, H.264/aac, CRF 23, preset fast, 6-second segments → `/hls/{assetId}/master.m3u8`
+
+**Worker**: DB-as-queue pattern. Polls every 2s using `FOR UPDATE SKIP LOCKED` for atomic job claiming. Recovers stale `PROCESSING` jobs on startup. Graceful shutdown on SIGTERM.
+
+---
+
+## Role Permissions
+
+### Global Roles
+
+| Action | ADMIN | MEMBER |
+|---|---|---|
+| User management | ✓ | ✗ |
+| View all users | ✓ | ✗ |
+
+### Project Roles
+
+| Action | ADMIN | EDITOR | REVIEWER | VIEWER |
+|---|---|---|---|---|
+| Upload videos | ✓ | ✓ | ✗ | ✗ |
+| Delete videos | ✓ | ✓ | ✗ | ✗ |
+| Cancel/requeue transcode | ✓ | ✓ | ✗ | ✗ |
+| Post comments | ✓ | ✓ | ✓ | ✗ |
+| Resolve comments | ✓ | ✓ | ✓ | ✗ |
+| Change project settings | ✓ | ✗ | ✗ | ✗ |
+| Manage members | ✓ | ✗ | ✗ | ✗ |
+
+---
+
+## Deployment
+
+### Prerequisites
+
+- Docker & Docker Compose
+- FFmpeg (bundled in API/worker Docker image)
+- PostgreSQL client (optional, for debugging)
+
+### Quick Start
+
+```bash
+# 1. Clone and enter project
+cd vidreview
+
+# 2. Configure environment
+cp .env.example .env
+# Edit .env — at minimum set a strong JWT_SECRET:
+# JWT_SECRET=$(openssl rand -hex 32)
+
+# 3. Start all services
+sudo docker compose up -d
+
+# 4. Verify health
+sudo docker compose ps
+# All containers should show "healthy" or "Up"
+
+# 5. First-time DB schema push
+sudo docker exec vidreview-api npx prisma db push
+```
+
+**Default credentials** (dev/demo):
+```
+Email:    admin@vidreview.local
+Password: admin123
+```
+
+### Production Deployment Checklist
+
+- [ ] Generate a strong `JWT_SECRET` (minimum 32 random bytes)
+- [ ] Set `NODE_ENV=production` on all services
+- [ ] Set `ALLOWED_ORIGINS` to your domain (e.g., `https://vidreview.example.com`)
+- [ ] Configure TLS/SSL (reverse proxy in front of `:3000`)
+- [ ] Increase `POLL_INTERVAL_MS` if worker CPU is high (default: 2000ms)
+- [ ] Set `MAX_FILE_SIZE_MB` based on storage capacity
+- [ ] Mount a persistent volume for `/app/uploads` (video files can be large)
+- [ ] Set up automated backups for the PostgreSQL volume
+- [ ] Prune old completed assets to free disk space
+
+### Updating
+
+```bash
+# Rebuild and restart
+sudo docker compose build
+sudo docker compose up -d
+```
+
+### Container Logs
+
+```bash
+# All services
+sudo docker compose logs -f
+
+# API only
+sudo docker logs vidreview-api -f
+
+# Worker only (transcode jobs)
+sudo docker logs vidreview-worker -f
+
+# Frontend
+sudo docker logs vidreview-frontend -f
+```
+
+### Database Access
+
+```bash
+# Interactive psql
+sudo docker exec -it vidreview-db psql -U vidreview -d vidreview
+
+# Run migrations
+sudo docker exec vidreview-api npx prisma db push
+
+# Reset database (⚠️ destroys all data)
+sudo docker exec vidreview-db psql -U vidreview -d vidreview -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
+sudo docker exec vidreview-api npx prisma db push
+```
+
+---
+
+## Design System
+
+Dark theme, indigo brand (`#6366F1`), CSS custom properties.
+
+### CSS Variables
+
+```css
+--bg:         #0B0B14   /* Page background */
+--bg-card:    #13131F   /* Card / modal background */
+--bg-input:   #1A1A2E   /* Input field background */
+--border:     rgba(255,255,255,0.08)
+--text:       #E2E8F0   /* Primary text */
+--text-muted: #94A3B8   /* Secondary / muted text */
+--text-subtle: #64748B /* Placeholder / disabled */
+--accent:     #6366F1   /* Brand indigo */
+--accent-hover: #4F46E5
+```
+
+### Typography
+
+| Element | Size | Weight | Color |
+|---|---|---|---|
+| H1 (page title) | 24px | 600 | `--text` |
+| H2 (section) | 18px | 600 | `--text` |
+| Body | 14px | 400 | `--text` |
+| Caption / meta | 12px | 400 | `--text-muted` |
+| Badge | 11px | 500 | varies |
+
+---
+
+## Development
+
+### Local Development (without Docker)
+
+```bash
+# Terminal 1 — API
+npm run dev:api
+
+# Terminal 2 — Frontend
+npm run dev:frontend
+
+# Database must be accessible at DATABASE_URL
+# Uploads served at localhost:3001/uploads/
+```
+
+### Adding a New API Route
+
+1. Create the route file in `packages/api/src/routes/`
+2. Register it in `packages/api/src/index.ts`
+3. Add a typed helper to `src/lib/api.ts`
+4. Update this README's API Reference table
+
+### Adding a New Frontend Page
+
+1. Create `src/app/(dashboard)/<path>/page.tsx`
+2. Wrap with `'use client'`
+3. Use `useAuth()` from `@/lib/auth-context` for auth guard
+4. Redirect if not authenticated: `if (!user) { router.push('/login'); return; }`
+
+---
+
+## Environment Variables
+
+| Variable | Default | Description |
+|---|---|---|
+| `DATABASE_URL` | `postgresql://vidreview:vidreview123@localhost:5432/vidreview` | PostgreSQL connection string |
+| `JWT_SECRET` | `change-me` | JWT signing secret (**change in production**) |
+| `JWT_EXPIRES_IN` | `7d` | JWT token expiry |
+| `API_PORT` | `3001` | API server port |
+| `NODE_ENV` | `development` | `development` or `production` |
+| `UPLOAD_DIR` | `./uploads` | Local upload directory (API container) |
+| `MAX_FILE_SIZE_MB` | `500` | Max upload file size in MB |
+| `ALLOWED_ORIGINS` | `*` | CORS allowed origins (comma-separated) |
+| `NEXT_PUBLIC_API_URL` | `http://localhost:3001` | API base URL for frontend |
+| `POLL_INTERVAL_MS` | `2000` | Worker poll interval in milliseconds |

+ 21 - 0
docker-compose.yml

@@ -43,6 +43,27 @@ services:
       timeout: 5s
       retries: 5
 
+  # ── Transcode Worker ──────────────────────────────────────────────────────
+  # Standalone Node.js process that polls the DB for pending transcode jobs.
+  # Runs FFmpeg off the main API thread — uploads never block.
+  worker:
+    build:
+      context: .
+      dockerfile: Dockerfile.api
+    container_name: vidreview-worker
+    command: node src/worker/index.js
+    environment:
+      DATABASE_URL: postgresql://vidreview:vidreview123@postgres:5432/vidreview
+      NODE_ENV: production
+      UPLOAD_DIR: /app/uploads
+      POLL_INTERVAL_MS: 2000
+    depends_on:
+      postgres:
+        condition: service_healthy
+    volumes:
+      - uploads:/app/uploads
+    restart: unless-stopped
+
   frontend:
     build:
       context: .

+ 0 - 27
package-lock.json

@@ -52,7 +52,6 @@
       "os": [
         "aix"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -70,7 +69,6 @@
       "os": [
         "android"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -88,7 +86,6 @@
       "os": [
         "android"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -106,7 +103,6 @@
       "os": [
         "android"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -124,7 +120,6 @@
       "os": [
         "darwin"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -142,7 +137,6 @@
       "os": [
         "darwin"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -160,7 +154,6 @@
       "os": [
         "freebsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -178,7 +171,6 @@
       "os": [
         "freebsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -196,7 +188,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -214,7 +205,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -232,7 +222,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -250,7 +239,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -268,7 +256,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -286,7 +273,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -304,7 +290,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -322,7 +307,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -340,7 +324,6 @@
       "os": [
         "linux"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -358,7 +341,6 @@
       "os": [
         "netbsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -376,7 +358,6 @@
       "os": [
         "netbsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -394,7 +375,6 @@
       "os": [
         "openbsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -412,7 +392,6 @@
       "os": [
         "openbsd"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -430,7 +409,6 @@
       "os": [
         "openharmony"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -448,7 +426,6 @@
       "os": [
         "sunos"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -466,7 +443,6 @@
       "os": [
         "win32"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -484,7 +460,6 @@
       "os": [
         "win32"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -502,7 +477,6 @@
       "os": [
         "win32"
       ],
-      "peer": true,
       "engines": {
         "node": ">=18"
       }
@@ -2435,7 +2409,6 @@
       "version": "2.3.3",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
       "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "dev": true,
       "hasInstallScript": true,
       "license": "MIT",
       "optional": true,

+ 2 - 2
packages/api/package.json

@@ -6,8 +6,8 @@
     "dev": "tsx watch src/index.ts",
     "build": "tsc",
     "start": "node dist/index.js",
-    "db:push": "prisma db push",
-    "db:generate": "prisma generate",
+    "db:push": "prisma db push --schema=prisma/schema.prisma",
+    "db:generate": "prisma generate --schema=prisma/schema.prisma",
     "db:migrate": "prisma migrate dev",
     "db:studio": "prisma studio"
   },

+ 65 - 31
packages/api/prisma/schema.prisma

@@ -21,9 +21,11 @@ model User {
   createdAt   DateTime  @default(now())
   updatedAt   DateTime  @updatedAt
 
-  memberships ProjectMember[]
-  comments    Comment[]
-  projects    Project[]    // projects where this user is the owner
+  memberships       ProjectMember[]
+  comments          Comment[]
+  projects          Project[]    // projects where this user is the owner
+  resolvedComments  Comment[]    @relation("ResolvedBy")
+  requestedComments Comment[]    @relation("RequestedBy")
 }
 
 model Project {
@@ -40,6 +42,12 @@ model Project {
   owner       User     @relation(fields: [ownerId], references: [id])
 }
 
+model SiteSetting {
+  id       String @id @default(cuid())
+  name     String @unique
+  value    String
+}
+
 model ProjectMember {
   id         String @id @default(cuid())
   userId     String
@@ -57,40 +65,51 @@ model ProjectMember {
 }
 
 model Asset {
-  id        String      @id @default(cuid())
-  projectId String
-  title     String
-  filename  String
-  filePath  String
-  thumbnail String?
-  hlsPath   String?
-  duration  Float?
-  fps       Float       @default(30)
-  mimeType  String
-  status    AssetStatus @default(PENDING_REVIEW)
-  createdAt DateTime    @default(now())
-  updatedAt DateTime    @updatedAt
+  id              String          @id @default(cuid())
+  projectId       String
+  title           String
+  filename        String
+  filePath        String
+  thumbnail       String?
+  hlsPath         String?
+  duration        Float?
+  fps             Float           @default(30)
+  codec           String?
+  mimeType        String
+  status          AssetStatus     @default(PENDING_REVIEW)
+  transcodeStatus TranscodeStatus @default(PENDING)
+  transcodeProgress Int            @default(0)
+  transcodeError  String?
+  createdAt       DateTime        @default(now())
+  updatedAt       DateTime        @updatedAt
 
   project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
   comments Comment[]
 }
 
 model Comment {
-  id         String    @id @default(cuid())
-  assetId    String
-  userId     String
-  content    String
-  timestamp  Float?
-  annotations Json?
-  resolved   Boolean   @default(false)
-  parentId   String?
-  createdAt  DateTime  @default(now())
-  updatedAt  DateTime  @updatedAt
-
-  asset   Asset    @relation(fields: [assetId], references: [id], onDelete: Cascade)
-  user    User     @relation(fields: [userId], references: [id], onDelete: Cascade)
-  parent  Comment? @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
-  replies Comment[] @relation("Replies")
+  id           String         @id @default(cuid())
+  assetId      String
+  userId       String
+  content      String
+  timestamp    Float?
+  annotations  Json?
+  resolved     Boolean        @default(false)
+  resolveStatus ResolveStatus @default(UNRESOLVED)
+  resolvedById String?
+  resolvedByAt DateTime?
+  requestedById String?
+  requestedByAt DateTime?
+  parentId     String?
+  createdAt    DateTime       @default(now())
+  updatedAt    DateTime       @updatedAt
+
+  asset      Asset     @relation(fields: [assetId], references: [id], onDelete: Cascade)
+  user       User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  parent     Comment?  @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
+  replies    Comment[] @relation("Replies")
+  resolvedBy User?     @relation("ResolvedBy", fields: [resolvedById], references: [id])
+  requestedBy User?    @relation("RequestedBy", fields: [requestedById], references: [id])
 }
 
 enum Role {
@@ -136,3 +155,18 @@ enum AssetStatus {
   APPROVED
   REJECTED
 }
+
+enum ResolveStatus {
+  UNRESOLVED       // no request made
+  PENDING_APPROVAL // someone requested resolve, awaiting approval
+  RESOLVED         // approved and closed
+}
+
+enum TranscodeStatus {
+  PENDING
+  UPLOADING
+  PROCESSING
+  COMPLETED
+  FAILED
+  UNSUPPORTED_CODEC
+}

+ 2 - 0
packages/api/src/index.ts

@@ -10,6 +10,7 @@ import assetRoutes from './routes/assets';
 import commentRoutes from './routes/comments';
 import userRoutes from './routes/users';
 import invitationRoutes from './routes/invitations';
+import settingsRoutes from './routes/settings';
 
 const app = express();
 const PORT = process.env.API_PORT || 3001;
@@ -43,6 +44,7 @@ app.use('/api/assets', commentRoutes);
 app.use('/api/comments', commentRoutes);
 app.use('/api/users', userRoutes);
 app.use('/api/invitations', invitationRoutes);
+app.use('/api/settings', settingsRoutes);
 
 // ── 404 handler ─────────────────────────────────────────────────────────────
 app.use((_req, res) => {

+ 4 - 1
packages/api/src/lib/prisma.ts

@@ -1,4 +1,4 @@
-import { PrismaClient } from '@prisma/client';
+import { PrismaClient, TranscodeStatus, ResolveStatus } from '@prisma/client';
 
 const globalForPrisma = globalThis as unknown as {
   prisma: PrismaClient | undefined;
@@ -13,3 +13,6 @@ export const prisma =
 if (process.env.NODE_ENV !== 'production') {
   globalForPrisma.prisma = prisma;
 }
+
+// Re-export for convenience
+export { TranscodeStatus, ResolveStatus };

+ 140 - 53
packages/api/src/routes/assets.ts

@@ -2,22 +2,20 @@ import { Router, Request, Response } from 'express';
 import multer from 'multer';
 import path from 'path';
 import fs from 'fs';
-import { fork } from 'child_process';
 import { v4 as uuidv4 } from 'uuid';
-import { prisma } from '../lib/prisma';
+import { prisma, TranscodeStatus } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
-import { generateThumbnail } from '../services/ffmpeg';
+import { startTranscodeJob } from '../worker/dispatcher';
 
 const router = Router();
 router.use(authMiddleware);
 
 const str = (v: string | string[] | undefined): string => Array.isArray(v) ? v[0] ?? '' : (v ?? '');
 
-// Setup multer for file uploads
+// ── Multer ────────────────────────────────────────────────────────────────────
 const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
 const MAX_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB || '500') * 1024 * 1024);
 
-// Ensure upload directory exists
 fs.mkdirSync(UPLOAD_DIR, { recursive: true });
 
 const storage = multer.diskStorage({
@@ -28,7 +26,6 @@ const storage = multer.diskStorage({
   },
 });
 
-// Custom error class so multer passes it to Express error handler as JSON
 class MulterFileTypeError extends Error {
   code = 'ONLY_VIDEO';
   statusCode = 400;
@@ -38,12 +35,25 @@ class MulterFileTypeError extends Error {
   }
 }
 
+const ALLOWED_VIDEO_MIMETYPES = [
+  'video/mp4',
+  'video/quicktime',   // MOV (ProRes, H.264, etc.)
+  'video/webm',        // VP8, VP9, AV1
+  'video/x-msvideo',   // AVI
+  'video/mpeg',        // MPEG
+  'video/x-matroska',  // MKV
+  'video/3gpp',        // 3GP
+  'video/3gpp2',       // 3G2
+  'video/ogg',         // OGV (Theora)
+  'video/x-ms-wmv',    // WMV
+  'video/mp2t',        // TS
+];
+
 const upload = multer({
   storage,
   limits: { fileSize: MAX_SIZE },
   fileFilter: (_req, file, cb) => {
-    const allowed = ['video/mp4', 'video/quicktime', 'video/webm', 'video/x-msvideo', 'video/mpeg'];
-    if (allowed.includes(file.mimetype)) {
+    if (file.mimetype.startsWith('video/')) {
       cb(null, true);
     } else {
       cb(new MulterFileTypeError());
@@ -61,14 +71,17 @@ router.get('/', async (req: Request, res: Response) => {
       return;
     }
 
-    // Verify user has access
-    const membership = await prisma.projectMember.findFirst({
-      where: { projectId, userId: req.user!.userId },
-    });
+    const isAdmin = req.user!.globalRole === 'ADMIN';
 
-    if (!membership) {
-      res.status(403).json({ error: 'Forbidden' });
-      return;
+    // Verify user has access (admin bypass)
+    if (!isAdmin) {
+      const membership = await prisma.projectMember.findFirst({
+        where: { projectId: projectId as string, userId: req.user!.userId },
+      });
+      if (!membership) {
+        res.status(403).json({ error: 'Forbidden' });
+        return;
+      }
     }
 
     const assets = await prisma.asset.findMany({
@@ -86,13 +99,49 @@ router.get('/', async (req: Request, res: Response) => {
   }
 });
 
+// GET /api/assets/:id/status — lightweight polling endpoint for transcode progress
+router.get('/:id/status', async (req: Request, res: Response) => {
+  try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: str(req.params.id),
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
+      },
+      select: {
+        id: true,
+        title: true,
+        hlsPath: true,
+        transcodeStatus: true,
+        transcodeProgress: true,
+        transcodeError: true,
+        thumbnail: true,
+        duration: true,
+        codec: true,
+        status: true,
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    res.json({ asset });
+  } catch (err) {
+    console.error('Asset status error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 // GET /api/assets/:id
 router.get('/:id', async (req: Request, res: Response) => {
   try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.id),
-        project: { members: { some: { userId: req.user!.userId } } },
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
       },
       include: {
         project: {
@@ -105,6 +154,8 @@ router.get('/:id', async (req: Request, res: Response) => {
         comments: {
           include: {
             user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+            resolvedBy: { select: { id: true, name: true, email: true, avatarUrl: true } },
+            requestedBy: { select: { id: true, name: true, email: true, avatarUrl: true } },
             replies: {
               include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
             },
@@ -148,55 +199,32 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
     });
 
     if (!membership || !['ADMIN', 'EDITOR'].includes(membership.role)) {
-      // Delete uploaded file
       fs.unlinkSync(req.file.path);
       res.status(403).json({ error: 'Forbidden — must be admin or editor' });
       return;
     }
 
-    const filePath = req.file.filename;
     const assetTitle = title || path.parse(req.file.originalname).name;
 
-    // Generate thumbnail + get duration + FPS via FFmpeg
-    let thumbnail: string | null = null;
-    let duration: number | null = null;
-    let fps = 30;
-
-    try {
-      const result = await generateThumbnail(req.file.path, UPLOAD_DIR);
-      if (result.thumbnailPath) {
-        thumbnail = result.thumbnailPath;
-      }
-      duration = result.duration ?? null;
-      fps = result.fps;
-    } catch (ffmpegErr) {
-      console.warn('FFmpeg thumbnail generation failed:', ffmpegErr);
-    }
-
+    // Create asset immediately with PROCESSING status — worker fills in the rest
     const asset = await prisma.asset.create({
       data: {
         projectId,
         title: assetTitle,
-        filename: req.file.originalname,
-        filePath,
-        thumbnail,
-        duration,
-        fps,
+        filename: req.file.filename,
+        filePath: req.file.filename,
         mimeType: req.file.mimetype,
+        transcodeStatus: TranscodeStatus.PENDING,
+        transcodeProgress: 0,
       },
     });
 
-    // Start HLS transcode in background (non-blocking)
-    const fullPath = path.join(UPLOAD_DIR, filePath);
-    const { generateHLS } = await import('../services/ffmpeg').catch(() => ({ generateHLS: null }));
-    if (generateHLS) {
-      generateHLS(fullPath, UPLOAD_DIR, asset.id).then(hlsPath => {
-        if (hlsPath) {
-          prisma.asset.update({ where: { id: asset.id }, data: { hlsPath } }).catch(console.error);
-
-        }
-      }).catch(console.error);
-    }
+    // Fork worker (non-blocking) — no await, runs in background
+    startTranscodeJob({
+      assetId: asset.id,
+      videoPath: req.file.path,
+      outputDir: UPLOAD_DIR,
+    });
 
     res.status(201).json({ asset });
   } catch (err) {
@@ -216,10 +244,12 @@ router.put('/:id/status', async (req: Request, res: Response) => {
       return;
     }
 
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.id),
-        project: { members: { some: { userId: req.user!.userId } } },
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
       },
     });
 
@@ -240,13 +270,63 @@ router.put('/:id/status', async (req: Request, res: Response) => {
   }
 });
 
+// POST /api/assets/:id/transcode/cancel — stop/restart a transcode job
+router.post('/:id/transcode/cancel', async (req: Request, res: Response) => {
+  try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: str(req.params.id),
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    if (asset.transcodeStatus === 'COMPLETED') {
+      res.status(400).json({ error: 'Cannot cancel a completed transcode job' });
+      return;
+    }
+
+    // Reset to PENDING — worker will pick it up again on next poll
+    // Clean up any partially-written HLS directory
+    if (asset.hlsPath) {
+      const hlsDir = path.join(UPLOAD_DIR, 'hls', asset.id);
+      if (fs.existsSync(hlsDir)) {
+        fs.rmSync(hlsDir, { recursive: true, force: true });
+      }
+    }
+
+    const updated = await prisma.asset.update({
+      where: { id: str(req.params.id) },
+      data: {
+        transcodeStatus: TranscodeStatus.PENDING,
+        transcodeProgress: 0,
+        transcodeError: null,
+        hlsPath: null,
+      },
+    });
+
+    res.json({ asset: updated });
+  } catch (err) {
+    console.error('Cancel transcode error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 // DELETE /api/assets/:id
 router.delete('/:id', async (req: Request, res: Response) => {
   try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.id),
-        project: { members: { some: { userId: req.user!.userId, role: 'ADMIN' } } },
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId, role: { in: ['ADMIN', 'EDITOR'] } } } } }),
       },
     });
 
@@ -255,7 +335,7 @@ router.delete('/:id', async (req: Request, res: Response) => {
       return;
     }
 
-    // Delete file from disk
+    // Delete from disk
     const fullPath = path.join(UPLOAD_DIR, asset.filePath);
     if (fs.existsSync(fullPath)) {
       fs.unlinkSync(fullPath);
@@ -266,6 +346,13 @@ router.delete('/:id', async (req: Request, res: Response) => {
         fs.unlinkSync(thumbPath);
       }
     }
+    // Delete HLS directory (all segments + playlist)
+    if (asset.hlsPath) {
+      const hlsDir = path.join(UPLOAD_DIR, 'hls', asset.id);
+      if (fs.existsSync(hlsDir)) {
+        fs.rmSync(hlsDir, { recursive: true, force: true });
+      }
+    }
 
     await prisma.asset.delete({ where: { id: str(req.params.id) } });
     res.json({ message: 'Asset deleted' });

+ 8 - 1
packages/api/src/routes/auth.ts

@@ -12,6 +12,13 @@ const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
 // POST /api/auth/register
 router.post('/register', async (req: Request, res: Response) => {
   try {
+    // Check if registration is enabled
+    const setting = await prisma.siteSetting.findUnique({ where: { name: 'registration_enabled' } });
+    if (setting?.value === 'false') {
+      res.status(403).json({ error: 'Registration is currently disabled. Contact your administrator.' });
+      return;
+    }
+
     const { email, name, password } = req.body;
 
     if (!email || !name || !password) {
@@ -80,7 +87,7 @@ router.post('/register', async (req: Request, res: Response) => {
       maxAge: 7 * 24 * 60 * 60 * 1000,
     });
 
-    res.status(201).json({ user, token, acceptedProjects });
+    res.status(201).json({ user, token, acceptedProjects, userName: user.name });
   } catch (err) {
     console.error('Register error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 132 - 44
packages/api/src/routes/comments.ts

@@ -1,21 +1,33 @@
 import { Router, Request, Response } from 'express';
-import { prisma } from '../lib/prisma';
+import { prisma, ResolveStatus } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
 const router = Router();
 router.use(authMiddleware);
 
-const str = (v: string | string[] | undefined): string => Array.isArray(v) ? v[0] ?? '' : (v ?? '');
+const str = (v: string | string[] | undefined): string =>
+  Array.isArray(v) ? v[0] ?? '' : (v ?? '');
+
+const includeCommentRelations = {
+  user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+  replies: {
+    include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
+    orderBy: { createdAt: 'asc' as const },
+  },
+  resolvedBy: { select: { id: true, name: true, email: true, avatarUrl: true } },
+  requestedBy: { select: { id: true, name: true, email: true, avatarUrl: true } },
+};
 
 // GET /api/assets/:assetId/comments
 router.get('/:assetId/comments', async (req: Request, res: Response) => {
   try {
     const { resolved } = req.query;
+    const isAdmin = req.user!.globalRole === 'ADMIN';
 
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.assetId),
-        project: { members: { some: { userId: req.user!.userId } } },
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
       },
     });
 
@@ -32,15 +44,7 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
 
     const comments = await prisma.comment.findMany({
       where,
-      include: {
-        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-        replies: {
-          include: {
-            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-          },
-          orderBy: { createdAt: 'asc' },
-        },
-      },
+      include: includeCommentRelations,
       orderBy: { timestamp: 'asc' },
     });
 
@@ -61,10 +65,11 @@ router.post('/:assetId/comments', async (req: Request, res: Response) => {
       return;
     }
 
+    const isAdmin = req.user!.globalRole === 'ADMIN';
     const asset = await prisma.asset.findFirst({
       where: {
         id: str(req.params.assetId),
-        project: { members: { some: { userId: req.user!.userId } } },
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
       },
     });
 
@@ -82,14 +87,7 @@ router.post('/:assetId/comments', async (req: Request, res: Response) => {
         annotations: commentAnnotations ?? null,
         parentId: parentId ?? null,
       },
-      include: {
-        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-        replies: {
-          include: {
-            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-          },
-        },
-      },
+      include: includeCommentRelations,
     });
 
     res.status(201).json({ comment });
@@ -99,13 +97,23 @@ router.post('/:assetId/comments', async (req: Request, res: Response) => {
   }
 });
 
-// PUT /api/comments/:id/resolve — toggle resolved state
-router.put('/:id/resolve', async (req: Request, res: Response) => {
+// POST /api/comments/:id/resolve/request — request resolve approval
+router.post('/:id/resolve/request', async (req: Request, res: Response) => {
   try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
     const comment = await prisma.comment.findFirst({
       where: {
         id: str(req.params.id),
-        asset: { project: { members: { some: { userId: req.user!.userId } } } },
+        ...(isAdmin ? {} : { asset: { project: { members: { some: { userId: req.user!.userId } } } } }),
+      },
+      include: {
+        asset: {
+          include: {
+            project: {
+              include: { members: true },
+            },
+          },
+        },
       },
     });
 
@@ -114,19 +122,112 @@ router.put('/:id/resolve', async (req: Request, res: Response) => {
       return;
     }
 
+    if (comment.resolveStatus === ResolveStatus.RESOLVED) {
+      res.status(400).json({ error: 'Comment is already resolved' });
+      return;
+    }
+
+    // Requester must be a non-VIEWER member of the project
+    const membership = comment.asset.project.members.find(
+      (m: any) => m.userId === req.user!.userId
+    );
+    if (!membership || membership.role === 'VIEWER') {
+      res.status(403).json({ error: 'Viewers cannot request resolve' });
+      return;
+    }
+
+    // Idempotent — if already PENDING_APPROVAL, just update the requester
     const updated = await prisma.comment.update({
       where: { id: str(req.params.id) },
-      data: { resolved: !comment.resolved },
+      data: {
+        resolveStatus: ResolveStatus.PENDING_APPROVAL,
+        requestedById: req.user!.userId,
+        requestedByAt: new Date(),
+      },
+      include: includeCommentRelations,
+    });
+
+    res.json({ comment: updated });
+  } catch (err) {
+    console.error('Request resolve error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/comments/:id/resolve — approve or reject resolve request
+router.put('/:id/resolve', async (req: Request, res: Response) => {
+  try {
+    const { action } = req.body; // 'approve' | 'reject'
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
+    const comment = await prisma.comment.findFirst({
+      where: {
+        id: str(req.params.id),
+        ...(isAdmin ? {} : { asset: { project: { members: { some: { userId: req.user!.userId } } } } }),
+      },
       include: {
-        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-        replies: {
+        asset: {
           include: {
-            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+            project: {
+              include: { members: true },
+            },
           },
         },
       },
     });
 
+    if (!comment) {
+      res.status(404).json({ error: 'Comment not found' });
+      return;
+    }
+
+    // Approver must be: comment author OR project ADMIN
+    const isCommentAuthor = comment.userId === req.user!.userId;
+    const isProjectAdmin = comment.asset.project.members.some(
+      (m: any) => m.userId === req.user!.userId && m.role === 'ADMIN'
+    );
+
+    if (!isCommentAuthor && !isProjectAdmin) {
+      res.status(403).json({
+        error: 'Only the comment author or a project ADMIN can approve this request',
+      });
+      return;
+    }
+
+    if (action === 'reject') {
+      const updated = await prisma.comment.update({
+        where: { id: str(req.params.id) },
+        data: {
+          resolved: false,
+          resolveStatus: ResolveStatus.UNRESOLVED,
+          requestedById: null,
+          requestedByAt: null,
+        },
+        include: includeCommentRelations,
+      });
+      res.json({ comment: updated });
+      return;
+    }
+
+    // Default: approve
+    if (comment.resolveStatus !== ResolveStatus.PENDING_APPROVAL) {
+      res.status(400).json({ error: 'No pending resolve request to approve' });
+      return;
+    }
+
+    const updated = await prisma.comment.update({
+      where: { id: str(req.params.id) },
+      data: {
+        resolved: true,
+        resolveStatus: ResolveStatus.RESOLVED,
+        resolvedById: req.user!.userId,
+        resolvedByAt: new Date(),
+        requestedById: null,
+        requestedByAt: null,
+      },
+      include: includeCommentRelations,
+    });
+
     res.json({ comment: updated });
   } catch (err) {
     console.error('Resolve comment error:', err);
@@ -140,10 +241,7 @@ router.put('/:id/annotations', async (req: Request, res: Response) => {
     const { annotations } = req.body;
 
     const comment = await prisma.comment.findFirst({
-      where: {
-        id: str(req.params.id),
-        userId: req.user!.userId,
-      },
+      where: { id: str(req.params.id), userId: req.user!.userId },
     });
 
     if (!comment) {
@@ -154,14 +252,7 @@ router.put('/:id/annotations', async (req: Request, res: Response) => {
     const updated = await prisma.comment.update({
       where: { id: str(req.params.id) },
       data: { annotations: annotations ?? null },
-      include: {
-        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-        replies: {
-          include: {
-            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-          },
-        },
-      },
+      include: includeCommentRelations,
     });
 
     res.json({ comment: updated });
@@ -175,10 +266,7 @@ router.put('/:id/annotations', async (req: Request, res: Response) => {
 router.delete('/:id', async (req: Request, res: Response) => {
   try {
     const comment = await prisma.comment.findFirst({
-      where: {
-        id: str(req.params.id),
-        userId: req.user!.userId,
-      },
+      where: { id: str(req.params.id), userId: req.user!.userId },
     });
 
     if (!comment) {

+ 107 - 15
packages/api/src/routes/invitations.ts

@@ -49,16 +49,6 @@ router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
       return;
     }
 
-    if (invitation.status !== 'PENDING') {
-      res.status(410).json({ error: `Invitation has been ${invitation.status.toLowerCase()}` });
-      return;
-    }
-
-    if (invitation.expiresAt < new Date()) {
-      res.status(410).json({ error: 'Invitation has expired' });
-      return;
-    }
-
     // If user is logged in, check if this is their invitation
     const isOwnInvitation = req.user?.email === invitation.email;
     const alreadyMember = req.user
@@ -67,6 +57,7 @@ router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
         }))
       : false;
 
+    // Return full info even for expired/used — frontend shows appropriate UI
     res.json({
       invitation: {
         id: invitation.id,
@@ -75,8 +66,10 @@ router.get('/:token', optionalAuth, async (req: Request, res: Response) => {
         projectName: invitation.project.name,
         projectId: invitation.projectId,
         expiresAt: invitation.expiresAt,
+        status: invitation.status,
+        isExpired: invitation.status === 'EXPIRED' || invitation.expiresAt < new Date(),
         isOwnInvitation,
-        alreadyMember,
+        alreadyMember: alreadyMember || invitation.status === 'ACCEPTED',
         isLoggedIn: !!req.user,
       },
     });
@@ -247,7 +240,104 @@ router.post('/project/:projectId', authMiddleware, async (req: Request, res: Res
   }
 });
 
-// DELETE /api/invitations/:id — revoke invitation (project admin/editor only)
+// ── Admin: workspace-wide invite ─────────────────────────────────────────────────
+
+// POST /api/invitations — admin can invite any user by email to any project
+router.post('/', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    if (req.user!.globalRole !== 'ADMIN') {
+      res.status(403).json({ error: 'Admin access required' });
+      return;
+    }
+
+    const { email, projectId, role = 'REVIEWER' } = req.body as {
+      email: string;
+      projectId: string;
+      role?: string;
+    };
+
+    if (!email || !projectId) {
+      res.status(400).json({ error: 'email and projectId are required' });
+      return;
+    }
+
+    const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER'];
+    if (!validRoles.includes(role)) {
+      res.status(400).json({ error: 'Invalid role' });
+      return;
+    }
+
+    // Verify project exists
+    const project = await prisma.project.findUnique({ where: { id: projectId } });
+    if (!project) {
+      res.status(404).json({ error: 'Project not found' });
+      return;
+    }
+
+    // Check if already a member
+    const existingMember = await prisma.user.findUnique({ where: { email } });
+    if (existingMember) {
+      const member = await prisma.projectMember.findFirst({
+        where: { projectId, userId: existingMember.id },
+      });
+      if (member) {
+        res.status(409).json({ error: 'User is already a member of this project' });
+        return;
+      }
+    }
+
+    // Revoke any existing pending invite for this email+project
+    await prisma.invitation.updateMany({
+      where: { projectId, email, status: 'PENDING' },
+      data: { status: 'REVOKED' },
+    });
+
+    const token = randomBytes(32).toString('hex');
+    const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS);
+
+    const invitation = await prisma.invitation.create({
+      data: {
+        email,
+        projectId,
+        role: role as any,
+        token,
+        invitedBy: req.user!.userId,
+        expiresAt,
+      },
+    });
+
+    const inviteUrl = `/invite/${token}`;
+    res.status(201).json({ invitation, inviteUrl });
+  } catch (err) {
+    console.error('Admin invite error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// GET /api/invitations — admin: list all pending workspace invitations
+router.get('/', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    if (req.user!.globalRole !== 'ADMIN') {
+      res.status(403).json({ error: 'Admin access required' });
+      return;
+    }
+
+    const invitations = await prisma.invitation.findMany({
+      where: { status: 'PENDING' },
+      include: {
+        project: { select: { id: true, name: true } },
+      },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    res.json({ invitations });
+  } catch (err) {
+    console.error('List invitations error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/invitations/:id — revoke invitation (admin or project admin/editor)
 router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
   try {
     const invitation = await prisma.invitation.findUnique({
@@ -259,7 +349,8 @@ router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
       return;
     }
 
-    if (!(await canInvite(invitation.projectId, req.user!.userId))) {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+    if (!isAdmin && !(await canInvite(invitation.projectId, req.user!.userId))) {
       res.status(403).json({ error: 'Forbidden' });
       return;
     }
@@ -281,11 +372,12 @@ router.delete('/:id', authMiddleware, async (req: Request, res: Response) => {
   }
 });
 
-// Resend invitation — create new token for same email
+// Resend invitation — create new token for same email (project admin/editor)
 router.post('/project/:projectId/resend', authMiddleware, async (req: Request, res: Response) => {
   try {
     const projectId = str(req.params.projectId);
-    if (!(await canInvite(projectId, req.user!.userId))) {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+    if (!isAdmin && !(await canInvite(projectId, req.user!.userId))) {
       res.status(403).json({ error: 'Forbidden' });
       return;
     }

+ 8 - 7
packages/api/src/routes/projects.ts

@@ -13,10 +13,11 @@ router.use(authMiddleware);
 // GET /api/projects — list projects for current user
 router.get('/', async (req: Request, res: Response) => {
   try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
     const projects = await prisma.project.findMany({
-      where: {
-        members: { some: { userId: req.user!.userId } },
-      },
+      where: isAdmin
+        ? {} // ADMINs see all projects
+        : { members: { some: { userId: req.user!.userId } } },
       include: {
         members: {
           include: {
@@ -74,11 +75,11 @@ router.post('/', async (req: Request, res: Response) => {
 // GET /api/projects/:id
 router.get('/:id', async (req: Request, res: Response) => {
   try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
     const project = await prisma.project.findFirst({
-      where: {
-        id: str(req.params.id),
-        members: { some: { userId: req.user!.userId } },
-      },
+      where: isAdmin
+        ? { id: str(req.params.id) }
+        : { id: str(req.params.id), members: { some: { userId: req.user!.userId } } },
       include: {
         members: {
           include: {

+ 49 - 0
packages/api/src/routes/settings.ts

@@ -0,0 +1,49 @@
+import { Router, Request, Response } from 'express';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const router = Router();
+router.use(authMiddleware);
+
+const SITE_SETTINGS_KEY = 'registration_enabled';
+
+// GET /api/settings/registration — admin only
+router.get('/registration', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.globalRole !== 'ADMIN') {
+      res.status(403).json({ error: 'Admin only' });
+      return;
+    }
+    const setting = await prisma.siteSetting.findUnique({ where: { name: SITE_SETTINGS_KEY } });
+    res.json({ enabled: setting?.value !== 'false' });
+  } catch (err) {
+    console.error('Get registration setting error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/settings/registration — admin only
+router.put('/registration', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.globalRole !== 'ADMIN') {
+      res.status(403).json({ error: 'Admin only' });
+      return;
+    }
+    const { enabled } = req.body;
+    if (typeof enabled !== 'boolean') {
+      res.status(400).json({ error: 'enabled must be a boolean' });
+      return;
+    }
+    await prisma.siteSetting.upsert({
+      where: { name: SITE_SETTINGS_KEY },
+      create: { name: SITE_SETTINGS_KEY, value: String(enabled) },
+      update: { value: String(enabled) },
+    });
+    res.json({ enabled });
+  } catch (err) {
+    console.error('Update registration setting error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 22 - 0
packages/api/src/worker/dispatcher.ts

@@ -0,0 +1,22 @@
+/**
+ * Transcode Dispatcher
+ * ─────────────────────────────────────────────────────
+ * Since the transcode worker runs as a SEPARATE Docker service (worker/),
+ * this module is a no-op stub. The API just creates an asset with
+ * transcodeStatus = PENDING and the worker picks it up from the DB.
+ *
+ * This file exists so that other code (e.g. routes/assets.ts) can
+ * import { startTranscodeJob } without changes.
+ */
+export interface TranscodeJob {
+  assetId: string;
+  videoPath: string;
+  outputDir: string;
+}
+
+/** No-op — the worker service polls the DB. */
+export function startTranscodeJob(job: TranscodeJob): void {
+  // Worker handles it — nothing to do here.
+  // But we validate the asset row so it starts in PENDING state.
+  // (already done by the upload route before calling this)
+}

+ 277 - 0
packages/api/src/worker/index.js

@@ -0,0 +1,277 @@
+'use strict';
+
+/**
+ * Transcode Worker Service
+ * ─────────────────────────────────────────────────────
+ * Standalone Node.js process (runs in its own Docker service).
+ * Polls the database for pending transcode jobs.
+ *
+ * DB-as-queue pattern:
+ *   1. API creates asset → transcodeStatus = PENDING
+ *   2. Worker polls → atomically claims one PENDING job (UPDATE ... WHERE status=PENDING)
+ *   3. Worker processes thumbnail + HLS → updates DB
+ *   4. Repeat
+ *
+ * No external queue needed — uses the existing PostgreSQL database.
+ */
+const { PrismaClient } = require('@prisma/client');
+const ffmpeg = require('fluent-ffmpeg');
+const path = require('path');
+const fs = require('fs');
+
+const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads';
+const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '5000', 10);
+
+const prisma = new PrismaClient({
+  datasources: { db: { url: process.env.DATABASE_URL } },
+});
+
+/** ── Helpers ──────────────────────────────────────────────────────────────── */
+function send(type, data) {
+  const msg = JSON.stringify({ type, ...data, ts: new Date().toISOString() });
+  process.send && process.send(msg);
+  console.log(`[worker] ${type}`, JSON.stringify(data));
+}
+
+function sleep(ms) {
+  return new Promise(r => setTimeout(r, ms));
+}
+
+/** ── Thumbnail ──────────────────────────────────────────────────────────── */
+function probeAndThumbnail(videoPath, outputDir) {
+  return new Promise((resolve) => {
+    const videoFilename = path.basename(videoPath, path.extname(videoPath));
+    const thumbFilename = videoFilename + '_thumb.jpg';
+
+    fs.mkdirSync(outputDir, { recursive: true });
+
+    ffmpeg.ffprobe(videoPath, (err, metadata) => {
+      const duration = metadata?.format?.duration ?? null;
+      let fps = 30;
+      let codecName = 'unknown';
+
+      const videoStream = metadata?.streams?.find(s => s.codec_type === 'video');
+      if (videoStream) {
+        codecName = videoStream.codec_name || 'unknown';
+        if (videoStream.r_frame_rate) {
+          const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
+          fps = den ? Math.round(num / den) : num;
+        }
+      }
+
+      send('metadata', { codec: codecName, duration, fps });
+
+      ffmpeg(videoPath)
+        .on('error', () => resolve({ thumbnailPath: null, duration, fps, codec: codecName }))
+        .on('end', () => resolve({ thumbnailPath: thumbFilename, duration, fps, codec: codecName }))
+        .screenshots({
+          count: 1,
+          folder: outputDir,
+          filename: thumbFilename,
+          size: '320x?',
+          timemarks: ['1'],
+        });
+    });
+  });
+}
+
+/** ── HLS Transcode ─────────────────────────────────────────────────────── */
+function transcodeToHLS(videoPath, outputDir, assetId, duration) {
+  return new Promise((resolve, reject) => {
+    const hlsDir = path.join(outputDir, 'hls', assetId);
+    fs.mkdirSync(hlsDir, { recursive: true });
+
+    const playlistPath = path.join(hlsDir, 'master.m3u8');
+    const segmentPattern = path.join(hlsDir, 'segment_%03d.ts');
+    let lastPct = 0;
+
+    ffmpeg(videoPath)
+      // ── Re-encode to H.264 + AAC (universal browser support) ─────────────
+      .outputOptions([
+        '-c:v libx264',         // H.264 — every browser supports via MSE
+        '-c:a aac',             // AAC — universal audio codec
+        '-movflags +faststart', // moov atom at front → fast playback start
+        '-preset fast',
+        '-crf 23',
+        // HLS output
+        '-f hls',
+        '-hls_time 6',
+        '-hls_playlist_type vod',
+        '-hls_segment_filename', segmentPattern,
+      ])
+      .output(playlistPath)
+      .on('progress', ({ percent }) => {
+        const pct = Math.round(Math.min(99, percent ?? lastPct));
+        if (pct > lastPct) {
+          lastPct = pct;
+          send('progress', { progress: pct });
+          // Also update DB progress periodically
+          prisma.asset.update({
+            where: { id: assetId },
+            data: { transcodeProgress: pct, transcodeStatus: 'PROCESSING' },
+          }).catch(() => {}); // ignore errors
+        }
+      })
+      .on('error', (err) => reject(new Error('HLS_TRANSCODE_FAILED: ' + err.message)))
+      .on('end', () => resolve('/hls/' + assetId + '/master.m3u8'))
+      .run();
+  });
+}
+
+/** ── Process one job ───────────────────────────────────────────────────── */
+async function processJob(asset) {
+  const { id: assetId, filePath } = asset;
+  const videoPath = path.join(UPLOAD_DIR, filePath);
+
+  send('started', { assetId, filePath });
+
+  // Check file exists
+  if (!fs.existsSync(videoPath)) {
+    send('error', { assetId, error: 'Video file not found on disk: ' + videoPath });
+    await prisma.asset.update({
+      where: { id: assetId },
+      data: { transcodeStatus: 'FAILED', transcodeError: 'Video file not found on server', transcodeProgress: 0 },
+    });
+    return;
+  }
+
+  try {
+    // Phase 1: thumbnail + probe
+    await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PROCESSING', transcodeProgress: 0 } });
+    const thumbResult = await probeAndThumbnail(videoPath, UPLOAD_DIR);
+
+    // Update DB with metadata
+    await prisma.asset.update({
+      where: { id: assetId },
+      data: {
+        thumbnail: thumbResult.thumbnailPath ?? null,
+        codec: thumbResult.codec ?? null,
+        duration: thumbResult.duration ?? null,
+        fps: thumbResult.fps ?? 30,
+        transcodeProgress: 10,
+      },
+    });
+
+    // Phase 2: HLS
+    const hlsPath = await transcodeToHLS(videoPath, UPLOAD_DIR, assetId, thumbResult.duration);
+
+    // Done!
+    await prisma.asset.update({
+      where: { id: assetId },
+      data: {
+        transcodeStatus: 'COMPLETED',
+        transcodeProgress: 100,
+        transcodeError: null,
+        hlsPath,
+        codec: thumbResult.codec ?? null,
+        duration: thumbResult.duration ?? null,
+        fps: thumbResult.fps ?? null,
+      },
+    });
+
+    send('done', { assetId, hlsPath });
+  } catch (err) {
+    send('error', { assetId, error: err.message });
+    // Only mark failed if not already deleted or completed by another process
+    try {
+      await prisma.asset.update({
+        where: { id: assetId },
+        data: { transcodeStatus: 'FAILED', transcodeError: err.message, transcodeProgress: 0 },
+      });
+    } catch {}
+  }
+}
+
+/** ── Poll loop ─────────────────────────────────────────────────────────── */
+async function poll() {
+  try {
+    // Atomically claim one PENDING job
+    // Prisma's updateMany returns the count; we use raw SQL for the atomic claim
+    const result = await prisma.$executeRaw`
+      UPDATE "Asset"
+      SET    "transcodeStatus" = 'PROCESSING',
+             "transcodeProgress" = 0,
+             "updatedAt" = NOW()
+      WHERE  id = (
+        SELECT id FROM "Asset"
+        WHERE  "transcodeStatus" = 'PENDING'
+        ORDER  BY "createdAt" ASC
+        LIMIT  1
+        FOR    UPDATE SKIP LOCKED
+      )
+      RETURNING id, "filePath", "transcodeStatus"
+    `;
+
+    if (!result || result === 0) {
+      return; // No jobs
+    }
+
+    // Re-fetch the claimed asset (result doesn't return full row with $executeRaw)
+    const claimed = await prisma.asset.findFirst({
+      where: { transcodeStatus: 'PROCESSING' },
+      orderBy: { updatedAt: 'asc' },
+      take: 1,
+    });
+
+    if (!claimed) return;
+
+    await processJob(claimed);
+  } catch (err) {
+    console.error('[worker] Poll error:', err.message);
+  }
+}
+
+/** ── Main ──────────────────────────────────────────────────────────────── */
+async function main() {
+  console.log('[worker] Starting transcode worker...');
+  console.log('[worker] UPLOAD_DIR:', UPLOAD_DIR);
+  console.log('[worker] DATABASE_URL:', process.env.DATABASE_URL ? '(set)' : 'MISSING!');
+  console.log('[worker] Poll interval:', POLL_INTERVAL_MS, 'ms');
+
+  // Make sure upload dir exists
+  fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+
+  send('ready', { UPLOAD_DIR, POLL_INTERVAL_MS });
+
+  // Process any stale PROCESSING jobs (worker crashed mid-job) on startup
+  await recoverStaleJobs();
+
+  // Main poll loop
+  setInterval(poll, POLL_INTERVAL_MS);
+}
+
+/** Recover stale jobs — assets stuck in PROCESSING from a crashed worker */
+async function recoverStaleJobs() {
+  try {
+    const stale = await prisma.asset.findMany({
+      where: { transcodeStatus: 'PROCESSING' },
+      select: { id: true },
+    });
+    if (stale.length > 0) {
+      console.log(`[worker] Recovering ${stale.length} stale job(s)...`);
+      await prisma.asset.updateMany({
+        where: { id: { in: stale.map(s => s.id) } },
+        data: { transcodeStatus: 'PENDING', transcodeProgress: 0 },
+      });
+    }
+  } catch (err) {
+    console.warn('[worker] recoverStaleJobs error:', err.message);
+  }
+}
+
+main().catch(err => {
+  console.error('[worker] Fatal error:', err);
+  process.exit(1);
+});
+
+// Graceful shutdown
+process.on('SIGTERM', async () => {
+  console.log('[worker] SIGTERM received, shutting down...');
+  await prisma.$disconnect();
+  process.exit(0);
+});
+process.on('SIGINT', async () => {
+  console.log('[worker] SIGINT received, shutting down...');
+  await prisma.$disconnect();
+  process.exit(0);
+});

+ 142 - 0
packages/api/src/workers/transcode.js

@@ -0,0 +1,142 @@
+'use strict';
+
+/**
+ * Transcode Worker
+ * ─────────────────────────
+ * Forked from the main Express process. Handles:
+ *   1. Thumbnail extraction (1 JPEG frame)
+ *   2. HLS transcoding (H.264/AAC, single 720p stream)
+ *
+ * Always re-encodes the input video to H.264 + AAC — this is required for
+ * broad browser / HLS.js compatibility regardless of the source codec
+ * (e.g. ProRes, VP9, AV1, H.265, etc.).
+ *
+ * Communicates progress back to the parent via process.send() (IPC).
+ *
+ * Usage: node transcode.js <assetId> <videoPath> <outputDir>
+ */
+const ffmpeg = require('fluent-ffmpeg');
+const path = require('path');
+const fs = require('fs');
+
+const [, , assetId, videoPath, outputDir] = process.argv;
+
+function send(msg) {
+  try { process.send(msg); } catch {}
+}
+
+/** ── 1. Extract thumbnail + probe metadata ─────────────────────────────────── */
+function probeAndThumbnail(videoPath, outputDir) {
+  return new Promise((resolve) => {
+    const videoFilename = path.basename(videoPath, path.extname(videoPath));
+    const thumbFilename = videoFilename + '_thumb.jpg';
+
+    fs.mkdirSync(outputDir, { recursive: true });
+
+    ffmpeg.ffprobe(videoPath, (err, metadata) => {
+      const duration = metadata?.format?.duration ?? null;
+      let fps = 30;
+      let codecName = 'unknown';
+
+      const videoStream = metadata?.streams?.find(s => s.codec_type === 'video');
+      if (videoStream) {
+        codecName = videoStream.codec_name || 'unknown';
+        if (videoStream.r_frame_rate) {
+          const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
+          fps = den ? Math.round(num / den) : num;
+        }
+      }
+
+      send({ type: 'metadata', assetId, duration, fps, codec: codecName });
+
+      ffmpeg(videoPath)
+        .on('error', () => resolve({ thumbnailPath: null, duration, fps, codec: codecName }))
+        .on('end', () => resolve({ thumbnailPath: thumbFilename, duration, fps, codec: codecName }))
+        .screenshots({
+          count: 1,
+          folder: outputDir,
+          filename: thumbFilename,
+          size: '320x?',
+          timemarks: ['1'],
+        });
+    });
+  });
+}
+
+/** ── 2. Transcode to HLS (H.264 + AAC) ─────────────────────────────────────── */
+/**
+ * We use a SINGLE output stream at 720p.
+ * This is the simplest and most reliable approach:
+ *   • H.264 is supported by every modern browser natively via MSE.
+ *   • AAC is supported everywhere.
+ *   • FFmpeg creates the .m3u8 playlist + .ts segment files.
+ *   • A master playlist pointing to the single variant is written afterwards.
+ *
+ * Adding more quality levels later only requires additional .output() calls
+ * with per-stream `-map` / `-b:v:N` options (fluent-ffmpeg supports this).
+ */
+function transcodeToHLS(videoPath, outputDir, assetId, duration) {
+  return new Promise((resolve, reject) => {
+    const hlsDir = path.join(outputDir, 'hls', assetId);
+    fs.mkdirSync(hlsDir, { recursive: true });
+
+    const playlistPath = path.join(hlsDir, 'master.m3u8');
+    const segmentPattern = path.join(hlsDir, 'segment_%03d.ts');
+
+    // Estimate total segments for progress reporting
+    const totalSegments = duration > 0 ? Math.ceil(duration / 6) : 100;
+    let lastPct = 0;
+
+    ffmpeg(videoPath)
+      // ── Re-encode to H.264 + AAC ──────────────────────────────────────────
+      .outputOptions([
+        '-c:v libx264',         // always re-encode → H.264 (universal support)
+        '-c:a aac',             // always re-encode → AAC (universal support)
+        '-movflags +faststart', // moov atom at front → fast playback start
+        '-preset fast',
+        '-crf 23',
+        // HLS output
+        '-f hls',
+        '-hls_time 6',
+        '-hls_playlist_type vod',
+        '-hls_segment_filename', segmentPattern,
+      ])
+      .output(playlistPath)
+      .on('progress', ({ percent }) => {
+        const pct = Math.round(Math.min(99, percent ?? lastPct));
+        if (pct > lastPct) {
+          lastPct = pct;
+          send({ type: 'progress', assetId, phase: 'transcode', progress: pct });
+        }
+      })
+      .on('error', (err) => reject(new Error('HLS_TRANSCODE_FAILED: ' + err.message)))
+      .on('end', () => resolve('/hls/' + assetId + '/master.m3u8'))
+      .run();
+  });
+}
+
+/** ── Main ───────────────────────────────────────────────────────────────────── */
+async function run() {
+  if (!assetId || !videoPath || !outputDir) {
+    console.error('Usage: node transcode.js <assetId> <videoPath> <outputDir>');
+    process.exit(1);
+  }
+
+  try {
+    send({ type: 'start', assetId, phase: 'thumbnail', progress: 0 });
+
+    const thumbResult = await probeAndThumbnail(videoPath, outputDir);
+    send({ type: 'thumbnail_done', assetId, phase: 'thumbnail', progress: 100, ...thumbResult });
+
+    send({ type: 'start', assetId, phase: 'transcode', progress: 0 });
+    const hlsPath = await transcodeToHLS(videoPath, outputDir, assetId, thumbResult.duration);
+
+    send({ type: 'done', assetId, phase: 'done', progress: 100, hlsPath, ...thumbResult });
+    process.exit(0);
+  } catch (err) {
+    send({ type: 'error', assetId, phase: 'error', progress: 0, error: err.message });
+    process.exit(1);
+  }
+}
+
+run();

+ 28 - 15
prisma/schema.prisma

@@ -67,21 +67,34 @@ model Asset {
 }
 
 model Comment {
-  id         String    @id @default(cuid())
-  assetId    String
-  userId     String
-  content    String
-  timestamp  Float?
-  annotation Json?
-  resolved   Boolean   @default(false)
-  parentId   String?
-  createdAt  DateTime  @default(now())
-  updatedAt  DateTime  @updatedAt
-
-  asset   Asset    @relation(fields: [assetId], references: [id], onDelete: Cascade)
-  user    User     @relation(fields: [userId], references: [id], onDelete: Cascade)
-  parent  Comment? @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
-  replies Comment[] @relation("Replies")
+  id           String         @id @default(cuid())
+  assetId      String
+  userId       String
+  content      String
+  timestamp    Float?
+  annotations  Json?
+  resolved     Boolean        @default(false)
+  resolveStatus ResolveStatus @default(UNRESOLVED)
+  resolvedById String?
+  resolvedByAt DateTime?
+  requestedById String?
+  requestedByAt DateTime?
+  parentId     String?
+  createdAt    DateTime       @default(now())
+  updatedAt    DateTime       @updatedAt
+
+  asset       Asset     @relation(fields: [assetId], references: [id], onDelete: Cascade)
+  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  parent      Comment?  @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
+  replies     Comment[] @relation("Replies")
+  resolvedBy  User?     @relation("ResolvedBy", fields: [resolvedById], references: [id])
+  requestedBy User?    @relation("RequestedBy", fields: [requestedById], references: [id])
+}
+
+enum ResolveStatus {
+  UNRESOLVED
+  PENDING_APPROVAL
+  RESOLVED
 }
 
 enum Role {

+ 12 - 0
src/app/(auth)/login/page.tsx

@@ -3,6 +3,7 @@
 import { useState, useEffect, Suspense } from 'react';
 import { useRouter, useSearchParams } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
+import { invitationsApi } from '@/lib/api';
 import { Button } from '@/components/ui/button';
 
 function LoginForm() {
@@ -16,6 +17,17 @@ function LoginForm() {
   const [loading, setLoading] = useState(false);
   const [justJoined, setJustJoined] = useState(false);
 
+  // Pre-fill email if coming from invite link
+  useEffect(() => {
+    if (inviteToken) {
+      invitationsApi.verify(inviteToken)
+        .then(({ invitation }) => {
+          if (!email) setEmail(invitation.email);
+        })
+        .catch(() => {});
+    }
+  }, [inviteToken]); // eslint-disable-line react-hooks/exhaustive-deps
+
   useEffect(() => {
     if (acceptedProjects.length > 0 && !justJoined) {
       setJustJoined(true);

+ 27 - 15
src/app/(auth)/register/page.tsx

@@ -10,7 +10,7 @@ function RegisterForm() {
   const router = useRouter();
   const searchParams = useSearchParams();
   const inviteToken = searchParams.get('invite_token');
-  const { register, acceptedProjects, clearAcceptedProjects } = useAuth();
+  const { register, acceptedProjects, clearAcceptedProjects, justRegisteredName } = useAuth();
   const [name, setName] = useState('');
   const [email, setEmail] = useState('');
   const [password, setPassword] = useState('');
@@ -20,12 +20,15 @@ function RegisterForm() {
   const [inviteLoading, setInviteLoading] = useState(false);
   const [justJoined, setJustJoined] = useState(false);
 
-  // Verify invite token if present
+  // Verify invite token if present — auto-fill email
   useEffect(() => {
     if (inviteToken) {
       setInviteLoading(true);
       invitationsApi.verify(inviteToken)
-        .then(({ invitation }) => setInviteInfo(invitation))
+        .then(({ invitation }) => {
+          setInviteInfo(invitation);
+          setEmail(invitation.email);
+        })
         .catch(() => { /* invalid token, not critical */ })
         .finally(() => setInviteLoading(false));
     }
@@ -131,20 +134,29 @@ function RegisterForm() {
           ) : null}
 
           {/* Accepted projects notification */}
-          {justJoined && acceptedProjects.length > 0 && (
+          {justJoined && (
             <div className="mb-5 rounded-lg px-4 py-3 text-sm animate-scale-in"
                  style={{ background: 'rgba(34,197,94,0.12)', border: '1px solid rgba(34,197,94,0.25)', color: '#86EFAC' }}>
-              <p className="font-medium mb-2">You're now a member of:</p>
-              {acceptedProjects.map(p => (
-                <button
-                  key={p.projectId}
-                  onClick={() => handleGoToProject(p.projectId)}
-                  className="block text-left hover:underline"
-                  style={{ color: '#86EFAC' }}
-                >
-                  → {p.projectName}
-                </button>
-              ))}
+              {justRegisteredName && (
+                <p className="font-medium mb-1">Welcome, {justRegisteredName}!</p>
+              )}
+              {acceptedProjects.length > 0 ? (
+                <>
+                  <p className="font-medium mb-2">You're now a member of:</p>
+                  {acceptedProjects.map(p => (
+                    <button
+                      key={p.projectId}
+                      onClick={() => handleGoToProject(p.projectId)}
+                      className="block text-left hover:underline"
+                      style={{ color: '#86EFAC' }}
+                    >
+                      → {p.projectName}
+                    </button>
+                  ))}
+                </>
+              ) : (
+                <p>Your account is ready. Redirecting…</p>
+              )}
             </div>
           )}
 

+ 1 - 5
src/app/(dashboard)/layout.tsx

@@ -7,7 +7,7 @@ import { useAuth } from '@/lib/auth-context';
 import { Avatar } from '@/components/ui/avatar';
 
 export default function DashboardLayout({ children }: { children: React.ReactNode }) {
-  const { user, loading } = useAuth();
+  const { user, loading, logout } = useAuth();
   const router = useRouter();
   const pathname = usePathname();
 
@@ -130,10 +130,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
             </div>
             <button
               onClick={async () => {
-                const { logout } = await import('@/lib/auth-context').then(m => {
-                  const { useAuth } = m;
-                  return { logout: m.useAuth().logout };
-                });
                 await logout();
                 router.push('/login');
               }}

+ 302 - 45
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -1,10 +1,27 @@
 'use client';
 
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
-import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation } from '@/lib/api';
+import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
 import { useDropzone } from 'react-dropzone';
+import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
+
+async function safeCopy(text: string): Promise<void> {
+  if (typeof window === 'undefined') return;
+  if (navigator.clipboard?.writeText) {
+    try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
+  } else {
+    // Fallback: create a temp input so we can use execCommand on insecure contexts
+    const el = document.createElement('textarea');
+    el.value = text;
+    el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
+    document.body.appendChild(el);
+    el.focus(); el.select();
+    try { document.execCommand('copy'); } catch { /* ignore */ }
+    document.body.removeChild(el);
+  }
+}
 
 const ROLE_COLORS: Record<string, string> = {
   ADMIN:   'badge-danger',
@@ -32,7 +49,7 @@ export default function ProjectDetailPage() {
   const [assets, setAssets] = useState<Asset[]>([]);
   const [loading, setLoading] = useState(true);
   const [uploading, setUploading] = useState(false);
-  const [activeTab, setActiveTab] = useState<'videos' | 'members'>('videos');
+  const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
 
   // Invite form state
   const [inviteEmail, setInviteEmail] = useState('');
@@ -124,11 +141,16 @@ export default function ProjectDetailPage() {
       const { invitations } = await invitationsApi.list(token, projectId);
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
       const fullUrl = `${window.location.origin}${inviteUrl}`;
-      await navigator.clipboard.writeText(fullUrl);
+      await safeCopy(fullUrl);
       setCreatedLink(fullUrl);
       setInviteEmail('');
-    } catch (err) {
-      setInviteError(err instanceof Error ? err.message : 'Failed to create link');
+    } catch (err: any) {
+      const msg = err instanceof Error ? err.message : String(err);
+      if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
+        setInviteError(`An invitation for "${inviteEmail.trim()}" is already pending or the user is already a member.`);
+      } else {
+        setInviteError(msg || 'Failed to create invitation link');
+      }
     } finally {
       setInviting(false);
     }
@@ -182,7 +204,7 @@ export default function ProjectDetailPage() {
   const handleCopyLink = async (invite: Invitation) => {
     const base = window.location.origin;
     const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
-    await navigator.clipboard.writeText(url);
+    await safeCopy(url);
     setCopiedInviteId(invite.id);
     setTimeout(() => setCopiedInviteId(null), 2000);
   };
@@ -227,12 +249,69 @@ export default function ProjectDetailPage() {
     REJECTED:          'Rejected',
   };
 
-  const formatDuration = (s: number | null | undefined) => {
-    if (!s) return '';
-    const m = Math.floor(s / 60);
-    const sec = Math.floor(s % 60);
-    return `${m}:${sec.toString().padStart(2, '0')}`;
+  // ── Transcode status helpers ────────────────────────────────────────────────
+  const transcodeColors: Record<TranscodeStatus, { text: string; dot: string; bg: string }> = {
+    PENDING:           { text: '#94A3B8', dot: 'bg-slate-400',    bg: 'rgba(148,163,184,0.10)' },
+    UPLOADING:         { text: '#60A5FA', dot: 'bg-blue-400',     bg: 'rgba(96,165,250,0.10)'  },
+    PROCESSING:        { text: '#A78BFA', dot: 'bg-violet-400',    bg: 'rgba(167,139,250,0.10)' },
+    COMPLETED:         { text: '#34D399', dot: 'bg-emerald-400',   bg: 'rgba(52,211,153,0.10)'  },
+    FAILED:            { text: '#F87171', dot: 'bg-red-400',       bg: 'rgba(248,113,113,0.10)' },
+    UNSUPPORTED_CODEC: { text: '#FBBF24', dot: 'bg-amber-400',     bg: 'rgba(251,191,36,0.10)'  },
+  };
+
+  const transcodeLabels: Record<TranscodeStatus, string> = {
+    PENDING:           'Queued',
+    UPLOADING:         'Uploading',
+    PROCESSING:        'Processing',
+    COMPLETED:         'Ready',
+    FAILED:            'Failed',
+    UNSUPPORTED_CODEC: 'Unsupported codec',
+  };
+
+  // Poll for assets that are still processing
+  const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
+
+  // ── Delete asset ─────────────────────────────────────────────────────────
+  const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
+  const [deletingId, setDeletingId] = useState<string | null>(null);
+
+  const handleDeleteAsset = (id: string, title: string) => {
+    setConfirmDelete({ id, title });
+  };
+
+  const confirmDeleteAsset = async () => {
+    if (!token || !confirmDelete) return;
+    setDeletingId(confirmDelete.id);
+    try {
+      await assetsApi.delete(token, confirmDelete.id);
+      setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
+      setConfirmDelete(null);
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to delete video');
+    } finally {
+      setDeletingId(null);
+    }
   };
+  useEffect(() => {
+    const processingAssets = assets.filter(a =>
+      ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
+    );
+    if (processingAssets.length === 0) {
+      if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
+      return;
+    }
+    if (pollingRef.current) return; // already polling
+
+    pollingRef.current = setInterval(async () => {
+      if (!token) return;
+      try {
+        const { assets: updated } = await assetsApi.list(token, projectId);
+        setAssets(updated);
+      } catch {}
+    }, 3000);
+
+    return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
+  }, [token, projectId, assets]);
 
   if (loading) {
     return (
@@ -283,17 +362,28 @@ export default function ProjectDetailPage() {
         {/* Tabs */}
         <div className="flex items-center gap-1 p-1 rounded-lg"
              style={{ background: 'rgba(255,255,255,0.04)' }}>
-          {[['videos', 'Videos'], ['members', 'Members']].map(([tab, label]) => (
+          {[['videos', 'Videos', assets.length], ['transcode', 'Transcode Tasks', assets.filter(a => a.transcodeStatus !== 'COMPLETED').length], ['members', 'Members', members.length]].map(([tab, label, count]) => (
             <button key={tab}
               onClick={() => setActiveTab(tab as any)}
-              className="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
+              className="px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
               style={{
                 background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
                 color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
               }}>
               {label}
+              {tab !== 'members' && (count as number) > 0 && (
+                <span className="text-[10px] px-1.5 py-0.5 rounded-full"
+                      style={{
+                        background: tab === 'transcode'
+                          ? 'rgba(167,139,250,0.25)'
+                          : 'rgba(255,255,255,0.06)',
+                        color: tab === 'transcode' ? '#A78BFA' : 'inherit',
+                      }}>
+                  {count}
+                </span>
+              )}
               {tab === 'members' && (
-                <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full"
+                <span className="ml-0.5 text-[10px] px-1.5 py-0.5 rounded-full"
                       style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
                   {members.length}
                 </span>
@@ -367,45 +457,105 @@ export default function ProjectDetailPage() {
             ) : (
               <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
                 {assets.map((asset, i) => (
-                  <a key={asset.id}
-                     href={`/review/${asset.id}`}
-                     className="card overflow-hidden group cursor-pointer"
+                  <div key={asset.id}
+                     className="card overflow-hidden group"
                      style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
 
                     {/* Thumbnail */}
-                    <div className="relative aspect-video" style={{ background: '#080810' }}>
-                      {asset.thumbnail ? (
-                        <img
-                          src={`/uploads/${asset.thumbnail}`}
-                          alt={asset.title}
-                          className="w-full h-full object-cover"
-                          style={{ opacity: 0.85 }}
-                        />
-                      ) : (
-                        <div className="w-full h-full flex items-center justify-center">
-                          <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
-                            <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
-                            <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
-                          </svg>
+                    <div className="relative aspect-video" style={{ background: '#080810' }} onClick={() => router.push(`/review/${asset.id}`)}>
+                      {/* Play overlay — only show when ready */}
+                      {asset.transcodeStatus === 'COMPLETED' && (
+                        <>
+                          {asset.thumbnail ? (
+                            <img
+                              src={`/uploads/${asset.thumbnail}`}
+                              alt={asset.title}
+                              className="w-full h-full object-cover"
+                              style={{ opacity: 0.85 }}
+                            />
+                          ) : (
+                            <div className="w-full h-full flex items-center justify-center">
+                              <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                              </svg>
+                            </div>
+                          )}
+                          <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
+                               style={{ background: 'rgba(0,0,0,0.35)' }}>
+                            <div className="w-12 h-12 rounded-full flex items-center justify-center"
+                                 style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
+                              <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
+                                <path d="M8 5v14l11-7z" />
+                              </svg>
+                            </div>
+                          </div>
+                        </>
+                      )}
+
+                      {/* Not ready — show transcode status overlay */}
+                      {asset.transcodeStatus !== 'COMPLETED' && (
+                        <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
+                          {/* Animated spinner */}
+                          {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
+                            <div className="w-10 h-10 rounded-full animate-spin"
+                                 style={{ borderColor: transcodeColors[asset.transcodeStatus]?.dot.replace('bg-','#').replace('-400','' ) || '#6366F1', borderTopColor: 'transparent', borderWidth: '3px' }} />
+                          )}
+                          {/* Error icon */}
+                          {asset.transcodeStatus === 'FAILED' && (
+                            <div className="w-10 h-10 rounded-full flex items-center justify-center"
+                                 style={{ background: 'rgba(248,113,113,0.15)' }}>
+                              <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+                              </svg>
+                            </div>
+                          )}
+                          {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
+                            <div className="w-10 h-10 rounded-full flex items-center justify-center"
+                                 style={{ background: 'rgba(251,191,36,0.15)' }}>
+                              <svg className="w-5 h-5" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+                              </svg>
+                            </div>
+                          )}
+
+                          {/* Status label */}
+                          <span className="text-xs font-medium px-2.5 py-1 rounded-full"
+                                style={{ background: transcodeColors[asset.transcodeStatus]?.bg, color: transcodeColors[asset.transcodeStatus]?.text }}>
+                            {transcodeLabels[asset.transcodeStatus]}
+                          </span>
                         </div>
                       )}
 
-                      {asset.duration && (
+                      {/* Progress bar */}
+                      {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
+                        <div className="absolute bottom-0 left-0 right-0 h-1 overflow-hidden"
+                             style={{ background: 'rgba(0,0,0,0.3)' }}>
+                          <div
+                            className="h-full transition-all duration-500"
+                            style={{
+                              width: `${asset.transcodeProgress}%`,
+                              background: 'linear-gradient(90deg, #818CF8, #A78BFA)',
+                            }}
+                          />
+                        </div>
+                      )}
+
+                      {/* Duration badge */}
+                      {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
                         <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
                               style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
-                          {formatDuration(asset.duration)}
+                          {(() => { const m = Math.floor(asset.duration! / 60); const s = Math.floor(asset.duration! % 60); return `${m}:${s.toString().padStart(2,'0')}`; })()}
                         </span>
                       )}
 
-                      <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
-                           style={{ background: 'rgba(0,0,0,0.35)' }}>
-                        <div className="w-12 h-12 rounded-full flex items-center justify-center"
-                             style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
-                          <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
-                            <path d="M8 5v14l11-7z" />
-                          </svg>
-                        </div>
-                      </div>
+                      {/* Codec badge */}
+                      {asset.codec && asset.transcodeStatus !== 'COMPLETED' && (
+                        <span className="absolute top-2 left-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
+                              style={{ background: 'rgba(0,0,0,0.6)', color: '#94A3B8' }}>
+                          {asset.codec}
+                        </span>
+                      )}
                     </div>
 
                     {/* Info */}
@@ -419,19 +569,84 @@ export default function ProjectDetailPage() {
                           {statusLabels[asset.status]}
                         </span>
                       </div>
-                      <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-muted)' }}>
+
+                      {/* Transcode status row */}
+                      {asset.transcodeStatus !== 'COMPLETED' && (
+                        <div className="mb-2 flex items-center gap-1.5">
+                          <div
+                            className={`w-1.5 h-1.5 rounded-full shrink-0 ${['UPLOADING','PROCESSING'].includes(asset.transcodeStatus) ? 'animate-pulse' : ''} ${transcodeColors[asset.transcodeStatus]?.dot}`}
+                          />
+                          <span className="text-[11px] truncate" style={{ color: transcodeColors[asset.transcodeStatus]?.text }}>
+                            {transcodeLabels[asset.transcodeStatus]}
+                            {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0
+                              ? ` — ${asset.transcodeProgress}%`
+                              : ''}
+                          </span>
+                          {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
+                            <span className="text-[10px] truncate" style={{ color: '#F87171' }}>
+                              : {asset.transcodeError}
+                            </span>
+                          )}
+                          {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
+                            <span className="text-[10px] truncate" style={{ color: '#FB923C' }}>
+                              — will re-encode to H.264
+                            </span>
+                          )}
+                        </div>
+                      )}
+
+                      <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
                         <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
                         <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
                         <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
+                        <div className="flex-1" />
+                        {canManage && (
+                          <button
+                            onClick={(e) => { e.stopPropagation(); handleDeleteAsset(asset.id, asset.title); }}
+                            className="p-1 rounded transition-colors hover:bg-red-500/20 flex-shrink-0"
+                            title="Delete video"
+                          >
+                            <svg className="w-3.5 h-3.5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
+                            </svg>
+                          </button>
+                        )}
                       </div>
                     </div>
-                  </a>
+                  </div>
                 ))}
               </div>
             )}
           </>
         )}
 
+        {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
+        {activeTab === 'transcode' && (
+          <div className="animate-fade-in">
+            <TranscodeTasksPanel
+              assets={assets}
+              token={token}
+              canManage={canManage}
+              onDelete={handleDeleteAsset}
+              onCancel={async (id) => {
+                if (!token) return;
+                try {
+                  await assetsApi.cancelTranscode(token, id);
+                  setAssets(prev => prev.map(a => a.id === id ? {
+                    ...a,
+                    transcodeStatus: 'PENDING',
+                    transcodeProgress: 0,
+                    transcodeError: null,
+                    hlsPath: null,
+                  } : a));
+                } catch (err) {
+                  alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
+                }
+              }}
+            />
+          </div>
+        )}
+
         {/* ── Members Tab ─────────────────────────────────────────────────── */}
         {activeTab === 'members' && (
           <div className="max-w-3xl animate-fade-in">
@@ -758,6 +973,48 @@ export default function ProjectDetailPage() {
         )}
       </div>
 
+      {/* Delete asset confirm modal */}
+      {confirmDelete && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center"
+             style={{ background: 'rgba(0,0,0,0.7)' }}>
+          <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
+            <div className="flex items-center gap-3 mb-4">
+              <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
+                   style={{ background: 'rgba(248,113,113,0.15)' }}>
+                <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
+                </svg>
+              </div>
+              <div>
+                <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
+                <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
+                  "{confirmDelete.title}"
+                </p>
+              </div>
+            </div>
+            <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
+              This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
+            </p>
+            <div className="flex gap-3 justify-end">
+              <button
+                onClick={() => setConfirmDelete(null)}
+                disabled={!!deletingId}
+                className="btn btn-secondary btn-md"
+              >
+                Cancel
+              </button>
+              <button
+                onClick={confirmDeleteAsset}
+                disabled={!!deletingId}
+                className="btn btn-danger btn-md"
+              >
+                {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* Remove member confirm modal */}
       {confirmRemove && (
         <div className="fixed inset-0 z-50 flex items-center justify-center"

+ 75 - 6
src/app/(dashboard)/settings/page.tsx

@@ -1,11 +1,11 @@
 'use client';
 
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
 import { useAuth } from '@/lib/auth-context';
-import { usersApi } from '@/lib/api';
+import { usersApi, settingsApi } from '@/lib/api';
 
 export default function SettingsPage() {
-  const { user, token, updateUserData, refreshUser } = useAuth();
+  const { user, token, updateUserData } = useAuth();
   const [name, setName] = useState(user?.name ?? '');
   const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl ?? '');
   const [currentPassword, setCurrentPassword] = useState('');
@@ -14,14 +14,25 @@ export default function SettingsPage() {
   const [loading, setLoading] = useState(false);
   const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
 
-  if (!user || !token) return null;
+  // Registration toggle (admin only)
+  const [registrationEnabled, setRegistrationEnabled] = useState(true);
+  const [loadingReg, setLoadingReg] = useState(false);
+
+  const isAdmin = user?.globalRole === 'ADMIN';
+
+  useEffect(() => {
+    if (!token || !isAdmin) return;
+    settingsApi.getRegistration(token).then(({ enabled }) => {
+      setRegistrationEnabled(enabled);
+    }).catch(() => {});
+  }, [token, isAdmin]);
 
   const handleProfile = async (e: React.FormEvent) => {
     e.preventDefault();
     setLoading(true);
     setMessage(null);
     try {
-      const { user: updated } = await usersApi.updateMe(token, {
+      const { user: updated } = await usersApi.updateMe(token!, {
         name: name.trim(),
         avatarUrl: avatarUrl.trim() || undefined,
       });
@@ -47,7 +58,7 @@ export default function SettingsPage() {
     setLoading(true);
     setMessage(null);
     try {
-      const { user: updated } = await usersApi.updateMe(token, {
+      const { user: updated } = await usersApi.updateMe(token!, {
         currentPassword,
         newPassword,
       });
@@ -63,6 +74,21 @@ export default function SettingsPage() {
     }
   };
 
+  const handleRegistrationToggle = async (enabled: boolean) => {
+    if (!token) return;
+    setLoadingReg(true);
+    try {
+      await settingsApi.setRegistration(token, enabled);
+      setRegistrationEnabled(enabled);
+    } catch (err) {
+      // ignore
+    } finally {
+      setLoadingReg(false);
+    }
+  };
+
+  if (!user || !token) return null;
+
   return (
     <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
       {/* Header */}
@@ -189,6 +215,49 @@ export default function SettingsPage() {
           </form>
         </section>
 
+        {/* Admin: Workspace settings */}
+        {isAdmin && (
+          <section className="card p-6">
+            <h2 className="text-base font-semibold mb-5" style={{ color: 'var(--text)' }}>
+              Workspace Settings
+            </h2>
+            <div className="space-y-4">
+              {/* Registration toggle */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-sm font-medium" style={{ color: 'var(--text)' }}>
+                    Member Registration
+                  </p>
+                  <p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>
+                    Allow new users to create an account via the login page.
+                    When disabled, only invited users can join via invite link.
+                  </p>
+                </div>
+                <button
+                  onClick={() => handleRegistrationToggle(!registrationEnabled)}
+                  disabled={loadingReg}
+                  className={`relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
+                    registrationEnabled ? 'bg-indigo-600' : 'bg-gray-600'
+                  }`}
+                  style={{ outline: 'none' }}
+                >
+                  <span
+                    className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition duration-200 ease-in-out ${
+                      registrationEnabled ? 'translate-x-5' : 'translate-x-0'
+                    }`}
+                  />
+                </button>
+              </div>
+
+              <p className="text-xs" style={{ color: registrationEnabled ? 'var(--text-subtle)' : '#FCA5A5' }}>
+                {registrationEnabled
+                  ? 'Registration is open — anyone can sign up.'
+                  : 'Registration is closed — only invited users can join via invite link.'}
+              </p>
+            </div>
+          </section>
+        )}
+
         {/* Account info */}
         <section className="card p-6">
           <h2 className="text-base font-semibold mb-4" style={{ color: 'var(--text)' }}>

+ 405 - 82
src/app/(dashboard)/users/page.tsx

@@ -1,23 +1,47 @@
 'use client';
 
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
 import { useAuth } from '@/lib/auth-context';
-import { usersApi, AdminUser } from '@/lib/api';
+import { usersApi, projectsApi, invitationsApi, AdminUser, AdminInvitation } from '@/lib/api';
 
 const GLOBAL_ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
   ADMIN:  { label: 'Admin',  badge: 'badge-danger' },
   MEMBER: { label: 'Member', badge: 'badge-muted' },
 };
 
+const INVITE_ROLE_LABELS: Record<string, string> = {
+  ADMIN:   'Admin',
+  EDITOR:  'Editor',
+  REVIEWER:'Reviewer',
+  VIEWER:  'Viewer',
+};
+
 export default function UsersPage() {
   const { user: currentUser, token } = useAuth();
   const [users, setUsers] = useState<AdminUser[]>([]);
+  const [invitations, setInvitations] = useState<AdminInvitation[]>([]);
+  const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
   const [loading, setLoading] = useState(true);
   const [updating, setUpdating] = useState<string | null>(null);
   const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
+  const [activeTab, setActiveTab] = useState<'users' | 'invites'>('users');
+
+  // Invite form
+  const [inviteEmail, setInviteEmail] = useState('');
+  const [inviteProject, setInviteProject] = useState('');
+  const [inviteRole, setInviteRole] = useState('REVIEWER');
+  const [inviting, setInviting] = useState(false);
+  const [inviteError, setInviteError] = useState('');
+  const [inviteSuccess, setInviteSuccess] = useState('');
+  const [createdLink, setCreatedLink] = useState('');
+  const [revokingId, setRevokingId] = useState<string | null>(null);
+  const [copiedId, setCopiedId] = useState<string | null>(null);
+  const inviteUrlMap = useRef<Record<string, string>>({});
+
+  const isAdmin = currentUser?.globalRole === 'ADMIN';
 
   const loadUsers = useCallback(async () => {
-    if (!token || currentUser?.globalRole !== 'ADMIN') return;
+    if (!token || !isAdmin) return;
     try {
       const { users: u } = await usersApi.list(token);
       setUsers(u);
@@ -26,10 +50,94 @@ export default function UsersPage() {
     } finally {
       setLoading(false);
     }
-  }, [token, currentUser?.globalRole]);
+  }, [token, isAdmin]);
+
+  const loadInvitations = useCallback(async () => {
+    if (!token || !isAdmin) return;
+    try {
+      const [{ invitations: inv }, { projects: proj }] = await Promise.all([
+        invitationsApi.listAll(token),
+        projectsApi.list(token),
+      ]);
+      setInvitations(inv);
+      setProjects(proj.map((p: any) => ({ id: p.id, name: p.name })));
+      // Cache invite URLs
+      for (const i of inv) {
+        inviteUrlMap.current[i.token] = `${window.location.origin}/invite/${i.token}`;
+      }
+    } catch {
+      console.error('Failed to load invitations');
+    } finally {
+      setLoading(false);
+    }
+  }, [token, isAdmin]);
+
+  useEffect(() => {
+    if (!token || !isAdmin) return;
+    if (activeTab === 'users') loadUsers();
+    else loadInvitations();
+  }, [token, isAdmin, activeTab, loadUsers, loadInvitations]);
+
+  // ── Send invite ──────────────────────────────────────────────────────────────
+  const handleInvite = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!token || !inviteEmail.trim() || !inviteProject) return;
+    setInviting(true);
+    setInviteError('');
+    setInviteSuccess('');
+    setCreatedLink('');
+    try {
+      const { inviteUrl } = await invitationsApi.adminInvite(token, inviteEmail.trim(), inviteProject, inviteRole);
+      const fullUrl = `${window.location.origin}${inviteUrl}`;
+      inviteUrlMap.current[inviteUrl.split('/').pop()!] = fullUrl;
+      setInvitations(prev => [...invitations, {
+        id: Math.random().toString(),
+        email: inviteEmail.trim(),
+        projectId: inviteProject,
+        role: inviteRole,
+        token: inviteUrl.split('/').pop()!,
+        status: 'PENDING',
+        invitedBy: null,
+        expiresAt: '',
+        createdAt: new Date().toISOString(),
+        project: projects.find(p => p.id === inviteProject)!,
+      }]);
+      setInviteEmail('');
+      setInviteProject('');
+      setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
+      await navigator.clipboard.writeText(fullUrl).catch(() => {});
+      setCreatedLink(fullUrl);
+      setTimeout(() => setInviteSuccess(''), 4000);
+    } catch (err) {
+      setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
+    } finally {
+      setInviting(false);
+    }
+  };
+
+  // ── Revoke invite ────────────────────────────────────────────────────────────
+  const handleRevoke = async (invitationId: string) => {
+    if (!token) return;
+    setRevokingId(invitationId);
+    try {
+      await invitationsApi.revoke(token, invitationId);
+      setInvitations(prev => prev.filter(i => i.id !== invitationId));
+    } catch {
+      alert('Failed to revoke invitation');
+    } finally {
+      setRevokingId(null);
+    }
+  };
 
-  useEffect(() => { loadUsers(); }, [loadUsers]);
+  // ── Copy link ────────────────────────────────────────────────────────────────
+  const handleCopy = async (inv: AdminInvitation) => {
+    const url = inviteUrlMap.current[inv.token] ?? `${window.location.origin}/invite/${inv.token}`;
+    await navigator.clipboard.writeText(url).catch(() => {});
+    setCopiedId(inv.id);
+    setTimeout(() => setCopiedId(null), 2000);
+  };
 
+  // ── User actions ─────────────────────────────────────────────────────────────
   const handleGlobalRoleChange = async (userId: string, globalRole: string) => {
     if (!token) return;
     setUpdating(userId);
@@ -67,7 +175,7 @@ export default function UsersPage() {
     }
   };
 
-  if (currentUser?.globalRole !== 'ADMIN') {
+  if (!isAdmin) {
     return (
       <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
         <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Admin access required</p>
@@ -85,12 +193,35 @@ export default function UsersPage() {
                 borderBottom: '1px solid rgba(255,255,255,0.06)',
               }}>
         <div>
-          <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Users</h1>
+          <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Workspace</h1>
           <p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
-            {loading ? '…' : `${users.length} user${users.length !== 1 ? 's' : ''}`}
+            {loading ? '…' : activeTab === 'users' ? `${users.length} user${users.length !== 1 ? 's' : ''}` : `${invitations.length} pending invitation${invitations.length !== 1 ? 's' : ''}`}
           </p>
         </div>
-        <button onClick={loadUsers} className="btn btn-secondary btn-md">
+
+        {/* Tabs */}
+        <div className="flex items-center gap-1 p-1 rounded-lg"
+             style={{ background: 'rgba(255,255,255,0.04)' }}>
+          {[['users', 'Users'], ['invites', 'Invitations']].map(([tab, label]) => (
+            <button key={tab}
+              onClick={() => setActiveTab(tab as any)}
+              className="px-4 py-1.5 rounded-md text-xs font-medium transition-all"
+              style={{
+                background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
+                color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
+              }}>
+              {label}
+              {tab === 'invites' && invitations.length > 0 && (
+                <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full"
+                      style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
+                  {invitations.length}
+                </span>
+              )}
+            </button>
+          ))}
+        </div>
+
+        <button onClick={() => activeTab === 'users' ? loadUsers() : loadInvitations()} className="btn btn-secondary btn-md">
           <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
             <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
           </svg>
@@ -99,93 +230,285 @@ export default function UsersPage() {
       </header>
 
       <div className="px-8 py-6">
-        {loading ? (
-          <div className="space-y-3">
-            {[1,2,3,4].map(i => (
-              <div key={i} className="card h-20 skeleton" style={{ animationDelay: `${i*60}ms` }} />
-            ))}
-          </div>
-        ) : (
-          <div className="space-y-3">
-            {users.map(u => {
-              const roleCfg = GLOBAL_ROLE_CONFIG[u.globalRole] ?? GLOBAL_ROLE_CONFIG.MEMBER;
-              const isMe = u.id === currentUser?.id;
-
-              return (
-                <div key={u.id}
-                     className="card flex items-center gap-4 p-4 animate-fade-in"
-                     style={{ opacity: u.active ? 1 : 0.5 }}>
-                  {/* Avatar */}
-                  <div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold shrink-0"
-                       style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
-                    {u.name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
-                  </div>
 
-                  {/* Info */}
-                  <div className="flex-1 min-w-0">
-                    <div className="flex items-center gap-2">
-                      <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{u.name}</span>
-                      {isMe && <span className="badge badge-brand text-[10px]">you</span>}
-                      {!u.active && <span className="badge badge-muted text-[10px]">inactive</span>}
+        {/* ── Users Tab ──────────────────────────────────────────────────────── */}
+        {activeTab === 'users' && (
+          <>
+            {loading ? (
+              <div className="space-y-3">
+                {[1,2,3,4].map(i => (
+                  <div key={i} className="card h-20 skeleton" style={{ animationDelay: `${i*60}ms` }} />
+                ))}
+              </div>
+            ) : (
+              <div className="space-y-3">
+                {users.map(u => {
+                  const roleCfg = GLOBAL_ROLE_CONFIG[u.globalRole] ?? GLOBAL_ROLE_CONFIG.MEMBER;
+                  const isMe = u.id === currentUser?.id;
+
+                  return (
+                    <div key={u.id}
+                         className="card flex items-center gap-4 p-4 animate-fade-in"
+                         style={{ opacity: u.active ? 1 : 0.5 }}>
+                      {/* Avatar */}
+                      <div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold shrink-0"
+                           style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
+                        {u.name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
+                      </div>
+
+                      {/* Info */}
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2">
+                          <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{u.name}</span>
+                          {isMe && <span className="badge badge-brand text-[10px]">you</span>}
+                          {!u.active && <span className="badge badge-muted text-[10px]">inactive</span>}
+                        </div>
+                        <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
+                      </div>
+
+                      {/* Stats */}
+                      <div className="hidden sm:flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
+                        <span>{u._count?.memberships ?? 0} projects</span>
+                        <span>{u._count?.comments ?? 0} comments</span>
+                        <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
+                      </div>
+
+                      {/* Role selector */}
+                      <div className="shrink-0">
+                        <select
+                          value={u.globalRole}
+                          onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
+                          disabled={updating === u.id || isMe}
+                          className="input text-xs py-1.5 pr-6"
+                          style={{ width: 'auto', minWidth: 0 }}
+                        >
+                          {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
+                            <option key={value} value={value}>{cfg.label}</option>
+                          ))}
+                        </select>
+                      </div>
+
+                      {/* Actions */}
+                      {!isMe && (
+                        <div className="flex items-center gap-1 shrink-0">
+                          <button
+                            onClick={() => handleToggleActive(u.id, u.active)}
+                            disabled={updating === u.id}
+                            className={`btn btn-sm ${u.active ? 'btn-secondary' : 'btn-primary'}`}
+                            title={u.active ? 'Deactivate' : 'Activate'}
+                          >
+                            {u.active ? (
+                              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
+                              </svg>
+                            ) : (
+                              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
+                              </svg>
+                            )}
+                          </button>
+                          <button
+                            onClick={() => setConfirmDelete(u.id)}
+                            className="btn btn-danger btn-sm"
+                            title="Delete user"
+                          >
+                            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                            </svg>
+                          </button>
+                        </div>
+                      )}
                     </div>
-                    <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
-                  </div>
+                  );
+                })}
+              </div>
+            )}
+          </>
+        )}
 
-                  {/* Stats */}
-                  <div className="hidden sm:flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
-                    <span>{u._count?.memberships ?? 0} projects</span>
-                    <span>{u._count?.comments ?? 0} comments</span>
-                    <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
-                  </div>
+        {/* ── Invitations Tab ────────────────────────────────────────────────── */}
+        {activeTab === 'invites' && (
+          <div className="max-w-3xl animate-fade-in">
+            {/* Invite form */}
+            <div className="card p-5 mb-6">
+              <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
+                Invite member
+              </h2>
 
-                  {/* Role selector */}
-                  <div className="shrink-0">
+              <form onSubmit={handleInvite} className="space-y-4">
+                <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
+                  <div className="sm:col-span-2">
+                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
+                    <input
+                      type="email"
+                      className="input"
+                      value={inviteEmail}
+                      onChange={e => setInviteEmail(e.target.value)}
+                      placeholder="colleague@company.com"
+                      required
+                    />
+                  </div>
+                  <div>
+                    <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
                     <select
-                      value={u.globalRole}
-                      onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
-                      disabled={updating === u.id || isMe}
-                      className="input text-xs py-1.5 pr-6"
-                      style={{ width: 'auto', minWidth: 0 }}
+                      className="input"
+                      value={inviteRole}
+                      onChange={e => setInviteRole(e.target.value)}
                     >
-                      {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
-                        <option key={value} value={value}>{cfg.label}</option>
+                      {Object.entries(INVITE_ROLE_LABELS).map(([v, l]) => (
+                        <option key={v} value={v}>{l}</option>
                       ))}
                     </select>
                   </div>
+                </div>
+
+                <div>
+                  <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Project</label>
+                  <select
+                    className="input"
+                    value={inviteProject}
+                    onChange={e => setInviteProject(e.target.value)}
+                    required
+                  >
+                    <option value="">Select a project…</option>
+                    {projects.map(p => (
+                      <option key={p.id} value={p.id}>{p.name}</option>
+                    ))}
+                  </select>
+                </div>
 
-                  {/* Actions */}
-                  {!isMe && (
-                    <div className="flex items-center gap-1 shrink-0">
-                      <button
-                        onClick={() => handleToggleActive(u.id, u.active)}
-                        disabled={updating === u.id}
-                        className={`btn btn-sm ${u.active ? 'btn-secondary' : 'btn-primary'}`}
-                        title={u.active ? 'Deactivate' : 'Activate'}
-                      >
-                        {u.active ? (
-                          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                            <path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
-                          </svg>
-                        ) : (
-                          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                            <path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
-                          </svg>
-                        )}
-                      </button>
-                      <button
-                        onClick={() => setConfirmDelete(u.id)}
-                        className="btn btn-danger btn-sm"
-                        title="Delete user"
-                      >
+                {inviteError && (
+                  <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
+                )}
+
+                <div className="flex items-center gap-3">
+                  <button type="submit" disabled={inviting || !inviteEmail.trim() || !inviteProject} className="btn btn-primary btn-md">
+                    {inviting ? 'Sending…' : (
+                      <span className="flex items-center gap-1.5">
                         <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                          <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
                         </svg>
-                      </button>
-                    </div>
+                        Send Invite
+                      </span>
+                    )}
+                  </button>
+                  {inviteSuccess && (
+                    <span className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</span>
                   )}
                 </div>
-              );
-            })}
+
+                {createdLink && (
+                  <div className="rounded-lg p-3 animate-scale-in"
+                       style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
+                    <div className="flex items-center gap-2 mb-1">
+                      <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                      </svg>
+                      <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>Link copied to clipboard!</span>
+                    </div>
+                    <p className="text-[10px] truncate" style={{ color: 'rgba(134,239,172,0.7)' }}>{createdLink}</p>
+                  </div>
+                )}
+              </form>
+            </div>
+
+            {/* Pending invitations */}
+            <div className="card overflow-hidden">
+              <div className="px-5 py-4 border-b flex items-center justify-between"
+                   style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
+                <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Pending invitations</h2>
+                <span className="text-xs px-2 py-0.5 rounded-full"
+                      style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
+                  {invitations.length}
+                </span>
+              </div>
+
+              {loading ? (
+                <div className="p-8 text-center">
+                  <div className="w-5 h-5 rounded-full animate-spin mx-auto"
+                       style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
+                </div>
+              ) : invitations.length === 0 ? (
+                <div className="p-8 text-center">
+                  <div className="w-12 h-12 rounded-2xl mx-auto mb-3 flex items-center justify-center"
+                       style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
+                    <svg className="w-5 h-5" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
+                    </svg>
+                  </div>
+                  <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No pending invitations</p>
+                  <p className="text-xs mt-1" style={{ color: 'var(--text-subtle)' }}>Use the form above to invite new members</p>
+                </div>
+              ) : (
+                <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
+                  {invitations.map(inv => (
+                    <div key={inv.id} className="flex items-center gap-4 px-5 py-4">
+                      {/* Icon */}
+                      <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
+                           style={{ background: 'rgba(99,102,241,0.08)' }}>
+                        <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
+                        </svg>
+                      </div>
+
+                      {/* Info */}
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2">
+                          <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
+                          <span className="badge badge-brand text-[10px] capitalize">{inv.role.toLowerCase()}</span>
+                        </div>
+                        <div className="flex items-center gap-2 mt-0.5">
+                          <span className="text-xs" style={{ color: '#818CF8' }}>{inv.project?.name}</span>
+                          <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>·</span>
+                          <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
+                            Sent {new Date(inv.createdAt).toLocaleDateString()} · Expires {new Date(inv.expiresAt).toLocaleDateString()}
+                          </span>
+                        </div>
+                      </div>
+
+                      {/* Actions */}
+                      <div className="flex items-center gap-1.5 shrink-0">
+                        <button
+                          onClick={() => handleCopy(inv)}
+                          className="btn btn-secondary btn-sm"
+                          title="Copy invite link"
+                        >
+                          {copiedId === inv.id ? (
+                            <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                            </svg>
+                          ) : (
+                            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
+                            </svg>
+                          )}
+                        </button>
+                        <button
+                          onClick={() => handleRevoke(inv.id)}
+                          disabled={revokingId === inv.id}
+                          className="btn btn-danger btn-sm"
+                          title="Revoke invitation"
+                        >
+                          {revokingId === inv.id ? '…' : (
+                            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                              <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                            </svg>
+                          )}
+                        </button>
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+
+              {invitations.length > 0 && (
+                <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
+                  <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
+                    Invitation links expire after 7 days. Copy a link and send it manually to your invitee.
+                  </p>
+                </div>
+              )}
+            </div>
           </div>
         )}
       </div>

+ 43 - 0
src/app/invite/[token]/page.tsx

@@ -24,6 +24,8 @@ export default function InvitePage() {
       .finally(() => setLoading(false));
   }, [token]);
 
+  const isUsedOrExpired = invitation && (invitation.alreadyMember || invitation.isExpired);
+
   const handleAccept = async () => {
     if (!authToken) {
       // Redirect to login with invite token, come back after
@@ -76,6 +78,47 @@ export default function InvitePage() {
     );
   }
 
+  {/* Show project info even for expired/used invitations */}
+  if (isUsedOrExpired) {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>
+        <div className="card p-8 max-w-sm w-full mx-4 text-center">
+          <div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-4"
+               style={{ background: invitation!.alreadyMember ? 'rgba(34,197,94,0.1)' : 'rgba(251,191,36,0.1)' }}>
+            {invitation!.alreadyMember ? (
+              <svg className="w-6 h-6" style={{ color: '#22C55E' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+              </svg>
+            ) : (
+              <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+              </svg>
+            )}
+          </div>
+          <h1 className="text-lg font-semibold mb-2" style={{ color: '#F9FAFB' }}>
+            {invitation!.alreadyMember ? 'Already Joined' : 'Invitation Expired'}
+          </h1>
+          <p className="text-sm mb-1" style={{ color: '#6B7280' }}>
+            {invitation!.alreadyMember
+              ? `You're already a member of ${invitation!.projectName}.`
+              : `This invitation to ${invitation!.projectName} is no longer valid.`}
+          </p>
+          <p className="text-xs mb-6" style={{ color: '#4B5563' }}>
+            {invitation!.alreadyMember
+              ? 'Visit your projects to start collaborating.'
+              : 'Ask the project admin to send a new invitation.'}
+          </p>
+          <button
+            onClick={() => user ? router.push('/projects') : router.push('/login')}
+            className="btn btn-primary btn-md w-full"
+          >
+            {user ? 'Go to Projects' : 'Sign In'}
+          </button>
+        </div>
+      </div>
+    );
+  }
+
   if (accepted) {
     return (
       <div className="min-h-screen flex items-center justify-center" style={{ background: '#0A0B14' }}>

+ 302 - 78
src/app/review/[assetId]/page.tsx

@@ -3,7 +3,7 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
-import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData } from '@/lib/api';
+import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
 import { Avatar } from '@/components/ui/avatar';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
@@ -19,6 +19,15 @@ const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass
   REJECTED:          { label: 'Rejected',           colorClass: 'text-danger', bgClass: 'badge-danger',  dotClass: 'status-dot-rejected' },
 };
 
+const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
+  PENDING:           { label: 'Queued',             color: '#94A3B8', bg: 'rgba(148,163,184,0.08)',  spinner: false },
+  UPLOADING:         { label: 'Uploading video…',   color: '#60A5FA', bg: 'rgba(96,165,250,0.08)',   spinner: true  },
+  PROCESSING:        { label: 'Transcoding…',      color: '#A78BFA', bg: 'rgba(167,139,250,0.08)',  spinner: true  },
+  COMPLETED:         { label: 'Ready',              color: '#34D399', bg: 'rgba(52,211,153,0.08)',   spinner: false },
+  FAILED:            { label: 'Transcode failed',   color: '#F87171', bg: 'rgba(248,113,113,0.08)',  spinner: false },
+  UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)',   spinner: false },
+};
+
 function formatTimecode(seconds: number, fps: number = 30): string {
   if (!seconds || isNaN(seconds)) return '00:00:00:00';
   const h = Math.floor(seconds / 3600);
@@ -66,6 +75,31 @@ export default function ReviewPage() {
 
   const fps = asset?.fps ?? 30;
 
+  // Derive the current user's project role
+  const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
+  const isProjectAdmin = currentUserRole === 'ADMIN';
+  const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
+
+  // ── Poll for transcode progress ───────────────────────────────────────────
+  const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
+  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
+
+  useEffect(() => {
+    if (isTranscoding) {
+      if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
+      return;
+    }
+    if (pollRef.current) return;
+    pollRef.current = setInterval(async () => {
+      if (!token) return;
+      try {
+        const { asset: updated } = await assetsApi.getStatus(token, assetId);
+        setAsset(prev => prev ? { ...prev, ...updated } : prev);
+      } catch {}
+    }, 2000);
+    return () => { if (pollRef.current) clearInterval(pollRef.current); };
+  }, [token, assetId, isTranscoding]);
+
   // Load asset + comments
   const loadData = useCallback(async () => {
     if (!token) return;
@@ -146,13 +180,23 @@ export default function ReviewPage() {
     }
   };
 
-  const handleResolve = async (commentId: string) => {
+  const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
     if (!token) return;
     try {
-      const { comment } = await commentsApi.resolve(token, commentId);
+      const { comment } = await commentsApi.resolve(token, commentId, action);
       setComments(prev => prev.map(c => c.id === commentId ? comment : c));
-    } catch {
-      alert('Failed to resolve comment');
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to update comment');
+    }
+  };
+
+  const handleRequestResolve = async (commentId: string) => {
+    if (!token) return;
+    try {
+      const { comment } = await commentsApi.requestResolve(token, commentId);
+      setComments(prev => prev.map(c => c.id === commentId ? comment : c));
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to request resolve');
     }
   };
 
@@ -171,22 +215,13 @@ export default function ReviewPage() {
   };
 
   // ── Annotation actions ─────────────────────────────────────────────────────
-  // User clicks "Add annotation" on a comment
+  // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
   const handleAddAnnotationClick = (comment: Comment) => {
     const existingCount = comment.annotations?.length ?? 0;
     if (existingCount >= MAX_ANNOTATIONS) {
       alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
       return;
     }
-    // Seek to comment timestamp if it exists
-    if (comment.timestamp != null) {
-      const videoEl = document.querySelector('video') as HTMLVideoElement | null;
-      if (videoEl) {
-        videoEl.pause();
-        videoEl.currentTime = comment.timestamp;
-      }
-      setCurrentTime(comment.timestamp);
-    }
     setPendingStrokes([]);
     setAnnotatingComment(comment);
     setDrawMode(true);
@@ -203,37 +238,23 @@ export default function ReviewPage() {
     });
   };
 
-  // Save pending strokes — use handleAddComment with replyTo set if annotating a comment
-  const handleSaveAnnotations = (content: string, timestamp?: number) => {
+  // Save pending strokes as annotation on the parent comment (no separate reply)
+  const handleSaveAnnotations = () => {
     const strokes = pendingStrokesRef.current;
     const parent = annotatingCommentRef.current;
+    if (!token || !parent || strokes.length === 0) {
+      setPendingStrokes([]);
+      setDrawMode(false);
+      setAnnotatingComment(null);
+      return;
+    }
+    setSubmitting(true);
     setPendingStrokes([]);
     setDrawMode(false);
     setAnnotatingComment(null);
-    if (parent) {
-      setReplyTo(parent);
-      setNewComment(content.trim() || '(annotation)');
-      // Also call handleAddComment with strokes and parentId
-      const parentId = parent.id;
-      if (!token) return;
-      setSubmitting(true);
-      commentsApi.create(token, assetId, {
-        content: content.trim() || '(annotation)',
-        timestamp,
-        annotations: strokes,
-        parentId,
-      }).then(({ comment }) => {
-        setComments(prev => prev.map(c =>
-          c.id === parentId
-            ? { ...c, replies: [...(c.replies ?? []), comment] }
-            : c
-        ));
-        setReplyTo(null);
-        setNewComment('');
-      }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save')).finally(() => setSubmitting(false));
-    } else {
-      handleAddComment(content.trim() || '(annotation)', timestamp, strokes);
-    }
+    commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
+      setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
+    }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
   };
 
   // Discard pending strokes
@@ -285,6 +306,8 @@ export default function ReviewPage() {
   const status = asset?.status ?? 'PENDING_REVIEW';
   const statusCfg = STATUS_CONFIG[status];
 
+  const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
+
   const videoUrl = asset?.hlsPath
     ? `${API_BASE}/uploads${asset.hlsPath}`
     : asset
@@ -294,8 +317,8 @@ export default function ReviewPage() {
   const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
   const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
 
-  // Flatten all comment annotations with their timestamps for the player to display
-  const visibleAnnotations = allComments.flatMap(c =>
+  // Only main comments (not replies) have annotations that should show on the video
+  const visibleAnnotations = visibleComments.flatMap(c =>
     (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
   );
 
@@ -403,11 +426,78 @@ export default function ReviewPage() {
             onDrawModeChange={setDrawMode}
             onDrawToolChange={setDrawTool}
             onDrawColorChange={setDrawColor}
+            pendingStrokes={pendingStrokes}
             onStrokeComplete={handleStrokeComplete}
             onTimeUpdate={handleTimeUpdate}
             onCommentClick={handleCommentSeek}
           />
 
+          {/* Transcode status overlay — shown when video is not ready */}
+          {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
+            <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
+                 style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
+              {transcodeCfg.spinner ? (
+                <div className="w-8 h-8 rounded-full animate-spin shrink-0"
+                     style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
+              ) : asset.transcodeStatus === 'FAILED' ? (
+                <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
+                     style={{ background: 'rgba(248,113,113,0.15)' }}>
+                  <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+                  </svg>
+                </div>
+              ) : (
+                <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
+                     style={{ background: 'rgba(251,191,36,0.15)' }}>
+                  <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+                  </svg>
+                </div>
+              )}
+
+              <div className="flex-1 min-w-0">
+                <div className="flex items-center gap-2 mb-1">
+                  <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
+                    {transcodeCfg.label}
+                  </span>
+                  {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
+                    <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
+                      {asset.transcodeProgress}%
+                    </span>
+                  )}
+                </div>
+                {asset.transcodeStatus === 'PROCESSING' && (
+                  <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
+                    <div
+                      className="h-full rounded-full transition-all duration-500"
+                      style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
+                    />
+                  </div>
+                )}
+                {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
+                  <p className="text-xs mt-1" style={{ color: '#F87171' }}>
+                    {asset.transcodeError}
+                  </p>
+                )}
+                {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
+                  <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
+                    {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
+                  </p>
+                )}
+                {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
+                  <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
+                    Converting from {asset.codec.toUpperCase()} → H.264/AAC
+                  </p>
+                )}
+                {asset.transcodeStatus === 'UPLOADING' && (
+                  <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
+                    Video uploaded — queued for processing
+                  </p>
+                )}
+              </div>
+            </div>
+          )}
+
           {/* Keyboard shortcuts */}
           <div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
             <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
@@ -461,7 +551,7 @@ export default function ReviewPage() {
               </svg>
               <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
                 {annotatingComment
-                  ? `Drawing on comment by ${annotatingComment.user?.name} — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
+                  ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
                   : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
               </span>
               <div className="flex items-center gap-1.5">
@@ -473,12 +563,9 @@ export default function ReviewPage() {
                   Undo all
                 </button>
                 <button
-                  onClick={() => {
-                    const text = window.prompt('Add a note (optional):') ?? '';
-                    handleSaveAnnotations(text, currentTime);
-                  }}
-                  disabled={submitting}
-                  className="text-xs px-2 py-0.5 rounded transition-colors"
+                  onClick={handleSaveAnnotations}
+                  disabled={submitting || pendingStrokes.length === 0}
+                  className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
                   style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
                 >
                   {submitting ? 'Saving…' : 'Save'}
@@ -510,10 +597,14 @@ export default function ReviewPage() {
                     comment={comment}
                     currentUserId={user?.id ?? ''}
                     fps={fps}
+                    canComment={canComment}
+                    isProjectAdmin={isProjectAdmin}
                     onTimestampClick={handleCommentSeek}
                     onReply={() => { setReplyTo(comment); }}
-                    onResolve={() => handleResolve(comment.id)}
-                    onDelete={() => handleDeleteComment(comment.id)}
+                    onResolve={(action) => handleResolve(comment.id, action)}
+                    onRequestResolve={() => handleRequestResolve(comment.id)}
+                    onDeleteSelf={() => handleDeleteComment(comment.id)}
+                    onDelete={(id) => handleDeleteComment(id)}
                     onAddAnnotation={() => handleAddAnnotationClick(comment)}
                     onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
                   />
@@ -546,7 +637,7 @@ export default function ReviewPage() {
                   <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
                 </svg>
                 {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
-                {annotatingComment ? ` → will be saved as reply to "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
+                {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
                 <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
               </div>
             )}
@@ -606,9 +697,13 @@ function CommentItem({
   comment,
   currentUserId,
   fps,
+  canComment,
+  isProjectAdmin,
   onTimestampClick,
   onReply,
   onResolve,
+  onRequestResolve,
+  onDeleteSelf,
   onDelete,
   onAddAnnotation,
   onDeleteAnnotation,
@@ -616,23 +711,35 @@ function CommentItem({
   comment: Comment;
   currentUserId: string;
   fps: number;
+  canComment: boolean | undefined;
+  isProjectAdmin: boolean;
   onTimestampClick: (c: Comment) => void;
   onReply: () => void;
-  onResolve: () => void;
-  onDelete: () => void;
+  onResolve: (action: 'approve' | 'reject') => void;
+  onRequestResolve: () => void;
+  onDeleteSelf: () => void;
+  onDelete: (id: string) => void;
   onAddAnnotation: () => void;
   onDeleteAnnotation: (annotations: AnnotationData[]) => void;
 }) {
   const isOwner = comment.userId === currentUserId;
+  const isCommentAuthor = comment.userId === currentUserId;
   const name = comment.user?.name ?? 'Unknown';
   const isReply = !!comment.parentId;
   const annotations = comment.annotations ?? [];
   const canAddMore = annotations.length < MAX_ANNOTATIONS;
 
+  // Resolve state machine
+  const isResolved = comment.resolveStatus === 'RESOLVED';
+  const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
+  const canApprove = isCommentAuthor || isProjectAdmin;
+  const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
+  const canReopen = isResolved && canApprove;
+
   return (
     <div
       className="p-4 animate-fade-in"
-      style={{ opacity: comment.resolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
+      style={{ opacity: isResolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
     >
       <div className="flex gap-2.5">
         <Avatar name={name} size="sm" />
@@ -650,10 +757,21 @@ function CommentItem({
                 {formatTimecode(comment.timestamp, fps)}
               </button>
             )}
-            {comment.resolved && (
+            {isPending && (
+              <span className="text-xs px-1.5 py-0.5 rounded"
+                    style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
+                Pending approval
+              </span>
+            )}
+            {isResolved && (
               <span className="text-xs px-1.5 py-0.5 rounded"
                     style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
-                Resolved
+                Approved
+              </span>
+            )}
+            {isResolved && comment.resolvedBy && (
+              <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
+                by {comment.resolvedBy.name}
               </span>
             )}
             <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
@@ -725,19 +843,90 @@ function CommentItem({
                 </svg>
               </button>
             )}
-            <button
-              onClick={onResolve}
-              className="text-xs px-2 py-1 rounded-md transition-colors"
-              style={{ color: comment.resolved ? '#86EFAC' : '#6366F1' }}
-              title={comment.resolved ? 'Unresolve' : 'Mark as resolved'}
-            >
-              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
-              </svg>
-            </button>
+
+            {/* Resolve / approval workflow buttons */}
+            {!isReply && !isResolved && !isPending && (
+              <>
+                {canRequest ? (
+                  <button
+                    onClick={onRequestResolve}
+                    className="text-xs px-2 py-1 rounded-md transition-colors"
+                    style={{ color: '#6366F1' }}
+                    title="Request resolve approval"
+                  >
+                    <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                    </svg>
+                    Request resolve
+                  </button>
+                ) : (
+                  <span
+                    className="text-xs px-2 py-1 opacity-30"
+                    style={{ color: '#6366F1' }}
+                    title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
+                  >
+                    <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                    </svg>
+                    Request resolve
+                  </span>
+                )}
+              </>
+            )}
+
+            {isPending && canApprove && !isReply && (
+              <>
+                <button
+                  onClick={() => onResolve('approve')}
+                  className="text-xs px-2 py-1 rounded-md transition-colors"
+                  style={{ color: '#86EFAC' }}
+                  title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
+                >
+                  <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                  </svg>
+                  Approve
+                </button>
+                <button
+                  onClick={() => onResolve('reject')}
+                  className="text-xs px-2 py-1 rounded-md transition-colors"
+                  style={{ color: '#FCA5A5' }}
+                  title="Reject resolve request"
+                >
+                  <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                  </svg>
+                  Reject
+                </button>
+              </>
+            )}
+
+            {isPending && !canApprove && !isReply && (
+              <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
+                <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+                Awaiting approval
+              </span>
+            )}
+
+            {canReopen && !isReply && (
+              <button
+                onClick={() => onResolve('reject')}
+                className="text-xs px-2 py-1 rounded-md transition-colors"
+                style={{ color: '#86EFAC' }}
+                title="Reopen comment"
+              >
+                <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                </svg>
+                Reopen
+              </button>
+            )}
+
             {isOwner && (
               <button
-                onClick={onDelete}
+                onClick={onDeleteSelf}
                 className="text-xs px-2 py-1 rounded-md transition-colors"
                 style={{ color: 'var(--text-subtle)' }}
                 title="Delete comment"
@@ -753,17 +942,11 @@ function CommentItem({
           {comment.replies && comment.replies.length > 0 && (
             <div className="mt-3 space-y-3">
               {comment.replies.map(reply => (
-                <CommentItem
+                <ReplyItem
                   key={reply.id}
                   comment={reply}
-                  currentUserId={currentUserId}
-                  fps={fps}
-                  onTimestampClick={onTimestampClick}
-                  onReply={() => {}}
-                  onResolve={onResolve}
-                  onDelete={onDelete}
-                  onAddAnnotation={() => {}}
-                  onDeleteAnnotation={onDeleteAnnotation}
+                  isOwner={reply.userId === currentUserId}
+                  onDelete={() => onDelete(reply.id)}
                 />
               ))}
             </div>
@@ -773,3 +956,44 @@ function CommentItem({
     </div>
   );
 }
+
+// ── ReplyItem ──────────────────────────────────────────────────────────────
+// Replies have no resolve, no annotation, no timestamp — just content + delete
+function ReplyItem({
+  comment,
+  isOwner,
+  onDelete,
+}: {
+  comment: Comment;
+  isOwner: boolean;
+  onDelete: (id: string) => void;
+}) {
+  return (
+    <div className="flex gap-2.5 animate-fade-in">
+      <Avatar name={comment.user?.name ?? 'U'} size="sm" />
+      <div className="flex-1 min-w-0">
+        <div className="flex items-center gap-2 mb-0.5">
+          <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
+            {comment.user?.name ?? 'Unknown'}
+          </span>
+          <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
+            {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+          </span>
+        </div>
+        <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
+          {comment.content}
+        </p>
+        {isOwner && (
+          <button
+            onClick={() => onDelete(comment.id)}
+            className="text-xs mt-1 transition-colors"
+            style={{ color: 'var(--text-subtle)' }}
+            title="Delete reply"
+          >
+            Delete
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}

+ 167 - 31
src/components/comments/CommentPanel.tsx

@@ -8,10 +8,13 @@ import { Button } from '@/components/ui/button';
 interface Props {
   comments: Comment[];
   currentUserId: string;
+  currentUserRole?: string;
+  isProjectAdmin: boolean;
   currentTime: number;
   pendingAnnotation: unknown;
   onAddComment: (data: { content: string; timestamp?: number; annotation?: unknown; parentId?: string }) => Promise<void>;
-  onResolve: (commentId: string) => Promise<void>;
+  onResolve: (commentId: string, action: 'approve' | 'reject') => Promise<void>;
+  onRequestResolve: (commentId: string) => Promise<void>;
   onDelete: (commentId: string) => Promise<void>;
   onCommentClick: (comment: Comment) => void;
 }
@@ -19,10 +22,13 @@ interface Props {
 export function CommentPanel({
   comments,
   currentUserId,
+  currentUserRole,
+  isProjectAdmin,
   currentTime,
   pendingAnnotation,
   onAddComment,
   onResolve,
+  onRequestResolve,
   onDelete,
   onCommentClick,
 }: Props) {
@@ -32,7 +38,9 @@ export function CommentPanel({
   const [replyText, setReplyText] = useState('');
   const [showResolved, setShowResolved] = useState(false);
 
-  const visibleComments = comments.filter(c => !c.resolved || showResolved);
+  const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
+
+  const visibleComments = comments.filter(c => c.resolveStatus !== 'RESOLVED' || showResolved);
   const timestampedComments = visibleComments.filter(c => c.timestamp != null);
   const generalComments = visibleComments.filter(c => c.timestamp == null);
 
@@ -68,6 +76,9 @@ export function CommentPanel({
     }
   };
 
+  const openCount = comments.filter(c => c.resolveStatus !== 'RESOLVED').length;
+  const pendingCount = comments.filter(c => c.resolveStatus === 'PENDING_APPROVAL').length;
+
   return (
     <div className="flex flex-col h-full">
       {/* Header */}
@@ -75,7 +86,8 @@ export function CommentPanel({
         <h2 className="font-semibold text-gray-900">
           Comments
           <span className="ml-2 text-xs text-gray-400 font-normal">
-            {comments.filter(c => !c.resolved).length} open
+            {openCount} open
+            {pendingCount > 0 && <span className="ml-1 text-amber-500">({pendingCount} pending)</span>}
           </span>
         </h2>
         <button
@@ -108,9 +120,12 @@ export function CommentPanel({
               <CommentItem
                 key={comment.id}
                 comment={comment}
-                isOwner={comment.userId === currentUserId}
+                currentUserId={currentUserId}
+                canComment={canComment}
+                isProjectAdmin={isProjectAdmin}
                 onReply={() => setReplyTo(comment)}
-                onResolve={() => onResolve(comment.id)}
+                onResolve={(action) => onResolve(comment.id, action)}
+                onRequestResolve={() => onRequestResolve(comment.id)}
                 onDelete={() => onDelete(comment.id)}
                 onTimestampClick={() => onCommentClick(comment)}
               />
@@ -128,9 +143,12 @@ export function CommentPanel({
               <CommentItem
                 key={comment.id}
                 comment={comment}
-                isOwner={comment.userId === currentUserId}
+                currentUserId={currentUserId}
+                canComment={canComment}
+                isProjectAdmin={isProjectAdmin}
                 onReply={() => setReplyTo(comment)}
-                onResolve={() => onResolve(comment.id)}
+                onResolve={(action) => onResolve(comment.id, action)}
+                onRequestResolve={() => onRequestResolve(comment.id)}
                 onDelete={() => onDelete(comment.id)}
               />
             ))}
@@ -141,7 +159,20 @@ export function CommentPanel({
       {/* Reply form */}
       {replyTo && (
         <div className="border-t border-gray-200 p-3 bg-blue-50">
-          <p className="text-xs text-blue-600 mb-2">Replying to {replyTo.user.name}</p>
+          <p className="text-xs text-blue-600 mb-2 flex items-center gap-1">
+            <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
+            </svg>
+            Replying to {replyTo.user?.name ?? 'Unknown'}
+            <button
+              onClick={() => setReplyTo(null)}
+              className="ml-auto text-gray-400 hover:text-gray-600"
+            >
+              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+              </svg>
+            </button>
+          </p>
           <form onSubmit={handleReply} className="flex gap-2">
             <textarea
               value={replyText}
@@ -152,7 +183,7 @@ export function CommentPanel({
               autoFocus
             />
             <div className="flex flex-col gap-1">
-              <Button type="submit" size="sm" loading={submitting}>Send</Button>
+              <Button type="submit" size="sm" loading={submitting} disabled={!replyText.trim()}>Send</Button>
               <Button type="button" variant="ghost" size="sm" onClick={() => setReplyTo(null)}>Cancel</Button>
             </div>
           </form>
@@ -176,11 +207,12 @@ export function CommentPanel({
           <textarea
             value={newComment}
             onChange={e => setNewComment(e.target.value)}
-            placeholder="Add a comment..."
-            className="flex-1 text-sm rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
+            placeholder={canComment ? 'Add a comment...' : 'Viewers cannot comment'}
+            disabled={!canComment}
+            className="flex-1 text-sm rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none disabled:bg-gray-50 disabled:text-gray-400"
             rows={2}
           />
-          <Button type="submit" size="sm" loading={submitting} disabled={!newComment.trim()}>
+          <Button type="submit" size="sm" loading={submitting} disabled={!newComment.trim() || !canComment}>
             Send
           </Button>
         </form>
@@ -191,44 +223,77 @@ export function CommentPanel({
 
 function CommentItem({
   comment,
-  isOwner,
+  currentUserId,
+  canComment,
+  isProjectAdmin,
   onReply,
   onResolve,
+  onRequestResolve,
   onDelete,
   onTimestampClick,
 }: {
   comment: Comment;
-  isOwner: boolean;
+  currentUserId: string;
+  canComment: boolean | undefined;
+  isProjectAdmin: boolean;
   onReply: () => void;
-  onResolve: () => void;
+  onResolve: (action: 'approve' | 'reject') => void;
+  onRequestResolve: () => void;
   onDelete: () => void;
   onTimestampClick?: () => void;
 }) {
+  const isOwner = comment.userId === currentUserId;
+  const isCommentAuthor = comment.userId === currentUserId;
+
+  const isResolved = comment.resolveStatus === 'RESOLVED';
+  const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
+  const canApprove = isCommentAuthor || isProjectAdmin;
+  const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
+  const canReopen = isResolved && canApprove;
+
+  const itemClass = `px-4 py-3 hover:bg-gray-50 transition-colors ${isResolved ? 'opacity-60' : ''}`;
+
   return (
-    <div className={`px-4 py-3 hover:bg-gray-50 transition-colors ${comment.resolved ? 'opacity-60' : ''}`}>
+    <div className={itemClass}>
       <div className="flex items-start gap-2.5">
-        <Avatar name={comment.user.name} src={comment.user.avatarUrl} size="sm" />
+        <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
         <div className="flex-1 min-w-0">
-          <div className="flex items-center gap-2">
-            <span className="text-sm font-medium text-gray-900">{comment.user.name}</span>
+          {/* Meta */}
+          <div className="flex items-center gap-2 flex-wrap">
+            <span className="text-sm font-medium text-gray-900">{comment.user?.name ?? 'Unknown'}</span>
             {comment.timestamp != null && onTimestampClick && (
               <button
                 onClick={onTimestampClick}
-                className="text-xs text-blue-600 bg-blue-50 hover:bg-blue-100 px-1.5 py-0.5 rounded font-mono"
+                className="text-xs text-blue-600 bg-blue-50 hover:bg-blue-100 px-1.5 py-0.5 rounded font-mono transition-colors"
               >
                 {formatTime(comment.timestamp)}
               </button>
             )}
-            {comment.resolved && (
-              <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded">Resolved</span>
+            {isPending && (
+              <span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded flex items-center gap-1">
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+                Pending approval
+                {comment.requestedBy && <span className="text-amber-600">by {comment.requestedBy.name}</span>}
+              </span>
+            )}
+            {isResolved && (
+              <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded flex items-center gap-1">
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                </svg>
+                Approved
+                {comment.resolvedBy && <span className="text-green-600">by {comment.resolvedBy.name}</span>}
+              </span>
             )}
           </div>
 
           {/* Annotation preview */}
-          {comment.annotations && (
+          {comment.annotations && comment.annotations.length > 0 && (
             <div className="mt-1 text-xs text-gray-500 italic flex items-center gap-1">
-              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
+              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
               </svg>
               Has drawing annotation
             </div>
@@ -241,9 +306,9 @@ function CommentItem({
             <div className="mt-2 ml-2 border-l-2 border-gray-100 pl-3 space-y-2">
               {comment.replies.map(reply => (
                 <div key={reply.id} className="flex items-start gap-2">
-                  <Avatar name={reply.user.name} size="sm" />
+                  <Avatar name={reply.user?.name ?? 'U'} src={reply.user?.avatarUrl} size="sm" />
                   <div>
-                    <span className="text-xs font-medium text-gray-800">{reply.user.name}</span>
+                    <span className="text-xs font-medium text-gray-800">{reply.user?.name ?? 'Unknown'}</span>
                     <p className="text-sm text-gray-600 mt-0.5">{reply.content}</p>
                   </div>
                 </div>
@@ -256,11 +321,82 @@ function CommentItem({
             <button onClick={onReply} className="text-xs text-gray-400 hover:text-gray-600 transition-colors">
               Reply
             </button>
-            <button onClick={onResolve} className={`text-xs hover:underline transition-colors ${comment.resolved ? 'text-gray-400' : 'text-green-600 hover:text-green-700'}`}>
-              {comment.resolved ? 'Reopen' : 'Resolve'}
-            </button>
+
+            {/* Request resolve */}
+            {!isResolved && !isPending && (
+              canRequest ? (
+                <button
+                  onClick={onRequestResolve}
+                  className="text-xs text-indigo-600 hover:text-indigo-700 transition-colors flex items-center gap-1"
+                >
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                  </svg>
+                  Request resolve
+                </button>
+              ) : (
+                <span className="text-xs text-gray-300 cursor-not-allowed flex items-center gap-1" title={
+                  !canComment ? 'Viewers cannot request resolve'
+                    : isCommentAuthor ? 'Cannot resolve your own comment'
+                    : undefined
+                }>
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                  </svg>
+                  Request resolve
+                </span>
+              )
+            )}
+
+            {/* Approve / Reject when pending */}
+            {isPending && canApprove && (
+              <>
+                <button
+                  onClick={() => onResolve('approve')}
+                  className="text-xs text-green-600 hover:text-green-700 transition-colors flex items-center gap-1"
+                >
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                  </svg>
+                  Approve
+                </button>
+                <button
+                  onClick={() => onResolve('reject')}
+                  className="text-xs text-red-400 hover:text-red-600 transition-colors flex items-center gap-1"
+                >
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                  </svg>
+                  Reject
+                </button>
+              </>
+            )}
+
+            {/* Awaiting approval badge when pending but user can't approve */}
+            {isPending && !canApprove && (
+              <span className="text-xs text-amber-500 flex items-center gap-1">
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+                Awaiting approval
+              </span>
+            )}
+
+            {/* Reopen when resolved */}
+            {canReopen && (
+              <button
+                onClick={() => onResolve('reject')}
+                className="text-xs text-green-600 hover:text-green-700 transition-colors flex items-center gap-1"
+              >
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                </svg>
+                Reopen
+              </button>
+            )}
+
             {isOwner && (
-              <button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-600 transition-colors">
+              <button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-600 transition-colors ml-auto">
                 Delete
               </button>
             )}

+ 263 - 0
src/components/transcode/TranscodeTasksPanel.tsx

@@ -0,0 +1,263 @@
+'use client';
+
+import { useState } from 'react';
+import { Asset, TranscodeStatus } from '@/lib/api';
+
+interface Props {
+  assets: Asset[];
+  token: string | null;
+  canManage: boolean;
+  onDelete: (id: string, title: string) => void;
+  onCancel: (id: string) => void;
+}
+
+const STATUS_CONFIG: Record<TranscodeStatus, {
+  label: string;
+  color: string;
+  bg: string;
+  dot: string;
+  spinning: boolean;
+}> = {
+  PENDING:           { label: 'Queued',         color: '#94A3B8', bg: 'rgba(148,163,184,0.10)', dot: 'bg-slate-400',   spinning: false },
+  UPLOADING:        { label: 'Uploading',       color: '#60A5FA', bg: 'rgba(96,165,250,0.10)',   dot: 'bg-blue-400',    spinning: true  },
+  PROCESSING:       { label: 'Processing',      color: '#A78BFA', bg: 'rgba(167,139,250,0.10)',  dot: 'bg-violet-400',  spinning: true  },
+  COMPLETED:        { label: 'Ready',           color: '#34D399', bg: 'rgba(52,211,153,0.10)',   dot: 'bg-emerald-400', spinning: false },
+  FAILED:           { label: 'Failed',          color: '#F87171', bg: 'rgba(248,113,113,0.10)',  dot: 'bg-red-400',     spinning: false },
+  UNSUPPORTED_CODEC: { label: 'Unsupported',  color: '#FBBF24', bg: 'rgba(251,191,36,0.10)',   dot: 'bg-amber-400',   spinning: false },
+};
+
+function formatDuration(s: number | null | undefined): string {
+  if (!s) return '—';
+  const m = Math.floor(s / 60);
+  const sec = Math.floor(s % 60);
+  return `${m}:${sec.toString().padStart(2, '0')}`;
+}
+
+function TranscodeTaskRow({
+  asset,
+  canManage,
+  onDelete,
+  onCancel,
+}: {
+  asset: Asset;
+  canManage: boolean;
+  onDelete: (id: string, title: string) => void;
+  onCancel: (id: string) => void;
+}) {
+  const cfg = STATUS_CONFIG[asset.transcodeStatus] ?? STATUS_CONFIG.PENDING;
+  const canAct = canManage && asset.transcodeStatus !== 'COMPLETED';
+  const canDelete = canManage;
+
+  return (
+    <div
+      className="flex items-center gap-4 px-5 py-3.5 border-b last:border-0 transition-colors hover:bg-white/[0.02]"
+      style={{ borderColor: 'rgba(255,255,255,0.04)' }}
+    >
+      {/* Status indicator */}
+      <div className="flex items-center justify-center w-8 shrink-0">
+        {cfg.spinning ? (
+          <div
+            className="w-3 h-3 rounded-full animate-spin"
+            style={{ borderColor: cfg.color, borderTopColor: 'transparent', borderWidth: '2px' }}
+          />
+        ) : asset.transcodeStatus === 'COMPLETED' ? (
+          <div className="w-3 h-3 rounded-full" style={{ background: '#34D399' }} />
+        ) : asset.transcodeStatus === 'FAILED' ? (
+          <div className="w-3 h-3 rounded-full" style={{ background: '#F87171' }} />
+        ) : (
+          <div className="w-3 h-3 rounded-full" style={{ background: cfg.color }} />
+        )}
+      </div>
+
+      {/* Info */}
+      <div className="flex-1 min-w-0">
+        <div className="flex items-center gap-2 mb-0.5">
+          <span className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>
+            {asset.title}
+          </span>
+          <span className="text-[10px] px-1.5 py-0.5 rounded shrink-0 font-mono"
+                style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-subtle)' }}>
+            {asset.filename.length > 30 ? asset.filename.slice(0, 30) + '…' : asset.filename}
+          </span>
+        </div>
+
+        {/* Error message */}
+        {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
+          <p className="text-xs mb-1" style={{ color: '#F87171' }}>
+            {asset.transcodeError}
+          </p>
+        )}
+
+        {/* Codec + duration */}
+        <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-subtle)' }}>
+          {asset.codec && (
+            <span className="font-mono" style={{ color: '#818CF8' }}>
+              {asset.codec.toUpperCase()} → H.264
+            </span>
+          )}
+          {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
+            <>
+              <span>{formatDuration(asset.duration)}</span>
+            </>
+          )}
+          <span>{new Date(asset.createdAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
+        </div>
+      </div>
+
+      {/* Status badge */}
+      <div className="flex flex-col items-end gap-1 shrink-0 min-w-[100px]">
+        <span className="text-xs font-medium px-2.5 py-1 rounded-full"
+              style={{ background: cfg.bg, color: cfg.color }}>
+          {cfg.label}
+        </span>
+
+        {/* Progress bar */}
+        {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
+          <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
+            <div
+              className="h-full rounded-full transition-all"
+              style={{ width: `${asset.transcodeProgress}%`, background: cfg.color }}
+            />
+          </div>
+        )}
+        {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
+          <span className="text-[10px] font-mono" style={{ color: cfg.color }}>
+            {asset.transcodeProgress}%
+          </span>
+        )}
+      </div>
+
+      {/* Actions */}
+      {canManage && (
+        <div className="flex items-center gap-1 shrink-0">
+          {canAct && (
+            <button
+              onClick={() => onCancel(asset.id)}
+              className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors"
+              style={{
+                background: 'rgba(251,191,36,0.10)',
+                color: '#FBBF24',
+                border: '1px solid rgba(251,191,36,0.20)',
+              }}
+              title="Cancel and re-queue"
+            >
+              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+              </svg>
+              <span>Cancel</span>
+            </button>
+          )}
+          {canDelete && (
+            <button
+              onClick={() => onDelete(asset.id, asset.title)}
+              className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors"
+              style={{
+                background: 'rgba(248,113,113,0.08)',
+                color: '#F87171',
+                border: '1px solid rgba(248,113,113,0.15)',
+              }}
+              title="Delete video"
+            >
+              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
+              </svg>
+              <span>Delete</span>
+            </button>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel }: Props) {
+  const [filter, setFilter] = useState<'all' | 'processing' | 'completed' | 'failed'>('all');
+
+  const filtered = assets.filter(a => {
+    if (filter === 'all') return true;
+    if (filter === 'processing') return ['PENDING', 'UPLOADING', 'PROCESSING'].includes(a.transcodeStatus);
+    if (filter === 'completed') return a.transcodeStatus === 'COMPLETED';
+    if (filter === 'failed') return ['FAILED', 'UNSUPPORTED_CODEC'].includes(a.transcodeStatus);
+    return true;
+  });
+
+  const processingCount = assets.filter(a => ['PENDING', 'UPLOADING', 'PROCESSING'].includes(a.transcodeStatus)).length;
+  const completedCount = assets.filter(a => a.transcodeStatus === 'COMPLETED').length;
+  const failedCount = assets.filter(a => ['FAILED', 'UNSUPPORTED_CODEC'].includes(a.transcodeStatus)).length;
+
+  return (
+    <div>
+      {/* Stats row */}
+      <div className="grid grid-cols-3 gap-4 mb-6">
+        {[
+          { key: 'all', label: 'Total', count: assets.length, color: '#6366F1', bg: 'rgba(99,102,241,0.08)' },
+          { key: 'processing', label: 'Processing', count: processingCount, color: '#A78BFA', bg: 'rgba(167,139,250,0.08)' },
+          { key: 'completed', label: 'Ready', count: completedCount, color: '#34D399', bg: 'rgba(52,211,153,0.08)' },
+          { key: 'failed', label: 'Failed', count: failedCount, color: '#F87171', bg: 'rgba(248,113,113,0.08)' },
+        ].map(({ key, label, count, color, bg }) => (
+          <button
+            key={key}
+            onClick={() => setFilter(key as typeof filter)}
+            className="card p-4 text-left transition-all hover:brightness-110"
+            style={{
+              background: filter === key ? bg : 'rgba(255,255,255,0.02)',
+              border: `1px solid ${filter === key ? color + '40' : 'rgba(255,255,255,0.06)'}`,
+            }}
+          >
+            <div className="text-2xl font-bold mb-0.5" style={{ color }}>
+              {count}
+            </div>
+            <div className="text-xs" style={{ color: 'var(--text-muted)' }}>
+              {label}
+            </div>
+          </button>
+        ))}
+      </div>
+
+      {/* Task list */}
+      {filtered.length === 0 ? (
+        <div className="card p-16 text-center">
+          <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
+               style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
+            <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+            </svg>
+          </div>
+          <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
+            {filter === 'all' ? 'No videos uploaded yet' :
+             filter === 'processing' ? 'No active transcode jobs' :
+             filter === 'completed' ? 'No completed transcode jobs' :
+             'No failed transcode jobs'}
+          </p>
+          <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+            {filter === 'all'
+              ? 'Upload videos in the Videos tab to see them here'
+              : 'Upload more videos to test the transcode pipeline'}
+          </p>
+        </div>
+      ) : (
+        <div className="card overflow-hidden">
+          {/* Header */}
+          <div className="px-5 py-3.5 border-b flex items-center gap-4"
+               style={{ borderColor: 'rgba(255,255,255,0.06)', background: 'rgba(255,255,255,0.02)' }}>
+            <div className="w-8" />
+            <div className="flex-1 text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Video</div>
+            <div className="w-[120px] text-xs font-medium text-right shrink-0" style={{ color: 'var(--text-subtle)' }}>Status</div>
+            <div className="w-[130px] shrink-0" />
+          </div>
+
+          {/* Rows */}
+          {filtered.map(asset => (
+            <TranscodeTaskRow
+              key={asset.id}
+              asset={asset}
+              canManage={canManage}
+              onDelete={onDelete}
+              onCancel={onCancel}
+            />
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 10 - 2
src/components/video-player/AnnotationCanvas.tsx

@@ -67,6 +67,8 @@ interface Props {
   color: string;
   width: number;
   height: number;
+  // Already-saved strokes to keep on canvas until Save/Undo
+  pendingStrokes: AnnotationData[];
   onStrokeComplete: (stroke: AnnotationData) => void;
 }
 
@@ -84,19 +86,25 @@ export function AnnotationCanvas({
   color,
   width,
   height,
+  pendingStrokes,
   onStrokeComplete,
 }: Props) {
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const isDrawingRef = useRef(false);
   const drawRef = useRef<DrawState | null>(null);
 
-  // ── Full canvas redraw (clear + live stroke) ─────────────────────────────────
+  // ── Full canvas redraw (clear + all saved strokes + live stroke) ─────────────
   function redraw() {
     const canvas = canvasRef.current;
     if (!canvas) return;
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
     ctx.clearRect(0, 0, canvas.width, canvas.height);
+    // Draw all strokes that are still pending (not yet saved)
+    for (const stroke of pendingStrokes) {
+      drawShape(ctx, stroke);
+    }
+    // Draw the stroke currently being drawn
     if (drawRef.current) drawShape(ctx, toAnnotation(drawRef.current));
   }
 
@@ -126,7 +134,7 @@ export function AnnotationCanvas({
     canvas.width = width;
     canvas.height = height;
     redraw();
-  }, [width, height]);
+  }, [width, height, pendingStrokes]);
 
   // ── Normalise mouse / touch position ────────────────────────────────────────
   function pos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {

+ 165 - 16
src/components/video-player/Timeline.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useRef } from 'react';
+import { useRef, useCallback, useEffect, useState } from 'react';
 import { Comment } from '../../lib/api';
 
 interface Props {
@@ -14,19 +14,87 @@ interface Props {
 
 export function Timeline({ duration, currentTime, fps, comments, onSeek, onCommentClick }: Props) {
   const trackRef = useRef<HTMLDivElement>(null);
-  const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
+  const draggingRef = useRef(false);
+  const rafRef = useRef<number | null>(null);
+  const [displayTime, setDisplayTime] = useState(currentTime);
+  const [hoveredComment, setHoveredComment] = useState<Comment | null>(null);
+  const [tickHoverPos, setTickHoverPos] = useState<{ x: number; pos: number } | null>(null);
 
-  const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
-    if (!trackRef.current) return;
+  // Smoothly track displayTime during drag using RAF
+  useEffect(() => {
+    if (draggingRef.current) return;
+    setDisplayTime(currentTime);
+  }, [currentTime]);
+
+  const getTimeFromEvent = useCallback((e: MouseEvent | React.MouseEvent) => {
+    if (!trackRef.current) return null;
     const rect = trackRef.current.getBoundingClientRect();
     const x = e.clientX - rect.left;
     const ratio = Math.max(0, Math.min(1, x / rect.width));
-    onSeek(ratio * duration);
+    return ratio * duration;
+  }, [duration]);
+
+  const handleMouseMove = useCallback((e: MouseEvent) => {
+    if (!draggingRef.current) return;
+    if (rafRef.current) cancelAnimationFrame(rafRef.current);
+    rafRef.current = requestAnimationFrame(() => {
+      const t = getTimeFromEvent(e);
+      if (t !== null) {
+        setDisplayTime(t);
+        onSeek(t);
+      }
+    });
+  }, [getTimeFromEvent, onSeek]);
+
+  const handleMouseUp = useCallback((e: MouseEvent) => {
+    if (!draggingRef.current) return;
+    draggingRef.current = false;
+    document.body.style.userSelect = '';
+    document.body.style.cursor = '';
+    const t = getTimeFromEvent(e);
+    if (t !== null) {
+      setDisplayTime(t);
+      onSeek(t);
+    }
+  }, [getTimeFromEvent, onSeek]);
+
+  useEffect(() => {
+    window.addEventListener('mousemove', handleMouseMove);
+    window.addEventListener('mouseup', handleMouseUp);
+    return () => {
+      window.removeEventListener('mousemove', handleMouseMove);
+      window.removeEventListener('mouseup', handleMouseUp);
+      if (rafRef.current) cancelAnimationFrame(rafRef.current);
+    };
+  }, [handleMouseMove, handleMouseUp]);
+
+  const handleMouseDown = (e: React.MouseEvent) => {
+    e.preventDefault();
+    draggingRef.current = true;
+    document.body.style.userSelect = 'none';
+    document.body.style.cursor = 'col-resize';
+    const t = getTimeFromEvent(e.nativeEvent);
+    if (t !== null) {
+      setDisplayTime(t);
+      onSeek(t);
+    }
+  };
+
+  const handleTickEnter = (comment: Comment, e: React.MouseEvent) => {
+    setHoveredComment(comment);
+    setTickHoverPos({ x: e.clientX, pos: duration > 0 ? (comment.timestamp! / duration) * 100 : 0 });
+  };
+
+  const handleTickLeave = () => {
+    setHoveredComment(null);
+    setTickHoverPos(null);
   };
 
+  const progress = duration > 0 ? (displayTime / duration) * 100 : 0;
+
   return (
-    <div className="relative py-2">
-      {/* Tick marks for comments */}
+    <div className="relative py-2 select-none">
+      {/* Comment tick marks */}
       <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
         {comments.map(comment => {
           if (comment.timestamp == null) return null;
@@ -37,10 +105,11 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
               className="absolute top-1/2 -translate-y-1/2 w-0.5 h-3 rounded-full transition-transform hover:h-4"
               style={{
                 left: `${pos}%`,
-                backgroundColor: comment.resolved ? '#22c55e' : '#818CF8',
+                backgroundColor: comment.resolveStatus === 'RESOLVED' ? '#22c55e' : '#818CF8',
                 pointerEvents: 'auto',
               }}
-              title={`${comment.user?.name ?? ''}: ${comment.content.slice(0, 40)}`}
+              onMouseEnter={(e) => handleTickEnter(comment, e)}
+              onMouseLeave={handleTickLeave}
               onClick={(e) => {
                 e.stopPropagation();
                 onCommentClick(comment);
@@ -50,27 +119,107 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
         })}
       </div>
 
-      {/* Progress bar */}
+      {/* Hover tooltip bubble — positioned relative to the tick */}
+      {hoveredComment && tickHoverPos && (
+        <div
+          className="absolute z-50 pointer-events-none"
+          style={{
+            // Position below the tick mark: tick is at top-1/2 of track, so bottom is below it
+            // Clamp so bubble doesn't overflow left/right edges
+            left: `clamp(60px, ${tickHoverPos.pos}%, calc(100% - 60px))`,
+            transform: 'translateX(-50%)',
+            bottom: 'calc(100% + 14px)',
+          }}
+        >
+          {/* Bubble */}
+          <div
+            className="max-w-[200px] rounded-xl px-3 py-2 shadow-xl"
+            style={{
+              background: 'rgba(20, 22, 40, 0.96)',
+              border: '1px solid rgba(255,255,255,0.10)',
+              backdropFilter: 'blur(8px)',
+            }}
+          >
+            {/* Header */}
+            <div className="flex items-center gap-1.5 mb-1">
+              <div
+                className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0"
+                style={{
+                  background: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
+                  color: '#fff',
+                }}
+              >
+                {(hoveredComment.user?.name ?? 'U').charAt(0).toUpperCase()}
+              </div>
+              <span className="text-xs font-medium truncate" style={{ color: '#E2E8F0' }}>
+                {hoveredComment.user?.name ?? 'Unknown'}
+              </span>
+              <span
+                className="text-[10px] font-mono shrink-0 ml-auto"
+                style={{ color: '#818CF8' }}
+              >
+                {formatTimecode(hoveredComment.timestamp ?? 0, fps)}
+              </span>
+            </div>
+            {/* Content */}
+            <p className="text-[11px] leading-snug line-clamp-3" style={{ color: '#94A3B8' }}>
+              {hoveredComment.content}
+            </p>
+            {/* Status */}
+            {hoveredComment.resolveStatus === 'PENDING_APPROVAL' && (
+              <span className="mt-1 inline-flex items-center gap-1 text-[10px]" style={{ color: '#FCD34D' }}>
+                <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+                Pending approval
+              </span>
+            )}
+            {hoveredComment.resolveStatus === 'RESOLVED' && (
+              <span className="mt-1 inline-flex items-center gap-1 text-[10px]" style={{ color: '#86EFAC' }}>
+                <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                </svg>
+                Approved{hoveredComment.resolvedBy ? ` by ${hoveredComment.resolvedBy.name}` : ''}
+              </span>
+            )}
+          </div>
+          {/* Arrow */}
+          <div
+            className="w-2 h-2 rotate-45 mx-auto"
+            style={{
+              background: 'rgba(20, 22, 40, 0.96)',
+              borderRight: '1px solid rgba(255,255,255,0.10)',
+              borderBottom: '1px solid rgba(255,255,255,0.10)',
+              marginTop: '-5px',
+            }}
+          />
+        </div>
+      )}
+
+      {/* Progress bar with drag support */}
       <div
         ref={trackRef}
-        className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer group"
-        onClick={handleTrackClick}
+        className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer"
+        onMouseDown={handleMouseDown}
       >
         {/* Played */}
         <div
-          className="absolute h-full bg-blue-500 rounded-full transition-all"
+          className="absolute h-full bg-blue-500 rounded-full"
           style={{ width: `${progress}%` }}
         />
         {/* Scrubber */}
         <div
-          className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 bg-white rounded-full shadow-lg border-2 border-blue-500 opacity-0 group-hover:opacity-100 transition-opacity"
-          style={{ left: `calc(${progress}% - 7px)` }}
+          className="absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 bg-white rounded-full shadow-lg border-2 border-blue-500 transition-opacity"
+          style={{
+            left: `calc(${progress}% - 7px)`,
+            opacity: draggingRef.current ? 1 : undefined,
+          }}
         />
       </div>
 
       {/* Time display */}
       <div className="flex justify-between mt-1 text-xs text-gray-400 font-mono">
-        <span>{formatTimecode(currentTime, fps)}</span>
+        <span>{formatTimecode(displayTime, fps)}</span>
         <span>{formatTimecode(duration, fps)}</span>
       </div>
     </div>

+ 58 - 25
src/components/video-player/VideoPlayer.tsx

@@ -25,6 +25,8 @@ interface Props {
   onDrawModeChange: (active: boolean) => void;
   onDrawToolChange: (tool: Tool) => void;
   onDrawColorChange: (color: string) => void;
+  // Pending strokes in draw mode (shown on canvas until Save/Undo)
+  pendingStrokes: AnnotationData[];
   // Called after each completed stroke (mouseUp)
   onStrokeComplete: (stroke: AnnotationData) => void;
   // Called when video time updates
@@ -45,6 +47,7 @@ export function VideoPlayer({
   onDrawModeChange,
   onDrawToolChange,
   onDrawColorChange,
+  pendingStrokes,
   onStrokeComplete,
   onTimeUpdate,
   onCommentClick,
@@ -52,6 +55,7 @@ export function VideoPlayer({
   const videoRef = useRef<HTMLVideoElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const displayCanvasRef = useRef<HTMLCanvasElement>(null);
+  const videoCallbackRef = useRef<number | null>(null);
   const [playing, setPlaying] = useState(false);
   const [currentTime, setCurrentTime] = useState(0);
   const [duration, setDuration] = useState(0);
@@ -63,6 +67,40 @@ export function VideoPlayer({
   const [dims, setDims] = useState({ width: 0, height: 0 });
 
   const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
+  const redrawAnnotationsRef = useRef<(time: number) => void>(() => {});
+
+  // Refs so the rVFC callback always sees current values without re-registering
+  const fpsRef = useRef(fps);
+  const visibleAnnotationsRef = useRef(visibleAnnotations);
+  const drawModeRef = useRef(drawMode);
+  fpsRef.current = fps;
+  visibleAnnotationsRef.current = visibleAnnotations;
+  drawModeRef.current = drawMode;
+
+  // ── requestVideoFrameCallback loop — fires every rendered frame ─────────────────
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    function onFrame(_now: number, metadata: { mediaTime: number }) {
+      const t = metadata.mediaTime;
+      setCurrentTime(t);
+      onTimeUpdate(t);
+      // Redraw annotations every frame using refs (avoids stale closure)
+      redrawAnnotationsRef.current(t);
+      // Re-register for next frame
+      videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
+    }
+
+    videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
+    return () => {
+      if (videoCallbackRef.current !== null) {
+        try { (video as any).cancelVideoFrameCallback(videoCallbackRef.current); } catch { /* ignore */ }
+        videoCallbackRef.current = null;
+      }
+    };
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []); // run once; ref-based callback is self-perpetuating
 
   // HLS setup
   useEffect(() => {
@@ -92,35 +130,34 @@ export function VideoPlayer({
     return () => obs.disconnect();
   }, []);
 
-  // Resize + redraw display canvas when dims or annotations change
-  useEffect(() => {
-    const canvas = displayCanvasRef.current;
-    if (!canvas) return;
-    canvas.width = dims.width;
-    canvas.height = dims.height;
-    redrawAnnotations();
-  }, [dims, visibleAnnotations]); // eslint-disable-line react-hooks/exhaustive-deps
-
-  // Redraw annotations on every time update
-  useEffect(() => {
-    redrawAnnotations();
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [currentTime]);
-
-  function redrawAnnotations() {
+  // ── Annotation draw function (ref-based, callable from rVFC callback) ───────────────
+  redrawAnnotationsRef.current = (time: number) => {
     const canvas = displayCanvasRef.current;
     if (!canvas) return;
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
     ctx.clearRect(0, 0, canvas.width, canvas.height);
+    if (drawModeRef.current) return; // draw mode canvas handles it
 
-    const frameRange = 3 / (fps || 30); // ±3 frames in seconds
-    for (const { annotation, timestamp } of visibleAnnotations) {
-      if (Math.abs(currentTime - timestamp) <= frameRange) {
+    const anns = visibleAnnotationsRef.current;
+    if (!anns || anns.length === 0) return;
+    const frameRange = 3 / (fpsRef.current || 30);
+    for (const { annotation, timestamp } of anns) {
+      if (Math.abs(time - timestamp) <= frameRange) {
         drawShape(ctx, annotation);
       }
     }
-  }
+  };
+
+  // Resize + initial draw
+  useEffect(() => {
+    const canvas = displayCanvasRef.current;
+    if (!canvas) return;
+    canvas.width = dims.width;
+    canvas.height = dims.height;
+    redrawAnnotationsRef.current(0);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [dims]);
 
   // Keyboard shortcuts
   useEffect(() => {
@@ -234,11 +271,6 @@ export function VideoPlayer({
         onClick={() => { if (!drawMode) togglePlay(); }}
         onPlay={() => setPlaying(true)}
         onPause={() => setPlaying(false)}
-        onTimeUpdate={() => {
-          const t = videoRef.current?.currentTime ?? 0;
-          setCurrentTime(t);
-          onTimeUpdate(t);
-        }}
         onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
         playsInline
       />
@@ -257,6 +289,7 @@ export function VideoPlayer({
         color={drawColor}
         width={dims.width}
         height={dims.height}
+        pendingStrokes={pendingStrokes}
         onStrokeComplete={onStrokeComplete}
       />
 

+ 89 - 3
src/lib/api.ts

@@ -37,7 +37,7 @@ async function apiFetch<T = unknown>(
 
 export const authApi = {
   register: (data: { email: string; name: string; password: string }) =>
-    apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/register', {
+    apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[]; userName: string }>('/api/auth/register', {
       method: 'POST',
       body: JSON.stringify(data),
     }),
@@ -108,6 +108,9 @@ export const assetsApi = {
   get: (token: string, id: string) =>
     apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }),
 
+  getStatus: (token: string, id: string) =>
+    apiFetch<{ asset: AssetStatusInfo }>(`/api/assets/${id}/status`, { token }),
+
   upload: (token: string, formData: FormData) =>
     fetch(`${API_BASE}/api/assets/upload`, {
       method: 'POST',
@@ -124,6 +127,9 @@ export const assetsApi = {
 
   delete: (token: string, id: string) =>
     apiFetch(`/api/assets/${id}`, { method: 'DELETE', token }),
+
+  cancelTranscode: (token: string, id: string) =>
+    apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/cancel`, { method: 'POST', token }),
 };
 
 // ── Comments ─────────────────────────────────────────────────────────────────
@@ -146,8 +152,15 @@ export const commentsApi = {
       token,
     }),
 
-  resolve: (token: string, id: string) =>
-    apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, { method: 'PUT', token }),
+  requestResolve: (token: string, id: string) =>
+    apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve/request`, { method: 'POST', token }),
+
+  resolve: (token: string, id: string, action?: 'approve' | 'reject') =>
+    apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, {
+      method: 'PUT',
+      body: JSON.stringify({ action }),
+      token,
+    }),
 
   updateAnnotations: (token: string, id: string, annotations: AnnotationData[]) =>
     apiFetch<{ comment: Comment }>(`/api/comments/${id}/annotations`, {
@@ -224,6 +237,18 @@ export const invitationsApi = {
       token,
     }),
 
+  // Admin: invite user to any project
+  adminInvite: (token: string, email: string, projectId: string, role: string) =>
+    apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations', {
+      method: 'POST',
+      body: JSON.stringify({ email, projectId, role }),
+      token,
+    }),
+
+  // Admin: list all pending workspace invitations
+  listAll: (token: string) =>
+    apiFetch<{ invitations: AdminInvitation[] }>('/api/invitations', { token }),
+
   // Revoke an invitation
   revoke: (token: string, invitationId: string) =>
     apiFetch(`/api/invitations/${invitationId}`, { method: 'DELETE', token }),
@@ -237,6 +262,20 @@ export const invitationsApi = {
     }),
 };
 
+// ── Site Settings ──────────────────────────────────────────────────────────────
+
+export const settingsApi = {
+  getRegistration: (token: string) =>
+    apiFetch<{ enabled: boolean }>('/api/settings/registration', { token }),
+
+  setRegistration: (token: string, enabled: boolean) =>
+    apiFetch<{ enabled: boolean }>('/api/settings/registration', {
+      method: 'PUT',
+      body: JSON.stringify({ enabled }),
+      token,
+    }),
+};
+
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 export interface User {
@@ -278,6 +317,19 @@ export interface Invitation {
   createdAt: string;
 }
 
+export interface AdminInvitation {
+  id: string;
+  email: string;
+  projectId: string;
+  role: string;
+  token: string;
+  status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
+  invitedBy?: string | null;
+  expiresAt: string;
+  createdAt: string;
+  project: { id: string; name: string };
+}
+
 export interface InvitationInfo {
   id: string;
   email: string;
@@ -285,11 +337,21 @@ export interface InvitationInfo {
   projectName: string;
   projectId: string;
   expiresAt: string;
+  status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
+  isExpired: boolean;
   isOwnInvitation: boolean;
   alreadyMember: boolean;
   isLoggedIn: boolean;
 }
 
+export type TranscodeStatus =
+  | 'PENDING'
+  | 'UPLOADING'
+  | 'PROCESSING'
+  | 'COMPLETED'
+  | 'FAILED'
+  | 'UNSUPPORTED_CODEC';
+
 export interface Asset {
   id: string;
   projectId: string;
@@ -300,12 +362,29 @@ export interface Asset {
   hlsPath?: string | null;
   duration?: number | null;
   fps?: number;
+  codec?: string | null;
   mimeType: string;
   status: string;
+  transcodeStatus: TranscodeStatus;
+  transcodeProgress: number;
+  transcodeError?: string | null;
   createdAt: string;
   _count?: { comments: number };
 }
 
+export interface AssetStatusInfo {
+  id: string;
+  title: string;
+  hlsPath?: string | null;
+  transcodeStatus: TranscodeStatus;
+  transcodeProgress: number;
+  transcodeError?: string | null;
+  thumbnail?: string | null;
+  duration?: number | null;
+  codec?: string | null;
+  status: string;
+}
+
 export interface AssetWithComments extends Asset {
   project: Project;
   comments: Comment[];
@@ -319,10 +398,17 @@ export interface Comment {
   timestamp?: number | null;
   annotations?: AnnotationData[] | null;
   resolved: boolean;
+  resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED';
+  resolvedById?: string | null;
+  resolvedByAt?: string | null;
+  requestedById?: string | null;
+  requestedByAt?: string | null;
   parentId?: string | null;
   createdAt: string;
   user: User;
   replies?: Comment[];
+  resolvedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
+  requestedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
 }
 
 export interface AnnotationData {

+ 6 - 2
src/lib/auth-context.tsx

@@ -13,6 +13,7 @@ interface AuthContextValue {
   token: string | null;
   loading: boolean;
   acceptedProjects: AcceptedProject[];
+  justRegisteredName: string;
   clearAcceptedProjects: () => void;
   login: (email: string, password: string) => Promise<void>;
   register: (email: string, name: string, password: string) => Promise<void>;
@@ -28,6 +29,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   const [token, setToken] = useState<string | null>(null);
   const [loading, setLoading] = useState(true);
   const [acceptedProjects, setAcceptedProjects] = useState<AcceptedProject[]>([]);
+  const [justRegisteredName, setJustRegisteredName] = useState('');
 
   useEffect(() => {
     const savedToken = localStorage.getItem('vidreview_token');
@@ -57,12 +59,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   }, []);
 
   const register = useCallback(async (email: string, name: string, password: string) => {
-    const { user: u, token: t, acceptedProjects: ap } = await authApi.register({ email, name, password });
+    const result = await authApi.register({ email, name, password });
+    const { user: u, token: t, acceptedProjects: ap, userName } = result;
     localStorage.setItem('vidreview_token', t);
     localStorage.setItem('vidreview_user', JSON.stringify(u));
     setToken(t);
     setUser(u);
     setAcceptedProjects(ap ?? []);
+    setJustRegisteredName(userName ?? name);
   }, []);
 
   const logout = useCallback(async () => {
@@ -93,7 +97,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
   }, []);
 
   return (
-    <AuthContext.Provider value={{ user, token, loading, acceptedProjects, clearAcceptedProjects, login, register, logout, refreshUser, updateUserData }}>
+    <AuthContext.Provider value={{ user, token, loading, acceptedProjects, justRegisteredName, clearAcceptedProjects, login, register, logout, refreshUser, updateUserData }}>
       {children}
     </AuthContext.Provider>
   );

+ 3 - 1
src/next.config.js

@@ -2,9 +2,11 @@
 const nextConfig = {
   // Explicit webpack alias for @/ imports (resolves from src/)
   webpack: (config) => {
-    config.resolve.alias['@'] = require('path').resolve(__dirname, 'src');
+    config.resolve.alias['@'] = require('path').resolve(__dirname);
     return config;
   },
+  // Silence Turbopack-only warning — we use webpack for production builds
+  turbopack: {},
   // Proxy /api requests to Express backend
   async rewrites() {
     return [