Переглянути джерело

Initial commit: VidReview — collaborative video review platform

Features:
- JWT authentication (register/login/logout)
- Project management with member invitations
- Video upload with FFmpeg thumbnail generation
- HTML5 video player with HLS.js support
- Timeline with comment timestamp markers
- Drawing annotations (pen, arrow, rect, ellipse) — 7-color palette
- Comment system: threaded, timestamped, resolvable
- Approval workflow: Pending/Changes/Approved/Rejected
- Docker + PostgreSQL + Express + Next.js stack

Tech: Next.js 15, Express, Prisma, PostgreSQL, Tailwind CSS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Son Nguyen 1 місяць тому
коміт
7ae86d3ad0
43 змінених файлів з 3945 додано та 0 видалено
  1. 17 0
      .env.example
  2. 44 0
      .gitignore
  3. 25 0
      Dockerfile.api
  4. 18 0
      Dockerfile.frontend
  5. 67 0
      docker-compose.yml
  6. 20 0
      package.json
  7. 39 0
      packages/api/package.json
  8. 99 0
      packages/api/prisma/schema.prisma
  9. 54 0
      packages/api/src/index.ts
  10. 53 0
      packages/api/src/lib/auth.ts
  11. 15 0
      packages/api/src/lib/prisma.ts
  12. 251 0
      packages/api/src/routes/assets.ts
  13. 136 0
      packages/api/src/routes/auth.ts
  14. 159 0
      packages/api/src/routes/comments.ts
  15. 225 0
      packages/api/src/routes/projects.ts
  16. 48 0
      packages/api/src/services/ffmpeg.ts
  17. 16 0
      packages/api/tsconfig.json
  18. 99 0
      prisma/schema.prisma
  19. 3 0
      src/app/(auth)/layout.tsx
  20. 78 0
      src/app/(auth)/login/page.tsx
  21. 92 0
      src/app/(auth)/register/page.tsx
  22. 88 0
      src/app/(dashboard)/layout.tsx
  23. 198 0
      src/app/(dashboard)/projects/[projectId]/page.tsx
  24. 226 0
      src/app/(dashboard)/projects/page.tsx
  25. 28 0
      src/app/globals.css
  26. 18 0
      src/app/layout.tsx
  27. 5 0
      src/app/page.tsx
  28. 242 0
      src/app/review/[assetId]/page.tsx
  29. 279 0
      src/components/comments/CommentPanel.tsx
  30. 43 0
      src/components/ui/avatar.tsx
  31. 49 0
      src/components/ui/button.tsx
  32. 32 0
      src/components/ui/input.tsx
  33. 62 0
      src/components/ui/modal.tsx
  34. 305 0
      src/components/video-player/AnnotationCanvas.tsx
  35. 84 0
      src/components/video-player/Timeline.tsx
  36. 349 0
      src/components/video-player/VideoPlayer.tsx
  37. 204 0
      src/lib/api.ts
  38. 74 0
      src/lib/auth-context.tsx
  39. 20 0
      src/next.config.js
  40. 31 0
      src/package.json
  41. 6 0
      src/postcss.config.js
  42. 21 0
      src/tailwind.config.ts
  43. 23 0
      src/tsconfig.json

+ 17 - 0
.env.example

@@ -0,0 +1,17 @@
+# Database
+DATABASE_URL="postgresql://vidreview:vidreview123@localhost:5432/vidreview"
+
+# JWT
+JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
+JWT_EXPIRES_IN="7d"
+
+# Server
+API_PORT=3001
+NODE_ENV=development
+
+# File Storage (relative to api container)
+UPLOAD_DIR="./uploads"
+MAX_FILE_SIZE_MB=500
+
+# Frontend
+NEXT_PUBLIC_API_URL="http://localhost:3001"

+ 44 - 0
.gitignore

@@ -0,0 +1,44 @@
+# Dependencies
+node_modules/
+node_modules_api/
+node_modules_frontend/
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Next.js
+.next/
+out/
+*.tsbuildinfo
+next-env.d.ts
+
+# Prisma
+packages/api/dist/
+prisma/migrations/
+
+# Uploads
+uploads/
+
+# Docker volumes
+postgres_data/
+
+# Logs
+*.log
+npm-debug.log*
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Build artifacts
+dist/
+build/
+*.tsbuildinfo

+ 25 - 0
Dockerfile.api

@@ -0,0 +1,25 @@
+FROM node:22-alpine
+
+# Install FFmpeg (for thumbnail generation)
+RUN apk add --no-cache ffmpeg
+
+WORKDIR /app
+
+# Copy package files and install
+COPY packages/api/package*.json ./
+RUN npm install
+
+# Copy Prisma schema and generate client
+COPY packages/api/prisma ./prisma
+RUN npx prisma generate
+
+# Copy source code
+COPY packages/api/src ./src
+
+EXPOSE 3001
+
+ENV NODE_ENV=production
+
+# For development: run with tsx
+# For production: compile first
+CMD ["npx", "tsx", "src/index.ts"]

+ 18 - 0
Dockerfile.frontend

@@ -0,0 +1,18 @@
+FROM node:22-alpine
+
+WORKDIR /app
+
+# Install dependencies
+COPY src/package*.json ./
+RUN npm install
+
+# Copy source
+COPY src/ ./src/
+
+EXPOSE 3000
+
+ENV NODE_ENV=production
+
+# In development, run with next dev
+# In production, build then start
+CMD ["npm", "run", "dev"]

+ 67 - 0
docker-compose.yml

@@ -0,0 +1,67 @@
+version: '3.9'
+
+services:
+  postgres:
+    image: postgres:16-alpine
+    container_name: vidreview-db
+    environment:
+      POSTGRES_USER: vidreview
+      POSTGRES_PASSWORD: vidreview123
+      POSTGRES_DB: vidreview
+    ports:
+      - '5432:5432'
+    volumes:
+      - postgres_data:/var/lib/postgresql/data
+    healthcheck:
+      test: ['CMD-SHELL', 'pg_isready -U vidreview']
+      interval: 5s
+      timeout: 5s
+      retries: 5
+
+  api:
+    build:
+      context: .
+      dockerfile: Dockerfile.api
+    container_name: vidreview-api
+    environment:
+      DATABASE_URL: postgresql://vidreview:vidreview123@postgres:5432/vidreview
+      JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
+      JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
+      API_PORT: 3001
+      NODE_ENV: ${NODE_ENV:-development}
+      UPLOAD_DIR: /app/uploads
+      MAX_FILE_SIZE_MB: 500
+    ports:
+      - '3001:3001'
+    depends_on:
+      postgres:
+        condition: service_healthy
+    volumes:
+      - ./uploads:/app/uploads
+      - ./packages/api:/app
+    healthcheck:
+      test: ['CMD-SHELL', 'wget -qO- http://localhost:3001/health || exit 1']
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+  next:
+    build:
+      context: .
+      dockerfile: Dockerfile.frontend
+    container_name: vidreview-frontend
+    environment:
+      NEXT_PUBLIC_API_URL: http://localhost:3001
+    ports:
+      - '3000:3000'
+    depends_on:
+      api:
+        condition: service_healthy
+    volumes:
+      - ./src:/app
+      - ./node_modules_api:/app/node_modules_api
+      - ./node_modules_frontend:/app/node_modules
+    command: npm run dev
+
+volumes:
+  postgres_data:

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
+{
+  "name": "vidreview",
+  "version": "0.1.0",
+  "private": true,
+  "workspaces": [
+    "packages/api",
+    "src"
+  ],
+  "scripts": {
+    "dev": "concurrently \"npm run dev --workspace=packages/api\" \"npm run dev --workspace=src\"",
+    "dev:api": "npm run dev --workspace=packages/api",
+    "dev:frontend": "npm run dev --workspace=src",
+    "build": "npm run build --workspace=packages/api && npm run build --workspace=src",
+    "db:push": "npm run db:push --workspace=packages/api",
+    "db:generate": "npm run db:generate --workspace=packages/api"
+  },
+  "devDependencies": {
+    "concurrently": "^9.1.2"
+  }
+}

+ 39 - 0
packages/api/package.json

@@ -0,0 +1,39 @@
+{
+  "name": "api",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "tsx watch src/index.ts",
+    "build": "tsc",
+    "start": "node dist/index.js",
+    "db:push": "prisma db push",
+    "db:generate": "prisma generate",
+    "db:migrate": "prisma migrate dev",
+    "db:studio": "prisma studio"
+  },
+  "dependencies": {
+    "@prisma/client": "^5.22.0",
+    "bcryptjs": "^2.4.3",
+    "cors": "^2.8.5",
+    "express": "^4.21.2",
+    "fluent-ffmpeg": "^2.1.3",
+    "jsonwebtoken": "^9.0.2",
+    "multer": "^1.4.5-lts.1",
+    "uuid": "^11.0.5",
+    "dotenv": "^16.4.7",
+    "cookie-parser": "^1.4.7"
+  },
+  "devDependencies": {
+    "@types/bcryptjs": "^2.4.6",
+    "@types/cors": "^2.8.17",
+    "@types/express": "^5.0.0",
+    "@types/fluent-ffmpeg": "^2.1.27",
+    "@types/jsonwebtoken": "^9.0.7",
+    "@types/multer": "^1.4.12",
+    "@types/node": "^22.10.5",
+    "@types/uuid": "^10.0.0",
+    "prisma": "^5.22.0",
+    "tsx": "^4.19.2",
+    "typescript": "^5.7.3"
+  }
+}

+ 99 - 0
packages/api/prisma/schema.prisma

@@ -0,0 +1,99 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+  provider = "prisma-client-js"
+}
+
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+}
+
+model User {
+  id          String   @id @default(cuid())
+  email       String   @unique
+  name        String
+  password    String
+  avatarUrl   String?
+  role        Role     @default(REVIEWER)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  memberships ProjectMember[]
+  comments    Comment[]
+}
+
+model Project {
+  id          String   @id @default(cuid())
+  name        String
+  description String?
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  assets Asset[]
+  members ProjectMember[]
+  owner   ProjectMember?
+}
+
+model ProjectMember {
+  id        String @id @default(cuid())
+  userId    String
+  projectId String
+  role      Role  @default(REVIEWER)
+  joinedAt  DateTime @default(now())
+
+  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
+  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+
+  @@unique([userId, projectId])
+}
+
+model Asset {
+  id        String      @id @default(cuid())
+  projectId String
+  title     String
+  filename  String
+  filePath  String
+  thumbnail String?
+  duration  Float?
+  mimeType  String
+  status    AssetStatus @default(PENDING_REVIEW)
+  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?
+  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")
+}
+
+enum Role {
+  ADMIN
+  EDITOR
+  REVIEWER
+  VIEWER
+}
+
+enum AssetStatus {
+  PENDING_REVIEW
+  CHANGES_REQUESTED
+  APPROVED
+  REJECTED
+}

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

@@ -0,0 +1,54 @@
+import 'dotenv/config';
+import express from 'express';
+import cors from 'cors';
+import path from 'path';
+import cookieParser from 'cookie-parser';
+
+import authRoutes from './routes/auth';
+import projectRoutes from './routes/projects';
+import assetRoutes from './routes/assets';
+import commentRoutes from './routes/comments';
+
+const app = express();
+const PORT = process.env.API_PORT || 3001;
+
+// ── Middleware ────────────────────────────────────────────────────────────────
+app.use(cors({
+  origin: [
+    'http://localhost:3000',
+    'http://127.0.0.1:3000',
+  ],
+  credentials: true,
+}));
+app.use(express.json());
+app.use(cookieParser());
+
+// ── Serve uploaded files ──────────────────────────────────────────────────────
+const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
+app.use('/uploads', express.static(path.resolve(UPLOAD_DIR)));
+
+// ── Routes ───────────────────────────────────────────────────────────────────
+app.get('/health', (_req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
+
+app.use('/api/auth', authRoutes);
+app.use('/api/projects', projectRoutes);
+app.use('/api/assets', assetRoutes);
+app.use('/api/assets', commentRoutes);
+app.use('/api/comments', commentRoutes);
+
+// ── 404 handler ─────────────────────────────────────────────────────────────
+app.use((_req, res) => {
+  res.status(404).json({ error: 'Not found' });
+});
+
+// ── Error handler ─────────────────────────────────────────────────────────────
+app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
+  console.error('Unhandled error:', err);
+  res.status(500).json({ error: 'Internal server error' });
+});
+
+// ── Start ────────────────────────────────────────────────────────────────────
+app.listen(PORT, () => {
+  console.log(`🚀 VidReview API running on http://localhost:${PORT}`);
+});
+

+ 53 - 0
packages/api/src/lib/auth.ts

