Selaa lähdekoodia

feat: user management, FPS-aware timecode, reply comments, auto-pause draw, HLS encoding

Backend:
- Add user management API (list users, update role/active, delete) via /api/users
- Add GET/PUT /api/users/me for profile + password changes
- Record actual FPS from FFprobe stream r_frame_rate on upload
- Save FPS and hlsPath fields to Asset model
- Start HLS multi-quality transcode (480p/720p/1080p) in background after upload
- Add Prisma schema fields: User.active, Asset.fps, Asset.hlsPath

Frontend:
- User management page (/users) — admin-only, role selector, activate/deactivate, delete
- Settings page (/settings) — profile editing, password change
- Admin-only "Users" nav link in dashboard sidebar
- VideoPlayer: HH:MM:SS:FF timecode display using actual video FPS
- VideoPlayer: auto-pause when activating draw mode (C key or button)
- Review page: click comment timestamp → seek without auto-play
- Review page: inline reply form with nested comment rendering
- Review page: show/hide resolved comments toggle
- Review page: prefer HLS URL over original file when available
- auth-context: add refreshUser() and updateUserData() helpers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Son Nguyen 1 kuukausi sitten
vanhempi
sitoutus
f223f771d1

+ 8 - 5
packages/api/prisma/schema.prisma