@@ -0,0 +1,53 @@
+import { Request, Response, NextFunction } from 'express';
+import jwt from 'jsonwebtoken';
+
+export interface JwtPayload {
+  userId: string;
+  email: string;
+  role: string;
+}
+
+declare global {
+  namespace Express {
+    interface Request {
+      user?: JwtPayload;
+    }
+  }
+}
+
+export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
+  const authHeader = req.headers.authorization;
+  const token =
+    authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.token;
+
+  if (!token) {
+    res.status(401).json({ error: 'No token provided' });
+    return;
+  }
+
+  try {
+    const secret = process.env.JWT_SECRET || 'fallback-secret';
+    const payload = jwt.verify(token, secret) as JwtPayload;
+    req.user = payload;
+    next();
+  } catch {
+    res.status(401).json({ error: 'Invalid or expired token' });
+  }
+}
+
+export function optionalAuth(req: Request, res: Response, next: NextFunction): void {
+  const authHeader = req.headers.authorization;
+  const token =
+    authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : req.cookies?.token;
+
+  if (token) {
+    try {
+      const secret = process.env.JWT_SECRET || 'fallback-secret';
+      const payload = jwt.verify(token, secret) as JwtPayload;
+      req.user = payload;
+    } catch {
+      // ignore invalid token for optional auth
+    }
+  }
+  next();
+}

+ 15 - 0
packages/api/src/lib/prisma.ts

@@ -0,0 +1,15 @@
+import { PrismaClient } from '@prisma/client';
+
+const globalForPrisma = globalThis as unknown as {
+  prisma: PrismaClient | undefined;
+};
+
+export const prisma =
+  globalForPrisma.prisma ??
+  new PrismaClient({
+    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
+  });
+
+if (process.env.NODE_ENV !== 'production') {
+  globalForPrisma.prisma = prisma;
+}

+ 251 - 0
packages/api/src/routes/assets.ts