@@ -11,14 +11,15 @@ datasource db {
 }
 
 model User {
-  id          String   @id @default(cuid())
-  email       String   @unique
+  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
+  role        Role      @default(REVIEWER)
+  active      Boolean   @default(true)
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
 
   memberships ProjectMember[]
   comments    Comment[]
@@ -61,7 +62,9 @@ model Asset {
   filename  String
   filePath  String
   thumbnail String?
+  hlsPath   String?
   duration  Float?
+  fps       Float       @default(30)
   mimeType  String
   status    AssetStatus @default(PENDING_REVIEW)
   createdAt DateTime    @default(now())

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

@@ -8,6 +8,7 @@ import authRoutes from './routes/auth';
 import projectRoutes from './routes/projects';
 import assetRoutes from './routes/assets';
 import commentRoutes from './routes/comments';
+import userRoutes from './routes/users';
 
 const app = express();
 const PORT = process.env.API_PORT || 3001;
@@ -39,6 +40,7 @@ app.use('/api/projects', projectRoutes);
 app.use('/api/assets', assetRoutes);
 app.use('/api/assets', commentRoutes);
 app.use('/api/comments', commentRoutes);
+app.use('/api/users', userRoutes);
 
 // ── 404 handler ─────────────────────────────────────────────────────────────
 app.use((_req, res) => {

+ 17 - 2
packages/api/src/routes/assets.ts

@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
 import multer from 'multer';
 import path from 'path';
 import fs from 'fs';
+import { fork } from 'child_process';
 import { v4 as uuidv4 } from 'uuid';
 import { prisma } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
@@ -146,9 +147,10 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
     const filePath = req.file.filename;
     const assetTitle = title || path.parse(req.file.originalname).name;
 
-    // Generate thumbnail + get duration via FFmpeg
+    // Generate thumbnail + get duration + FPS via FFmpeg
     let thumbnail: string | null = null;
     let duration: number | null = null;
+    let fps = 30;
 
     try {
       const result = await generateThumbnail(req.file.path, UPLOAD_DIR);
@@ -156,9 +158,9 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
         thumbnail = result.thumbnailPath;
       }
       duration = result.duration ?? null;
+      fps = result.fps;
     } catch (ffmpegErr) {
       console.warn('FFmpeg thumbnail generation failed:', ffmpegErr);
-      // Continue without thumbnail
     }
 
     const asset = await prisma.asset.create({
@@ -169,10 +171,23 @@ router.post('/upload', upload.single('video'), async (req: Request, res: Respons
         filePath,
         thumbnail,
         duration,
+        fps,
         mimeType: req.file.mimetype,
       },
     });
 
+    // Start HLS transcode in background (non-blocking)
+    const fullPath = path.join(UPLOAD_DIR, filePath);
+    const { generateHLS } = await import('../services/ffmpeg').catch(() => ({ generateHLS: null }));
+    if (generateHLS) {
+      generateHLS(fullPath, UPLOAD_DIR, asset.id).then(hlsPath => {
+        if (hlsPath) {
+          prisma.asset.update({ where: { id: asset.id }, data: { hlsPath } }).catch(console.error);
+
+        }
+      }).catch(console.error);
+    }
+
     res.status(201).json({ asset });
   } catch (err) {
     console.error('Upload error:', err);

+ 284 - 0
packages/api/src/routes/users.ts

@@ -0,0 +1,284 @@
+import { Router, Request, Response } from 'express';
+import bcrypt from 'bcryptjs';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const router = Router();
+router.use(authMiddleware);
+
+const str = (v: string | string[] | undefined): string =>
+  Array.isArray(v) ? v[0] ?? '' : (v ?? '');
+
+// GET /api/users — list all users (admin only)
+router.get('/', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.role !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — admin only' });
+      return;
+    }
+
+    const users = await prisma.user.findMany({
+      select: {
+        id: true,
+        email: true,
+        name: true,
+        role: true,
+        avatarUrl: true,
+        active: true,
+        createdAt: true,
+        _count: {
+          select: {
+            memberships: true,
+            comments: true,
+          },
+        },
+      },
+      orderBy: { createdAt: 'desc' },
+    });
+
+    res.json({ users });
+  } catch (err) {
+    console.error('List users error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// GET /api/users/me — get current user profile
+router.get('/me', 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,
+        active: true,
+        createdAt: true,
+        _count: {
+          select: {
+            memberships: true,
+            comments: true,
+          },
+        },
+      },
+    });
+
+    if (!user) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    res.json({ user });
+  } catch (err) {
+    console.error('Get me error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/users/me — update current user profile
+router.put('/me', async (req: Request, res: Response) => {
+  try {
+    const { name, avatarUrl, currentPassword, newPassword } = req.body;
+
+    // If changing password, verify current password
+    if (newPassword) {
+      if (!currentPassword) {
+        res.status(400).json({ error: 'currentPassword is required to change password' });
+        return;
+      }
+      if (newPassword.length < 6) {
+        res.status(400).json({ error: 'New password must be at least 6 characters' });
+        return;
+      }
+      const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
+      if (!user) {
+        res.status(404).json({ error: 'User not found' });
+        return;
+      }
+      const valid = await bcrypt.compare(currentPassword, user.password);
+      if (!valid) {
+        res.status(401).json({ error: 'Current password is incorrect' });
+        return;
+      }
+      const hashed = await bcrypt.hash(newPassword, 12);
+      const updated = await prisma.user.update({
+        where: { id: req.user!.userId },
+        data: {
+          name: name?.trim() || undefined,
+          avatarUrl: avatarUrl ?? undefined,
+          password: hashed,
+        },
+        select: {
+          id: true,
+          email: true,
+          name: true,
+          role: true,
+          avatarUrl: true,
+          active: true,
+        },
+      });
+      res.json({ user: updated });
+      return;
+    }
+
+    const updated = await prisma.user.update({
+      where: { id: req.user!.userId },
+      data: {
+        name: name?.trim() || undefined,
+        avatarUrl: avatarUrl ?? undefined,
+      },
+      select: {
+        id: true,
+        email: true,
+        name: true,
+        role: true,
+        avatarUrl: true,
+        active: true,
+      },
+    });
+
+    res.json({ user: updated });
+  } catch (err) {
+    console.error('Update me error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/users/:id/role — change user role (admin only)
+router.put('/:id/role', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.role !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — admin only' });
+      return;
+    }
+
+    const { role } = req.body;
+    const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER'];
+    if (!validRoles.includes(role)) {
+      res.status(400).json({ error: 'Invalid role. Must be one of: ' + validRoles.join(', ') });
+      return;
+    }
+
+    const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
+    if (!user) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    // Prevent last admin from being demoted
+    if (role !== 'ADMIN') {
+      const adminCount = await prisma.user.count({ where: { role: 'ADMIN', active: true } });
+      if (adminCount <= 1 && user.role === 'ADMIN') {
+        res.status(400).json({ error: 'Cannot demote the last admin' });
+        return;
+      }
+    }
+
+    const updated = await prisma.user.update({
+      where: { id: str(req.params.id) },
+      data: { role: role as any },
+      select: {
+        id: true,
+        email: true,
+        name: true,
+        role: true,
+        avatarUrl: true,
+        active: true,
+        createdAt: true,
+      },
+    });
+
+    res.json({ user: updated });
+  } catch (err) {
+    console.error('Update role error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// PUT /api/users/:id/active — activate/deactivate user (admin only)
+router.put('/:id/active', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.role !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — admin only' });
+      return;
+    }
+
+    const { active } = req.body;
+    if (typeof active !== 'boolean') {
+      res.status(400).json({ error: 'active must be a boolean' });
+      return;
+    }
+
+    const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
+    if (!user) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    // Prevent deactivating yourself
+    if (user.id === req.user!.userId) {
+      res.status(400).json({ error: 'Cannot deactivate your own account' });
+      return;
+    }
+
+    // Prevent last admin from being deactivated
+    if (!active && user.role === 'ADMIN') {
+      const adminCount = await prisma.user.count({ where: { role: 'ADMIN', active: true } });
+      if (adminCount <= 1) {
+        res.status(400).json({ error: 'Cannot deactivate the last admin' });
+        return;
+      }
+    }
+
+    const updated = await prisma.user.update({
+      where: { id: str(req.params.id) },
+      data: { active },
+      select: {
+        id: true,
+        email: true,
+        name: true,
+        role: true,
+        avatarUrl: true,
+        active: true,
+        createdAt: true,
+      },
+    });
+
+    res.json({ user: updated });
+  } catch (err) {
+    console.error('Update active error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// DELETE /api/users/:id — delete user (admin only)
+router.delete('/:id', async (req: Request, res: Response) => {
+  try {
+    if (req.user!.role !== 'ADMIN') {
+      res.status(403).json({ error: 'Forbidden — admin only' });
+      return;
+    }
+
+    const user = await prisma.user.findUnique({ where: { id: str(req.params.id) } });
+    if (!user) {
+      res.status(404).json({ error: 'User not found' });
+      return;
+    }
+
+    // Prevent deleting yourself
+    if (user.id === req.user!.userId) {
+      res.status(400).json({ error: 'Cannot delete your own account' });
+      return;
+    }
+
+    await prisma.user.delete({ where: { id: str(req.params.id) } });
+    res.json({ message: 'User deleted' });
+  } catch (err) {
+    console.error('Delete user error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 70 - 11
packages/api/src/services/ffmpeg.ts

@@ -1,40 +1,46 @@
 import ffmpeg from 'fluent-ffmpeg';
 import path from 'path';
 import fs from 'fs';
+import { v4 as uuidv4 } from 'uuid';
 
-export interface ThumbnailResult {
+export interface MediaInfo {
   thumbnailPath: string | null;
   duration: number | null;
+  fps: number;
 }
 
 /**
- * Generate thumbnail (1 frame at 1 second) and extract video duration.
+ * Generate thumbnail (1 frame at 1 second), extract video duration and FPS.
  */
 export function generateThumbnail(
   videoPath: string,
   outputDir: string
-): Promise<ThumbnailResult> {
-  return new Promise((resolve, reject) => {
+): Promise<MediaInfo> {
+  return new Promise((resolve) => {
     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)
+      // Extract FPS from video stream
+      let fps = 30;
+      const videoStream = metadata?.streams?.find(s => s.codec_type === 'video');
+      if (videoStream?.r_frame_rate) {
+        const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
+        fps = den ? Math.round(num / den) : num;
+      }
+
       ffmpeg(videoPath)
-        .on('error', (err) => {
-          console.error('FFmpeg error:', err.message);
+        .on('error', () => {
           // Return what we have even if thumbnail fails
-          resolve({ thumbnailPath: null, duration });
+          resolve({ thumbnailPath: null, duration, fps });
         })
         .on('end', () => {
-          resolve({ thumbnailPath: thumbFilename, duration });
+          resolve({ thumbnailPath: thumbFilename, duration, fps });
         })
         .screenshots({
           count: 1,
@@ -46,3 +52,56 @@ export function generateThumbnail(
     });
   });
 }
+
+/**
+ * Generate HLS segments from a video file.
+ * Creates multiple quality levels for adaptive streaming.
+ * Returns the path to the HLS master playlist.
+ */
+export async function generateHLS(
+  videoPath: string,
+  outputDir: string,
+  assetId: string
+): Promise<string | null> {
+  const hlsDir = path.join(outputDir, 'hls', assetId);
+  const masterPlaylist = path.join(hlsDir, 'master.m3u8');
+
+  return new Promise((resolve) => {
+    fs.mkdirSync(hlsDir, { recursive: true });
+
+    const outputPattern = path.join(hlsDir, 'stream_%v.m3u8');
+
+    ffmpeg(videoPath)
+      .outputOptions([
+        // Multiple quality levels
+        '-map 0:v',
+        '-map 0:a?',
+        // Quality level 1: 480p (1500kbps)
+        '-map 0:v? -map 0:a? -c:v:0 libx264 -b:v:0 1500k -maxrate:v:0 1600k -c:a:0 aac -b:a:0 64k',
+        // Quality level 2: 720p (3000kbps)
+        '-map 0:v? -map 0:a? -c:v:1 libx264 -b:v:1 3000k -maxrate:v:1 3200k -c:a:1 aac -b:a:1 96k',
+        // Quality level 3: 1080p (5000kbps)
+        '-map 0:v? -map 0:a? -c:v:2 libx264 -b:v:2 5000k -maxrate:v:2 5400k -c:a:2 aac -b:a:2 128k',
+        // HLS settings
+        '-f hls',
+        `-hls_time 6`,
+        '-hls_playlist_type vod',
+        '-hls_segment_filename', path.join(hlsDir, 'segment_%v_%03d.ts'),
+        '-master_pl_name', 'master.m3u8',
+        // Use variable bitrate for quality
+        '-preset', 'fast',
+        '-crf', '23',
+        // Scaling
+        '-vf', 'scale=-2:480,scale=-2:720,scale=-2:1080',
+      ])
+      .output(outputPattern)
+      .on('error', (err) => {
+        console.error('HLS generation error:', err.message);
+        resolve(null);
+      })
+      .on('end', () => {
+        resolve(`/hls/${assetId}/master.m3u8`);
+      })
+      .run();
+  });
+}

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

@@ -89,6 +89,19 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
 
           {/* Secondary links */}
           <div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
+            {user.role === 'ADMIN' && (
+              <NavLink
+                href="/users"
+                active={isActive('/users')}
+                icon={
+                  <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
+                  </svg>
+                }
+              >
+                Users
+              </NavLink>
+            )}
             <NavLink
               href="/settings"
               active={isActive('/settings')}

+ 223 - 0
src/app/(dashboard)/settings/page.tsx

@@ -0,0 +1,223 @@
+'use client';
+
+import { useState } from 'react';
+import { useAuth } from '@/lib/auth-context';
+import { usersApi } from '@/lib/api';
+
+export default function SettingsPage() {
+  const { user, token, updateUserData, refreshUser } = useAuth();
+  const [name, setName] = useState(user?.name ?? '');
+  const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl ?? '');
+  const [currentPassword, setCurrentPassword] = useState('');
+  const [newPassword, setNewPassword] = useState('');
+  const [confirmPassword, setConfirmPassword] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
+
+  if (!user || !token) return null;
+
+  const handleProfile = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setLoading(true);
+    setMessage(null);
+    try {
+      const { user: updated } = await usersApi.updateMe(token, {
+        name: name.trim(),
+        avatarUrl: avatarUrl.trim() || undefined,
+      });
+      updateUserData(updated);
+      setMessage({ type: 'success', text: 'Profile updated successfully' });
+    } catch (err) {
+      setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handlePassword = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (newPassword !== confirmPassword) {
+      setMessage({ type: 'error', text: 'New passwords do not match' });
+      return;
+    }
+    if (newPassword.length < 6) {
+      setMessage({ type: 'error', text: 'Password must be at least 6 characters' });
+      return;
+    }
+    setLoading(true);
+    setMessage(null);
+    try {
+      const { user: updated } = await usersApi.updateMe(token, {
+        currentPassword,
+        newPassword,
+      });
+      updateUserData(updated);
+      setCurrentPassword('');
+      setNewPassword('');
+      setConfirmPassword('');
+      setMessage({ type: 'success', text: 'Password changed successfully' });
+    } catch (err) {
+      setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Failed to change password' });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  return (
+    <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
+      {/* Header */}
+      <header className="sticky top-0 z-10 px-8 py-4 flex items-center shrink-0"
+              style={{
+                background: 'rgba(10,11,20,0.80)',
+                backdropFilter: 'blur(12px)',
+                borderBottom: '1px solid rgba(255,255,255,0.06)',
+              }}>
+        <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Settings</h1>
+      </header>
+
+      <div className="max-w-2xl mx-auto px-8 py-8 space-y-8">
+
+        {/* Profile section */}
+        <section className="card p-6">
+          <h2 className="text-base font-semibold mb-5" style={{ color: 'var(--text)' }}>
+            Profile
+          </h2>
+
+          <form onSubmit={handleProfile} className="space-y-4">
+            <div className="flex items-center gap-4">
+              <div className="w-14 h-14 rounded-full flex items-center justify-center text-xl font-semibold shrink-0"
+                   style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: '1px solid rgba(99,102,241,0.25)' }}>
+                {name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
+              </div>
+              <div>
+                <p className="text-sm font-medium" style={{ color: 'var(--text)' }}>{user.email}</p>
+                <p className="text-xs mt-0.5 capitalize" style={{ color: 'var(--text-muted)' }}>
+                  {user.role.toLowerCase()}
+                </p>
+              </div>
+            </div>
+
+            <div>
+              <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
+                Display name
+              </label>
+              <input
+                className="input"
+                value={name}
+                onChange={e => setName(e.target.value)}
+                placeholder="Your name"
+                required
+              />
+            </div>
+
+            <div>
+              <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
+                Avatar URL <span style={{ color: 'var(--text-subtle)', fontWeight: 400 }}>(optional)</span>
+              </label>
+              <input
+                className="input"
+                type="url"
+                value={avatarUrl}
+                onChange={e => setAvatarUrl(e.target.value)}
+                placeholder="https://example.com/avatar.jpg"
+              />
+            </div>
+
+            <div className="flex justify-end pt-2">
+              <button type="submit" className="btn btn-primary btn-md" disabled={loading}>
+                {loading ? 'Saving…' : 'Save Changes'}
+              </button>
+            </div>
+          </form>
+        </section>
+
+        {/* Password section */}
+        <section className="card p-6">
+          <h2 className="text-base font-semibold mb-5" style={{ color: 'var(--text)' }}>
+            Change Password
+          </h2>
+
+          <form onSubmit={handlePassword} className="space-y-4">
+            <div>
+              <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
+                Current password
+              </label>
+              <input
+                className="input"
+                type="password"
+                value={currentPassword}
+                onChange={e => setCurrentPassword(e.target.value)}
+                placeholder="••••••••"
+                required
+              />
+            </div>
+
+            <div className="grid grid-cols-2 gap-4">
+              <div>
+                <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
+                  New password
+                </label>
+                <input
+                  className="input"
+                  type="password"
+                  value={newPassword}
+                  onChange={e => setNewPassword(e.target.value)}
+                  placeholder="At least 6 characters"
+                  required
+                />
+              </div>
+              <div>
+                <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
+                  Confirm new password
+                </label>
+                <input
+                  className="input"
+                  type="password"
+                  value={confirmPassword}
+                  onChange={e => setConfirmPassword(e.target.value)}
+                  placeholder="Repeat new password"
+                  required
+                />
+              </div>
+            </div>
+
+            <div className="flex justify-end pt-2">
+              <button type="submit" className="btn btn-primary btn-md" disabled={loading}>
+                {loading ? 'Updating…' : 'Change Password'}
+              </button>
+            </div>
+          </form>
+        </section>
+
+        {/* Account info */}
+        <section className="card p-6">
+          <h2 className="text-base font-semibold mb-4" style={{ color: 'var(--text)' }}>
+            Account Info
+          </h2>
+          <div className="space-y-3 text-sm">
+            <div className="flex justify-between">
+              <span style={{ color: 'var(--text-muted)' }}>Email</span>
+              <span style={{ color: 'var(--text)' }}>{user.email}</span>
+            </div>
+            <div className="flex justify-between">
+              <span style={{ color: 'var(--text-muted)' }}>Role</span>
+              <span className="badge badge-brand capitalize">{user.role.toLowerCase()}</span>
+            </div>
+          </div>
+        </section>
+
+        {/* Message */}
+        {message && (
+          <div className={`rounded-lg px-4 py-3 text-sm animate-scale-in`}
+               style={{
+                 background: message.type === 'success' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
+                 border: `1px solid ${message.type === 'success' ? 'rgba(34,197,94,0.25)' : 'rgba(239,68,68,0.25)'}`,
+                 color: message.type === 'success' ? '#86EFAC' : '#FCA5A5',
+               }}>
+            {message.text}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 223 - 0
src/app/(dashboard)/users/page.tsx

@@ -0,0 +1,223 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import { useAuth } from '@/lib/auth-context';
+import { usersApi, AdminUser } from '@/lib/api';
+
+const ROLE_CONFIG: Record<string, { label: string; badge: string }> = {
+  ADMIN:    { label: 'Admin',    badge: 'badge-danger' },
+  EDITOR:   { label: 'Editor',   badge: 'badge-brand' },
+  REVIEWER: { label: 'Reviewer', badge: 'badge-warning' },
+  VIEWER:   { label: 'Viewer',   badge: 'badge-muted' },
+};
+
+export default function UsersPage() {
+  const { user: currentUser, token } = useAuth();
+  const [users, setUsers] = useState<AdminUser[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [updating, setUpdating] = useState<string | null>(null);
+  const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
+
+  const loadUsers = useCallback(async () => {
+    if (!token || currentUser?.role !== 'ADMIN') return;
+    try {
+      const { users: u } = await usersApi.list(token);
+      setUsers(u);
+    } catch {
+      console.error('Failed to load users');
+    } finally {
+      setLoading(false);
+    }
+  }, [token, currentUser?.role]);
+
+  useEffect(() => { loadUsers(); }, [loadUsers]);
+
+  const handleRoleChange = async (userId: string, role: string) => {
+    if (!token) return;
+    setUpdating(userId);
+    try {
+      const { user: updated } = await usersApi.updateRole(token, userId, role);
+      setUsers(prev => prev.map(u => u.id === userId ? { ...u, role: updated.role } : u));
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to update role');
+    } finally {
+      setUpdating(null);
+    }
+  };
+
+  const handleToggleActive = async (userId: string, currentActive: boolean) => {
+    if (!token) return;
+    setUpdating(userId);
+    try {
+      const { user: updated } = await usersApi.updateActive(token, userId, !currentActive);
+      setUsers(prev => prev.map(u => u.id === userId ? { ...u, active: updated.active } : u));
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to update status');
+    } finally {
+      setUpdating(null);
+    }
+  };
+
+  const handleDelete = async (userId: string) => {
+    if (!token) return;
+    try {
+      await usersApi.deleteUser(token, userId);
+      setUsers(prev => prev.filter(u => u.id !== userId));
+      setConfirmDelete(null);
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to delete user');
+    }
+  };
+
+  if (currentUser?.role !== 'ADMIN') {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Admin access required</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
+      {/* Header */}
+      <header className="sticky top-0 z-10 px-8 py-4 flex items-center justify-between shrink-0"
+              style={{
+                background: 'rgba(10,11,20,0.80)',
+                backdropFilter: 'blur(12px)',
+                borderBottom: '1px solid rgba(255,255,255,0.06)',
+              }}>
+        <div>
+          <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Users</h1>
+          <p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
+            {loading ? '…' : `${users.length} user${users.length !== 1 ? 's' : ''}`}
+          </p>
+        </div>
+        <button onClick={loadUsers} className="btn btn-secondary btn-md">
+          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
+          </svg>
+          Refresh
+        </button>
+      </header>
+
+      <div className="px-8 py-6">
+        {loading ? (
+          <div className="space-y-3">
+            {[1,2,3,4].map(i => (
+              <div key={i} className="card h-20 skeleton" style={{ animationDelay: `${i*60}ms` }} />
+            ))}
+          </div>
+        ) : (
+          <div className="space-y-3">
+            {users.map(u => {
+              const roleCfg = ROLE_CONFIG[u.role] ?? ROLE_CONFIG.VIEWER;
+              const isMe = u.id === currentUser?.id;
+
+              return (
+                <div key={u.id}
+                     className="card flex items-center gap-4 p-4 animate-fade-in"
+                     style={{ opacity: u.active ? 1 : 0.5 }}>
+                  {/* Avatar */}
+                  <div className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold shrink-0"
+                       style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
+                    {u.name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
+                  </div>
+
+                  {/* Info */}
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{u.name}</span>
+                      {isMe && <span className="badge badge-brand text-[10px]">you</span>}
+                      {!u.active && <span className="badge badge-muted text-[10px]">inactive</span>}
+                    </div>
+                    <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
+                  </div>
+
+                  {/* Stats */}
+                  <div className="hidden sm:flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
+                    <span>{u._count?.memberships ?? 0} projects</span>
+                    <span>{u._count?.comments ?? 0} comments</span>
+                    <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
+                  </div>
+
+                  {/* Role selector */}
+                  <div className="shrink-0">
+                    <select
+                      value={u.role}
+                      onChange={e => handleRoleChange(u.id, e.target.value)}
+                      disabled={updating === u.id || isMe}
+                      className="input text-xs py-1.5 pr-6"
+                      style={{ width: 'auto', minWidth: 0 }}
+                    >
+                      {Object.entries(ROLE_CONFIG).map(([value, cfg]) => (
+                        <option key={value} value={value}>{cfg.label}</option>
+                      ))}
+                    </select>
+                  </div>
+
+                  {/* Actions */}
+                  {!isMe && (
+                    <div className="flex items-center gap-1 shrink-0">
+                      <button
+                        onClick={() => handleToggleActive(u.id, u.active)}
+                        disabled={updating === u.id}
+                        className={`btn btn-sm ${u.active ? 'btn-secondary' : 'btn-primary'}`}
+                        title={u.active ? 'Deactivate' : 'Activate'}
+                      >
+                        {u.active ? (
+                          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
+                          </svg>
+                        ) : (
+                          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                            <path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
+                          </svg>
+                        )}
+                      </button>
+                      <button
+                        onClick={() => setConfirmDelete(u.id)}
+                        className="btn btn-danger btn-sm"
+                        title="Delete user"
+                      >
+                        <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                        </svg>
+                      </button>
+                    </div>
+                  )}
+                </div>
+              );
+            })}
+          </div>
+        )}
+      </div>
+
+      {/* Delete confirm modal */}
+      {confirmDelete && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center"
+             style={{ background: 'rgba(0,0,0,0.7)' }}>
+          <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in"
+               style={{ maxWidth: '400px' }}>
+            <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
+              Delete user?
+            </h3>
+            <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
+              This will permanently delete the user and all their comments. This action cannot be undone.
+            </p>
+            <div className="flex gap-3 justify-end">
+              <button onClick={() => setConfirmDelete(null)} className="btn btn-secondary btn-md">
+                Cancel
+              </button>
+              <button
+                onClick={() => handleDelete(confirmDelete)}
+                className="btn btn-danger btn-md"
+              >
+                Delete
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 162 - 99
src/app/review/[assetId]/page.tsx

@@ -4,8 +4,9 @@ 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 { Modal } from '@/components/ui/modal';
 import { Avatar } from '@/components/ui/avatar';
+import { VideoPlayer } from '@/components/video-player/VideoPlayer';
+import { Tool } from '@/components/video-player/AnnotationCanvas';
 
 const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
 
@@ -16,10 +17,14 @@ const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass
   REJECTED:          { label: 'Rejected',           colorClass: 'text-danger', bgClass: 'badge-danger',  dotClass: 'status-dot-rejected' },
 };
 
-function formatTime(s: number): string {
-  const m = Math.floor(s / 60);
-  const sec = Math.floor(s % 60);
-  return `${m}:${sec.toString().padStart(2, '0')}`;
+// HH:MM:SS:FF format
+function formatTimecode(seconds: number, fps: number = 30): string {
+  if (!seconds || isNaN(seconds)) return '00:00:00:00';
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  const s = Math.floor(seconds % 60);
+  const f = Math.floor((seconds % 1) * fps);
+  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
 }
 
 export default function ReviewPage() {
@@ -33,17 +38,19 @@ export default function ReviewPage() {
   const [loading, setLoading] = useState(true);
   const [currentTime, setCurrentTime] = useState(0);
   const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
-  const [panelWidth, setPanelWidth] = useState(360);
+  const [panelWidth, setPanelWidth] = useState(380);
   const [showApproval, setShowApproval] = useState(false);
   const [updatingStatus, setUpdatingStatus] = useState(false);
   const [newComment, setNewComment] = useState('');
   const [submitting, setSubmitting] = useState(false);
-  const [showResolveConfirm, setShowResolveConfirm] = useState<string | null>(null);
+  const [replyTo, setReplyTo] = useState<Comment | null>(null);
+  const [showResolved, setShowResolved] = useState(false);
   const isDraggingRef = useRef(false);
   const panelRef = useRef<HTMLDivElement>(null);
-  const videoRef = useRef<HTMLVideoElement>(null);
   const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
 
+  const fps = asset?.fps ?? 30;
+
   // Load asset + comments
   const loadData = useCallback(async () => {
     if (!token) return;
@@ -102,10 +109,20 @@ export default function ReviewPage() {
         content: content.trim(),
         timestamp,
         annotation: annotation as AnnotationData | undefined,
+        parentId: replyTo?.id,
       });
-      setComments(prev => [...prev, comment]);
+      if (replyTo) {
+        setComments(prev => prev.map(c =>
+          c.id === replyTo.id
+            ? { ...c, replies: [...(c.replies ?? []), comment] }
+            : c
+        ));
+      } else {
+        setComments(prev => [...prev, comment]);
+      }
       setNewComment('');
       setPendingAnnotation(null);
+      setReplyTo(null);
     } catch (err) {
       alert(err instanceof Error ? err.message : 'Failed to add comment');
     } finally {
@@ -118,7 +135,6 @@ export default function ReviewPage() {
     try {
       const { comment } = await commentsApi.resolve(token, commentId);
       setComments(prev => prev.map(c => c.id === commentId ? comment : c));
-      setShowResolveConfirm(null);
     } catch {
       alert('Failed to resolve comment');
     }
@@ -129,7 +145,8 @@ export default function ReviewPage() {
     if (!confirm('Delete this comment?')) return;
     try {
       await commentsApi.delete(token, commentId);
-      setComments(prev => prev.filter(c => c.id !== commentId));
+      setComments(prev => prev.filter(c => c.id !== commentId && c.replies?.some(r => r.id !== commentId)));
+      setComments(prev => prev.map(c => c.id === commentId ? c : { ...c, replies: c.replies?.filter(r => r.id !== commentId) }));
     } catch {
       alert('Failed to delete comment');
     }
@@ -153,17 +170,27 @@ export default function ReviewPage() {
     setPendingAnnotation(annotation);
   };
 
-  const handleCommentClick = (comment: Comment) => {
-    if (comment.timestamp && videoRef.current) {
-      videoRef.current.currentTime = comment.timestamp;
-      videoRef.current.play();
-    }
-  };
+  // Seek to comment WITHOUT auto-play
+  const handleCommentSeek = useCallback((comment: Comment) => {
+    setCurrentTime(comment.timestamp ?? 0);
+  }, []);
+
+  const handleTimeUpdate = useCallback((time: number) => {
+    setCurrentTime(time);
+  }, []);
 
   const status = asset?.status ?? 'PENDING_REVIEW';
   const statusCfg = STATUS_CONFIG[status];
-  const videoUrl = asset ? `${API_BASE}/uploads/${asset.filePath}` : '';
-  const videoType = asset?.mimeType ?? 'video/mp4';
+
+  // Determine video URL: prefer HLS if available
+  const videoUrl = asset?.hlsPath
+    ? `${API_BASE}/uploads${asset.hlsPath}`
+    : asset
+      ? `${API_BASE}/uploads/${asset.filePath}`
+      : '';
+
+  const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
+  const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
 
   if (loading) {
     return (
@@ -180,7 +207,7 @@ export default function ReviewPage() {
   if (!asset) return null;
 
   return (
-    <div className="h-screen flex flex-col overflow-hidden" style={{ background: '#070710' }}>
+    <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
 
       {/* ── Top bar ──────────────────────────────────────────── */}
       <header className="h-12 flex items-center px-4 gap-3 shrink-0"
@@ -190,7 +217,6 @@ export default function ReviewPage() {
                 zIndex: 50,
               }}>
 
-        {/* Back */}
         <button
           onClick={() => router.push(`/projects/${asset.projectId}`)}
           className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
@@ -204,14 +230,12 @@ export default function ReviewPage() {
 
         <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
 
-        {/* Title */}
         <div className="flex-1 min-w-0">
           <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
             {asset.title}
           </h1>
         </div>
 
-        {/* Project name */}
         <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
           {asset.project?.name}
         </span>
@@ -266,56 +290,25 @@ export default function ReviewPage() {
         {/* Video area */}
         <div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 min-w-0">
 
-          {/* Video */}
-          <div className="video-container flex-shrink-0" style={{ borderRadius: '12px' }}>
-            <video
-              ref={videoRef}
-              src={videoUrl}
-              className="w-full h-full object-contain"
-              style={{ background: '#000', borderRadius: '12px' }}
-              controls
-              onTimeUpdate={e => setCurrentTime((e.target as HTMLVideoElement).currentTime)}
-            />
-          </div>
-
-          {/* Timeline */}
-          <div className="flex-shrink-0 px-1">
-            <div className="flex items-center gap-3">
-              <span className="text-xs font-mono w-10 text-right shrink-0" style={{ color: 'var(--text-muted)' }}>
-                {formatTime(currentTime)}
-              </span>
-              <div className="flex-1 timeline-track" style={{ height: '4px', background: 'rgba(255,255,255,0.10)', borderRadius: '99px' }}>
-                {/* Comment markers */}
-                {comments.filter(c => c.timestamp != null).map(c => {
-                  const pct = asset.duration ? (c.timestamp! / asset.duration) * 100 : 0;
-                  return (
-                    <button
-                      key={c.id}
-                      onClick={() => handleCommentClick(c)}
-                      className="absolute w-2 h-2 rounded-full border border-white/20"
-                      style={{
-                        background: c.resolved ? '#6366F1' : '#F59E0B',
-                        left: `${Math.min(pct, 100)}%`,
-                        top: '50%',
-                        transform: 'translate(-50%, -50%)',
-                        zIndex: 2,
-                      }}
-                      title={c.content.slice(0, 50)}
-                    />
-                  );
-                })}
-              </div>
-              <span className="text-xs font-mono w-10 shrink-0" style={{ color: 'var(--text-muted)' }}>
-                {asset.duration ? formatTime(asset.duration) : '0:00'}
-              </span>
-            </div>
-          </div>
+          {/* Custom video player */}
+          <VideoPlayer
+            src={videoUrl}
+            mimeType={asset.mimeType}
+            fps={fps}
+            comments={allComments}
+            pendingAnnotation={pendingAnnotation}
+            onAnnotationCreated={handleAnnotationCreated}
+            onTimeUpdate={handleTimeUpdate}
+            onCommentClick={handleCommentSeek}
+          />
 
           {/* Keyboard shortcuts */}
           <div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
             <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
-            <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek</span>
-            <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> comment</span>
+            <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
+            <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>U</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>I</kbd> prev/next frame</span>
+            <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw annotation</span>
+            <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
           </div>
         </div>
 
@@ -340,25 +333,32 @@ export default function ReviewPage() {
           <div className="px-4 py-3 flex items-center justify-between shrink-0"
                style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
             <div className="flex items-center gap-2">
-              <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
-                Comments
-              </h2>
+              <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
               <span className="text-xs px-1.5 py-0.5 rounded-full"
                     style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
                 {comments.length}
               </span>
             </div>
-            <div className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-muted)' }}>
-              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
-              </svg>
-              <span className="font-mono">{formatTime(currentTime)}</span>
+            <div className="flex items-center gap-3">
+              {/* Timecode display */}
+              <span className="font-mono text-xs" style={{ color: '#818CF8' }}>
+                {formatTimecode(currentTime, fps)}
+              </span>
+              {/* Toggle resolved */}
+              <button
+                onClick={() => setShowResolved(v => !v)}
+                className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
+                style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
+                title="Toggle resolved comments"
+              >
+                {showResolved ? 'Hide resolved' : 'Show resolved'}
+              </button>
             </div>
           </div>
 
           {/* Comment list */}
           <div className="flex-1 overflow-y-auto scroll-area">
-            {comments.length === 0 ? (
+            {visibleComments.length === 0 ? (
               <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
                 <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
                      style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
@@ -366,19 +366,21 @@ export default function ReviewPage() {
                     <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
                   </svg>
                 </div>
-                <p className="text-xs font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
+                <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
                 <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
-                  Add a frame-accurate comment using the field below
+                  Pause video, press <kbd className="px-1 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> to draw, then submit
                 </p>
               </div>
             ) : (
-              <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
-                {comments.map(comment => (
+              <div>
+                {visibleComments.map(comment => (
                   <CommentItem
                     key={comment.id}
                     comment={comment}
                     currentUserId={user?.id ?? ''}
-                    onTimestampClick={handleCommentClick}
+                    fps={fps}
+                    onTimestampClick={handleCommentSeek}
+                    onReply={() => { setReplyTo(comment); }}
                     onResolve={() => handleResolve(comment.id)}
                     onDelete={() => handleDeleteComment(comment.id)}
                   />
@@ -387,18 +389,36 @@ export default function ReviewPage() {
             )}
           </div>
 
-          {/* New comment input */}
+          {/* New comment / reply input */}
           <div className="shrink-0 p-3"
                style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
+            {replyTo && (
+              <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
+                </svg>
+                Replying to {replyTo.user?.name}
+                <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                  </svg>
+                </button>
+              </div>
+            )}
+
             <div className="flex items-center gap-1.5 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
               <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                 <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
               </svg>
-              Comment at {formatTime(currentTime)}
+              {formatTimecode(currentTime, fps)}
               {!!pendingAnnotation && (
-                <span className="ml-1 text-xs" style={{ color: '#818CF8' }}>(with annotation)</span>
+                <span className="ml-1" style={{ color: '#818CF8' }}>(+ annotation)</span>
+              )}
+              {!!replyTo && (
+                <span className="ml-1" style={{ color: '#818CF8' }}>(reply)</span>
               )}
             </div>
+
             <form
               onSubmit={e => {
                 e.preventDefault();
@@ -414,7 +434,7 @@ export default function ReviewPage() {
                   className="input flex-1"
                   value={newComment}
                   onChange={e => setNewComment(e.target.value)}
-                  placeholder="Add a comment…"
+                  placeholder={replyTo ? 'Write a reply…' : 'Add a comment…'}
                   rows={1}
                   style={{ resize: 'none', overflow: 'hidden' }}
                   onKeyDown={e => {
@@ -450,41 +470,43 @@ export default function ReviewPage() {
 function CommentItem({
   comment,
   currentUserId,
+  fps,
   onTimestampClick,
+  onReply,
   onResolve,
   onDelete,
 }: {
   comment: Comment;
   currentUserId: string;
+  fps: number;
   onTimestampClick: (c: Comment) => void;
+  onReply: () => void;
   onResolve: () => void;
   onDelete: () => void;
 }) {
   const isOwner = comment.userId === currentUserId;
   const name = comment.user?.name ?? 'Unknown';
-  const initials = name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
+  const isReply = !!comment.parentId;
 
   return (
-    <div className="p-4 animate-fade-in" style={{ opacity: comment.resolved ? 0.55 : 1 }}>
+    <div
+      className="p-4 animate-fade-in"
+      style={{ opacity: comment.resolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
+    >
       <div className="flex gap-2.5">
         <Avatar name={name} size="sm" />
 
         <div className="flex-1 min-w-0">
           {/* Meta */}
-          <div className="flex items-center gap-2 mb-1">
+          <div className="flex items-center gap-2 mb-1 flex-wrap">
             <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
             {comment.timestamp != null && (
               <button
                 onClick={() => onTimestampClick(comment)}
-                className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors"
-                style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}
+                className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
+                style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
               >
-                {(() => {
-                  const s = comment.timestamp!;
-                  const m = Math.floor(s / 60);
-                  const sec = Math.floor(s % 60);
-                  return `${m}:${sec.toString().padStart(2, '0')}`;
-                })()}
+                {formatTimecode(comment.timestamp, fps)}
               </button>
             )}
             {comment.resolved && (
@@ -498,14 +520,37 @@ function CommentItem({
             </span>
           </div>
 
+          {/* Annotation preview */}
+          {comment.annotation && (
+            <div className="mb-2 text-xs px-2 py-1 rounded inline-flex items-center gap-1"
+                 style={{ background: 'rgba(99,102,241,0.08)', color: '#818CF8', border: '1px solid rgba(99,102,241,0.15)' }}>
+              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
+              </svg>
+              {comment.annotation.type} annotation
+            </div>
+          )}
+
           {/* Content */}
-          <p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
+          <p className="text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
             {comment.content}
           </p>
 
           {/* Actions */}
           {!comment.resolved && (
             <div className="flex items-center gap-1">
+              {!isReply && (
+                <button
+                  onClick={onReply}
+                  className="text-xs px-2 py-1 rounded-md transition-colors"
+                  style={{ color: 'var(--text-muted)' }}
+                  title="Reply"
+                >
+                  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
+                  </svg>
+                </button>
+              )}
               <button
                 onClick={onResolve}
                 className="text-xs px-2 py-1 rounded-md transition-colors"
@@ -530,6 +575,24 @@ function CommentItem({
               )}
             </div>
           )}
+
+          {/* Replies */}
+          {comment.replies && comment.replies.length > 0 && (
+            <div className="mt-3 space-y-3">
+              {comment.replies.map(reply => (
+                <CommentItem
+                  key={reply.id}
+                  comment={reply}
+                  currentUserId={currentUserId}
+                  fps={fps}
+                  onTimestampClick={onTimestampClick}
+                  onReply={() => {}}
+                  onResolve={onResolve}
+                  onDelete={onDelete}
+                />
+              ))}
+            </div>
+          )}
         </div>
       </div>
     </div>

+ 19 - 8
src/components/video-player/VideoPlayer.tsx

@@ -11,6 +11,7 @@ type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
 interface Props {
   src: string;
   mimeType: string;
+  fps?: number;
   comments: Comment[];
   pendingAnnotation: AnnotationData | null;
   onAnnotationCreated: (annotation: AnnotationData) => void;
@@ -21,6 +22,7 @@ interface Props {
 export function VideoPlayer({
   src,
   mimeType,
+  fps = 30,
   comments,
   pendingAnnotation,
   onAnnotationCreated,
@@ -81,7 +83,11 @@ export function VideoPlayer({
       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 === 'KeyC') {
+        e.preventDefault();
+        if (!drawMode) videoRef.current?.pause();
+        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); }
@@ -295,8 +301,8 @@ export function VideoPlayer({
             className="w-16 h-1 accent-blue-500"
           />
 
-          <span className="text-xs text-white/60 ml-1">
-            {formatTime(currentTime)} / {formatTime(duration)}
+          <span className="text-xs text-white/60 ml-1 font-mono">
+            {formatTimecode(currentTime, fps)} / {formatTimecode(duration, fps)}
           </span>
 
           <div className="flex-1" />
@@ -314,7 +320,10 @@ export function VideoPlayer({
 
           {/* Draw mode */}
           <button
-            onClick={() => setDrawMode(v => !v)}
+            onClick={() => {
+              if (!drawMode) videoRef.current?.pause();
+              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'
             }`}
@@ -341,9 +350,11 @@ export function VideoPlayer({
   );
 }
 
-function formatTime(s: number): string {
-  if (!s || isNaN(s)) return '0:00';
-  const m = Math.floor(s / 60);
+function formatTimecode(s: number, fps: number = 30): string {
+  if (!s || isNaN(s)) return '00:00:00:00';
+  const h = Math.floor(s / 3600);
+  const m = Math.floor((s % 3600) / 60);
   const sec = Math.floor(s % 60);
-  return `${m}:${sec.toString().padStart(2, '0')}`;
+  const f = Math.floor((s % 1) * fps);
+  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
 }

+ 50 - 0
src/lib/api.ts

@@ -143,6 +143,45 @@ export const commentsApi = {
     apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
 };
 
+// ── Users ────────────────────────────────────────────────────────────────────
+
+export const usersApi = {
+  list: (token: string) =>
+    apiFetch<{ users: AdminUser[] }>('/api/users', { token }),
+
+  getMe: (token: string) =>
+    apiFetch<{ user: AdminUser }>('/api/users/me', { token }),
+
+  updateMe: (token: string, data: {
+    name?: string;
+    avatarUrl?: string;
+    currentPassword?: string;
+    newPassword?: string;
+  }) =>
+    apiFetch<{ user: AdminUser }>('/api/users/me', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+      token,
+    }),
+
+  updateRole: (token: string, id: string, role: string) =>
+    apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, {
+      method: 'PUT',
+      body: JSON.stringify({ role }),
+      token,
+    }),
+
+  updateActive: (token: string, id: string, active: boolean) =>
+    apiFetch<{ user: AdminUser }>(`/api/users/${id}/active`, {
+      method: 'PUT',
+      body: JSON.stringify({ active }),
+      token,
+    }),
+
+  deleteUser: (token: string, id: string) =>
+    apiFetch(`/api/users/${id}`, { method: 'DELETE', token }),
+};
+
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 export interface User {
@@ -153,6 +192,15 @@ export interface User {
   avatarUrl?: string | null;
 }
 
+export interface AdminUser extends User {
+  active: boolean;
+  createdAt: string;
+  _count?: {
+    memberships: number;
+    comments: number;
+  };
+}
+
 export interface Project {
   id: string;
   name: string;
@@ -169,7 +217,9 @@ export interface Asset {
   filename: string;
   filePath: string;
   thumbnail?: string | null;
+  hlsPath?: string | null;
   duration?: number | null;
+  fps?: number;
   mimeType: string;
   status: string;
   createdAt: string;

+ 23 - 3
src/lib/auth-context.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
-import { authApi, User } from './api';
+import { authApi, usersApi, User } from './api';
 
 interface AuthContextValue {
   user: User | null;
@@ -10,6 +10,8 @@ interface AuthContextValue {
   login: (email: string, password: string) => Promise<void>;
   register: (email: string, name: string, password: string) => Promise<void>;
   logout: () => Promise<void>;
+  refreshUser: () => Promise<void>;
+  updateUserData: (data: Partial<User>) => void;
 }
 
 const AuthContext = createContext<AuthContextValue | null>(null);
@@ -60,8 +62,26 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     setUser(null);
   }, []);
 
+  const refreshUser = useCallback(async () => {
+    if (!token) return;
+    try {
+      const { user: u } = await usersApi.getMe(token);
+      setUser(u);
+      localStorage.setItem('vidreview_user', JSON.stringify(u));
+    } catch { /* ignore refresh errors */ }
+  }, [token]);
+
+  const updateUserData = useCallback((data: Partial<User>) => {
+    setUser(prev => {
+      if (!prev) return prev;
+      const updated = { ...prev, ...data };
+      localStorage.setItem('vidreview_user', JSON.stringify(updated));
+      return updated;
+    });
+  }, []);
+
   return (
-    <AuthContext.Provider value={{ user, token, loading, login, register, logout }}>
+    <AuthContext.Provider value={{ user, token, loading, login, register, logout, refreshUser, updateUserData }}>
       {children}
     </AuthContext.Provider>
   );
@@ -70,5 +90,5 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
 export function useAuth() {
   const ctx = useContext(AuthContext);
   if (!ctx) throw new Error('useAuth must be used within AuthProvider');
-  return ctx;
+  return ctx as Required<AuthContextValue>;
 }

+ 0 - 21
src/tailwind.config.ts

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