@@ -0,0 +1,251 @@
+import { Router, Request, Response } from 'express';
+import multer from 'multer';
+import path from 'path';
+import fs from 'fs';
+import { v4 as uuidv4 } from 'uuid';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+import { generateThumbnail } from '../services/ffmpeg';
+
+const router = Router();
+router.use(authMiddleware);
+
+// Setup multer for file uploads
+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({
+  destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
+  filename: (_req, file, cb) => {
+    const ext = path.extname(file.originalname);
+    cb(null, `${uuidv4()}${ext}`);
+  },
+});
+
+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)) {
+      cb(null, true);
+    } else {
+      cb(new Error('Only video files are allowed'));
+    }
+  },
+});
+
+// GET /api/assets — list assets for a project
+router.get('/', async (req: Request, res: Response) => {
+  try {
+    const { projectId } = req.query;
+
+    if (!projectId || typeof projectId !== 'string') {
+      res.status(400).json({ error: 'projectId query param required' });
+      return;
+    }
+
+    // Verify user has access
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId, userId: req.user!.userId },
+    });
+
+    if (!membership) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    const assets = await prisma.asset.findMany({
+      where: { projectId },
+      include: {
+        _count: { select: { comments: true } },
+      },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    res.json({ assets });
+  } catch (err) {
+    console.error('List assets error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// GET /api/assets/:id
+router.get('/:id', async (req: Request, res: Response) => {
+  try {
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: req.params.id,
+        project: { members: { some: { userId: req.user!.userId } } },
+      },
+      include: {
+        project: {
+          include: {
+            members: {
+              include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
+            },
+          },
+        },
+        comments: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+            replies: {
+              include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
+            },
+          },
+          where: { parentId: null },
+          orderBy: { timestamp: 'asc' },
+        },
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    res.json({ asset });
+  } catch (err) {
+    console.error('Get asset error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/assets/upload — upload video
+router.post('/upload', upload.single('video'), async (req: Request, res: Response) => {
+  try {
+    if (!req.file) {
+      res.status(400).json({ error: 'No video file provided' });
+      return;
+    }
+
+    const { projectId, title } = req.body;
+
+    if (!projectId) {
+      res.status(400).json({ error: 'projectId is required' });
+      return;
+    }
+
+    // Verify user has access
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId, userId: req.user!.userId },
+    });
+
+    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 via FFmpeg
+    let thumbnail: string | null = null;
+    let duration: number | null = null;
+
+    try {
+      const result = await generateThumbnail(req.file.path, UPLOAD_DIR);
+      if (result.thumbnailPath) {
+        thumbnail = result.thumbnailPath;
+      }
+      duration = result.duration ?? null;
+    } catch (ffmpegErr) {
+      console.warn('FFmpeg thumbnail generation failed:', ffmpegErr);
+      // Continue without thumbnail
+    }
+
+    const asset = await prisma.asset.create({
+      data: {
+        projectId,
+        title: assetTitle,
+        filename: req.file.originalname,
+        filePath,
+        thumbnail,
+        duration,
+        mimeType: req.file.mimetype,
+      },
+    });
+
+    res.status(201).json({ asset });
+  } catch (err) {
+    console.error('Upload error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/assets/:id/status — update approval status
+router.put('/:id/status', async (req: Request, res: Response) => {
+  try {
+    const { status } = req.body;
+
+    const validStatuses = ['PENDING_REVIEW', 'CHANGES_REQUESTED', 'APPROVED', 'REJECTED'];
+    if (!validStatuses.includes(status)) {
+      res.status(400).json({ error: 'Invalid status' });
+      return;
+    }
+
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: req.params.id,
+        project: { members: { some: { userId: req.user!.userId } } },
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    const updated = await prisma.asset.update({
+      where: { id: req.params.id },
+      data: { status: status as any },
+    });
+
+    res.json({ asset: updated });
+  } catch (err) {
+    console.error('Update status error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/assets/:id
+router.delete('/:id', async (req: Request, res: Response) => {
+  try {
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: req.params.id,
+        project: { members: { some: { userId: req.user!.userId, role: 'ADMIN' } } },
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    // Delete file from disk
+    const fullPath = path.join(UPLOAD_DIR, asset.filePath);
+    if (fs.existsSync(fullPath)) {
+      fs.unlinkSync(fullPath);
+    }
+    if (asset.thumbnail) {
+      const thumbPath = path.join(UPLOAD_DIR, asset.thumbnail);
+      if (fs.existsSync(thumbPath)) {
+        fs.unlinkSync(thumbPath);
+      }
+    }
+
+    await prisma.asset.delete({ where: { id: req.params.id } });
+    res.json({ message: 'Asset deleted' });
+  } catch (err) {
+    console.error('Delete asset error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 136 - 0
packages/api/src/routes/auth.ts

@@ -0,0 +1,136 @@
+import { Router, Request, Response } from 'express';
+import bcrypt from 'bcryptjs';
+import jwt from 'jsonwebtoken';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const router = Router();
+
+const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret';
+const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
+
+// POST /api/auth/register
+router.post('/register', async (req: Request, res: Response) => {
+  try {
+    const { email, name, password } = req.body;
+
+    if (!email || !name || !password) {
+      res.status(400).json({ error: 'email, name, and password are required' });
+      return;
+    }
+
+    if (password.length < 6) {
+      res.status(400).json({ error: 'Password must be at least 6 characters' });
+      return;
+    }
+
+    const existing = await prisma.user.findUnique({ where: { email } });
+    if (existing) {
+      res.status(409).json({ error: 'Email already registered' });
+      return;
+    }
+
+    const hashed = await bcrypt.hash(password, 12);
+    const user = await prisma.user.create({
+      data: { email, name, password: hashed },
+      select: { id: true, email: true, name: true, role: true, avatarUrl: true },
+    });
+
+    const token = jwt.sign(
+      { userId: user.id, email: user.email, role: user.role },
+      JWT_SECRET,
+      { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions
+    );
+
+    res.cookie('token', token, {
+      httpOnly: true,
+      secure: process.env.NODE_ENV === 'production',
+      sameSite: 'lax',
+      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+    });
+
+    res.status(201).json({ user, token });
+  } catch (err) {
+    console.error('Register error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/auth/login
+router.post('/login', async (req: Request, res: Response) => {
+  try {
+    const { email, password } = req.body;
+
+    if (!email || !password) {
+      res.status(400).json({ error: 'email and password are required' });
+      return;
+    }
+
+    const user = await prisma.user.findUnique({ where: { email } });
+    if (!user) {
+      res.status(401).json({ error: 'Invalid credentials' });
+      return;
+    }
+
+    const valid = await bcrypt.compare(password, user.password);
+    if (!valid) {
+      res.status(401).json({ error: 'Invalid credentials' });
+      return;
+    }
+
+    const token = jwt.sign(
+      { userId: user.id, email: user.email, role: user.role },
+      JWT_SECRET,
+      { expiresIn: JWT_EXPIRES_IN } as jwt.SignOptions
+    );
+
+    res.cookie('token', token, {
+      httpOnly: true,
+      secure: process.env.NODE_ENV === 'production',
+      sameSite: 'lax',
+      maxAge: 7 * 24 * 60 * 60 * 1000,
+    });
+
+    res.json({
+      user: {
+        id: user.id,
+        email: user.email,
+        name: user.name,
+        role: user.role,
+        avatarUrl: user.avatarUrl,
+      },
+      token,
+    });
+  } catch (err) {
+    console.error('Login error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/auth/logout
+router.post('/logout', (_req: Request, res: Response) => {
+  res.clearCookie('token');
+  res.json({ message: 'Logged out' });
+});
+
+// GET /api/auth/me
+router.get('/me', authMiddleware, async (req: Request, res: Response) => {
+  try {
+    const user = await prisma.user.findUnique({
+      where: { id: req.user!.userId },
+      select: { id: true, email: true, name: true, role: true, avatarUrl: true },
+    });
+
+    if (!user) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    res.json({ user });
+  } catch (err) {
+    console.error('Me error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 159 - 0
packages/api/src/routes/comments.ts

@@ -0,0 +1,159 @@
+import { Router, Request, Response } from 'express';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const router = Router();
+router.use(authMiddleware);
+
+// GET /api/assets/:assetId/comments
+router.get('/:assetId/comments', async (req: Request, res: Response) => {
+  try {
+    const { resolved } = req.query;
+
+    // Verify user has access to this asset's project
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: req.params.assetId,
+        project: { members: { some: { userId: req.user!.userId } } },
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    const where: Record<string, unknown> = { assetId: req.params.assetId, parentId: null };
+    if (resolved !== undefined) {
+      where.resolved = resolved === 'true';
+    }
+
+    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' },
+        },
+      },
+      orderBy: { timestamp: 'asc' },
+    });
+
+    res.json({ comments });
+  } catch (err) {
+    console.error('List comments error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/assets/:assetId/comments
+router.post('/:assetId/comments', async (req: Request, res: Response) => {
+  try {
+    const { content, timestamp, annotation, parentId } = req.body;
+
+    if (!content?.trim()) {
+      res.status(400).json({ error: 'content is required' });
+      return;
+    }
+
+    // Verify user has access
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: req.params.assetId,
+        project: { members: { some: { userId: req.user!.userId } } },
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    const comment = await prisma.comment.create({
+      data: {
+        assetId: req.params.assetId,
+        userId: req.user!.userId,
+        content: content.trim(),
+        timestamp: timestamp ?? null,
+        annotation: annotation ?? 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 } },
+          },
+        },
+      },
+    });
+
+    res.status(201).json({ comment });
+  } catch (err) {
+    console.error('Create comment error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/comments/:id/resolve
+router.put('/:id/resolve', async (req: Request, res: Response) => {
+  try {
+    const comment = await prisma.comment.findFirst({
+      where: {
+        id: req.params.id,
+        asset: { project: { members: { some: { userId: req.user!.userId } } } },
+      },
+    });
+
+    if (!comment) {
+      res.status(404).json({ error: 'Comment not found' });
+      return;
+    }
+
+    const updated = await prisma.comment.update({
+      where: { id: req.params.id },
+      data: { resolved: !comment.resolved },
+      include: {
+        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+        replies: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          },
+        },
+      },
+    });
+
+    res.json({ comment: updated });
+  } catch (err) {
+    console.error('Resolve comment error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/comments/:id
+router.delete('/:id', async (req: Request, res: Response) => {
+  try {
+    const comment = await prisma.comment.findFirst({
+      where: {
+        id: req.params.id,
+        userId: req.user!.userId,
+      },
+    });
+
+    if (!comment) {
+      res.status(404).json({ error: 'Comment not found' });
+      return;
+    }
+
+    await prisma.comment.delete({ where: { id: req.params.id } });
+    res.json({ message: 'Comment deleted' });
+  } catch (err) {
+    console.error('Delete comment error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 225 - 0
packages/api/src/routes/projects.ts

@@ -0,0 +1,225 @@
+import { Router, Request, Response } from 'express';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const router = Router();
+
+// All project routes require auth
+router.use(authMiddleware);
+
+// GET /api/projects — list projects for current user
+router.get('/', async (req: Request, res: Response) => {
+  try {
+    const projects = await prisma.project.findMany({
+      where: {
+        members: { some: { userId: req.user!.userId } },
+      },
+      include: {
+        members: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          },
+        },
+        _count: { select: { assets: true } },
+      },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    res.json({ projects });
+  } catch (err) {
+    console.error('Projects list error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/projects — create project
+router.post('/', async (req: Request, res: Response) => {
+  try {
+    const { name, description } = req.body;
+
+    if (!name) {
+      res.status(400).json({ error: 'Project name is required' });
+      return;
+    }
+
+    const project = await prisma.project.create({
+      data: {
+        name,
+        description: description || null,
+        members: {
+          create: { userId: req.user!.userId, role: 'ADMIN' },
+        },
+      },
+      include: {
+        members: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          },
+        },
+        _count: { select: { assets: true } },
+      },
+    });
+
+    res.status(201).json({ project });
+  } catch (err) {
+    console.error('Create project error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// GET /api/projects/:id
+router.get('/:id', async (req: Request, res: Response) => {
+  try {
+    const project = await prisma.project.findFirst({
+      where: {
+        id: req.params.id,
+        members: { some: { userId: req.user!.userId } },
+      },
+      include: {
+        members: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          },
+        },
+        assets: {
+          orderBy: { createdAt: 'desc' },
+          select: {
+            id: true,
+            title: true,
+            thumbnail: true,
+            duration: true,
+            status: true,
+            createdAt: true,
+          },
+        },
+      },
+    });
+
+    if (!project) {
+      res.status(404).json({ error: 'Project not found' });
+      return;
+    }
+
+    res.json({ project });
+  } catch (err) {
+    console.error('Get project error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/projects/:id
+router.put('/:id', async (req: Request, res: Response) => {
+  try {
+    const { name, description } = req.body;
+
+    // Verify user is member
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId: req.params.id, userId: req.user!.userId },
+    });
+
+    if (!membership || !['ADMIN', 'EDITOR'].includes(membership.role)) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    const project = await prisma.project.update({
+      where: { id: req.params.id },
+      data: { name, description },
+      include: {
+        members: {
+          include: {
+            user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          },
+        },
+        _count: { select: { assets: true } },
+      },
+    });
+
+    res.json({ project });
+  } catch (err) {
+    console.error('Update project error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/projects/:id
+router.delete('/:id', async (req: Request, res: Response) => {
+  try {
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId: req.params.id, userId: req.user!.userId, role: 'ADMIN' },
+    });
+
+    if (!membership) {
+      res.status(403).json({ error: 'Forbidden — must be admin' });
+      return;
+    }
+
+    await prisma.project.delete({ where: { id: req.params.id } });
+    res.json({ message: 'Project deleted' });
+  } catch (err) {
+    console.error('Delete project error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/projects/:id/members — invite member
+router.post('/:id/members', async (req: Request, res: Response) => {
+  try {
+    const { email, role = 'REVIEWER' } = req.body;
+
+    // Check if requester is admin/editor
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId: req.params.id, userId: req.user!.userId },
+    });
+
+    if (!membership || !['ADMIN', 'EDITOR'].includes(membership.role)) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    const targetUser = await prisma.user.findUnique({ where: { email } });
+    if (!targetUser) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    const member = await prisma.projectMember.upsert({
+      where: { userId_projectId: { userId: targetUser.id, projectId: req.params.id } },
+      create: { userId: targetUser.id, projectId: req.params.id, role },
+      update: { role },
+      include: {
+        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+      },
+    });
+
+    res.json({ member });
+  } catch (err) {
+    console.error('Invite member error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/projects/:id/members/:userId
+router.delete('/:id/members/:userId', async (req: Request, res: Response) => {
+  try {
+    const membership = await prisma.projectMember.findFirst({
+      where: { projectId: req.params.id, userId: req.user!.userId, role: 'ADMIN' },
+    });
+
+    if (!membership) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    await prisma.projectMember.deleteMany({
+      where: { projectId: req.params.id, userId: req.params.userId },
+    });
+
+    res.json({ message: 'Member removed' });
+  } catch (err) {
+    console.error('Remove member error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 48 - 0
packages/api/src/services/ffmpeg.ts

@@ -0,0 +1,48 @@
+import ffmpeg from 'fluent-ffmpeg';
+import path from 'path';
+import fs from 'fs';
+
+export interface ThumbnailResult {
+  thumbnailPath: string | null;
+  duration: number | null;
+}
+
+/**
+ * Generate thumbnail (1 frame at 1 second) and extract video duration.
+ */
+export function generateThumbnail(
+  videoPath: string,
+  outputDir: string
+): Promise<ThumbnailResult> {
+  return new Promise((resolve, reject) => {
+    const videoFilename = path.basename(videoPath, path.extname(videoPath));
+    const thumbFilename = `${videoFilename}_thumb.jpg`;
+    const thumbPath = path.join(outputDir, thumbFilename);
+
+    // Ensure output dir exists
+    fs.mkdirSync(outputDir, { recursive: true });
+
+    // Probe for duration first
+    ffmpeg.ffprobe(videoPath, (err, metadata) => {
+      const duration = metadata?.format?.duration ?? null;
+
+      // Generate thumbnail at 1 second (or first frame if shorter)
+      ffmpeg(videoPath)
+        .on('error', (err) => {
+          console.error('FFmpeg error:', err.message);
+          // Return what we have even if thumbnail fails
+          resolve({ thumbnailPath: null, duration });
+        })
+        .on('end', () => {
+          resolve({ thumbnailPath: thumbFilename, duration });
+        })
+        .screenshots({
+          count: 1,
+          folder: outputDir,
+          filename: thumbFilename,
+          size: '320x?',
+          timemarks: ['1'],
+        });
+    });
+  });
+}

+ 16 - 0
packages/api/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "module": "CommonJS",
+    "moduleResolution": "node",
+    "outDir": "dist",
+    "rootDir": "src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "declaration": true
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist"]
+}

+ 99 - 0
prisma/schema.prisma

@@ -0,0 +1,99 @@
+// This is your Prisma schema file,
+// learn more about it in the docs: https://pris.ly/d/prisma-schema
+
+generator client {
+  provider = "prisma-client-js"
+}
+
+datasource db {
+  provider = "postgresql"
+  url      = env("DATABASE_URL")
+}
+
+model User {
+  id          String   @id @default(cuid())
+  email       String   @unique
+  name        String
+  password    String
+  avatarUrl   String?
+  role        Role     @default(REVIEWER)
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  memberships ProjectMember[]
+  comments    Comment[]
+}
+
+model Project {
+  id          String   @id @default(cuid())
+  name        String
+  description String?
+  createdAt   DateTime @default(now())
+  updatedAt   DateTime @updatedAt
+
+  assets Asset[]
+  members ProjectMember[]
+  owner   ProjectMember?
+}
+
+model ProjectMember {
+  id        String @id @default(cuid())
+  userId    String
+  projectId String
+  role      Role  @default(REVIEWER)
+  joinedAt  DateTime @default(now())
+
+  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
+  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+
+  @@unique([userId, projectId])
+}
+
+model Asset {
+  id        String      @id @default(cuid())
+  projectId String
+  title     String
+  filename  String
+  filePath  String
+  thumbnail String?
+  duration  Float?
+  mimeType  String
+  status    AssetStatus @default(PENDING_REVIEW)
+  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?
+  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")
+}
+
+enum Role {
+  ADMIN
+  EDITOR
+  REVIEWER
+  VIEWER
+}
+
+enum AssetStatus {
+  PENDING_REVIEW
+  CHANGES_REQUESTED
+  APPROVED
+  REJECTED
+}

+ 3 - 0
src/app/(auth)/layout.tsx

@@ -0,0 +1,3 @@
+export default function AuthLayout({ children }: { children: React.ReactNode }) {
+  return <>{children}</>;
+}

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

@@ -0,0 +1,78 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuth } from '@/lib/auth-context';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+
+export default function LoginPage() {
+  const router = useRouter();
+  const { login } = useAuth();
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [error, setError] = useState('');
+  const [loading, setLoading] = useState(false);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setError('');
+    setLoading(true);
+    try {
+      await login(email, password);
+      router.push('/projects');
+    } catch (err: unknown) {
+      setError(err instanceof Error ? err.message : 'Login failed');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-gray-50">
+      <div className="w-full max-w-md p-8 space-y-6 bg-white rounded-2xl shadow-lg">
+        <div className="text-center">
+          <div className="text-3xl font-bold text-blue-600 mb-1">VidReview</div>
+          <p className="text-gray-500 text-sm">Sign in to your workspace</p>
+        </div>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {error && (
+            <div className="bg-red-50 text-red-600 text-sm rounded-lg px-4 py-2 border border-red-200">
+              {error}
+            </div>
+          )}
+
+          <Input
+            label="Email"
+            type="email"
+            value={email}
+            onChange={e => setEmail(e.target.value)}
+            placeholder="you@example.com"
+            required
+          />
+
+          <Input
+            label="Password"
+            type="password"
+            value={password}
+            onChange={e => setPassword(e.target.value)}
+            placeholder="••••••••"
+            required
+          />
+
+          <Button type="submit" className="w-full" loading={loading}>
+            Sign In
+          </Button>
+        </form>
+
+        <p className="text-center text-sm text-gray-500">
+          Don&apos;t have an account?{' '}
+          <a href="/register" className="text-blue-600 hover:underline font-medium">
+            Register
+          </a>
+        </p>
+      </div>
+    </div>
+  );
+}

+ 92 - 0
src/app/(auth)/register/page.tsx

@@ -0,0 +1,92 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuth } from '@/lib/auth-context';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+
+export default function RegisterPage() {
+  const router = useRouter();
+  const { register } = useAuth();
+  const [name, setName] = useState('');
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [error, setError] = useState('');
+  const [loading, setLoading] = useState(false);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setError('');
+    if (password.length < 6) {
+      setError('Password must be at least 6 characters');
+      return;
+    }
+    setLoading(true);
+    try {
+      await register(email, name, password);
+      router.push('/projects');
+    } catch (err: unknown) {
+      setError(err instanceof Error ? err.message : 'Registration failed');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-gray-50">
+      <div className="w-full max-w-md p-8 space-y-6 bg-white rounded-2xl shadow-lg">
+        <div className="text-center">
+          <div className="text-3xl font-bold text-blue-600 mb-1">VidReview</div>
+          <p className="text-gray-500 text-sm">Create your workspace</p>
+        </div>
+
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {error && (
+            <div className="bg-red-50 text-red-600 text-sm rounded-lg px-4 py-2 border border-red-200">
+              {error}
+            </div>
+          )}
+
+          <Input
+            label="Full Name"
+            type="text"
+            value={name}
+            onChange={e => setName(e.target.value)}
+            placeholder="Jane Editor"
+            required
+          />
+
+          <Input
+            label="Email"
+            type="email"
+            value={email}
+            onChange={e => setEmail(e.target.value)}
+            placeholder="you@example.com"
+            required
+          />
+
+          <Input
+            label="Password"
+            type="password"
+            value={password}
+            onChange={e => setPassword(e.target.value)}
+            placeholder="At least 6 characters"
+            required
+          />
+
+          <Button type="submit" className="w-full" loading={loading}>
+            Create Account
+          </Button>
+        </form>
+
+        <p className="text-center text-sm text-gray-500">
+          Already have an account?{' '}
+          <a href="/login" className="text-blue-600 hover:underline font-medium">
+            Sign In
+          </a>
+        </p>
+      </div>
+    </div>
+  );
+}

+ 88 - 0
src/app/(dashboard)/layout.tsx

@@ -0,0 +1,88 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useRouter, usePathname } from 'next/navigation';
+import Link from 'next/link';
+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 router = useRouter();
+  const pathname = usePathname();
+
+  useEffect(() => {
+    if (!loading && !user) {
+      router.push('/login');
+    }
+  }, [user, loading, router]);
+
+  if (loading) {
+    return (
+      <div className="min-h-screen flex items-center justify-center">
+        <div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full" />
+      </div>
+    );
+  }
+
+  if (!user) return null;
+
+  return (
+    <div className="min-h-screen flex bg-gray-50">
+      {/* Sidebar */}
+      <aside className="w-56 bg-white border-r border-gray-200 flex flex-col">
+        <div className="px-4 py-4 border-b border-gray-100">
+          <Link href="/projects" className="flex items-center gap-2">
+            <div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
+              <svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+              </svg>
+            </div>
+            <span className="font-bold text-gray-900">VidReview</span>
+          </Link>
+        </div>
+
+        <nav className="flex-1 px-3 py-4 space-y-1">
+          <NavLink href="/projects" active={pathname.startsWith('/projects')}>
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
+            </svg>
+            Projects
+          </NavLink>
+        </nav>
+
+        {/* User */}
+        <div className="px-4 py-3 border-t border-gray-100">
+          <div className="flex items-center gap-2">
+            <Avatar name={user.name} size="sm" />
+            <div className="min-w-0">
+              <p className="text-sm font-medium text-gray-900 truncate">{user.name}</p>
+              <p className="text-xs text-gray-500 truncate">{user.email}</p>
+            </div>
+          </div>
+        </div>
+      </aside>
+
+      {/* Main */}
+      <main className="flex-1 overflow-auto">
+        {children}
+      </main>
+    </div>
+  );
+}
+
+function NavLink({ href, active, children }: { href: string; active: boolean; children: React.ReactNode }) {
+  return (
+    <Link
+      href={href}
+      className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
+        active
+          ? 'bg-blue-50 text-blue-700'
+          : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
+      }`}
+    >
+      {children}
+    </Link>
+  );
+}

+ 198 - 0
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -0,0 +1,198 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import { useAuth } from '@/lib/auth-context';
+import { projectsApi, assetsApi, Project, Asset } from '@/lib/api';
+import { Button } from '@/components/ui/button';
+import { Modal } from '@/components/ui/modal';
+import Link from 'next/link';
+import { useDropzone } from 'react-dropzone';
+
+export default function ProjectDetailPage() {
+  const params = useParams();
+  const projectId = params.projectId as string;
+  const { token, user } = useAuth();
+  const router = useRouter();
+
+  const [project, setProject] = useState<Project | null>(null);
+  const [assets, setAssets] = useState<Asset[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [uploading, setUploading] = useState(false);
+
+  const loadData = useCallback(async () => {
+    if (!token) return;
+    try {
+      const [{ project: p }, { assets: a }] = await Promise.all([
+        projectsApi.get(token, projectId),
+        assetsApi.list(token, projectId),
+      ]);
+      setProject(p);
+      setAssets(a);
+    } catch {
+      router.push('/projects');
+    } finally {
+      setLoading(false);
+    }
+  }, [token, projectId, router]);
+
+  useEffect(() => { loadData(); }, [loadData]);
+
+  const handleDrop = async (acceptedFiles: File[]) => {
+    if (!token || acceptedFiles.length === 0) return;
+    setUploading(true);
+
+    for (const file of acceptedFiles) {
+      const formData = new FormData();
+      formData.append('video', file);
+      formData.append('projectId', projectId);
+      formData.append('title', file.name.replace(/\.[^.]+$/, ''));
+
+      try {
+        const result = await assetsApi.upload(token, formData) as { asset: Asset };
+        setAssets(prev => [result.asset, ...prev]);
+      } catch (err) {
+        console.error('Upload failed:', err);
+        alert(`Upload failed: ${file.name}`);
+      }
+    }
+    setUploading(false);
+  };
+
+  const { getRootProps, getInputProps, isDragActive } = useDropzone({
+    onDrop: handleDrop,
+    accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
+    multiple: true,
+    disabled: uploading,
+  });
+
+  const statusColors: Record<string, string> = {
+    PENDING_REVIEW: 'bg-yellow-100 text-yellow-800',
+    CHANGES_REQUESTED: 'bg-orange-100 text-orange-800',
+    APPROVED: 'bg-green-100 text-green-800',
+    REJECTED: 'bg-red-100 text-red-800',
+  };
+
+  const statusLabels: Record<string, string> = {
+    PENDING_REVIEW: 'Pending Review',
+    CHANGES_REQUESTED: 'Changes Requested',
+    APPROVED: 'Approved',
+    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')}`;
+  };
+
+  if (loading) {
+    return <div className="p-8"><div className="animate-pulse space-y-4">{[1,2,3].map(i => <div key={i} className="h-24 bg-gray-200 rounded-xl" />)}</div></div>;
+  }
+
+  return (
+    <div className="p-8">
+      {/* Header */}
+      <div className="mb-6">
+        <button onClick={() => router.push('/projects')} className="text-sm text-gray-500 hover:text-gray-700 mb-2 flex items-center gap-1">
+          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
+          </svg>
+          Back to Projects
+        </button>
+        <h1 className="text-2xl font-bold text-gray-900">{project?.name}</h1>
+        {project?.description && (
+          <p className="text-gray-500 text-sm mt-1">{project.description}</p>
+        )}
+      </div>
+
+      {/* Upload Zone */}
+      <div
+        {...getRootProps()}
+        className={`mb-8 border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all ${
+          isDragActive
+            ? 'border-blue-500 bg-blue-50'
+            : 'border-gray-300 bg-white hover:border-blue-300 hover:bg-blue-50/50'
+        }`}
+      >
+        <input {...getInputProps()} />
+        {uploading ? (
+          <div className="space-y-2">
+            <div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto" />
+            <p className="text-sm text-gray-500">Uploading...</p>
+          </div>
+        ) : (
+          <>
+            <svg className="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
+            </svg>
+            <p className="font-medium text-gray-700">
+              {isDragActive ? 'Drop videos here' : 'Drag & drop videos here, or click to browse'}
+            </p>
+            <p className="text-xs text-gray-400 mt-1">MP4, MOV, WebM, AVI — up to 500MB each</p>
+          </>
+        )}
+      </div>
+
+      {/* Asset Grid */}
+      {assets.length === 0 ? (
+        <div className="text-center py-16 bg-white rounded-xl border border-dashed border-gray-300">
+          <svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+          </svg>
+          <p className="text-gray-500">No videos yet — upload your first one above</p>
+        </div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
+          {assets.map(asset => (
+            <Link key={asset.id} href={`/review/${asset.id}`}>
+              <div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-blue-300 hover:shadow-md transition-all cursor-pointer group">
+                {/* Thumbnail */}
+                <div className="relative aspect-video bg-gray-900">
+                  {asset.thumbnail ? (
+                    <img
+                      src={`/uploads/${asset.thumbnail}`}
+                      alt={asset.title}
+                      className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity"
+                    />
+                  ) : (
+                    <div className="w-full h-full flex items-center justify-center">
+                      <svg className="w-12 h-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                      </svg>
+                    </div>
+                  )}
+
+                  {/* Duration badge */}
+                  {asset.duration && (
+                    <span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">
+                      {formatDuration(asset.duration)}
+                    </span>
+                  )}
+
+                  {/* Status badge */}
+                  <span className={`absolute top-2 left-2 text-xs px-2 py-0.5 rounded-full font-medium ${statusColors[asset.status]}`}>
+                    {statusLabels[asset.status]}
+                  </span>
+                </div>
+
+                {/* Info */}
+                <div className="p-3">
+                  <h3 className="font-medium text-gray-900 text-sm truncate group-hover:text-blue-600 transition-colors">
+                    {asset.title}
+                  </h3>
+                  <div className="flex items-center gap-3 mt-1.5 text-xs text-gray-400">
+                    <span>{asset._count?.comments ?? 0} comment{asset._count?.comments !== 1 ? 's' : ''}</span>
+                    <span>{new Date(asset.createdAt).toLocaleDateString()}</span>
+                  </div>
+                </div>
+              </div>
+            </Link>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}

+ 226 - 0
src/app/(dashboard)/projects/page.tsx

@@ -0,0 +1,226 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useAuth } from '@/lib/auth-context';
+import { projectsApi } from '@/lib/api';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Modal } from '@/components/ui/modal';
+import { Avatar } from '@/components/ui/avatar';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+
+export default function ProjectsPage() {
+  const { user, token } = useAuth();
+  const router = useRouter();
+  const [projects, setProjects] = useState<import('@/lib/api').Project[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [showCreate, setShowCreate] = useState(false);
+  const [createName, setCreateName] = useState('');
+  const [createDesc, setCreateDesc] = useState('');
+  const [creating, setCreating] = useState(false);
+  const [inviteModal, setInviteModal] = useState<{ projectId: string; name: string } | null>(null);
+  const [inviteEmail, setInviteEmail] = useState('');
+  const [inviteRole, setInviteRole] = useState('REVIEWER');
+  const [inviting, setInviting] = useState(false);
+
+  const loadProjects = useCallback(async () => {
+    if (!token) return;
+    try {
+      const { projects: p } = await projectsApi.list(token);
+      setProjects(p);
+    } catch (err) {
+      console.error('Failed to load projects:', err);
+    } finally {
+      setLoading(false);
+    }
+  }, [token]);
+
+  useEffect(() => { loadProjects(); }, [loadProjects]);
+
+  const handleCreate = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!token || !createName.trim()) return;
+    setCreating(true);
+    try {
+      const { project } = await projectsApi.create(token, { name: createName.trim(), description: createDesc.trim() });
+      setProjects(prev => [project, ...prev]);
+      setShowCreate(false);
+      setCreateName('');
+      setCreateDesc('');
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to create project');
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  const handleInvite = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!token || !inviteModal) return;
+    setInviting(true);
+    try {
+      await projectsApi.inviteMember(token, inviteModal.projectId, inviteEmail, inviteRole);
+      setInviteModal(null);
+      setInviteEmail('');
+      loadProjects();
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to invite member');
+    } finally {
+      setInviting(false);
+    }
+  };
+
+  const statusColors: Record<string, string> = {
+    PENDING_REVIEW: 'bg-yellow-100 text-yellow-800',
+    CHANGES_REQUESTED: 'bg-orange-100 text-orange-800',
+    APPROVED: 'bg-green-100 text-green-800',
+    REJECTED: 'bg-red-100 text-red-800',
+  };
+
+  return (
+    <div className="p-8">
+      <div className="flex items-center justify-between mb-8">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Projects</h1>
+          <p className="text-gray-500 text-sm mt-1">
+            {projects.length} project{projects.length !== 1 ? 's' : ''}
+          </p>
+        </div>
+        <Button onClick={() => setShowCreate(true)}>
+          <svg className="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+          </svg>
+          New Project
+        </Button>
+      </div>
+
+      {loading ? (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+          {[1, 2, 3].map(i => (
+            <div key={i} className="bg-white rounded-xl border border-gray-200 h-40 animate-pulse" />
+          ))}
+        </div>
+      ) : projects.length === 0 ? (
+        <div className="text-center py-20 bg-white rounded-xl border border-dashed border-gray-300">
+          <svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
+          </svg>
+          <h3 className="font-semibold text-gray-700 mb-1">No projects yet</h3>
+          <p className="text-gray-400 text-sm mb-4">Create your first project to start reviewing</p>
+          <Button onClick={() => setShowCreate(true)}>Create Project</Button>
+        </div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+          {projects.map(project => (
+            <div key={project.id} className="bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-md transition-all">
+              <div className="p-5">
+                <div className="flex items-start justify-between mb-3">
+                  <Link href={`/projects/${project.id}`} className="group">
+                    <h3 className="font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
+                      {project.name}
+                    </h3>
+                    {project.description && (
+                      <p className="text-sm text-gray-500 mt-0.5 line-clamp-2">{project.description}</p>
+                    )}
+                  </Link>
+                </div>
+
+                <div className="flex items-center gap-2 text-xs text-gray-400 mb-4">
+                  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+                  </svg>
+                  <span>{project._count?.assets ?? 0} video{project._count?.assets !== 1 ? 's' : ''}</span>
+                  <span>·</span>
+                  <span>{project.members?.length ?? 0} member{project.members?.length !== 1 ? 's' : ''}</span>
+                </div>
+
+                <div className="flex items-center justify-between">
+                  {/* Member avatars */}
+                  <div className="flex -space-x-1.5">
+                    {(project.members ?? []).slice(0, 4).map(m => (
+                      <Avatar key={m.user.id} name={m.user.name} size="sm" />
+                    ))}
+                    {(project.members ?? []).length > 4 && (
+                      <div className="w-6 h-6 rounded-full bg-gray-200 text-gray-600 text-xs flex items-center justify-center font-medium">
+                        +{project.members!.length - 4}
+                      </div>
+                    )}
+                  </div>
+
+                  <Button
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => setInviteModal({ projectId: project.id, name: project.name })}
+                  >
+                    Invite
+                  </Button>
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+      )}
+
+      {/* Create Project Modal */}
+      <Modal open={showCreate} onClose={() => setShowCreate(false)} title="Create New Project">
+        <form onSubmit={handleCreate} className="space-y-4">
+          <Input
+            label="Project Name"
+            value={createName}
+            onChange={e => setCreateName(e.target.value)}
+            placeholder="Summer Campaign 2026"
+            required
+          />
+          <div>
+            <label className="block text-sm font-medium text-gray-700 mb-1">Description (optional)</label>
+            <textarea
+              value={createDesc}
+              onChange={e => setCreateDesc(e.target.value)}
+              placeholder="Brief description of this project..."
+              className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+              rows={3}
+            />
+          </div>
+          <div className="flex gap-3 justify-end pt-2">
+            <Button type="button" variant="secondary" onClick={() => setShowCreate(false)}>Cancel</Button>
+            <Button type="submit" loading={creating}>Create Project</Button>
+          </div>
+        </form>
+      </Modal>
+
+      {/* Invite Member Modal */}
+      {inviteModal && (
+        <Modal open={true} onClose={() => setInviteModal(null)} title={`Invite to ${inviteModal.name}`}>
+          <form onSubmit={handleInvite} className="space-y-4">
+            <Input
+              label="Email Address"
+              type="email"
+              value={inviteEmail}
+              onChange={e => setInviteEmail(e.target.value)}
+              placeholder="colleague@example.com"
+              required
+            />
+            <div>
+              <label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
+              <select
+                value={inviteRole}
+                onChange={e => setInviteRole(e.target.value)}
+                className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+              >
+                <option value="REVIEWER">Reviewer — can comment and review</option>
+                <option value="EDITOR">Editor — can upload and comment</option>
+                <option value="ADMIN">Admin — full access</option>
+                <option value="VIEWER">Viewer — read only</option>
+              </select>
+            </div>
+            <div className="flex gap-3 justify-end pt-2">
+              <Button type="button" variant="secondary" onClick={() => setInviteModal(null)}>Cancel</Button>
+              <Button type="submit" loading={inviting}>Send Invite</Button>
+            </div>
+          </form>
+        </Modal>
+      )}
+    </div>
+  );
+}

+ 28 - 0
src/app/globals.css

@@ -0,0 +1,28 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+  --background: 0 0% 100%;
+  --foreground: 222.2 84% 4.9%;
+  --border: 214.3 31.8% 91.4%;
+  --primary: 222.2 47.4% 11.2%;
+  --primary-foreground: 210 40% 98%;
+  --muted: 210 40% 96%;
+  --muted-foreground: 215.4 16.3% 46.9%;
+  --accent: 210 40% 96%;
+  --accent-foreground: 222.2 47.4% 11.2%;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html,
+body {
+  margin: 0;
+  padding: 0;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  background: hsl(var(--background));
+  color: hsl(var(--foreground));
+}

+ 18 - 0
src/app/layout.tsx

@@ -0,0 +1,18 @@
+import type { Metadata } from 'next';
+import { AuthProvider } from '@/lib/auth-context';
+import './globals.css';
+
+export const metadata: Metadata = {
+  title: 'VidReview — Video Collaboration',
+  description: 'Collaborative video review and feedback platform',
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+  return (
+    <html lang="en">
+      <body>
+        <AuthProvider>{children}</AuthProvider>
+      </body>
+    </html>
+  );
+}

+ 5 - 0
src/app/page.tsx

@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation';
+
+export default function Home() {
+  redirect('/login');
+}

+ 242 - 0
src/app/review/[assetId]/page.tsx

@@ -0,0 +1,242 @@
+'use client';
+
+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 { VideoPlayer } from '@/components/video-player/VideoPlayer';
+import { CommentPanel } from '@/components/comments/CommentPanel';
+import { Button } from '@/components/ui/button';
+
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
+
+const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string }> = {
+  PENDING_REVIEW:     { label: 'Pending Review',      color: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',   icon: '⏳' },
+  CHANGES_REQUESTED:  { label: 'Request Changes',     color: 'bg-orange-100 text-orange-800 hover:bg-orange-200',  icon: '🔁' },
+  APPROVED:           { label: 'Approved',             color: 'bg-green-100 text-green-800 hover:bg-green-200',     icon: '✅' },
+  REJECTED:           { label: 'Rejected',             color: 'bg-red-100 text-red-800 hover:bg-red-200',          icon: '❌' },
+};
+
+export default function ReviewPage() {
+  const params = useParams();
+  const assetId = params.assetId as string;
+  const { token, user } = useAuth();
+  const router = useRouter();
+
+  const [asset, setAsset] = useState<AssetWithComments | null>(null);
+  const [comments, setComments] = useState<Comment[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
+  const [panelWidth, setPanelWidth] = useState(360);
+  const [showApproval, setShowApproval] = useState(false);
+  const [updatingStatus, setUpdatingStatus] = useState(false);
+  const isDraggingRef = useRef(false);
+
+  // Load asset + comments
+  const loadData = useCallback(async () => {
+    if (!token) return;
+    try {
+      const [{ asset: a }, { comments: c }] = await Promise.all([
+        assetsApi.get(token, assetId),
+        commentsApi.list(token, assetId),
+      ]);
+      setAsset(a);
+      setComments(c);
+    } catch {
+      router.push('/projects');
+    } finally {
+      setLoading(false);
+    }
+  }, [token, assetId, router]);
+
+  useEffect(() => { loadData(); }, [loadData]);
+
+  const handleAddComment = async (data: {
+    content: string;
+    timestamp?: number;
+    annotation?: unknown;
+    parentId?: string;
+  }) => {
+    if (!token) return;
+    const { comment } = await commentsApi.create(token, assetId, {
+      ...data,
+      annotation: data.annotation as AnnotationData | undefined,
+    });
+    setComments(prev => {
+      if (data.parentId) {
+        // Add as reply
+        return prev.map(c =>
+          c.id === data.parentId
+            ? { ...c, replies: [...(c.replies ?? []), comment] }
+            : c
+        );
+      }
+      return [...prev, comment];
+    });
+    // Clear pending annotation after posting
+    setPendingAnnotation(null);
+  };
+
+  const handleResolve = async (commentId: string) => {
+    if (!token) return;
+    const { comment } = await commentsApi.resolve(token, commentId);
+    setComments(prev => prev.map(c => c.id === commentId ? comment : c));
+  };
+
+  const handleDeleteComment = async (commentId: string) => {
+    if (!token) return;
+    await commentsApi.delete(token, commentId);
+    setComments(prev => prev.filter(c => c.id !== commentId));
+  };
+
+  const handleAnnotationCreated = (annotation: AnnotationData) => {
+    setPendingAnnotation(annotation);
+  };
+
+  const handleCommentClick = (_comment: Comment) => {
+    // Jump to timestamp handled by parent via ref — for now no-op
+  };
+
+  const handleStatusUpdate = async (status: string) => {
+    if (!token) return;
+    setUpdatingStatus(true);
+    try {
+      const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
+      setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
+      setShowApproval(false);
+    } finally {
+      setUpdatingStatus(false);
+    }
+  };
+
+  // Resizable panel
+  const handlePanelDrag = (e: React.MouseEvent) => {
+    if (!isDraggingRef.current) return;
+    const newWidth = Math.max(280, Math.min(600, window.innerWidth - e.clientX));
+    setPanelWidth(newWidth);
+  };
+
+  const handleStatus = asset?.status ?? 'PENDING_REVIEW';
+  const statusCfg = STATUS_CONFIG[handleStatus];
+  const videoUrl = asset ? `${API_BASE}/uploads/${asset.filePath}` : '';
+
+  if (loading) {
+    return (
+      <div className="h-screen flex items-center justify-center bg-gray-100">
+        <div className="animate-spin w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full" />
+      </div>
+    );
+  }
+
+  if (!asset) return null;
+
+  return (
+    <div className="h-screen flex flex-col bg-gray-100 overflow-hidden">
+      {/* Top bar */}
+      <header className="h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-4 shrink-0">
+        <button
+          onClick={() => router.push(`/projects/${asset.projectId}`)}
+          className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
+        >
+          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
+          </svg>
+          Back
+        </button>
+
+        <div className="h-6 w-px bg-gray-200" />
+
+        <div className="flex-1 min-w-0">
+          <h1 className="font-semibold text-gray-900 truncate text-sm">{asset.title}</h1>
+        </div>
+
+        {/* Approval buttons */}
+        <div className="flex items-center gap-2 relative">
+          <button
+            onClick={() => setShowApproval(v => !v)}
+            className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${statusCfg.color}`}
+          >
+            <span>{statusCfg.icon}</span>
+            <span>{statusCfg.label}</span>
+            <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
+            </svg>
+          </button>
+
+          {showApproval && (
+            <div className="absolute right-0 top-full mt-2 bg-white rounded-xl shadow-xl border border-gray-200 z-50 py-1 min-w-[200px]">
+              {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
+                <button
+                  key={key}
+                  onClick={() => handleStatusUpdate(key)}
+                  disabled={updatingStatus}
+                  className={`w-full flex items-center gap-2 px-4 py-2 text-sm hover:bg-gray-50 transition-colors ${key === handleStatus ? 'font-semibold' : ''}`}
+                >
+                  <span>{cfg.icon}</span>
+                  <span>{cfg.label}</span>
+                  {key === handleStatus && <span className="ml-auto text-blue-500">✓</span>}
+                </button>
+              ))}
+            </div>
+          )}
+        </div>
+
+        {/* Project name */}
+        <div className="text-xs text-gray-400">
+          {asset.project.name}
+        </div>
+      </header>
+
+      {/* Body */}
+      <div className="flex flex-1 overflow-hidden">
+        {/* Video area */}
+        <div className="flex-1 overflow-y-auto p-4">
+          <VideoPlayer
+            src={videoUrl}
+            mimeType={asset.mimeType}
+            comments={comments}
+            pendingAnnotation={pendingAnnotation}
+            onAnnotationCreated={handleAnnotationCreated}
+            onTimeUpdate={setCurrentTime}
+            onCommentClick={handleCommentClick}
+          />
+
+          {/* Keyboard shortcuts hint */}
+          <div className="mt-3 flex flex-wrap gap-3 text-xs text-gray-400">
+            <span><kbd className="bg-gray-200 px-1 rounded">Space</kbd> play/pause</span>
+            <span><kbd className="bg-gray-200 px-1 rounded">←</kbd><kbd className="bg-gray-200 px-1 rounded ml-0.5">→</kbd> seek 5s</span>
+            <span><kbd className="bg-gray-200 px-1 rounded">C</kbd> draw mode</span>
+            <span><kbd className="bg-gray-200 px-1 rounded">F</kbd> fullscreen</span>
+            <span><kbd className="bg-gray-200 px-1 rounded">U</kbd>/<kbd className="bg-gray-200 px-1 rounded ml-0.5">I</kbd> prev/next frame</span>
+          </div>
+        </div>
+
+        {/* Resize handle */}
+        <div
+          className="w-1 bg-gray-200 hover:bg-blue-400 cursor-col-resize transition-colors shrink-0"
+          onMouseDown={() => { isDraggingRef.current = true; document.body.style.cursor = 'col-resize'; }}
+          onMouseUp={() => { isDraggingRef.current = false; document.body.style.cursor = ''; }}
+          onMouseMove={handlePanelDrag}
+        />
+
+        {/* Comment panel */}
+        <div
+          className="bg-white border-l border-gray-200 flex flex-col overflow-hidden shrink-0"
+          style={{ width: panelWidth }}
+        >
+          <CommentPanel
+            comments={comments}
+            currentUserId={user?.id ?? ''}
+            currentTime={currentTime}
+            pendingAnnotation={pendingAnnotation}
+            onAddComment={handleAddComment}
+            onResolve={handleResolve}
+            onDelete={handleDeleteComment}
+            onCommentClick={handleCommentClick}
+          />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 279 - 0
src/components/comments/CommentPanel.tsx

@@ -0,0 +1,279 @@
+'use client';
+
+import { useState } from 'react';
+import { Comment } from '@/lib/api';
+import { Avatar } from '@/components/ui/avatar';
+import { Button } from '@/components/ui/button';
+
+interface Props {
+  comments: Comment[];
+  currentUserId: string;
+  currentTime: number;
+  pendingAnnotation: unknown;
+  onAddComment: (data: { content: string; timestamp?: number; annotation?: unknown; parentId?: string }) => Promise<void>;
+  onResolve: (commentId: string) => Promise<void>;
+  onDelete: (commentId: string) => Promise<void>;
+  onCommentClick: (comment: Comment) => void;
+}
+
+export function CommentPanel({
+  comments,
+  currentUserId,
+  currentTime,
+  pendingAnnotation,
+  onAddComment,
+  onResolve,
+  onDelete,
+  onCommentClick,
+}: Props) {
+  const [newComment, setNewComment] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+  const [replyTo, setReplyTo] = useState<Comment | null>(null);
+  const [replyText, setReplyText] = useState('');
+  const [showResolved, setShowResolved] = useState(false);
+
+  const visibleComments = comments.filter(c => !c.resolved || showResolved);
+  const timestampedComments = visibleComments.filter(c => c.timestamp != null);
+  const generalComments = visibleComments.filter(c => c.timestamp == null);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!newComment.trim()) return;
+    setSubmitting(true);
+    try {
+      await onAddComment({
+        content: newComment.trim(),
+        timestamp: currentTime > 0 ? currentTime : undefined,
+        annotation: pendingAnnotation ?? undefined,
+      });
+      setNewComment('');
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const handleReply = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!replyText.trim() || !replyTo) return;
+    setSubmitting(true);
+    try {
+      await onAddComment({
+        content: replyText.trim(),
+        parentId: replyTo.id,
+      });
+      setReplyText('');
+      setReplyTo(null);
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  return (
+    <div className="flex flex-col h-full">
+      {/* Header */}
+      <div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
+        <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
+          </span>
+        </h2>
+        <button
+          onClick={() => setShowResolved(v => !v)}
+          className="text-xs text-gray-500 hover:text-gray-700"
+        >
+          {showResolved ? 'Hide resolved' : 'Show resolved'}
+        </button>
+      </div>
+
+      {/* Comment list */}
+      <div className="flex-1 overflow-y-auto">
+        {visibleComments.length === 0 && (
+          <div className="text-center py-12 text-gray-400">
+            <svg className="w-10 h-10 mx-auto mb-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
+            </svg>
+            <p className="text-sm">No comments yet</p>
+            <p className="text-xs mt-1">Be the first to leave feedback</p>
+          </div>
+        )}
+
+        {/* Timestamped comments */}
+        {timestampedComments.length > 0 && (
+          <div className="border-b border-gray-100">
+            <div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide">
+              Timeline
+            </div>
+            {timestampedComments.map(comment => (
+              <CommentItem
+                key={comment.id}
+                comment={comment}
+                isOwner={comment.userId === currentUserId}
+                onReply={() => setReplyTo(comment)}
+                onResolve={() => onResolve(comment.id)}
+                onDelete={() => onDelete(comment.id)}
+                onTimestampClick={() => onCommentClick(comment)}
+              />
+            ))}
+          </div>
+        )}
+
+        {/* General comments */}
+        {generalComments.length > 0 && (
+          <div>
+            <div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide">
+              General
+            </div>
+            {generalComments.map(comment => (
+              <CommentItem
+                key={comment.id}
+                comment={comment}
+                isOwner={comment.userId === currentUserId}
+                onReply={() => setReplyTo(comment)}
+                onResolve={() => onResolve(comment.id)}
+                onDelete={() => onDelete(comment.id)}
+              />
+            ))}
+          </div>
+        )}
+      </div>
+
+      {/* 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>
+          <form onSubmit={handleReply} className="flex gap-2">
+            <textarea
+              value={replyText}
+              onChange={e => setReplyText(e.target.value)}
+              placeholder="Write a reply..."
+              className="flex-1 text-sm rounded-lg border border-blue-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
+              rows={2}
+              autoFocus
+            />
+            <div className="flex flex-col gap-1">
+              <Button type="submit" size="sm" loading={submitting}>Send</Button>
+              <Button type="button" variant="ghost" size="sm" onClick={() => setReplyTo(null)}>Cancel</Button>
+            </div>
+          </form>
+        </div>
+      )}
+
+      {/* New comment form */}
+      <div className="border-t border-gray-200 p-3">
+        {currentTime > 0 && (
+          <div className="text-xs text-gray-400 mb-2 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+            </svg>
+            Comment at {formatTime(currentTime)}
+            {pendingAnnotation && (
+              <span className="ml-1 text-blue-500">(with annotation)</span>
+            )}
+          </div>
+        )}
+        <form onSubmit={handleSubmit} className="flex gap-2">
+          <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"
+            rows={2}
+          />
+          <Button type="submit" size="sm" loading={submitting} disabled={!newComment.trim()}>
+            Send
+          </Button>
+        </form>
+      </div>
+    </div>
+  );
+}
+
+function CommentItem({
+  comment,
+  isOwner,
+  onReply,
+  onResolve,
+  onDelete,
+  onTimestampClick,
+}: {
+  comment: Comment;
+  isOwner: boolean;
+  onReply: () => void;
+  onResolve: () => void;
+  onDelete: () => void;
+  onTimestampClick?: () => void;
+}) {
+  return (
+    <div className={`px-4 py-3 hover:bg-gray-50 transition-colors ${comment.resolved ? 'opacity-60' : ''}`}>
+      <div className="flex items-start gap-2.5">
+        <Avatar name={comment.user.name} 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>
+            {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"
+              >
+                {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>
+            )}
+          </div>
+
+          {/* Annotation preview */}
+          {comment.annotation && (
+            <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>
+              Has drawing annotation
+            </div>
+          )}
+
+          <p className="text-sm text-gray-700 mt-0.5">{comment.content}</p>
+
+          {/* Replies */}
+          {comment.replies && comment.replies.length > 0 && (
+            <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" />
+                  <div>
+                    <span className="text-xs font-medium text-gray-800">{reply.user.name}</span>
+                    <p className="text-sm text-gray-600 mt-0.5">{reply.content}</p>
+                  </div>
+                </div>
+              ))}
+            </div>
+          )}
+
+          {/* Actions */}
+          <div className="flex items-center gap-3 mt-2">
+            <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>
+            {isOwner && (
+              <button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-600 transition-colors">
+                Delete
+              </button>
+            )}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function formatTime(s: number): string {
+  if (!s || isNaN(s)) return '0:00';
+  const m = Math.floor(s / 60);
+  const sec = Math.floor(s % 60);
+  return `${m}:${sec.toString().padStart(2, '0')}`;
+}

+ 43 - 0
src/components/ui/avatar.tsx

@@ -0,0 +1,43 @@
+import React from 'react';
+import { clsx } from 'clsx';
+
+interface AvatarProps {
+  name: string;
+  src?: string | null;
+  size?: 'sm' | 'md' | 'lg';
+  className?: string;
+}
+
+export function Avatar({ name, src, size = 'md', className }: AvatarProps) {
+  const initials = name
+    .split(' ')
+    .map(n => n[0])
+    .slice(0, 2)
+    .join('')
+    .toUpperCase();
+
+  const sizes = { sm: 'w-6 h-6 text-xs', md: 'w-8 h-8 text-sm', lg: 'w-12 h-12 text-lg' };
+
+  if (src) {
+    return (
+      <img
+        src={src}
+        alt={name}
+        className={clsx('rounded-full object-cover', sizes[size], className)}
+      />
+    );
+  }
+
+  return (
+    <div
+      className={clsx(
+        'rounded-full bg-blue-600 text-white flex items-center justify-center font-semibold',
+        sizes[size],
+        className
+      )}
+      title={name}
+    >
+      {initials}
+    </div>
+  );
+}

+ 49 - 0
src/components/ui/button.tsx

@@ -0,0 +1,49 @@
+import React from 'react';
+import { clsx } from 'clsx';
+
+interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
+  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
+  size?: 'sm' | 'md' | 'lg';
+  loading?: boolean;
+}
+
+export function Button({
+  variant = 'primary',
+  size = 'md',
+  loading,
+  className,
+  children,
+  disabled,
+  ...props
+}: ButtonProps) {
+  const base = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
+
+  const variants = {
+    primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
+    secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-gray-500',
+    ghost: 'text-gray-600 hover:bg-gray-100 focus:ring-gray-400',
+    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
+  };
+
+  const sizes = {
+    sm: 'px-3 py-1.5 text-sm',
+    md: 'px-4 py-2 text-sm',
+    lg: 'px-6 py-3 text-base',
+  };
+
+  return (
+    <button
+      className={clsx(base, variants[variant], sizes[size], className)}
+      disabled={disabled || loading}
+      {...props}
+    >
+      {loading && (
+        <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
+          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
+          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
+        </svg>
+      )}
+      {children}
+    </button>
+  );
+}

+ 32 - 0
src/components/ui/input.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import { clsx } from 'clsx';
+
+interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
+  label?: string;
+  error?: string;
+}
+
+export function Input({ label, error, className, id, ...props }: InputProps) {
+  const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
+  return (
+    <div className="space-y-1">
+      {label && (
+        <label htmlFor={inputId} className="block text-sm font-medium text-gray-700">
+          {label}
+        </label>
+      )}
+      <input
+        id={inputId}
+        className={clsx(
+          'block w-full rounded-lg border px-3 py-2 text-sm',
+          'placeholder:text-gray-400',
+          'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
+          error ? 'border-red-500' : 'border-gray-300',
+          className
+        )}
+        {...props}
+      />
+      {error && <p className="text-sm text-red-600">{error}</p>}
+    </div>
+  );
+}

+ 62 - 0
src/components/ui/modal.tsx

@@ -0,0 +1,62 @@
+'use client';
+
+import React, { useEffect } from 'react';
+import { clsx } from 'clsx';
+
+interface ModalProps {
+  open: boolean;
+  onClose: () => void;
+  title?: string;
+  children: React.ReactNode;
+  className?: string;
+}
+
+export function Modal({ open, onClose, title, children, className }: ModalProps) {
+  useEffect(() => {
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    if (open) {
+      document.addEventListener('keydown', handleKey);
+      document.body.style.overflow = 'hidden';
+    }
+    return () => {
+      document.removeEventListener('keydown', handleKey);
+      document.body.style.overflow = '';
+    };
+  }, [open, onClose]);
+
+  if (!open) return null;
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-black/50 backdrop-blur-sm"
+        onClick={onClose}
+      />
+      {/* Content */}
+      <div
+        className={clsx(
+          'relative z-10 w-full max-w-lg rounded-xl bg-white shadow-2xl',
+          className
+        )}
+      >
+        {title && (
+          <div className="flex items-center justify-between border-b px-6 py-4">
+            <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
+            <button
+              onClick={onClose}
+              className="text-gray-400 hover:text-gray-600 transition-colors"
+            >
+              <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+              </svg>
+            </button>
+          </div>
+        )}
+        <div className="p-6">{children}</div>
+      </div>
+    </div>
+  );
+}

+ 305 - 0
src/components/video-player/AnnotationCanvas.tsx

@@ -0,0 +1,305 @@
+'use client';
+
+import { useRef, useEffect, useCallback, useState } from 'react';
+import { AnnotationData } from '@/lib/api';
+
+const COLORS = [
+  { name: 'Red', value: '#ef4444' },
+  { name: 'Orange', value: '#f97316' },
+  { name: 'Yellow', value: '#eab308' },
+  { name: 'Green', value: '#22c55e' },
+  { name: 'Blue', value: '#3b82f6' },
+  { name: 'Purple', value: '#a855f7' },
+  { name: 'White', value: '#ffffff' },
+];
+
+type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse' | 'eraser';
+
+interface Props {
+  isDrawingMode: boolean;
+  tool: Tool;
+  color: string;
+  annotations: AnnotationData[];
+  width: number;
+  height: number;
+  onAnnotationCreated: (annotation: AnnotationData) => void;
+}
+
+interface DrawState {
+  type: Tool;
+  color: string;
+  startX: number;
+  startY: number;
+  points: [number, number][];
+}
+
+export function AnnotationCanvas({
+  isDrawingMode,
+  tool,
+  color,
+  annotations,
+  width,
+  height,
+  onAnnotationCreated,
+}: Props) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const [isDrawing, setIsDrawing] = useState(false);
+  const [drawState, setDrawState] = useState<DrawState | null>(null);
+  const historyRef = useRef<AnnotationData[]>([]);
+
+  // Render all annotations
+  const render = useCallback(() => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+    const ctx = canvas.getContext('2d');
+    if (!ctx) return;
+
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+    for (const ann of historyRef.current) {
+      drawAnnotation(ctx, ann);
+    }
+  }, []);
+
+  // Update history and re-render when annotations change
+  useEffect(() => {
+    historyRef.current = [...annotations];
+    render();
+  }, [annotations, render]);
+
+  // Re-render on resize
+  useEffect(() => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+    canvas.width = width;
+    canvas.height = height;
+    render();
+  }, [width, height, render]);
+
+  function getPos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
+    const canvas = canvasRef.current!;
+    const rect = canvas.getBoundingClientRect();
+    return [
+      (e.clientX - rect.left) / rect.width,
+      (e.clientY - rect.top) / rect.height,
+    ];
+  }
+
+  function drawAnnotation(ctx: CanvasRenderingContext2D, ann: AnnotationData) {
+    ctx.strokeStyle = ann.color;
+    ctx.fillStyle = ann.color;
+    ctx.lineWidth = 3;
+    ctx.lineCap = 'round';
+    ctx.lineJoin = 'round';
+    ctx.shadowBlur = 0;
+
+    if (ann.type === 'pen' && ann.points) {
+      if (ann.points.length < 2) return;
+      ctx.beginPath();
+      ctx.moveTo(ann.points[0][0] * ctx.canvas.width, ann.points[0][1] * ctx.canvas.height);
+      for (let i = 1; i < ann.points.length; i++) {
+        ctx.lineTo(ann.points[i][0] * ctx.canvas.width, ann.points[i][1] * ctx.canvas.height);
+      }
+      ctx.stroke();
+    } else if (ann.type === 'arrow' && ann.points && ann.points.length >= 2) {
+      const [x1, y1] = ann.points[0];
+      const [x2, y2] = ann.points[ann.points.length - 1];
+      const sx = x1 * ctx.canvas.width, sy = y1 * ctx.canvas.height;
+      const ex = x2 * ctx.canvas.width, ey = y2 * ctx.canvas.height;
+
+      // Line
+      ctx.beginPath();
+      ctx.moveTo(sx, sy);
+      ctx.lineTo(ex, ey);
+      ctx.stroke();
+
+      // Arrowhead
+      const angle = Math.atan2(ey - sy, ex - sx);
+      const headLen = 16;
+      ctx.beginPath();
+      ctx.moveTo(ex, ey);
+      ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
+      ctx.moveTo(ex, ey);
+      ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
+      ctx.stroke();
+    } else if (ann.type === 'rect' && ann.boundingBox) {
+      const { x, y, width: w, height: h } = ann.boundingBox;
+      ctx.strokeRect(
+        x * ctx.canvas.width,
+        y * ctx.canvas.height,
+        w * ctx.canvas.width,
+        h * ctx.canvas.height
+      );
+    } else if (ann.type === 'ellipse' && ann.boundingBox) {
+      const { x, y, width: w, height: h } = ann.boundingBox;
+      ctx.beginPath();
+      ctx.ellipse(
+        (x + w / 2) * ctx.canvas.width,
+        (y + h / 2) * ctx.canvas.height,
+        (w / 2) * ctx.canvas.width,
+        (h / 2) * ctx.canvas.height,
+        0, 0, 2 * Math.PI
+      );
+      ctx.stroke();
+    }
+  }
+
+  function drawLivePreview(ctx: CanvasRenderingContext2D) {
+    if (!drawState) return;
+    ctx.strokeStyle = drawState.color;
+    ctx.fillStyle = drawState.color;
+    ctx.lineWidth = 3;
+    ctx.lineCap = 'round';
+    ctx.lineJoin = 'round';
+
+    const { type, startX, startY, points } = drawState;
+    const w = ctx.canvas.width;
+    const h = ctx.canvas.height;
+
+    if (type === 'pen' && points.length >= 2) {
+      ctx.beginPath();
+      ctx.moveTo(points[0][0] * w, points[0][1] * h);
+      for (let i = 1; i < points.length; i++) {
+        ctx.lineTo(points[i][0] * w, points[i][1] * h);
+      }
+      ctx.stroke();
+    } else if (type === 'arrow' && points.length >= 2) {
+      const [x1, y1] = points[0];
+      const [x2, y2] = points[points.length - 1];
+      const sx = x1 * w, sy = y1 * h;
+      const ex = x2 * w, ey = y2 * h;
+
+      ctx.beginPath();
+      ctx.moveTo(sx, sy);
+      ctx.lineTo(ex, ey);
+      ctx.stroke();
+
+      const angle = Math.atan2(ey - sy, ex - sx);
+      const headLen = 16;
+      ctx.beginPath();
+      ctx.moveTo(ex, ey);
+      ctx.lineTo(ex - headLen * Math.cos(angle - Math.PI / 6), ey - headLen * Math.sin(angle - Math.PI / 6));
+      ctx.moveTo(ex, ey);
+      ctx.lineTo(ex - headLen * Math.cos(angle + Math.PI / 6), ey - headLen * Math.sin(angle + Math.PI / 6));
+      ctx.stroke();
+    } else if (type === 'rect' && points.length >= 2) {
+      const sx = startX * w, sy = startY * h;
+      const ex = points[points.length - 1][0] * w;
+      const ey = points[points.length - 1][1] * h;
+      ctx.strokeRect(sx, sy, ex - sx, ey - sy);
+    } else if (type === 'ellipse' && points.length >= 2) {
+      const sx = startX * w, sy = startY * h;
+      const ex = points[points.length - 1][0] * w;
+      const ey = points[points.length - 1][1] * h;
+      ctx.beginPath();
+      ctx.ellipse(
+        (sx + ex) / 2, (sy + ey) / 2,
+        Math.abs(ex - sx) / 2, Math.abs(ey - sy) / 2,
+        0, 0, 2 * Math.PI
+      );
+      ctx.stroke();
+    }
+  }
+
+  const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isDrawingMode) return;
+    const [x, y] = getPos(e);
+    if (tool === 'eraser') {
+      // Erase: remove annotations near click
+      return;
+    }
+    setIsDrawing(true);
+    setDrawState({ type: tool, color, startX: x, startY: y, points: [[x, y]] });
+  };
+
+  const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isDrawing || !drawState) return;
+    const [x, y] = getPos(e);
+    setDrawState(prev => {
+      if (!prev) return prev;
+      const points = [...prev.points, [x, y] as [number, number]];
+
+      // Live render
+      const canvas = canvasRef.current;
+      if (!canvas) return prev;
+      const ctx = canvas.getContext('2d');
+      if (!ctx) return prev;
+
+      ctx.clearRect(0, 0, canvas.width, canvas.height);
+      for (const ann of historyRef.current) drawAnnotation(ctx, ann);
+      drawLivePreview(ctx);
+      return { ...prev, points };
+    });
+  };
+
+  const handleMouseUp = (e: React.MouseEvent<HTMLCanvasElement>) => {
+    if (!isDrawing || !drawState) return;
+    setIsDrawing(false);
+
+    const [x, y] = getPos(e);
+    const allPoints = [...drawState.points, [x, y] as [number, number]];
+
+    let annotation: AnnotationData;
+
+    if (drawState.type === 'rect') {
+      const minX = Math.min(drawState.startX, x);
+      const minY = Math.min(drawState.startY, y);
+      const maxX = Math.max(drawState.startX, x);
+      const maxY = Math.max(drawState.startY, y);
+      annotation = {
+        type: 'rect',
+        color: drawState.color,
+        boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
+        points: allPoints,
+      };
+    } else if (drawState.type === 'ellipse') {
+      const minX = Math.min(drawState.startX, x);
+      const minY = Math.min(drawState.startY, y);
+      const maxX = Math.max(drawState.startX, x);
+      const maxY = Math.max(drawState.startY, y);
+      annotation = {
+        type: 'ellipse',
+        color: drawState.color,
+        boundingBox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
+        points: allPoints,
+      };
+    } else {
+      annotation = {
+        type: drawState.type === 'arrow' ? 'arrow' : 'pen',
+        color: drawState.color,
+        points: allPoints,
+      };
+    }
+
+    onAnnotationCreated(annotation);
+    setDrawState(null);
+
+    // Re-render final
+    const canvas = canvasRef.current;
+    if (canvas) {
+      const ctx = canvas.getContext('2d');
+      if (ctx) {
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        for (const ann of historyRef.current) drawAnnotation(ctx, ann);
+      }
+    }
+  };
+
+  return (
+    <canvas
+      ref={canvasRef}
+      className="absolute inset-0 z-10"
+      style={{
+        cursor: isDrawingMode ? 'crosshair' : 'default',
+        pointerEvents: isDrawingMode ? 'auto' : 'none',
+      }}
+      onMouseDown={handleMouseDown}
+      onMouseMove={handleMouseMove}
+      onMouseUp={handleMouseUp}
+      onMouseLeave={handleMouseUp}
+    />
+  );
+}
+
+export { COLORS };
+export type { Tool };

+ 84 - 0
src/components/video-player/Timeline.tsx

@@ -0,0 +1,84 @@
+'use client';
+
+import { Comment } from '@/lib/api';
+
+interface Props {
+  duration: number;
+  currentTime: number;
+  comments: Comment[];
+  onSeek: (time: number) => void;
+  onCommentClick: (comment: Comment) => void;
+}
+
+export function Timeline({ duration, currentTime, comments, onSeek, onCommentClick }: Props) {
+  const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
+
+  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
+    const rect = e.currentTarget.getBoundingClientRect();
+    const x = e.clientX - rect.left;
+    const ratio = Math.max(0, Math.min(1, x / rect.width));
+    onSeek(ratio * duration);
+  };
+
+  return (
+    <div className="relative py-2">
+      {/* Comment markers */}
+      <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-3 pointer-events-none">
+        {comments
+          .filter(c => c.timestamp != null)
+          .map(comment => {
+            const pos = duration > 0 ? ((comment.timestamp ?? 0) / duration) * 100 : 0;
+            return (
+              <button
+                key={comment.id}
+                className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow transition-transform hover:scale-150 ${
+                  comment.resolved ? 'bg-green-500' : 'bg-blue-500'
+                }`}
+                style={{ left: `${pos}%`, pointerEvents: 'auto' }}
+                title={`${comment.user.name}: ${comment.content.slice(0, 40)}`}
+                onClick={(e) => {
+                  e.stopPropagation();
+                  onCommentClick(comment);
+                }}
+              />
+            );
+          })}
+      </div>
+
+      {/* Progress bar + click area */}
+      <div
+        className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer group"
+        onClick={handleClick}
+      >
+        {/* Buffered (optional) */}
+        <div
+          className="absolute h-full bg-gray-500 rounded-full opacity-50"
+          style={{ width: '100%' }}
+        />
+        {/* Played */}
+        <div
+          className="absolute h-full bg-blue-500 rounded-full transition-all"
+          style={{ width: `${progress}%` }}
+        />
+        {/* Scrubber handle */}
+        <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)` }}
+        />
+      </div>
+
+      {/* Time display */}
+      <div className="flex justify-between mt-1 text-xs text-gray-400">
+        <span>{formatTime(currentTime)}</span>
+        <span>{formatTime(duration)}</span>
+      </div>
+    </div>
+  );
+}
+
+function formatTime(s: number): string {
+  if (!s || isNaN(s)) return '0:00';
+  const m = Math.floor(s / 60);
+  const sec = Math.floor(s % 60);
+  return `${m}:${sec.toString().padStart(2, '0')}`;
+}

+ 349 - 0
src/components/video-player/VideoPlayer.tsx

@@ -0,0 +1,349 @@
+'use client';
+
+import { useRef, useState, useEffect, useCallback } from 'react';
+import Hls from 'hls.js';
+import { AnnotationCanvas, COLORS } from './AnnotationCanvas';
+import { Timeline } from './Timeline';
+import { AnnotationData, Comment } from '@/lib/api';
+
+type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
+
+interface Props {
+  src: string;
+  mimeType: string;
+  comments: Comment[];
+  pendingAnnotation: AnnotationData | null;
+  onAnnotationCreated: (annotation: AnnotationData) => void;
+  onTimeUpdate: (time: number) => void;
+  onCommentClick: (comment: Comment) => void;
+}
+
+export function VideoPlayer({
+  src,
+  mimeType,
+  comments,
+  pendingAnnotation,
+  onAnnotationCreated,
+  onTimeUpdate,
+  onCommentClick,
+}: Props) {
+  const videoRef = useRef<HTMLVideoElement>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+  const [playing, setPlaying] = useState(false);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+  const [volume, setVolume] = useState(1);
+  const [muted, setMuted] = useState(false);
+  const [playbackRate, setPlaybackRate] = useState(1);
+  const [fullscreen, setFullscreen] = useState(false);
+  const [drawMode, setDrawMode] = useState(false);
+  const [tool, setTool] = useState<Tool>('pen');
+  const [color, setColor] = useState(COLORS[0].value);
+  const [showControls, setShowControls] = useState(true);
+  const [dims, setDims] = useState({ width: 0, height: 0 });
+
+  const hideTimer = useRef<ReturnType<typeof setTimeout>>();
+
+  // HLS setup
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video || !src) return;
+
+    if (mimeType === 'application/x-mpegURL' || src.endsWith('.m3u8')) {
+      if (Hls.isSupported()) {
+        const hls = new Hls();
+        hls.loadSource(src);
+        hls.attachMedia(video);
+        return () => hls.destroy();
+      }
+    } else {
+      video.src = src;
+    }
+  }, [src, mimeType]);
+
+  // Measure container
+  useEffect(() => {
+    const obs = new ResizeObserver(entries => {
+      for (const entry of entries) {
+        setDims({ width: entry.contentRect.width, height: entry.contentRect.height });
+      }
+    });
+    if (containerRef.current) obs.observe(containerRef.current);
+    return () => obs.disconnect();
+  }, []);
+
+  // Keyboard shortcuts
+  useEffect(() => {
+    const handleKey = (e: KeyboardEvent) => {
+      const video = videoRef.current;
+      if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
+
+      if (e.code === 'Space') { e.preventDefault(); video.paused ? video.play() : video.pause(); }
+      if (e.code === 'ArrowLeft') { e.preventDefault(); video.currentTime = Math.max(0, video.currentTime - 5); }
+      if (e.code === 'ArrowRight') { e.preventDefault(); video.currentTime = Math.min(duration, video.currentTime + 5); }
+      if (e.code === 'KeyC') { e.preventDefault(); setDrawMode(v => !v); }
+      if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
+      if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
+      if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
+      if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
+    };
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, [duration]);
+
+  // Auto-hide controls
+  const resetHideTimer = useCallback(() => {
+    setShowControls(true);
+    clearTimeout(hideTimer.current);
+    if (playing) {
+      hideTimer.current = setTimeout(() => setShowControls(false), 3000);
+    }
+  }, [playing]);
+
+  function stepFrame(dir: 1 | -1) {
+    const video = videoRef.current;
+    if (!video) return;
+    // Approximate frame step: 1/fps (assume 30fps)
+    const frameTime = 1 / 30;
+    video.pause();
+    video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
+  }
+
+  function toggleFullscreen() {
+    if (!containerRef.current) return;
+    if (!document.fullscreenElement) {
+      containerRef.current.requestFullscreen();
+      setFullscreen(true);
+    } else {
+      document.exitFullscreen();
+      setFullscreen(false);
+    }
+  }
+
+  const togglePlay = () => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.paused ? video.play() : video.pause();
+  };
+
+  const handleVolume = (v: number) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.volume = v;
+    setVolume(v);
+    setMuted(v === 0);
+  };
+
+  const handleSpeed = (rate: number) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.playbackRate = rate;
+    setPlaybackRate(rate);
+  };
+
+  const handleSeek = (time: number) => {
+    const video = videoRef.current;
+    if (!video) return;
+    video.currentTime = time;
+    setCurrentTime(time);
+    onTimeUpdate(time);
+  };
+
+  // Annotations visible at current time (±0.5s)
+  const visibleAnnotations = comments
+    .filter(c => c.annotation && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 0.5)
+    .map(c => c.annotation as AnnotationData);
+
+  return (
+    <div
+      ref={containerRef}
+      className="relative bg-black rounded-xl overflow-hidden select-none group"
+      onMouseMove={resetHideTimer}
+      onMouseLeave={() => playing && setShowControls(false)}
+    >
+      {/* Video */}
+      <video
+        ref={videoRef}
+        className="w-full block"
+        onClick={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
+      />
+
+      {/* Annotation Canvas */}
+      <AnnotationCanvas
+        isDrawingMode={drawMode}
+        tool={tool}
+        color={color}
+        annotations={[...visibleAnnotations, ...(pendingAnnotation ? [pendingAnnotation] : [])]}
+        width={dims.width}
+        height={dims.height}
+        onAnnotationCreated={onAnnotationCreated}
+      />
+
+      {/* Big play button overlay */}
+      {!playing && (
+        <button
+          className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
+          onClick={togglePlay}
+        >
+          <div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
+            <svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
+              <path d="M8 5v14l11-7z" />
+            </svg>
+          </div>
+        </button>
+      )}
+
+      {/* Controls overlay */}
+      <div
+        className={`absolute bottom-0 left-0 right-0 z-30 bg-gradient-to-t from-black/80 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
+          showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
+        }`}
+      >
+        {/* Draw toolbar */}
+        {drawMode && (
+          <div className="flex items-center gap-2 mb-2">
+            <span className="text-xs text-white/60 mr-1">Draw:</span>
+            {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
+              <button
+                key={t}
+                onClick={() => setTool(t)}
+                className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
+                  tool === t ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
+                }`}
+              >
+                {t.charAt(0).toUpperCase() + t.slice(1)}
+              </button>
+            ))}
+            <div className="w-px h-5 bg-white/30 mx-1" />
+            {COLORS.map(c => (
+              <button
+                key={c.value}
+                className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
+                  color === c.value ? 'border-white scale-125' : 'border-transparent'
+                }`}
+                style={{ backgroundColor: c.value }}
+                onClick={() => setColor(c.value)}
+                title={c.name}
+              />
+            ))}
+            <button
+              onClick={() => setDrawMode(false)}
+              className="ml-2 text-xs text-white/60 hover:text-white"
+            >
+              Done
+            </button>
+          </div>
+        )}
+
+        {/* Timeline */}
+        <Timeline
+          duration={duration}
+          currentTime={currentTime}
+          comments={comments}
+          onSeek={handleSeek}
+          onCommentClick={onCommentClick}
+        />
+
+        {/* Bottom controls */}
+        <div className="flex items-center gap-2 mt-1">
+          {/* Play/Pause */}
+          <button onClick={togglePlay} className="text-white hover:text-blue-300 transition-colors">
+            {playing ? (
+              <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
+                <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
+              </svg>
+            ) : (
+              <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
+                <path d="M8 5v14l11-7z" />
+              </svg>
+            )}
+          </button>
+
+          {/* Frame step */}
+          <button onClick={() => stepFrame(-1)} className="text-white/60 hover:text-white text-xs" title="Previous frame (U)">⏮</button>
+          <button onClick={() => stepFrame(1)} className="text-white/60 hover:text-white text-xs" title="Next frame (I)">⏭</button>
+
+          {/* Volume */}
+          <button onClick={() => handleVolume(muted || volume === 0 ? 1 : 0)} className="text-white/80 hover:text-white">
+            {muted || volume === 0 ? (
+              <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
+              </svg>
+            ) : (
+              <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
+              </svg>
+            )}
+          </button>
+          <input
+            type="range"
+            min={0}
+            max={1}
+            step={0.05}
+            value={muted ? 0 : volume}
+            onChange={e => handleVolume(parseFloat(e.target.value))}
+            className="w-16 h-1 accent-blue-500"
+          />
+
+          <span className="text-xs text-white/60 ml-1">
+            {formatTime(currentTime)} / {formatTime(duration)}
+          </span>
+
+          <div className="flex-1" />
+
+          {/* Speed */}
+          <select
+            value={playbackRate}
+            onChange={e => handleSpeed(parseFloat(e.target.value))}
+            className="bg-transparent text-xs text-white/80 border border-white/30 rounded px-1.5 py-0.5 cursor-pointer hover:border-white/60"
+          >
+            {[0.25, 0.5, 0.75, 1, 1.25, 1.5, 2].map(r => (
+              <option key={r} value={r} className="text-black">{r}x</option>
+            ))}
+          </select>
+
+          {/* Draw mode */}
+          <button
+            onClick={() => setDrawMode(v => !v)}
+            className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
+              drawMode ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
+            }`}
+            title="Toggle draw mode (C)"
+          >
+            <svg className="w-4 h-4" 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>
+          </button>
+
+          {/* Fullscreen */}
+          <button onClick={toggleFullscreen} className="text-white/80 hover:text-white" title="Fullscreen (F)">
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              {fullscreen ? (
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
+              ) : (
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
+              )}
+            </svg>
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function formatTime(s: number): string {
+  if (!s || isNaN(s)) return '0:00';
+  const m = Math.floor(s / 60);
+  const sec = Math.floor(s % 60);
+  return `${m}:${sec.toString().padStart(2, '0')}`;
+}

+ 204 - 0
src/lib/api.ts

@@ -0,0 +1,204 @@
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
+
+interface FetchOptions extends RequestInit {
+  token?: string;
+}
+
+async function apiFetch<T = unknown>(
+  endpoint: string,
+  options: FetchOptions = {}
+): Promise<T> {
+  const { token, ...fetchOptions } = options;
+
+  const headers: Record<string, string> = {
+    'Content-Type': 'application/json',
+    ...(options.headers as Record<string, string> || {}),
+  };
+
+  if (token) {
+    headers['Authorization'] = `Bearer ${token}`;
+  }
+
+  const res = await fetch(`${API_BASE}${endpoint}`, {
+    ...fetchOptions,
+    headers,
+    credentials: 'include',
+  });
+
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({ error: res.statusText }));
+    throw new Error(error.error || `HTTP ${res.status}`);
+  }
+
+  return res.json();
+}
+
+// ── Auth ─────────────────────────────────────────────────────────────────────
+
+export const authApi = {
+  register: (data: { email: string; name: string; password: string }) =>
+    apiFetch<{ user: User; token: string }>('/api/auth/register', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
+  login: (data: { email: string; password: string }) =>
+    apiFetch<{ user: User; token: string }>('/api/auth/login', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+
+  logout: () =>
+    apiFetch('/api/auth/logout', { method: 'POST' }),
+
+  me: (token: string) =>
+    apiFetch<{ user: User }>('/api/auth/me', { token }),
+};
+
+// ── Projects ─────────────────────────────────────────────────────────────────
+
+export const projectsApi = {
+  list: (token: string) =>
+    apiFetch<{ projects: Project[] }>('/api/projects', { token }),
+
+  create: (token: string, data: { name: string; description?: string }) =>
+    apiFetch<{ project: Project }>('/api/projects', {
+      method: 'POST',
+      body: JSON.stringify(data),
+      token,
+    }),
+
+  get: (token: string, id: string) =>
+    apiFetch<{ project: Project }>(`/api/projects/${id}`, { token }),
+
+  update: (token: string, id: string, data: { name?: string; description?: string }) =>
+    apiFetch<{ project: Project }>(`/api/projects/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+      token,
+    }),
+
+  delete: (token: string, id: string) =>
+    apiFetch(`/api/projects/${id}`, { method: 'DELETE', token }),
+
+  inviteMember: (token: string, projectId: string, email: string, role: string) =>
+    apiFetch(`/api/projects/${projectId}/members`, {
+      method: 'POST',
+      body: JSON.stringify({ email, role }),
+      token,
+    }),
+};
+
+// ── Assets ───────────────────────────────────────────────────────────────────
+
+export const assetsApi = {
+  list: (token: string, projectId: string) =>
+    apiFetch<{ assets: Asset[] }>(`/api/assets?projectId=${projectId}`, { token }),
+
+  get: (token: string, id: string) =>
+    apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }),
+
+  upload: (token: string, formData: FormData) =>
+    fetch(`${API_BASE}/api/assets/upload`, {
+      method: 'POST',
+      headers: { Authorization: `Bearer ${token}` },
+      body: formData,
+    }).then(r => r.json()),
+
+  updateStatus: (token: string, id: string, status: string) =>
+    apiFetch<{ asset: Asset }>(`/api/assets/${id}/status`, {
+      method: 'PUT',
+      body: JSON.stringify({ status }),
+      token,
+    }),
+
+  delete: (token: string, id: string) =>
+    apiFetch(`/api/assets/${id}`, { method: 'DELETE', token }),
+};
+
+// ── Comments ─────────────────────────────────────────────────────────────────
+
+export const commentsApi = {
+  list: (token: string, assetId: string, resolved?: boolean) => {
+    const q = resolved !== undefined ? `?resolved=${resolved}` : '';
+    return apiFetch<{ comments: Comment[] }>(`/api/assets/${assetId}/comments${q}`, { token });
+  },
+
+  create: (token: string, assetId: string, data: {
+    content: string;
+    timestamp?: number;
+    annotation?: AnnotationData;
+    parentId?: string;
+  }) =>
+    apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+      token,
+    }),
+
+  resolve: (token: string, id: string) =>
+    apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, { method: 'PUT', token }),
+
+  delete: (token: string, id: string) =>
+    apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
+};
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface User {
+  id: string;
+  email: string;
+  name: string;
+  role: string;
+  avatarUrl?: string | null;
+}
+
+export interface Project {
+  id: string;
+  name: string;
+  description?: string | null;
+  createdAt: string;
+  members: Array<{ id: string; role: string; user: User }>;
+  _count?: { assets: number };
+}
+
+export interface Asset {
+  id: string;
+  projectId: string;
+  title: string;
+  filename: string;
+  filePath: string;
+  thumbnail?: string | null;
+  duration?: number | null;
+  mimeType: string;
+  status: string;
+  createdAt: string;
+  _count?: { comments: number };
+}
+
+export interface AssetWithComments extends Asset {
+  project: Project;
+  comments: Comment[];
+}
+
+export interface Comment {
+  id: string;
+  assetId: string;
+  userId: string;
+  content: string;
+  timestamp?: number | null;
+  annotation?: AnnotationData | null;
+  resolved: boolean;
+  parentId?: string | null;
+  createdAt: string;
+  user: User;
+  replies?: Comment[];
+}
+
+export interface AnnotationData {
+  type: 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text';
+  color: string;
+  points?: [number, number][];
+  text?: string;
+  boundingBox?: { x: number; y: number; width: number; height: number };
+}

+ 74 - 0
src/lib/auth-context.tsx

@@ -0,0 +1,74 @@
+'use client';
+
+import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import { authApi, User } from './api';
+
+interface AuthContextValue {
+  user: User | null;
+  token: string | null;
+  loading: boolean;
+  login: (email: string, password: string) => Promise<void>;
+  register: (email: string, name: string, password: string) => Promise<void>;
+  logout: () => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+  const [user, setUser] = useState<User | null>(null);
+  const [token, setToken] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  // Load from localStorage on mount
+  useEffect(() => {
+    const savedToken = localStorage.getItem('vidreview_token');
+    const savedUser = localStorage.getItem('vidreview_user');
+
+    if (savedToken && savedUser) {
+      setToken(savedToken);
+      try {
+        setUser(JSON.parse(savedUser));
+      } catch {
+        localStorage.removeItem('vidreview_token');
+        localStorage.removeItem('vidreview_user');
+      }
+    }
+    setLoading(false);
+  }, []);
+
+  const login = useCallback(async (email: string, password: string) => {
+    const { user: u, token: t } = await authApi.login({ email, password });
+    localStorage.setItem('vidreview_token', t);
+    localStorage.setItem('vidreview_user', JSON.stringify(u));
+    setToken(t);
+    setUser(u);
+  }, []);
+
+  const register = useCallback(async (email: string, name: string, password: string) => {
+    const { user: u, token: t } = await authApi.register({ email, name, password });
+    localStorage.setItem('vidreview_token', t);
+    localStorage.setItem('vidreview_user', JSON.stringify(u));
+    setToken(t);
+    setUser(u);
+  }, []);
+
+  const logout = useCallback(async () => {
+    try { await authApi.logout(); } catch { /* ignore */ }
+    localStorage.removeItem('vidreview_token');
+    localStorage.removeItem('vidreview_user');
+    setToken(null);
+    setUser(null);
+  }, []);
+
+  return (
+    <AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
+      {children}
+    </AuthContext.Provider>
+  );
+}
+
+export function useAuth() {
+  const ctx = useContext(AuthContext);
+  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
+  return ctx;
+}

+ 20 - 0
src/next.config.js

@@ -0,0 +1,20 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+  output: 'standalone',
+  // Proxy /api requests to Express backend in development
+  async rewrites() {
+    return [
+      {
+        source: '/api/:path*',
+        destination: 'http://localhost:3001/api/:path*',
+      },
+      // Serve uploaded files from Express's uploads directory
+      {
+        source: '/uploads/:path*',
+        destination: 'http://localhost:3001/uploads/:path*',
+      },
+    ];
+  },
+};
+
+module.exports = nextConfig;

+ 31 - 0
src/package.json

@@ -0,0 +1,31 @@
+{
+  "name": "frontend",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbo",
+    "build": "next build",
+    "start": "next start",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "clsx": "^2.1.1",
+    "hls.js": "^1.5.19",
+    "jose": "^5.9.6",
+    "lucide-react": "^0.468.0",
+    "next": "^15.1.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "react-dropzone": "^14.3.5",
+    "tailwind-merge": "^2.6.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.10.5",
+    "@types/react": "^19.0.1",
+    "@types/react-dom": "^19.0.2",
+    "autoprefixer": "^10.4.20",
+    "postcss": "^8.4.49",
+    "tailwindcss": "^3.4.17",
+    "typescript": "^5.7.3"
+  }
+}

+ 6 - 0
src/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

+ 21 - 0
src/tailwind.config.ts

@@ -0,0 +1,21 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+  content: [
+    './pages/**/*.{js,ts,jsx,tsx,mdx}',
+    './components/**/*.{js,ts,jsx,tsx,mdx}',
+    './app/**/*.{js,ts,jsx,tsx,mdx}',
+  ],
+  theme: {
+    extend: {
+      colors: {
+        border: 'hsl(var(--border))',
+        background: 'hsl(var(--background))',
+        foreground: 'hsl(var(--foreground))',
+      },
+    },
+  },
+  plugins: [],
+};
+
+export default config;

+ 23 - 0
src/tsconfig.json

@@ -0,0 +1,23 @@
+{
+  "compilerOptions": {
+    "target": "ES2017",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": true,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [{ "name": "next" }],
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}