Преглед на файлове

feat: responsive sidebar, speech bubble, waveform, touch seeker, date grouping

- Sidebar: hamburger drawer on mobile with text labels, fixed overlap
  with content, active nav state via CSS class
- Speech bubble: floating overlay inside video frame with CSS pointer
  tail pointing to timeline, reduced window from ±1.5s to ±1s
- Waveform: removed (was causing issues and user requested removal)
- Timeline seeker: larger touch target (h-2 mobile), 32px touch ripple,
  timecode tooltip during drag, unified pointer+touch events
- Smart timecode: shared formatter in lib/format.ts, hides HH/MM
  based on total duration
- Project detail: videos grouped by date (Today/Yesterday/weekday),
  hour shown when multiple videos same day, AssetCard component
- Project header: tabs use icons on all sizes, abbreviations removed
- Soft delete comments: deleted/deletedAt/deletedById fields,
  DELETE → soft delete, POST /restore → project owner/ADMIN restore
- Pause transcode: transcodePaused flag, pause/resume endpoints,
  worker respects flag before and between processing phases
- Responsive fixes: settings/header px-8→px-4, users card layout
  restructured to prevent overflow on mobile
- User page: responsive overflow fix, stacked card layout on mobile
Claude Dev преди 1 месец
родител
ревизия
bf935f7138

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

@@ -85,6 +85,7 @@ model Asset {
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)
   transcodeError  String?
+  transcodePaused Boolean         @default(false)
   createdAt       DateTime        @default(now())
   updatedAt       DateTime        @updatedAt
 
@@ -107,6 +108,9 @@ model Comment {
   requestedById String?
   requestedByAt DateTime?
   parentId     String?
+  deleted      Boolean        @default(false)
+  deletedAt    DateTime?
+  deletedById  String?
   createdAt    DateTime       @default(now())
   updatedAt    DateTime       @updatedAt
 

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

@@ -132,6 +132,7 @@ router.get('/:id/status', async (req: Request, res: Response) => {
         transcodeStatus: true,
         transcodeProgress: true,
         transcodeError: true,
+        transcodePaused: true,
         thumbnail: true,
         duration: true,
         codec: true,
@@ -364,6 +365,74 @@ router.post('/:id/transcode/cancel', async (req: Request, res: Response) => {
   }
 });
 
+// POST /api/assets/:id/transcode/pause — pause a running transcode job
+router.post('/:id/transcode/pause', async (req: Request, res: Response) => {
+  try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: str(req.params.id),
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    if (!['PENDING', 'UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus)) {
+      res.status(400).json({ error: 'Cannot pause a completed or failed transcode' });
+      return;
+    }
+
+    const updated = await prisma.asset.update({
+      where: { id: str(req.params.id) },
+      data: { transcodePaused: true },
+    });
+
+    res.json({ asset: updated });
+  } catch (err) {
+    console.error('Pause transcode error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// POST /api/assets/:id/transcode/resume — resume a paused transcode job
+router.post('/:id/transcode/resume', async (req: Request, res: Response) => {
+  try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
+    const asset = await prisma.asset.findFirst({
+      where: {
+        id: str(req.params.id),
+        ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
+      },
+    });
+
+    if (!asset) {
+      res.status(404).json({ error: 'Asset not found' });
+      return;
+    }
+
+    if (!asset.transcodePaused) {
+      res.status(400).json({ error: 'Transcode is not paused' });
+      return;
+    }
+
+    const updated = await prisma.asset.update({
+      where: { id: str(req.params.id) },
+      data: { transcodePaused: false },
+    });
+
+    res.json({ asset: updated });
+  } catch (err) {
+    console.error('Resume transcode error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 // DELETE /api/assets/:id
 router.delete('/:id', async (req: Request, res: Response) => {
   try {

+ 57 - 4
packages/api/src/routes/comments.ts

@@ -21,7 +21,7 @@ const includeCommentRelations = {
 // GET /api/assets/:assetId/comments
 router.get('/:assetId/comments', async (req: Request, res: Response) => {
   try {
-    const { resolved } = req.query;
+    const { resolved, includeDeleted } = req.query;
     const isAdmin = req.user!.globalRole === 'ADMIN';
 
     const asset = await prisma.asset.findFirst({
@@ -38,6 +38,10 @@ router.get('/:assetId/comments', async (req: Request, res: Response) => {
 
     const assetId = str(req.params.assetId);
     const where: Record<string, unknown> = { assetId, parentId: null };
+    // Always filter soft-deleted unless explicitly requested
+    if (includeDeleted !== 'true') {
+      where.deleted = false;
+    }
     if (resolved !== undefined) {
       where.resolved = resolved === 'true';
     }
@@ -262,7 +266,7 @@ router.put('/:id/annotations', async (req: Request, res: Response) => {
   }
 });
 
-// DELETE /api/comments/:id
+// DELETE /api/comments/:id — soft delete (author only)
 router.delete('/:id', async (req: Request, res: Response) => {
   try {
     const comment = await prisma.comment.findFirst({
@@ -274,12 +278,61 @@ router.delete('/:id', async (req: Request, res: Response) => {
       return;
     }
 
-    await prisma.comment.delete({ where: { id: str(req.params.id) } });
-    res.json({ message: 'Comment deleted' });
+    // Soft delete — preserve for project owner restore
+    await prisma.comment.update({
+      where: { id: str(req.params.id) },
+      data: { deleted: true, deletedAt: new Date(), deletedById: req.user!.userId },
+    });
+    res.json({ message: 'Comment hidden' });
   } catch (err) {
     console.error('Delete comment error:', err);
     res.status(500).json({ error: 'Internal server error' });
   }
 });
 
+// POST /api/comments/:id/restore — project owner can restore a soft-deleted comment
+router.post('/:id/restore', async (req: Request, res: Response) => {
+  try {
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+
+    const comment = await prisma.comment.findFirst({
+      where: { id: str(req.params.id), deleted: true },
+      include: {
+        asset: {
+          include: {
+            project: { include: { members: true } },
+          },
+        },
+      },
+    });
+
+    if (!comment) {
+      res.status(404).json({ error: 'Deleted comment not found' });
+      return;
+    }
+
+    // Only project owner or ADMIN can restore
+    const isProjectOwner = comment.asset.project.ownerId === req.user!.userId;
+    const isProjectAdmin = comment.asset.project.members.some(
+      (m: any) => m.userId === req.user!.userId && m.role === 'ADMIN'
+    );
+
+    if (!isProjectOwner && !isProjectAdmin && !isAdmin) {
+      res.status(403).json({ error: 'Only project owner or ADMIN can restore comments' });
+      return;
+    }
+
+    const updated = await prisma.comment.update({
+      where: { id: str(req.params.id) },
+      data: { deleted: false, deletedAt: null, deletedById: null },
+      include: includeCommentRelations,
+    });
+
+    res.json({ comment: updated });
+  } catch (err) {
+    console.error('Restore comment error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 export default router;

+ 24 - 4
packages/api/src/worker/index.js

@@ -120,9 +120,20 @@ function transcodeToHLS(videoPath, outputDir, assetId, duration) {
 
 /** ── Process one job ───────────────────────────────────────────────────── */
 async function processJob(asset) {
-  const { id: assetId, filePath } = asset;
+  const { id: assetId, filePath, transcodePaused } = asset;
   const videoPath = path.join(UPLOAD_DIR, filePath);
 
+  // Respect pause flag — skip if user paused before worker picked it up
+  if (transcodePaused) {
+    send('paused', { assetId, reason: 'transcodePaused flag set by user, re-queuing' });
+    // Keep transcodePaused=true so it won't be re-claimed until user resumes
+    await prisma.asset.update({
+      where: { id: assetId },
+      data: { transcodeStatus: 'PENDING' },
+    });
+    return;
+  }
+
   send('started', { assetId, filePath });
 
   // Check file exists
@@ -140,6 +151,14 @@ async function processJob(asset) {
     await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PROCESSING', transcodeProgress: 0 } });
     const thumbResult = await probeAndThumbnail(videoPath, UPLOAD_DIR);
 
+    // Check pause flag between phases
+    const check1 = await prisma.asset.findUnique({ where: { id: assetId }, select: { transcodePaused: true } });
+    if (check1?.transcodePaused) {
+      send('paused', { assetId, reason: 'paused between thumbnail and HLS phases' });
+      await prisma.asset.update({ where: { id: assetId }, data: { transcodeStatus: 'PENDING', transcodePaused: false } });
+      return;
+    }
+
     // Update DB with metadata
     await prisma.asset.update({
       where: { id: assetId },
@@ -185,7 +204,7 @@ async function processJob(asset) {
 /** ── Poll loop ─────────────────────────────────────────────────────────── */
 async function poll() {
   try {
-    // Atomically claim one PENDING job
+    // Atomically claim one PENDING job that is NOT paused
     // Prisma's updateMany returns the count; we use raw SQL for the atomic claim
     const result = await prisma.$executeRaw`
       UPDATE "Asset"
@@ -195,11 +214,12 @@ async function poll() {
       WHERE  id = (
         SELECT id FROM "Asset"
         WHERE  "transcodeStatus" = 'PENDING'
+          AND  "transcodePaused" = false
         ORDER  BY "createdAt" ASC
         LIMIT  1
         FOR    UPDATE SKIP LOCKED
       )
-      RETURNING id, "filePath", "transcodeStatus"
+      RETURNING id, "filePath", "transcodeStatus", "transcodePaused"
     `;
 
     if (!result || result === 0) {
@@ -244,7 +264,7 @@ async function main() {
 async function recoverStaleJobs() {
   try {
     const stale = await prisma.asset.findMany({
-      where: { transcodeStatus: 'PROCESSING' },
+      where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
       select: { id: true },
     });
     if (stale.length > 0) {

+ 4 - 0
prisma/schema.prisma

@@ -85,6 +85,7 @@ model Asset {
   transcodeStatus TranscodeStatus @default(PENDING)
   transcodeProgress Int            @default(0)
   transcodeError  String?
+  transcodePaused Boolean         @default(false)
   createdAt       DateTime        @default(now())
   updatedAt       DateTime        @updatedAt
 
@@ -107,6 +108,9 @@ model Comment {
   requestedById String?
   requestedByAt DateTime?
   parentId     String?
+  deleted      Boolean        @default(false)
+  deletedAt    DateTime?
+  deletedById  String?
   createdAt    DateTime       @default(now())
   updatedAt    DateTime       @updatedAt
 

+ 155 - 95
src/app/(dashboard)/layout.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
 import { useRouter, usePathname } from 'next/navigation';
 import Link from 'next/link';
 import { useAuth } from '@/lib/auth-context';
@@ -10,6 +10,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
   const { user, loading, logout } = useAuth();
   const router = useRouter();
   const pathname = usePathname();
+  const [sidebarOpen, setSidebarOpen] = useState(false);
 
   useEffect(() => {
     if (!loading && !user) {
@@ -17,6 +18,11 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
     }
   }, [user, loading, router]);
 
+  // Close drawer on route change
+  useEffect(() => {
+    setSidebarOpen(false);
+  }, [pathname]);
+
   if (loading) {
     return (
       <div className="min-h-screen flex items-center justify-center"
@@ -44,109 +50,164 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
   const isActive = (href: string) =>
     href === '/' ? pathname === '/' : pathname.startsWith(href);
 
-  return (
-    <div className="min-h-screen flex" style={{ background: 'var(--bg)' }}>
+  const SidebarContent = () => (
+    <>
+      {/* Logo */}
+      <div className="py-4 flex justify-center md:justify-start md:px-4"
+           style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+        <Link href="/projects" className="flex items-center gap-2.5 group">
+          <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
+               style={{ background: '#6366F1', boxShadow: '0 0 16px rgba(99,102,241,0.4)' }}>
+            <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+              <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+            </svg>
+          </div>
+          <span className="font-semibold tracking-tight text-sm hidden md:block" style={{ color: 'var(--text)' }}>
+            VidReview
+          </span>
+        </Link>
+      </div>
 
-      {/* ── Sidebar ───────────────────────────────────────────── */}
-      <aside className="w-12 md:w-56 flex flex-col shrink-0"
-             style={{
-               background: 'rgba(10,11,20,0.95)',
-               borderRight: '1px solid rgba(255,255,255,0.06)',
-             }}>
-
-        {/* Logo */}
-        <div className="py-5 flex justify-center md:justify-start md:px-4"
-             style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
-          <Link href="/projects" className="flex items-center gap-2.5 group">
-            <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
-                 style={{ background: '#6366F1', boxShadow: '0 0 16px rgba(99,102,241,0.4)' }}>
-              <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
-                <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+      {/* Nav */}
+      <nav className="flex-1 px-3 py-4 overflow-y-auto">
+        <NavSection label="Workspace">
+          <NavLink
+            href="/projects"
+            active={isActive('/projects')}
+            onClick={() => setSidebarOpen(false)}
+            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="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
               </svg>
-            </div>
-            <span className="font-semibold tracking-tight text-sm hidden md:block" style={{ color: 'var(--text)' }}>
-              VidReview
-            </span>
-          </Link>
-        </div>
+            }
+          >
+            Projects
+          </NavLink>
+        </NavSection>
 
-        {/* Nav */}
-        <nav className="flex-1 px-3 py-4 overflow-y-auto">
-          <NavSection label="Workspace">
-            <NavLink
-              href="/projects"
-              active={isActive('/projects')}
-              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="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
-                </svg>
-              }
-            >
-              Projects
-            </NavLink>
-          </NavSection>
-
-          {/* Secondary links */}
-          <div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
-            {user.globalRole === '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>
-            )}
+        {/* Secondary links */}
+        <div className="mt-2 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
+          {user.globalRole === 'ADMIN' && (
             <NavLink
-              href="/settings"
-              active={isActive('/settings')}
+              href="/users"
+              active={isActive('/users')}
+              onClick={() => setSidebarOpen(false)}
               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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
-                  <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
+                  <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>
               }
             >
-              Settings
+              Users
             </NavLink>
-          </div>
-        </nav>
-
-        {/* User */}
-        <div className="py-3 flex justify-center md:justify-end px-3"
-             style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
-          <div className="flex items-center gap-2.5 p-2 rounded-lg transition-colors cursor-default"
-               style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
-            <Avatar name={user.name} size="md" />
-            <div className="flex-1 min-w-0 hidden md:block">
-              <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{user.name}</p>
-              <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>
-                {user.globalRole.toLowerCase()}
-              </p>
-            </div>
-            <button
-              onClick={async () => {
-                await logout();
-                router.push('/login');
-              }}
-              className="p-1.5 rounded-md transition-colors hover:bg-white/5"
-              title="Sign out"
-            >
-              <svg className="w-3.5 h-3.5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
+          )}
+          <NavLink
+            href="/settings"
+            active={isActive('/settings')}
+            onClick={() => setSidebarOpen(false)}
+            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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
+                <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
               </svg>
-            </button>
+            }
+          >
+            Settings
+          </NavLink>
+        </div>
+      </nav>
+
+      {/* User / logout */}
+      <div className="py-3 px-3"
+           style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
+        <div className="flex items-center gap-2.5 p-2 rounded-lg"
+             style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
+          <Avatar name={user.name} size="md" />
+          <div className="flex-1 min-w-0 hidden md:block">
+            <p className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{user.name}</p>
+            <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>
+              {user.globalRole.toLowerCase()}
+            </p>
           </div>
+          <button
+            onClick={async () => {
+              await logout();
+              router.push('/login');
+            }}
+            className="p-1.5 rounded-md transition-colors hover:bg-white/5"
+            title="Sign out"
+          >
+            <svg className="w-3.5 h-3.5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
+            </svg>
+          </button>
         </div>
+      </div>
+    </>
+  );
+
+  return (
+    <div className="min-h-screen flex" style={{ background: 'var(--bg)' }}>
+
+      {/* ── Mobile hamburger button ─────────────────────────── */}
+      <button
+        onClick={() => setSidebarOpen(true)}
+        className="fixed top-3 left-3 z-50 p-2 rounded-lg"
+        style={{
+          background: 'rgba(10,11,20,0.95)',
+          border: '1px solid rgba(255,255,255,0.10)',
+          backdropFilter: 'blur(8px)',
+          color: 'var(--text-muted)',
+        }}
+        aria-label="Open menu"
+      >
+        <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+          <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
+        </svg>
+      </button>
+
+      {/* ── Mobile overlay backdrop ─────────────────────────── */}
+      {sidebarOpen && (
+        <div
+          className="fixed inset-0 z-40 md:hidden"
+          style={{ background: 'rgba(0,0,0,0.6)' }}
+          onClick={() => setSidebarOpen(false)}
+        />
+      )}
+
+      {/* ── Sidebar (drawer on mobile, fixed sidebar on desktop) ─ */}
+      <aside
+        className={`
+          fixed md:relative inset-y-0 left-0 z-50 md:z-auto
+          flex flex-col shrink-0
+          transition-transform duration-200 ease-out md:transition-none
+          w-56 md:w-56
+          ${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
+        `}
+        style={{
+          background: 'rgba(10,11,20,0.97)',
+          borderRight: '1px solid rgba(255,255,255,0.06)',
+          backdropFilter: 'blur(16px)',
+        }}
+      >
+        {/* Close button on mobile */}
+        <button
+          onClick={() => setSidebarOpen(false)}
+          className="absolute top-3 right-3 p-1.5 rounded-lg md:hidden hover:bg-white/5"
+          style={{ color: 'var(--text-muted)' }}
+          aria-label="Close menu"
+        >
+          <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+          </svg>
+        </button>
+
+        <SidebarContent />
       </aside>
 
-      {/* ── Main content ──────────────────────────────────────── */}
-      <main className="flex-1 overflow-auto min-w-0" style={{ background: 'var(--bg)' }}>
+      {/* ── Main content (padding-top on mobile for hamburger) ─── */}
+      <main className="flex-1 overflow-auto min-w-0 pt-12 md:pt-0" style={{ background: 'var(--bg)' }}>
         {children}
       </main>
     </div>
@@ -170,23 +231,22 @@ function NavLink({
   active,
   icon,
   children,
+  onClick,
 }: {
   href: string;
   active: boolean;
   icon: React.ReactNode;
   children: React.ReactNode;
+  onClick?: () => void;
 }) {
   return (
     <Link
       href={href}
-      className="nav-item mb-0.5"
-      style={active ? {
-        background: 'rgba(99,102,241,0.15)',
-        color: '#A5B4FC',
-      } : undefined}
+      onClick={onClick}
+      className={`nav-item mb-0.5${active ? ' nav-item-active' : ''}`}
     >
       <span className="shrink-0">{icon}</span>
-      <span className="hidden md:inline">{children}</span>
+      <span>{children}</span>
     </Link>
   );
 }

+ 146 - 212
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
+import { AssetCard } from '@/components/ui/AssetCard';
 import { useDropzone } from 'react-dropzone';
 import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
 
@@ -37,6 +38,30 @@ const ROLE_LABELS: Record<string, string> = {
   VIEWER:  'Viewer',
 };
 
+function formatGroupDate(d: Date): string {
+  const now = new Date();
+  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+  const yesterday = new Date(today.getTime() - 86400000);
+  const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
+  if (videoDay.getTime() === today.getTime()) return 'Today';
+  if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday';
+  return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
+}
+
+function groupByDay(assets: Asset[]): [string, Asset[]][] {
+  const sorted = [...assets].sort(
+    (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+  );
+  const groups: Record<string, Asset[]> = {};
+  for (const a of sorted) {
+    const d = new Date(a.createdAt);
+    const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
+    if (!groups[day]) groups[day] = [];
+    groups[day].push(a);
+  }
+  return Object.entries(groups);
+}
+
 export default function ProjectDetailPage() {
   const params = useParams();
   const projectId = params.projectId as string;
@@ -352,7 +377,7 @@ export default function ProjectDetailPage() {
     <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
 
       {/* Header */}
-      <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-3 md:gap-5 shrink-0"
+      <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-5 shrink-0 flex-wrap"
               style={{
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
@@ -360,16 +385,16 @@ export default function ProjectDetailPage() {
               }}>
         <button
           onClick={() => router.push('/projects')}
-          className="flex items-center gap-1.5 text-sm transition-colors"
+          className="flex items-center gap-1.5 text-sm transition-colors shrink-0"
           style={{ color: 'var(--text-muted)' }}
         >
           <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
             <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
           </svg>
-          Projects
+          <span className="hidden sm:inline">Projects</span>
         </button>
 
-        <div className="w-px h-4" style={{ background: 'rgba(255,255,255,0.10)' }} />
+        <div className="w-px h-4 hidden sm:block shrink-0" style={{ background: 'rgba(255,255,255,0.10)' }} />
 
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2">
@@ -390,62 +415,74 @@ export default function ProjectDetailPage() {
             )}
           </div>
           {project?.description && (
-            <p className="text-xs truncate mt-0.5" style={{ color: 'var(--text-muted)' }}>
+            <p className="text-xs truncate mt-0.5 hidden sm:block" style={{ color: 'var(--text-muted)' }}>
               {project.description}
             </p>
           )}
         </div>
 
-        {/* Tabs */}
-        <div className="flex items-center gap-1 p-1 rounded-lg"
+        {/* Tabs — icon only on mobile, icon+label on sm+ */}
+        <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
              style={{ background: 'rgba(255,255,255,0.04)' }}>
-          {[['videos', 'Videos', assets.length], ['transcode', 'Transcode Tasks', assets.filter(a => a.transcodeStatus !== 'COMPLETED').length], ['members', 'Members', members.length]].map(([tab, label, count]) => (
+          {[
+            { tab: 'videos', label: 'Videos', count: assets.length },
+            { tab: 'transcode', label: 'Transcode Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
+            { tab: 'members', label: 'Members', count: members.length },
+          ].map(({ tab, label, count }) => (
             <button key={tab}
               onClick={() => setActiveTab(tab as any)}
-              className="px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
+              className="relative px-2 sm:px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 shrink-0"
               style={{
                 background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
                 color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
-              }}>
-              {label}
-              {tab !== 'members' && (count as number) > 0 && (
-                <span className="text-[10px] px-1.5 py-0.5 rounded-full"
-                      style={{
-                        background: tab === 'transcode'
-                          ? 'rgba(167,139,250,0.25)'
-                          : 'rgba(255,255,255,0.06)',
-                        color: tab === 'transcode' ? '#A78BFA' : 'inherit',
-                      }}>
-                  {count}
-                </span>
+              }}
+              title={label}
+            >
+              {tab === 'videos' && (
+                <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
+                </svg>
+              )}
+              {tab === 'transcode' && (
+                <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
+                </svg>
               )}
               {tab === 'members' && (
-                <span className="ml-0.5 text-[10px] px-1.5 py-0.5 rounded-full"
-                      style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
-                  {members.length}
-                </span>
+                <svg className="w-4 h-4 shrink-0" 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>
               )}
+              <span className="hidden sm:inline">{label}</span>
+              <span className="text-[10px] px-1 py-0.5 rounded-full"
+                    style={{
+                      background: tab === 'transcode'
+                        ? 'rgba(167,139,250,0.25)'
+                        : 'rgba(255,255,255,0.06)',
+                      color: tab === 'transcode' ? '#A78BFA' : 'inherit',
+                    }}>
+                {count}
+              </span>
             </button>
           ))}
         </div>
 
-        <div className="text-xs px-2 py-1.5 md:px-2.5 md:py-1 rounded-full shrink-0"
+        <div className="text-xs px-2 py-1.5 rounded-full shrink-0"
              style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
-          {assets.length} video{assets.length !== 1 ? 's' : ''}
+          {assets.length}
         </div>
 
         {/* Delete project — owner only */}
         {isOwner && (
           <button
             onClick={() => setConfirmDeleteProject(true)}
-            className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all shrink-0"
+            className="flex items-center justify-center p-1.5 rounded-lg transition-all shrink-0"
             style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
             title="Delete project"
           >
             <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
               <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
             </svg>
-            <span className="hidden sm:inline">Delete</span>
           </button>
         )}
       </header>
@@ -508,7 +545,7 @@ export default function ProjectDetailPage() {
               </div>
             )}
 
-            {/* Asset grid */}
+            {/* Asset grid — grouped by date */}
             {assets.length === 0 ? (
               <div className="text-center py-20 rounded-2xl animate-fade-in"
                    style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
@@ -522,194 +559,72 @@ export default function ProjectDetailPage() {
                 <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
               </div>
             ) : (
-              <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
-                {assets.map((asset, i) => (
-                  <div key={asset.id}
-                     className="card overflow-hidden group"
-                     style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
-
-                    {/* Thumbnail */}
-                    <div className="relative aspect-video" style={{ background: '#080810' }} onClick={() => router.push(`/review/${asset.id}`)}>
-                      {/* Play overlay — only show when ready */}
-                      {asset.transcodeStatus === 'COMPLETED' && (
-                        <>
-                          {asset.thumbnail ? (
-                            <img
-                              src={`/uploads/${asset.thumbnail}`}
-                              alt={asset.title}
-                              className="w-full h-full object-cover"
-                              style={{ opacity: 0.85 }}
-                            />
-                          ) : (
-                            <div className="w-full h-full flex items-center justify-center">
-                              <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
-                                <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
-                                <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
-                              </svg>
-                            </div>
-                          )}
-                          <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
-                               style={{ background: 'rgba(0,0,0,0.35)' }}>
-                            <div className="w-12 h-12 rounded-full flex items-center justify-center"
-                                 style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
-                              <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
-                                <path d="M8 5v14l11-7z" />
-                              </svg>
-                            </div>
-                          </div>
-                        </>
-                      )}
-
-                      {/* Not ready — show transcode status overlay */}
-                      {asset.transcodeStatus !== 'COMPLETED' && (
-                        <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
-                          {/* Animated spinner */}
-                          {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
-                            <div className="w-10 h-10 rounded-full animate-spin"
-                                 style={{ borderColor: transcodeColors[asset.transcodeStatus]?.dot.replace('bg-','#').replace('-400','' ) || '#6366F1', borderTopColor: 'transparent', borderWidth: '3px' }} />
-                          )}
-                          {/* Error icon */}
-                          {asset.transcodeStatus === 'FAILED' && (
-                            <div className="w-10 h-10 rounded-full flex items-center justify-center"
-                                 style={{ background: 'rgba(248,113,113,0.15)' }}>
-                              <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                                <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
-                              </svg>
-                            </div>
-                          )}
-                          {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
-                            <div className="w-10 h-10 rounded-full flex items-center justify-center"
-                                 style={{ background: 'rgba(251,191,36,0.15)' }}>
-                              <svg className="w-5 h-5" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                                <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
-                              </svg>
-                            </div>
-                          )}
-
-                          {/* Status label */}
-                          <span className="text-xs font-medium px-2.5 py-1 rounded-full"
-                                style={{ background: transcodeColors[asset.transcodeStatus]?.bg, color: transcodeColors[asset.transcodeStatus]?.text }}>
-                            {transcodeLabels[asset.transcodeStatus]}
-                          </span>
-                        </div>
-                      )}
-
-                      {/* Progress bar */}
-                      {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
-                        <div className="absolute bottom-0 left-0 right-0 h-1 overflow-hidden"
-                             style={{ background: 'rgba(0,0,0,0.3)' }}>
-                          <div
-                            className="h-full transition-all duration-500"
-                            style={{
-                              width: `${asset.transcodeProgress}%`,
-                              background: 'linear-gradient(90deg, #818CF8, #A78BFA)',
-                            }}
-                          />
-                        </div>
-                      )}
-
-                      {/* Duration badge */}
-                      {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
-                        <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
-                              style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
-                          {(() => { const m = Math.floor(asset.duration! / 60); const s = Math.floor(asset.duration! % 60); return `${m}:${s.toString().padStart(2,'0')}`; })()}
+              <div className="space-y-8">
+                {groupByDay(assets).map(([dayKey, dayAssets]) => {
+                  const groupDate = new Date(dayKey);
+                  const showHour = dayAssets.length > 1;
+                  return (
+                    <div key={dayKey}>
+                      {/* Date group header */}
+                      <div className="flex items-center gap-3 mb-4">
+                        <span className="text-xs font-semibold shrink-0" style={{ color: 'var(--text-muted)' }}>
+                          {formatGroupDate(groupDate)}
                         </span>
-                      )}
-
-                      {/* Codec badge */}
-                      {asset.codec && asset.transcodeStatus !== 'COMPLETED' && (
-                        <span className="absolute top-2 left-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
-                              style={{ background: 'rgba(0,0,0,0.6)', color: '#94A3B8' }}>
-                          {asset.codec}
+                        <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
+                        <span className="text-[10px] shrink-0" style={{ color: 'var(--text-subtle)' }}>
+                          {dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
                         </span>
-                      )}
-                    </div>
-
-                    {/* Info */}
-                    <div className="p-4">
-                      <div className="flex items-start justify-between gap-2 mb-1.5">
-                        <h3 className="text-sm font-medium truncate flex-1 transition-colors"
-                            style={{ color: 'var(--text)' }}>
-                          {asset.title}
-                        </h3>
-                        <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
-                          {statusLabels[asset.status]}
-                        </span>
-                      </div>
-
-                      {/* Uploader + date */}
-                      <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
-                        <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
-                          <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
-                        </svg>
-                        <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
-                        <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
-                        <span className="shrink-0 text-[10px]">
-                          {new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
-                        </span>
-                        <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
-                        <span className="shrink-0 text-[10px] truncate">{asset.filename}</span>
                       </div>
-
-                      {/* Transcode status row */}
-                      {asset.transcodeStatus !== 'COMPLETED' && (
-                        <div className="mb-2 flex items-center gap-1.5">
-                          <div
-                            className={`w-1.5 h-1.5 rounded-full shrink-0 ${['UPLOADING','PROCESSING'].includes(asset.transcodeStatus) ? 'animate-pulse' : ''} ${transcodeColors[asset.transcodeStatus]?.dot}`}
+                      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
+                        {dayAssets.map((asset, i) => (
+                          <AssetCard
+                            key={asset.id}
+                            asset={asset}
+                            canManage={canManage}
+                            showHour={showHour}
+                            onPlay={() => router.push(`/review/${asset.id}`)}
+                            onDelete={() => handleDeleteAsset(asset.id, asset.title)}
+                            onCancel={async (id) => {
+                              if (!token) return;
+                              try {
+                                await assetsApi.cancelTranscode(token, id);
+                                setAssets(prev => prev.map(a => a.id === id ? {
+                                  ...a,
+                                  transcodeStatus: 'PENDING',
+                                  transcodeProgress: 0,
+                                  transcodeError: null,
+                                  hlsPath: null,
+                                  transcodePaused: false,
+                                } : a));
+                              } catch (err) {
+                                alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
+                              }
+                            }}
+                            onPause={async (id) => {
+                              if (!token) return;
+                              try {
+                                await assetsApi.pauseTranscode(token, id);
+                                setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a));
+                              } catch (err) {
+                                alert(err instanceof Error ? err.message : 'Failed to pause transcode');
+                              }
+                            }}
+                            onResume={async (id) => {
+                              if (!token) return;
+                              try {
+                                await assetsApi.resumeTranscode(token, id);
+                                setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a));
+                              } catch (err) {
+                                alert(err instanceof Error ? err.message : 'Failed to resume transcode');
+                              }
+                            }}
+                            animationDelay={i * 40}
                           />
-                          <span className="text-[11px] truncate" style={{ color: transcodeColors[asset.transcodeStatus]?.text }}>
-                            {transcodeLabels[asset.transcodeStatus]}
-                            {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0
-                              ? ` — ${asset.transcodeProgress}%`
-                              : ''}
-                          </span>
-                          {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
-                            <span className="text-[10px] truncate" style={{ color: '#F87171' }}>
-                              : {asset.transcodeError}
-                            </span>
-                          )}
-                          {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
-                            <span className="text-[10px] truncate" style={{ color: '#FB923C' }}>
-                              — will re-encode to H.264
-                            </span>
-                          )}
-                        </div>
-                      )}
-
-                      <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
-                        <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
-                        <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
-                        <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
-                        <div className="flex-1" />
-
-                        {/* Download */}
-                        <a
-                          href={`/uploads/${asset.filePath}`}
-                          download={asset.filename}
-                          onClick={e => e.stopPropagation()}
-                          className="p-1 rounded transition-colors hover:bg-blue-500/20 flex-shrink-0"
-                          title="Download original"
-                        >
-                          <svg className="w-3.5 h-3.5" style={{ color: '#60A5FA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                            <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
-                          </svg>
-                        </a>
-
-                        {canManage && (
-                          <button
-                            onClick={(e) => { e.stopPropagation(); handleDeleteAsset(asset.id, asset.title); }}
-                            className="p-1 rounded transition-colors hover:bg-red-500/20 flex-shrink-0"
-                            title="Delete video"
-                          >
-                            <svg className="w-3.5 h-3.5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                              <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
-                            </svg>
-                          </button>
-                        )}
+                        ))}
                       </div>
                     </div>
-                  </div>
-                ))}
+                  );
+                })}
               </div>
             )}
           </>
@@ -733,11 +648,30 @@ export default function ProjectDetailPage() {
                     transcodeProgress: 0,
                     transcodeError: null,
                     hlsPath: null,
+                    transcodePaused: false,
                   } : a));
                 } catch (err) {
                   alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
                 }
               }}
+              onPause={async (id) => {
+                if (!token) return;
+                try {
+                  await assetsApi.pauseTranscode(token, id);
+                  setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a));
+                } catch (err) {
+                  alert(err instanceof Error ? err.message : 'Failed to pause transcode');
+                }
+              }}
+              onResume={async (id) => {
+                if (!token) return;
+                try {
+                  await assetsApi.resumeTranscode(token, id);
+                  setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a));
+                } catch (err) {
+                  alert(err instanceof Error ? err.message : 'Failed to resume transcode');
+                }
+              }}
             />
           </div>
         )}

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

@@ -92,7 +92,7 @@ export default function SettingsPage() {
   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"
+      <header className="sticky top-0 z-10 px-4 md:px-8 py-4 flex items-center shrink-0"
               style={{
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
@@ -101,7 +101,7 @@ export default function SettingsPage() {
         <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">
+      <div className="max-w-2xl mx-auto px-4 md:px-8 py-8 space-y-8">
 
         {/* Profile section */}
         <section className="card p-6">
@@ -178,7 +178,7 @@ export default function SettingsPage() {
               />
             </div>
 
-            <div className="grid grid-cols-2 gap-4">
+            <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
               <div>
                 <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
                   New password

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

@@ -242,7 +242,7 @@ export default function UsersPage() {
   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"
+      <header className="sticky top-0 z-10 px-4 md:px-8 py-4 flex items-center justify-between shrink-0 gap-3"
               style={{
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
@@ -281,11 +281,11 @@ export default function UsersPage() {
           <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
+          <span className="hidden sm:inline">Refresh</span>
         </button>
       </header>
 
-      <div className="px-8 py-6">
+      <div className="px-4 md:px-8 py-6">
 
         {/* ── Users Tab ──────────────────────────────────────────────────────── */}
         {activeTab === 'users' && (
@@ -304,32 +304,87 @@ export default function UsersPage() {
 
                   return (
                     <div key={u.id}
-                         className="card flex items-center gap-4 p-4 animate-fade-in"
+                         className="card animate-fade-in overflow-hidden"
                          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>
+                      {/* Top row: avatar + info + role/actions */}
+                      <div className="flex items-start gap-3 p-4">
+                        {/* Avatar */}
+                        <div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-semibold shrink-0 mt-0.5"
+                             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>}
+                        {/* Info */}
+                        <div className="flex-1 min-w-0">
+                          <div className="flex flex-wrap items-center gap-1.5">
+                            <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>
+
+                        {/* Role + Actions */}
+                        <div className="flex items-center gap-1.5 shrink-0">
+                          <select
+                            value={u.globalRole}
+                            onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
+                            disabled={updating === u.id || isMe}
+                            className="input text-xs py-1.5"
+                            style={{ minWidth: '90px', maxWidth: '100px' }}
+                          >
+                            {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
+                              <option key={value} value={value}>{cfg.label}</option>
+                            ))}
+                          </select>
+                          {!isMe && (
+                            <div className="flex items-center gap-1">
+                              <button
+                                onClick={() => openQuotaEdit(u)}
+                                disabled={updating === u.id}
+                                className="btn btn-secondary btn-sm"
+                                title="Edit storage quota"
+                              >
+                                <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="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
+                                </svg>
+                              </button>
+                              <button
+                                onClick={() => handleToggleActive(u.id, u.active)}
+                                disabled={updating === u.id}
+                                className={`btn btn-sm ${u.active ? 'btn-secondary' : 'btn-primary'}`}
+                                title={u.active ? 'Deactivate' : 'Activate'}
+                              >
+                                {u.active ? (
+                                  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                    <path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
+                                  </svg>
+                                ) : (
+                                  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                    <path strokeLinecap="round" strokeLinejoin="round" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
+                                  </svg>
+                                )}
+                              </button>
+                              <button
+                                onClick={() => setConfirmDelete(u.id)}
+                                className="btn btn-danger btn-sm"
+                                title="Delete user"
+                              >
+                                <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                  <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                                </svg>
+                              </button>
+                            </div>
+                          )}
                         </div>
-                        <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>{u.email}</p>
                       </div>
 
-                      {/* Stats */}
-                      <div className="hidden sm:flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
+                      {/* Stats row — desktop only */}
+                      <div className="hidden sm:flex items-center gap-4 px-4 pb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
                         <span>{u.ownedProjects ?? 0} owned</span>
                         <span>{((u._count?.memberships ?? 0) - (u.ownedProjects ?? 0))} shared</span>
                         <span>{u._count?.comments ?? 0} comments</span>
                         <span>{new Date(u.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
-
-                        {/* Storage bar — only for users with owned projects */}
                         {u.ownedProjects > 0 && (
                           <div className="flex items-center gap-2">
                             <div className="relative w-16 h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
@@ -351,14 +406,18 @@ export default function UsersPage() {
                         )}
                       </div>
 
+                      {/* Mobile stats — shown only on small screens */}
+                      <div className="flex sm:hidden items-center gap-3 px-4 pb-3 text-xs" style={{ color: 'var(--text-muted)' }}>
+                        <span>{u.ownedProjects ?? 0} owned</span>
+                        <span>{u._count?.comments ?? 0} comments</span>
+                      </div>
+
                       {/* Inline quota editor */}
                       {editingQuota === u.id && (
-                        <div className="w-full shrink-0 animate-fade-in"
+                        <div className="mx-4 mb-4 p-3 rounded-lg animate-fade-in"
                              style={{
                                background: 'rgba(99,102,241,0.06)',
                                border: '1px solid rgba(99,102,241,0.20)',
-                               borderRadius: '8px',
-                               padding: '10px 14px',
                              }}>
                           <div className="flex items-center gap-2 flex-wrap">
                             <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#A5B4FC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -401,10 +460,10 @@ export default function UsersPage() {
                               Cancel
                             </button>
                             {quotaError && (
-                              <span className="text-xs" style={{ color: '#F87171' }}>{quotaError}</span>
+                              <span className="text-xs w-full" style={{ color: '#F87171' }}>{quotaError}</span>
                             )}
                           </div>
-                          <div className="mt-1.5">
+                          <div className="mt-2">
                             <div className="relative w-full h-1.5 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
                               <div
                                 className="absolute left-0 top-0 h-full rounded-full"
@@ -420,62 +479,6 @@ export default function UsersPage() {
                           </div>
                         </div>
                       )}
-
-                      {/* Role selector */}
-                      <div className="shrink-0">
-                        <select
-                          value={u.globalRole}
-                          onChange={e => handleGlobalRoleChange(u.id, e.target.value)}
-                          disabled={updating === u.id || isMe}
-                          className="input text-xs py-1.5 pr-6"
-                          style={{ width: 'auto', minWidth: 0 }}
-                        >
-                          {Object.entries(GLOBAL_ROLE_CONFIG).map(([value, cfg]) => (
-                            <option key={value} value={value}>{cfg.label}</option>
-                          ))}
-                        </select>
-                      </div>
-
-                      {/* Actions */}
-                      {!isMe && (
-                        <div className="flex items-center gap-1 shrink-0">
-                          <button
-                            onClick={() => openQuotaEdit(u)}
-                            disabled={updating === u.id}
-                            className="btn btn-secondary btn-sm"
-                            title="Edit storage quota"
-                          >
-                            <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="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
-                            </svg>
-                          </button>
-                          <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>
                   );
                 })}
@@ -494,7 +497,7 @@ export default function UsersPage() {
               </h2>
 
               <form onSubmit={handleInvite} className="space-y-4">
-                <div className="flex items-end gap-3">
+                <div className="flex flex-col sm:flex-row items-stretch sm:items-end gap-3">
                   <div className="flex-1">
                     <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
                       Invite email address

+ 83 - 37
src/app/review/[assetId]/page.tsx

@@ -7,6 +7,7 @@ import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, Tra
 import { Avatar } from '@/components/ui/avatar';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
+import { formatTimecode } from '@/lib/format';
 
 const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
 
@@ -28,15 +29,6 @@ const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string;
   UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)',   spinner: false },
 };
 
-function formatTimecode(seconds: number, fps: number = 30): string {
-  if (!seconds || isNaN(seconds)) return '00:00:00:00';
-  const h = Math.floor(seconds / 3600);
-  const m = Math.floor((seconds % 3600) / 60);
-  const s = Math.floor(seconds % 60);
-  const f = Math.round(seconds * fps) % 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() {
   const params = useParams();
   const assetId = params.assetId as string;
@@ -89,6 +81,7 @@ export default function ReviewPage() {
   // Derive the current user's project role
   const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
   const isProjectAdmin = currentUserRole === 'ADMIN';
+  const isProjectOwner = asset?.project.ownerId === user?.id;
   const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
 
   // ── Poll for transcode progress ───────────────────────────────────────────
@@ -213,15 +206,24 @@ export default function ReviewPage() {
 
   const handleDeleteComment = async (commentId: string) => {
     if (!token) return;
-    if (!confirm('Delete this comment?')) return;
+    // Soft delete — just mark hidden, owner can restore
     try {
       await commentsApi.delete(token, commentId);
-      setComments(prev => prev
-        .filter(c => c.id !== commentId)
-        .map(c => ({ ...c, replies: c.replies?.filter(r => r.id !== commentId) }))
-      );
+      setComments(prev => prev.map(c =>
+        c.id === commentId ? { ...c, deleted: true } : c
+      ));
+    } catch {
+      alert('Failed to hide comment');
+    }
+  };
+
+  const handleRestoreComment = async (commentId: string) => {
+    if (!token) return;
+    try {
+      const { comment } = await commentsApi.restoreComment(token, commentId);
+      setComments(prev => prev.map(c => c.id === commentId ? comment : c));
     } catch {
-      alert('Failed to delete comment');
+      alert('Failed to restore comment');
     }
   };
 
@@ -326,12 +328,14 @@ export default function ReviewPage() {
       : '';
 
   const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
-  const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
+  const visibleComments = comments.filter(c => !c.deleted && (showResolved || !c.resolved));
 
-  // Only main comments (not replies) have annotations that should show on the video
-  const visibleAnnotations = visibleComments.flatMap(c =>
-    (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
-  );
+  // Only main comments (not replies, not deleted) have annotations that should show on the video
+  const visibleAnnotations = visibleComments
+    .filter(c => !c.deleted)
+    .flatMap(c =>
+      (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
+    );
 
   if (loading) {
     return (
@@ -537,13 +541,13 @@ export default function ReviewPage() {
           )}
 
           {/* Keyboard shortcuts */}
-          <div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
+          <div className="flex flex-wrap gap-3 text-xs shrink-0 hidden sm:flex" 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 ±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> 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 mode</span>
             <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
-            <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
+            <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps, asset?.duration ?? 0)}</span>
           </div>
         </div>
 
@@ -571,22 +575,22 @@ export default function ReviewPage() {
               }}
         >
           {/* Panel header */}
-          <div className="px-4 py-3 flex items-center justify-between shrink-0"
+          <div className="px-3 sm:px-4 py-2.5 sm: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-[13px] sm: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-2">
-              <span className="font-mono text-xs" style={{ color: '#818CF8' }}>
-                {formatTimecode(currentTime, fps)}
+              <span className="font-mono text-[11px] sm:text-xs hidden sm:inline" style={{ color: '#818CF8' }}>
+                {formatTimecode(currentTime, fps, asset?.duration ?? 0)}
               </span>
               <button
                 onClick={() => setShowResolved(v => !v)}
-                className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
+                className={`text-[11px] 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)' } : {}}
               >
                 {showResolved ? 'Hide resolved' : 'Show resolved'}
@@ -649,8 +653,10 @@ export default function ReviewPage() {
                     comment={comment}
                     currentUserId={user?.id ?? ''}
                     fps={fps}
+                    duration={asset?.duration ?? 0}
                     canComment={canComment}
                     isProjectAdmin={isProjectAdmin}
+                    isProjectOwner={isProjectOwner ?? false}
                     onTimestampClick={handleCommentSeek}
                     onReply={() => { setReplyTo(comment); }}
                     onResolve={(action) => handleResolve(comment.id, action)}
@@ -659,6 +665,7 @@ export default function ReviewPage() {
                     onDelete={(id) => handleDeleteComment(id)}
                     onAddAnnotation={() => handleAddAnnotationClick(comment)}
                     onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
+                    onRestore={handleRestoreComment}
                   />
                 ))}
               </div>
@@ -749,8 +756,10 @@ function CommentItem({
   comment,
   currentUserId,
   fps,
+  duration,
   canComment,
   isProjectAdmin,
+  isProjectOwner,
   onTimestampClick,
   onReply,
   onResolve,
@@ -759,12 +768,15 @@ function CommentItem({
   onDelete,
   onAddAnnotation,
   onDeleteAnnotation,
+  onRestore,
 }: {
   comment: Comment;
   currentUserId: string;
   fps: number;
+  duration: number;
   canComment: boolean | undefined;
   isProjectAdmin: boolean;
+  isProjectOwner: boolean;
   onTimestampClick: (c: Comment) => void;
   onReply: () => void;
   onResolve: (action: 'approve' | 'reject') => void;
@@ -773,6 +785,7 @@ function CommentItem({
   onDelete: (id: string) => void;
   onAddAnnotation: () => void;
   onDeleteAnnotation: (annotations: AnnotationData[]) => void;
+  onRestore: (id: string) => void;
 }) {
   const isOwner = comment.userId === currentUserId;
   const isCommentAuthor = comment.userId === currentUserId;
@@ -780,6 +793,8 @@ function CommentItem({
   const isReply = !!comment.parentId;
   const annotations = comment.annotations ?? [];
   const canAddMore = annotations.length < MAX_ANNOTATIONS;
+  const isDeleted = !!comment.deleted;
+  const canRestore = !isDeleted && (isProjectOwner || isProjectAdmin);
 
   // Resolve state machine
   const isResolved = comment.resolveStatus === 'RESOLVED';
@@ -791,7 +806,11 @@ function CommentItem({
   return (
     <div
       className="p-4 animate-fade-in"
-      style={{ opacity: isResolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
+      style={{
+        opacity: isDeleted ? 0.45 : isResolved ? 0.55 : 1,
+        paddingLeft: isReply ? '2.5rem' : undefined,
+        borderLeft: isDeleted ? '2px solid rgba(239,68,68,0.3)' : undefined,
+      }}
     >
       <div className="flex gap-2.5">
         <Avatar name={name} size="sm" />
@@ -806,7 +825,7 @@ function CommentItem({
                 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' }}
               >
-                {formatTimecode(comment.timestamp, fps)}
+                {formatTimecode(comment.timestamp, fps, duration)}
               </button>
             )}
             {isPending && (
@@ -864,13 +883,28 @@ function CommentItem({
           )}
 
           {/* Content */}
-          <p className="text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
+          <p className="text-[13px] sm:text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
             {comment.content}
           </p>
 
           {/* Actions */}
           <div className="flex items-center gap-1">
-            {!isReply && (
+            {/* Restore button for soft-deleted comments — project owner/ADMIN only */}
+            {isDeleted && (isProjectOwner || isProjectAdmin) && (
+              <button
+                onClick={() => onRestore(comment.id)}
+                className="text-xs px-2 py-1 rounded-md transition-colors"
+                style={{ color: '#86EFAC', background: 'rgba(34,197,94,0.10)' }}
+                title="Restore this comment"
+              >
+                <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+                </svg>
+                Restore
+              </button>
+            )}
+
+            {!isReply && !isDeleted && (
               <button
                 onClick={onAddAnnotation}
                 disabled={!canAddMore}
@@ -883,6 +917,18 @@ function CommentItem({
                 </svg>
               </button>
             )}
+            {!isReply && !isDeleted && (
+              <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>
+            )}
             {!isReply && (
               <button
                 onClick={onReply}
@@ -897,7 +943,7 @@ function CommentItem({
             )}
 
             {/* Resolve / approval workflow buttons */}
-            {!isReply && !isResolved && !isPending && (
+            {!isReply && !isDeleted && !isResolved && !isPending && (
               <>
                 {canRequest ? (
                   <button
@@ -926,7 +972,7 @@ function CommentItem({
               </>
             )}
 
-            {isPending && canApprove && !isReply && (
+            {isPending && canApprove && !isReply && !isDeleted && (
               <>
                 <button
                   onClick={() => onResolve('approve')}
@@ -953,7 +999,7 @@ function CommentItem({
               </>
             )}
 
-            {isPending && !canApprove && !isReply && (
+            {isPending && !canApprove && !isReply && !isDeleted && (
               <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
                 <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                   <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -962,7 +1008,7 @@ function CommentItem({
               </span>
             )}
 
-            {canReopen && !isReply && (
+            {canReopen && !isReply && !isDeleted && (
               <button
                 onClick={() => onResolve('reject')}
                 className="text-xs px-2 py-1 rounded-md transition-colors"
@@ -976,12 +1022,12 @@ function CommentItem({
               </button>
             )}
 
-            {isOwner && (
+            {isOwner && !isDeleted && (
               <button
                 onClick={onDeleteSelf}
                 className="text-xs px-2 py-1 rounded-md transition-colors"
                 style={{ color: 'var(--text-subtle)' }}
-                title="Delete comment"
+                title="Hide comment"
               >
                 <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" />

+ 79 - 22
src/components/transcode/TranscodeTasksPanel.tsx

@@ -9,6 +9,8 @@ interface Props {
   canManage: boolean;
   onDelete: (id: string, title: string) => void;
   onCancel: (id: string) => void;
+  onPause: (id: string) => void;
+  onResume: (id: string) => void;
 }
 
 const STATUS_CONFIG: Record<TranscodeStatus, {
@@ -38,14 +40,20 @@ function TranscodeTaskRow({
   canManage,
   onDelete,
   onCancel,
+  onPause,
+  onResume,
 }: {
   asset: Asset;
   canManage: boolean;
   onDelete: (id: string, title: string) => void;
   onCancel: (id: string) => void;
+  onPause: (id: string) => void;
+  onResume: (id: string) => void;
 }) {
   const cfg = STATUS_CONFIG[asset.transcodeStatus] ?? STATUS_CONFIG.PENDING;
-  const canAct = canManage && asset.transcodeStatus !== 'COMPLETED';
+  const isActive = ['PENDING', 'UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus);
+  const isPaused = !!asset.transcodePaused;
+  const canAct = canManage && asset.transcodeStatus !== 'COMPLETED' && asset.transcodeStatus !== 'FAILED';
   const canDelete = canManage;
 
   return (
@@ -106,13 +114,21 @@ function TranscodeTaskRow({
 
       {/* Status badge */}
       <div className="flex flex-col items-end gap-1 shrink-0 min-w-[100px]">
-        <span className="text-xs font-medium px-2.5 py-1 rounded-full"
-              style={{ background: cfg.bg, color: cfg.color }}>
-          {cfg.label}
-        </span>
+        <div className="flex items-center gap-1.5">
+          {isPaused && isActive && (
+            <span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
+                  style={{ background: 'rgba(251,191,36,0.15)', color: '#FCD34D' }}>
+              Paused
+            </span>
+          )}
+          <span className="text-xs font-medium px-2.5 py-1 rounded-full"
+                style={{ background: cfg.bg, color: cfg.color }}>
+            {cfg.label}
+          </span>
+        </div>
 
         {/* Progress bar */}
-        {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
+        {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && !isPaused && (
           <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
             <div
               className="h-full rounded-full transition-all"
@@ -131,21 +147,60 @@ function TranscodeTaskRow({
       {canManage && (
         <div className="flex items-center gap-1 shrink-0">
           {canAct && (
-            <button
-              onClick={() => onCancel(asset.id)}
-              className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium transition-colors"
-              style={{
-                background: 'rgba(251,191,36,0.10)',
-                color: '#FBBF24',
-                border: '1px solid rgba(251,191,36,0.20)',
-              }}
-              title="Cancel and re-queue"
-            >
-              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
-              </svg>
-              <span>Cancel</span>
-            </button>
+            <>
+              {/* Pause / Resume */}
+              {!isPaused ? (
+                <button
+                  onClick={() => onPause(asset.id)}
+                  className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors"
+                  style={{
+                    background: 'rgba(251,191,36,0.10)',
+                    color: '#FBBF24',
+                    border: '1px solid rgba(251,191,36,0.20)',
+                  }}
+                  title="Pause transcode"
+                >
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
+                  </svg>
+                  <span>Pause</span>
+                </button>
+              ) : (
+                <button
+                  onClick={() => onResume(asset.id)}
+                  className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors"
+                  style={{
+                    background: 'rgba(52,211,153,0.10)',
+                    color: '#34D399',
+                    border: '1px solid rgba(52,211,153,0.20)',
+                  }}
+                  title="Resume transcode"
+                >
+                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+                    <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                  </svg>
+                  <span>Resume</span>
+                </button>
+              )}
+
+              {/* Cancel */}
+              <button
+                onClick={() => onCancel(asset.id)}
+                className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors"
+                style={{
+                  background: 'rgba(248,113,113,0.08)',
+                  color: '#F87171',
+                  border: '1px solid rgba(248,113,113,0.15)',
+                }}
+                title="Cancel and re-queue"
+              >
+                <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>
+                <span>Cancel</span>
+              </button>
+            </>
           )}
           {canDelete && (
             <button
@@ -170,7 +225,7 @@ function TranscodeTaskRow({
   );
 }
 
-export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel }: Props) {
+export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel, onPause, onResume }: Props) {
   const [filter, setFilter] = useState<'all' | 'processing' | 'completed' | 'failed'>('all');
 
   const filtered = assets.filter(a => {
@@ -254,6 +309,8 @@ export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel }: P
               canManage={canManage}
               onDelete={onDelete}
               onCancel={onCancel}
+              onPause={onPause}
+              onResume={onResume}
             />
           ))}
         </div>

+ 240 - 0
src/components/ui/AssetCard.tsx

@@ -0,0 +1,240 @@
+'use client';
+
+import { Asset, TranscodeStatus } from '@/lib/api';
+
+interface Props {
+  asset: Asset;
+  canManage: boolean;
+  showHour: boolean;
+  onPlay: () => void;
+  onDelete: () => void;
+  onCancel: (id: string) => void;
+  onPause: (id: string) => void;
+  onResume: (id: string) => void;
+  animationDelay?: number;
+}
+
+const statusColors: Record<string, string> = {
+  PENDING_REVIEW:    'status-pending',
+  CHANGES_REQUESTED: 'status-changes',
+  APPROVED:          'status-approved',
+  REJECTED:          'status-rejected',
+};
+
+const statusLabels: Record<string, string> = {
+  PENDING_REVIEW:    'Pending',
+  CHANGES_REQUESTED: 'Changes',
+  APPROVED:          'Approved',
+  REJECTED:          'Rejected',
+};
+
+const transcodeColors: Record<TranscodeStatus, { text: string; dot: string; bg: string }> = {
+  PENDING:           { text: '#94A3B8', dot: 'bg-slate-400',    bg: 'rgba(148,163,184,0.10)' },
+  UPLOADING:         { text: '#60A5FA', dot: 'bg-blue-400',     bg: 'rgba(96,165,250,0.10)'  },
+  PROCESSING:        { text: '#A78BFA', dot: 'bg-violet-400',   bg: 'rgba(167,139,250,0.10)' },
+  COMPLETED:         { text: '#34D399', dot: 'bg-emerald-400',  bg: 'rgba(52,211,153,0.10)'  },
+  FAILED:            { text: '#F87171', dot: 'bg-red-400',      bg: 'rgba(248,113,113,0.10)' },
+  UNSUPPORTED_CODEC:  { text: '#FBBF24', dot: 'bg-amber-400',   bg: 'rgba(251,191,36,0.10)'  },
+};
+
+const transcodeLabels: Record<TranscodeStatus, string> = {
+  PENDING:           'Queued',
+  UPLOADING:         'Uploading',
+  PROCESSING:        'Processing',
+  COMPLETED:         'Ready',
+  FAILED:            'Failed',
+  UNSUPPORTED_CODEC: 'Unsupported codec',
+};
+
+function formatTime(d: Date, showHour: boolean): string {
+  if (showHour) {
+    return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
+  }
+  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+}
+
+export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCancel, onPause, onResume, animationDelay = 0 }: Props) {
+  const createdAt = new Date(asset.createdAt);
+
+  return (
+    <div
+      className="card overflow-hidden group"
+      style={{ animation: `slideUp 0.25s ease-out ${animationDelay}ms both` }}
+    >
+      {/* Thumbnail */}
+      <div className="relative aspect-video cursor-pointer" style={{ background: '#080810' }} onClick={onPlay}>
+        {asset.transcodeStatus === 'COMPLETED' && (
+          <>
+            {asset.thumbnail ? (
+              <img
+                src={`/uploads/${asset.thumbnail}`}
+                alt={asset.title}
+                className="w-full h-full object-cover"
+                style={{ opacity: 0.85 }}
+              />
+            ) : (
+              <div className="w-full h-full flex items-center justify-center">
+                <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
+                </svg>
+              </div>
+            )}
+            <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
+                 style={{ background: 'rgba(0,0,0,0.35)' }}>
+              <div className="w-12 h-12 rounded-full flex items-center justify-center"
+                   style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
+                <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
+                  <path d="M8 5v14l11-7z" />
+                </svg>
+              </div>
+            </div>
+          </>
+        )}
+
+        {/* Not ready */}
+        {asset.transcodeStatus !== 'COMPLETED' && (
+          <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
+            {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
+              <div className="w-10 h-10 rounded-full animate-spin"
+                   style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '3px' }} />
+            )}
+            {asset.transcodeStatus === 'FAILED' && (
+              <div className="w-10 h-10 rounded-full flex items-center justify-center"
+                   style={{ background: 'rgba(248,113,113,0.15)' }}>
+                <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+                </svg>
+              </div>
+            )}
+            {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
+              <div className="w-10 h-10 rounded-full flex items-center justify-center"
+                   style={{ background: 'rgba(251,191,36,0.15)' }}>
+                <svg className="w-5 h-5" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+                </svg>
+              </div>
+            )}
+            <span className="text-xs font-medium px-2.5 py-1 rounded-full"
+                  style={{ background: transcodeColors[asset.transcodeStatus]?.bg, color: transcodeColors[asset.transcodeStatus]?.text }}>
+              {transcodeLabels[asset.transcodeStatus]}
+            </span>
+          </div>
+        )}
+
+        {/* Progress bar */}
+        {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
+          <div className="absolute bottom-0 left-0 right-0 h-1 overflow-hidden" style={{ background: 'rgba(0,0,0,0.3)' }}>
+            <div
+              className="h-full transition-all duration-500"
+              style={{
+                width: `${asset.transcodeProgress}%`,
+                background: 'linear-gradient(90deg, #818CF8, #A78BFA)',
+              }}
+            />
+          </div>
+        )}
+
+        {/* Duration badge */}
+        {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
+          <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
+                style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
+            {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
+          </span>
+        )}
+
+        {/* Upload time — top left */}
+        <span className="absolute top-2 left-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
+              style={{ background: 'rgba(0,0,0,0.60)', color: '#94A3B8' }}>
+          {formatTime(createdAt, showHour)}
+        </span>
+
+        {/* Codec badge */}
+        {asset.codec && asset.transcodeStatus !== 'COMPLETED' && (
+          <span className="absolute top-2 right-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
+                style={{ background: 'rgba(0,0,0,0.60)', color: '#94A3B8' }}>
+            {asset.codec}
+          </span>
+        )}
+      </div>
+
+      {/* Info */}
+      <div className="p-4">
+        <div className="flex items-start justify-between gap-2 mb-1.5">
+          <h3 className="text-sm font-medium truncate flex-1 transition-colors"
+              style={{ color: 'var(--text)' }}>
+            {asset.title}
+          </h3>
+          <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
+            {statusLabels[asset.status]}
+          </span>
+        </div>
+
+        {/* Uploader + date */}
+        <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
+          <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
+          </svg>
+          <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
+          <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
+          <span className="shrink-0 text-[10px] truncate">{asset.filename}</span>
+        </div>
+
+        {/* Transcode status */}
+        {asset.transcodeStatus !== 'COMPLETED' && (
+          <div className="mb-2 flex items-center gap-1.5">
+            <div
+              className={`w-1.5 h-1.5 rounded-full shrink-0 ${['UPLOADING','PROCESSING'].includes(asset.transcodeStatus) ? 'animate-pulse' : ''} ${transcodeColors[asset.transcodeStatus]?.dot}`}
+            />
+            <span className="text-[11px] truncate" style={{ color: transcodeColors[asset.transcodeStatus]?.text }}>
+              {transcodeLabels[asset.transcodeStatus]}
+              {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0
+                ? ` — ${asset.transcodeProgress}%`
+                : ''}
+            </span>
+            {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
+              <span className="text-[10px] truncate" style={{ color: '#F87171' }}>
+                : {asset.transcodeError}
+              </span>
+            )}
+            {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
+              <span className="text-[10px] truncate" style={{ color: '#FB923C' }}>
+                — will re-encode to H.264
+              </span>
+            )}
+          </div>
+        )}
+
+        <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
+          <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
+          <div className="flex-1" />
+
+          {/* Download */}
+          <a
+            href={`/uploads/${asset.filePath}`}
+            download={asset.filename}
+            onClick={e => e.stopPropagation()}
+            className="p-1 rounded transition-colors hover:bg-blue-500/20 flex-shrink-0"
+            title="Download original"
+          >
+            <svg className="w-3.5 h-3.5" style={{ color: '#60A5FA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
+            </svg>
+          </a>
+
+          {canManage && (
+            <button
+              onClick={e => { e.stopPropagation(); onDelete(); }}
+              className="p-1 rounded transition-colors hover:bg-red-500/20 flex-shrink-0"
+              title="Delete video"
+            >
+              <svg className="w-3.5 h-3.5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
+              </svg>
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 80 - 0
src/components/video-player/SpeechBubble.tsx

@@ -0,0 +1,80 @@
+'use client';
+
+import { Comment } from '@/lib/api';
+import { formatTimecode } from '@/lib/format';
+
+interface Props {
+  comment: Comment;
+  fps?: number;
+  /** CSS left position (%) within the video frame */
+  left?: number;
+  onDismiss: () => void;
+}
+
+export function SpeechBubble({ comment, fps = 30, left, onDismiss }: Props) {
+  return (
+    <div
+      className="flex items-start gap-2 rounded-xl px-4 py-3 w-full"
+      style={{
+        background: 'rgba(20, 22, 40, 0.95)',
+        border: '1px solid rgba(255,255,255,0.12)',
+        backdropFilter: 'blur(12px)',
+        maxWidth: '300px',
+        position: 'relative',
+      }}
+    >
+      {/* Pointer tail — points downward toward timeline */}
+      {left !== undefined && (
+        <div
+          style={{
+            position: 'absolute',
+            bottom: '-9px',
+            left: `${left}%`,
+            transform: 'translateX(-50%)',
+            width: 0,
+            height: 0,
+            borderLeft: '7px solid transparent',
+            borderRight: '7px solid transparent',
+            borderTop: '9px solid rgba(99,102,241,0.35)',
+            filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.4))',
+          }}
+        />
+      )}
+
+      {/* Avatar */}
+      <div
+        className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"
+        style={{ background: 'linear-gradient(135deg, #6366F1, #8B5CF6)', color: '#fff' }}
+      >
+        {(comment.user?.name ?? 'U').charAt(0).toUpperCase()}
+      </div>
+
+      {/* Content */}
+      <div className="flex-1 min-w-0">
+        <div className="flex items-center gap-2 mb-1">
+          <span className="text-xs font-medium truncate" style={{ color: '#E2E8F0' }}>
+            {comment.user?.name ?? 'Unknown'}
+          </span>
+          <span className="text-[11px] font-mono shrink-0" style={{ color: '#818CF8' }}>
+            {formatTimecode(comment.timestamp ?? 0, fps)}
+          </span>
+        </div>
+        <p className="text-sm leading-relaxed" style={{ color: '#CBD5E1' }}>
+          {comment.content}
+        </p>
+      </div>
+
+      {/* Dismiss button */}
+      <button
+        onClick={onDismiss}
+        className="shrink-0 w-6 h-6 rounded-full flex items-center justify-center transition-colors hover:bg-white/10 mt-0.5"
+        style={{ color: 'rgba(255,255,255,0.45)' }}
+        title="Dismiss"
+      >
+        <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+          <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+        </svg>
+      </button>
+    </div>
+  );
+}

+ 94 - 129
src/components/video-player/Timeline.tsx

@@ -2,6 +2,7 @@
 
 import { useRef, useCallback, useEffect, useState } from 'react';
 import { Comment } from '../../lib/api';
+import { formatTimecode } from '../../lib/format';
 
 interface Props {
   duration: number;
@@ -17,8 +18,8 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
   const draggingRef = useRef(false);
   const rafRef = useRef<number | null>(null);
   const [displayTime, setDisplayTime] = useState(currentTime);
-  const [hoveredComment, setHoveredComment] = useState<Comment | null>(null);
-  const [tickHoverPos, setTickHoverPos] = useState<{ x: number; pos: number } | null>(null);
+  const [touchActive, setTouchActive] = useState(false);
+  const [touchX, setTouchX] = useState(0);
 
   // Smoothly track displayTime during drag using RAF
   useEffect(() => {
@@ -26,74 +27,93 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
     setDisplayTime(currentTime);
   }, [currentTime]);
 
-  const getTimeFromEvent = useCallback((e: MouseEvent | React.MouseEvent) => {
+  const getTimeFromX = useCallback((clientX: number) => {
     if (!trackRef.current) return null;
     const rect = trackRef.current.getBoundingClientRect();
-    const x = e.clientX - rect.left;
+    const x = clientX - rect.left;
     const ratio = Math.max(0, Math.min(1, x / rect.width));
     return ratio * duration;
   }, [duration]);
 
-  const handleMouseMove = useCallback((e: MouseEvent) => {
+  const seek = useCallback((t: number) => {
+    const clamped = Math.max(0, Math.min(duration, t));
+    setDisplayTime(clamped);
+    onSeek(clamped);
+  }, [duration, onSeek]);
+
+  // Generic pointer move — fires on both mouse and touch
+  const handleMove = useCallback((clientX: number) => {
     if (!draggingRef.current) return;
     if (rafRef.current) cancelAnimationFrame(rafRef.current);
     rafRef.current = requestAnimationFrame(() => {
-      const t = getTimeFromEvent(e);
-      if (t !== null) {
-        setDisplayTime(t);
-        onSeek(t);
-      }
+      const t = getTimeFromX(clientX);
+      if (t !== null) seek(t);
     });
-  }, [getTimeFromEvent, onSeek]);
+  }, [getTimeFromX, seek]);
 
-  const handleMouseUp = useCallback((e: MouseEvent) => {
+  const handleUp = useCallback((clientX: number) => {
     if (!draggingRef.current) return;
     draggingRef.current = false;
     document.body.style.userSelect = '';
     document.body.style.cursor = '';
-    const t = getTimeFromEvent(e);
-    if (t !== null) {
-      setDisplayTime(t);
-      onSeek(t);
-    }
-  }, [getTimeFromEvent, onSeek]);
+    document.body.style.touchAction = '';
+    const t = getTimeFromX(clientX);
+    if (t !== null) seek(t);
+    setTouchActive(false);
+  }, [getTimeFromX, seek]);
 
   useEffect(() => {
-    window.addEventListener('mousemove', handleMouseMove);
-    window.addEventListener('mouseup', handleMouseUp);
+    const onMouseMove = (e: MouseEvent) => handleMove(e.clientX);
+    const onMouseUp = (e: MouseEvent) => handleUp(e.clientX);
+    window.addEventListener('mousemove', onMouseMove);
+    window.addEventListener('mouseup', onMouseUp);
     return () => {
-      window.removeEventListener('mousemove', handleMouseMove);
-      window.removeEventListener('mouseup', handleMouseUp);
+      window.removeEventListener('mousemove', onMouseMove);
+      window.removeEventListener('mouseup', onMouseUp);
       if (rafRef.current) cancelAnimationFrame(rafRef.current);
     };
-  }, [handleMouseMove, handleMouseUp]);
+  }, [handleMove, handleUp]);
 
-  const handleMouseDown = (e: React.MouseEvent) => {
+  const handlePointerDown = (e: React.PointerEvent) => {
+    // Only handle primary pointer (left click / first touch)
+    if (e.button !== 0) return;
     e.preventDefault();
     draggingRef.current = true;
     document.body.style.userSelect = 'none';
     document.body.style.cursor = 'col-resize';
-    const t = getTimeFromEvent(e.nativeEvent);
-    if (t !== null) {
-      setDisplayTime(t);
-      onSeek(t);
-    }
+    document.body.style.touchAction = 'none'; // prevent scroll while scrubbing
+    const t = getTimeFromX(e.clientX);
+    if (t !== null) seek(t);
+  };
+
+  // Touch-specific: show tooltip while dragging
+  const handleTouchStart = (e: React.TouchEvent) => {
+    const touch = e.touches[0];
+    if (!touch) return;
+    setTouchActive(true);
+    setTouchX(touch.clientX);
   };
 
-  const handleTickEnter = (comment: Comment, e: React.MouseEvent) => {
-    setHoveredComment(comment);
-    setTickHoverPos({ x: e.clientX, pos: duration > 0 ? (comment.timestamp! / duration) * 100 : 0 });
+  const handleTouchMove = (e: React.TouchEvent) => {
+    const touch = e.touches[0];
+    if (!touch) return;
+    setTouchX(touch.clientX);
+    handleMove(touch.clientX);
   };
 
-  const handleTickLeave = () => {
-    setHoveredComment(null);
-    setTickHoverPos(null);
+  const handleTouchEnd = (e: React.TouchEvent) => {
+    const touch = e.changedTouches[0];
+    if (!touch) return;
+    handleUp(touch.clientX);
   };
 
   const progress = duration > 0 ? (displayTime / duration) * 100 : 0;
 
+  // Touch tooltip: show timecode above thumb while dragging on touch
+  const showTooltip = draggingRef.current || touchActive;
+
   return (
-    <div className="relative py-2 select-none">
+    <div className="relative py-3 select-none">
       {/* Comment tick marks */}
       <div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-0 pointer-events-none">
         {comments.map(comment => {
@@ -108,129 +128,74 @@ export function Timeline({ duration, currentTime, fps, comments, onSeek, onComme
                 backgroundColor: comment.resolveStatus === 'RESOLVED' ? '#22c55e' : '#818CF8',
                 pointerEvents: 'auto',
               }}
-              onMouseEnter={(e) => handleTickEnter(comment, e)}
-              onMouseLeave={handleTickLeave}
               onClick={(e) => {
                 e.stopPropagation();
                 onCommentClick(comment);
               }}
+              title={`${comment.user?.name ?? 'Unknown'}: ${comment.content.slice(0, 60)}`}
             />
           );
         })}
       </div>
 
-      {/* Hover tooltip bubble — positioned relative to the tick */}
-      {hoveredComment && tickHoverPos && (
+      {/* Touch tooltip — visible during drag */}
+      {showTooltip && (
         <div
-          className="absolute z-50 pointer-events-none"
+          className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-lg text-xs font-mono pointer-events-none whitespace-nowrap z-10"
           style={{
-            // Position below the tick mark: tick is at top-1/2 of track, so bottom is below it
-            // Clamp so bubble doesn't overflow left/right edges
-            left: `clamp(60px, ${tickHoverPos.pos}%, calc(100% - 60px))`,
-            transform: 'translateX(-50%)',
-            bottom: 'calc(100% + 14px)',
+            background: 'rgba(10,11,20,0.95)',
+            border: '1px solid rgba(99,102,241,0.35)',
+            color: '#A5B4FC',
+            backdropFilter: 'blur(8px)',
           }}
         >
-          {/* Bubble */}
-          <div
-            className="max-w-[200px] rounded-xl px-3 py-2 shadow-xl"
-            style={{
-              background: 'rgba(20, 22, 40, 0.96)',
-              border: '1px solid rgba(255,255,255,0.10)',
-              backdropFilter: 'blur(8px)',
-            }}
-          >
-            {/* Header */}
-            <div className="flex items-center gap-1.5 mb-1">
-              <div
-                className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0"
-                style={{
-                  background: 'linear-gradient(135deg, #6366F1, #8B5CF6)',
-                  color: '#fff',
-                }}
-              >
-                {(hoveredComment.user?.name ?? 'U').charAt(0).toUpperCase()}
-              </div>
-              <span className="text-xs font-medium truncate" style={{ color: '#E2E8F0' }}>
-                {hoveredComment.user?.name ?? 'Unknown'}
-              </span>
-              <span
-                className="text-[10px] font-mono shrink-0 ml-auto"
-                style={{ color: '#818CF8' }}
-              >
-                {formatTimecode(hoveredComment.timestamp ?? 0, fps)}
-              </span>
-            </div>
-            {/* Content */}
-            <p className="text-[11px] leading-snug line-clamp-3" style={{ color: '#94A3B8' }}>
-              {hoveredComment.content}
-            </p>
-            {/* Status */}
-            {hoveredComment.resolveStatus === 'PENDING_APPROVAL' && (
-              <span className="mt-1 inline-flex items-center gap-1 text-[10px]" style={{ color: '#FCD34D' }}>
-                <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
-                </svg>
-                Pending approval
-              </span>
-            )}
-            {hoveredComment.resolveStatus === 'RESOLVED' && (
-              <span className="mt-1 inline-flex items-center gap-1 text-[10px]" style={{ color: '#86EFAC' }}>
-                <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
-                </svg>
-                Approved{hoveredComment.resolvedBy ? ` by ${hoveredComment.resolvedBy.name}` : ''}
-              </span>
-            )}
-          </div>
-          {/* Arrow */}
-          <div
-            className="w-2 h-2 rotate-45 mx-auto"
-            style={{
-              background: 'rgba(20, 22, 40, 0.96)',
-              borderRight: '1px solid rgba(255,255,255,0.10)',
-              borderBottom: '1px solid rgba(255,255,255,0.10)',
-              marginTop: '-5px',
-            }}
-          />
+          {formatTimecode(displayTime, fps, duration)}
         </div>
       )}
 
-      {/* Progress bar with drag support */}
+      {/* Seek bar — large touch target */}
       <div
         ref={trackRef}
-        className="relative h-1.5 bg-gray-600 rounded-full cursor-pointer"
-        onMouseDown={handleMouseDown}
+        className="relative h-2 md:h-1.5 bg-white/10 rounded-full cursor-pointer group"
+        onPointerDown={handlePointerDown}
+        onTouchStart={handleTouchStart}
+        onTouchMove={handleTouchMove}
+        onTouchEnd={handleTouchEnd}
+        style={{ touchAction: 'none' }}
       >
-        {/* Played */}
+        {/* Played portion */}
         <div
-          className="absolute h-full bg-blue-500 rounded-full"
+          className="absolute left-0 top-0 h-full bg-indigo-500 rounded-full"
           style={{ width: `${progress}%` }}
         />
-        {/* Scrubber */}
+        {/* Scrubber — larger on touch, always visible while dragging */}
         <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 transition-opacity"
+          className="absolute top-1/2 -translate-y-1/2 w-4 h-4 md:w-3.5 md:h-3.5 bg-white rounded-full shadow-lg border-2 border-indigo-500 transition-opacity"
           style={{
-            left: `calc(${progress}% - 7px)`,
+            left: `calc(${progress}% - 8px)`,
             opacity: draggingRef.current ? 1 : undefined,
           }}
         />
+        {/* Touch ripple — visible on touch drag */}
+        {touchActive && (
+          <div
+            className="absolute top-1/2 -translate-y-1/2 rounded-full pointer-events-none"
+            style={{
+              left: `calc(${progress}% - 16px)`,
+              width: '32px',
+              height: '32px',
+              background: 'rgba(99,102,241,0.15)',
+              border: '1px solid rgba(99,102,241,0.3)',
+            }}
+          />
+        )}
       </div>
 
-      {/* Time display */}
-      <div className="flex justify-between mt-1 text-xs text-gray-400 font-mono">
-        <span>{formatTimecode(displayTime, fps)}</span>
-        <span>{formatTimecode(duration, fps)}</span>
+      {/* Timecode display */}
+      <div className="flex justify-between mt-1.5 text-xs text-gray-400 font-mono">
+        <span>{formatTimecode(displayTime, fps, duration)}</span>
+        <span>{formatTimecode(duration, fps, duration)}</span>
       </div>
     </div>
   );
 }
-
-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);
-  const f = Math.round(s * fps) % fps;
-  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
-}

+ 227 - 189
src/components/video-player/VideoPlayer.tsx

@@ -4,7 +4,9 @@ import { useRef, useState, useEffect, useCallback } from 'react';
 import Hls from 'hls.js';
 import { AnnotationCanvas, COLORS, drawShape, Tool } from './AnnotationCanvas';
 import { Timeline } from './Timeline';
+import { SpeechBubble } from './SpeechBubble';
 import { AnnotationData, Comment } from '@/lib/api';
+import { formatTimecode } from '@/lib/format';
 
 interface AnnotationWithTimestamp {
   annotation: AnnotationData;
@@ -16,22 +18,16 @@ interface Props {
   mimeType: string;
   fps?: number;
   comments: Comment[];
-  // Annotations to display during playback (not in draw mode)
   visibleAnnotations: AnnotationWithTimestamp[];
-  // Draw mode — controlled externally by parent
   drawMode: boolean;
   drawTool: Tool;
   drawColor: string;
   onDrawModeChange: (active: boolean) => void;
   onDrawToolChange: (tool: Tool) => void;
   onDrawColorChange: (color: string) => void;
-  // Pending strokes in draw mode (shown on canvas until Save/Undo)
   pendingStrokes: AnnotationData[];
-  // Called after each completed stroke (mouseUp)
   onStrokeComplete: (stroke: AnnotationData) => void;
-  // Called when video time updates
   onTimeUpdate: (time: number) => void;
-  // Called when user clicks a comment marker on timeline
   onCommentClick: (comment: Comment) => void;
 }
 
@@ -65,6 +61,38 @@ export function VideoPlayer({
   const [fullscreen, setFullscreen] = useState(false);
   const [showControls, setShowControls] = useState(true);
   const [dims, setDims] = useState({ width: 0, height: 0 });
+  // Speech bubble state — derived from currentTime (auto-show within ±1s of comment)
+  const BUBBLE_WINDOW = 1; // seconds before/after comment timestamp
+  const [dismissedSet, setDismissedSet] = useState<Set<string>>(new Set());
+  const dismissedRef = useRef<Set<string>>(new Set());
+  useEffect(() => { dismissedRef.current = dismissedSet; }, [dismissedSet]);
+  const [bubbleVisible, setBubbleVisible] = useState(false);
+
+  // Auto-detect which comment (if any) is within the ±1.5s window of currentTime
+  const activeComment: Comment | null = (() => {
+    if (!bubbleVisible) return null;
+    const ts = comments.filter(c => c.timestamp != null && !c.deleted && !dismissedRef.current.has(c.id));
+    // Find the comment whose timestamp is closest to currentTime within the window
+    let best: Comment | null = null;
+    let bestDist = Infinity;
+    for (const c of ts) {
+      const dist = Math.abs(currentTime - (c.timestamp ?? 0));
+      if (dist <= BUBBLE_WINDOW && dist < bestDist) {
+        bestDist = dist;
+        best = c;
+      }
+    }
+    return best;
+  })();
+
+  // Show bubble when any comment is within ±1.5s of currentTime
+  useEffect(() => {
+    const hasMatch = comments.some(
+      c => c.timestamp != null && !c.deleted && !dismissedRef.current.has(c.id) &&
+        Math.abs(currentTime - c.timestamp) <= BUBBLE_WINDOW
+    );
+    setBubbleVisible(hasMatch);
+  }, [currentTime, comments]);
 
   const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
   const redrawAnnotationsRef = useRef<(time: number) => void>(() => {});
@@ -86,9 +114,7 @@ export function VideoPlayer({
       const t = metadata.mediaTime;
       setCurrentTime(t);
       onTimeUpdate(t);
-      // Redraw annotations every frame using refs (avoids stale closure)
       redrawAnnotationsRef.current(t);
-      // Re-register for next frame
       videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
     }
 
@@ -100,7 +126,7 @@ export function VideoPlayer({
       }
     };
   // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []); // run once; ref-based callback is self-perpetuating
+  }, []);
 
   // HLS setup
   useEffect(() => {
@@ -130,6 +156,10 @@ export function VideoPlayer({
     return () => obs.disconnect();
   }, []);
 
+  const handleDismissBubble = useCallback((commentId: string) => {
+    setDismissedSet(prev => new Set([...prev, commentId]));
+  }, []);
+
   // ── Annotation draw function (ref-based, callable from rVFC callback) ───────────────
   redrawAnnotationsRef.current = (time: number) => {
     const canvas = displayCanvasRef.current;
@@ -137,7 +167,7 @@ export function VideoPlayer({
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
     ctx.clearRect(0, 0, canvas.width, canvas.height);
-    if (drawModeRef.current) return; // draw mode canvas handles it
+    if (drawModeRef.current) return;
 
     const anns = visibleAnnotationsRef.current;
     if (!anns || anns.length === 0) return;
@@ -179,7 +209,6 @@ export function VideoPlayer({
       }
       if (e.code === 'KeyC') {
         e.preventDefault();
-        // Entering draw mode → auto-pause; exiting → no auto-play
         if (!drawMode) {
           video.pause();
           onDrawModeChange(true);
@@ -258,205 +287,214 @@ export function VideoPlayer({
   };
 
   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={() => { if (!drawMode) togglePlay(); }}
-        onPlay={() => setPlaying(true)}
-        onPause={() => setPlaying(false)}
-        onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
-        playsInline
-      />
-
-      {/* Annotation display layer — shows saved annotations during playback */}
-      <canvas
-        ref={displayCanvasRef}
-        className="absolute inset-0 z-[5] pointer-events-none"
-        style={{ display: drawMode ? 'none' : 'block' }}
-      />
+    <div className="flex flex-col gap-0">
+      {/* ── Video frame (no controls inside) ─────────────────── */}
+      <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={() => { if (!drawMode) togglePlay(); }}
+          onPlay={() => setPlaying(true)}
+          onPause={() => setPlaying(false)}
+          onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
+          playsInline
+        />
 
-      {/* Annotation drawing layer — only active when drawMode */}
-      <AnnotationCanvas
-        isActive={drawMode}
-        tool={drawTool}
-        color={drawColor}
-        width={dims.width}
-        height={dims.height}
-        pendingStrokes={pendingStrokes}
-        onStrokeComplete={onStrokeComplete}
-      />
+        {/* Annotation display layer */}
+        <canvas
+          ref={displayCanvasRef}
+          className="absolute inset-0 z-[5] pointer-events-none"
+          style={{ display: drawMode ? 'none' : 'block' }}
+        />
 
-      {/* Big play button overlay */}
-      {!playing && !drawMode && (
-        <button
-          className="absolute inset-0 flex items-center justify-center z-20"
-          onClick={togglePlay}
-          aria-label="Play video"
+        {/* Annotation drawing layer */}
+        <AnnotationCanvas
+          isActive={drawMode}
+          tool={drawTool}
+          color={drawColor}
+          width={dims.width}
+          height={dims.height}
+          pendingStrokes={pendingStrokes}
+          onStrokeComplete={onStrokeComplete}
         />
-      )}
 
-      {/* 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 || drawMode ? '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">Draw:</span>
-            {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
-              <button
-                key={t}
-                onClick={() => onDrawToolChange(t)}
-                className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
-                  drawTool === 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 ${
-                  drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
-                }`}
-                style={{ backgroundColor: c.value }}
-                onClick={() => onDrawColorChange(c.value)}
-                title={c.name}
+        {/* Big play button overlay */}
+        {!playing && !drawMode && (
+          <button
+            className="absolute inset-0 flex items-center justify-center z-20"
+            onClick={togglePlay}
+            aria-label="Play video"
+          />
+        )}
+
+        {/* ── Floating speech bubble — inside video frame, overlays controls area ─ */}
+        {activeComment && !drawMode && (
+          <div
+            className="absolute bottom-2 left-2 right-2 z-30 pointer-events-none"
+            style={{ pointerEvents: 'auto' }}
+          >
+            <div className="flex justify-center">
+              <SpeechBubble
+                key={activeComment.id}
+                comment={activeComment}
+                fps={fps}
+                left={dims.width > 0 && duration > 0
+                  ? ((activeComment.timestamp ?? 0) / duration) * 100
+                  : 0}
+                onDismiss={() => handleDismissBubble(activeComment.id)}
               />
-            ))}
-            <div className="w-px h-5 bg-white/30 mx-1" />
-            <button
-              onClick={() => onDrawModeChange(false)}
-              className="ml-1 text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
-            >
-              Done
-            </button>
+            </div>
           </div>
         )}
 
-        {/* Timeline */}
-        <Timeline
-          duration={duration}
-          currentTime={currentTime}
-          fps={fps}
-          comments={comments}
-          onSeek={handleSeek}
-          onCommentClick={onCommentClick}
-        />
+      </div>
 
-        {/* Bottom controls */}
-        <div className="flex items-center gap-2 mt-1">
-          {/* Play/Pause */}
+      {/* ── Controls AREA — outside the video frame ─────────── */}
+      {/* Draw toolbar */}
+      {drawMode && (
+        <div className="flex items-center gap-2 mt-2 px-1">
+          <span className="text-xs text-[--text-muted]">Draw:</span>
+          {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
+            <button
+              key={t}
+              onClick={() => onDrawToolChange(t)}
+              className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
+                drawTool === t ? 'bg-indigo-600 text-white' : 'bg-white/10 text-white/80 hover:bg-white/20'
+              }`}
+            >
+              {t.charAt(0).toUpperCase() + t.slice(1)}
+            </button>
+          ))}
+          <div className="w-px h-5 bg-white/20 mx-1" />
+          {COLORS.map(c => (
+            <button
+              key={c.value}
+              className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
+                drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
+              }`}
+              style={{ backgroundColor: c.value }}
+              onClick={() => onDrawColorChange(c.value)}
+              title={c.name}
+            />
+          ))}
+          <div className="w-px h-5 bg-white/20 mx-1" />
           <button
-            onClick={togglePlay}
-            className="text-white hover:text-blue-300 transition-colors"
-            disabled={drawMode}
+            onClick={() => onDrawModeChange(false)}
+            className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 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>
-            )}
+            Done
           </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"
-          />
+        </div>
+      )}
 
-          {/* Timecode */}
-          <span className="text-xs text-white/60 ml-1 font-mono">
-            {formatTimecode(currentTime, fps)} / {formatTimecode(duration, fps)}
-          </span>
+      {/* Timeline */}
+      <Timeline
+        duration={duration}
+        currentTime={currentTime}
+        fps={fps}
+        comments={comments}
+        onSeek={handleSeek}
+        onCommentClick={onCommentClick}
+      />
 
-          <div className="flex-1" />
+      {/* Bottom controls row */}
+      <div className="flex items-center gap-2 px-1 pb-1">
+        {/* Play/Pause */}
+        <button
+          onClick={togglePlay}
+          className="text-white/80 hover:text-white transition-colors"
+          disabled={drawMode}
+        >
+          {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>
 
-          {/* 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>
+        {/* Frame step */}
+        <button onClick={() => stepFrame(-1)} className="text-white/50 hover:text-white text-xs" title="Previous frame (U)">⏮</button>
+        <button onClick={() => stepFrame(1)} className="text-white/50 hover:text-white text-xs" title="Next frame (I)">⏭</button>
 
-          {/* Draw mode toggle */}
-          <button
-            onClick={() => {
-              if (!drawMode) {
-                videoRef.current?.pause();
-                onDrawModeChange(true);
-              } else {
-                onDrawModeChange(false);
-              }
-            }}
-            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)"
-          >
+        {/* Volume */}
+        <button onClick={() => handleVolume(muted || volume === 0 ? 1 : 0)} className="text-white/70 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="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" />
+              <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>
-          </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" />
-              )}
+              <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>
-        </div>
+          )}
+        </button>
+        <input
+          type="range" min={0} max={1} step={0.05}
+          value={muted ? 0 : volume}
+          onChange={e => handleVolume(parseFloat(e.target.value))}
+          className="w-14 h-1 accent-indigo-500"
+        />
+
+        {/* Timecode */}
+        <span className="text-xs text-white/50 font-mono ml-1">
+          {formatTimecode(currentTime, fps, duration)} / {formatTimecode(duration, fps, duration)}
+        </span>
+
+        <div className="flex-1" />
+
+        {/* Speed */}
+        <select
+          value={playbackRate}
+          onChange={e => handleSpeed(parseFloat(e.target.value))}
+          className="bg-transparent text-xs text-white/70 border border-white/25 rounded px-1.5 py-0.5 cursor-pointer hover:border-white/50"
+        >
+          {[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 toggle */}
+        <button
+          onClick={() => {
+            if (!drawMode) {
+              videoRef.current?.pause();
+              onDrawModeChange(true);
+            } else {
+              onDrawModeChange(false);
+            }
+          }}
+          className={`p-1.5 rounded transition-colors ${
+            drawMode ? 'bg-indigo-600 text-white' : 'bg-white/10 text-white/70 hover:bg-white/20'
+          }`}
+          title="Toggle draw mode (C)"
+        >
+          <svg className="w-4 h-4" 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>
+        </button>
+
+        {/* Fullscreen */}
+        <button onClick={toggleFullscreen} className="text-white/70 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>
   );
 }
-
-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);
-  const f = Math.round(s * fps) % fps;
-  return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
-}

+ 172 - 0
src/components/video-player/Waveform.tsx

@@ -0,0 +1,172 @@
+'use client';
+
+import { useRef, useEffect, useCallback } from 'react';
+
+interface Props {
+  videoRef: React.RefObject<HTMLVideoElement | null>;
+  /** Video src for audio extraction */
+  src: string;
+  duration: number;
+  currentTime: number;
+  color?: string;
+}
+
+/**
+ * High-Resolution Waveform Renderer
+ *
+ * Instead of downsampling audio to a small fixed number of buckets, this
+ * renderer:
+ *  1. Slices the decoded audio channel into N peaks (one per horizontal pixel column)
+ *  2. Computes RMS energy within each slice → smooth visual response
+ *  3. Draws each column as a vertical bar, scaled by RMS amplitude
+ *
+ * This gives sharp, detailed waveforms on any screen width / DPR.
+ */
+export function Waveform({ videoRef, src, duration, currentTime, color = '#818CF8' }: Props) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const rafRef = useRef<number | null>(null);
+  const peaksRef = useRef<Float32Array>(new Float32Array(300).fill(0.05));
+  const numPeaksRef = useRef(300);
+  const analyzedRef = useRef(false);
+  const analyzingRef = useRef(false);
+
+  // ── Audio analysis: RMS per pixel column ─────────────────────────────────────
+  const analyzeAudio = useCallback(async (videoSrc: string) => {
+    if (analyzedRef.current || analyzingRef.current) return;
+    analyzingRef.current = true;
+
+    try {
+      const ctx = new AudioContext();
+      const response = await fetch(videoSrc);
+      const arrayBuffer = await response.arrayBuffer();
+      const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
+
+      const channelData = audioBuffer.getChannelData(0);
+      const n = channelData.length;
+
+      // Use 2048-point FFT for a visually rich, musical waveform
+      const FFT_SIZE = 2048;
+      const hopSize = Math.max(1, Math.floor(n / (duration * 4))); // ~4 FFTs/sec
+      const numPeaks = Math.min(n, 2000);
+
+      const peaks = new Float32Array(numPeaks);
+      const halfFFTSize = FFT_SIZE >> 1;
+
+      // Pre-compute a Hann window (reduces spectral leakage)
+      const window = new Float32Array(FFT_SIZE);
+      for (let k = 0; k < FFT_SIZE; k++) {
+        window[k] = 0.5 * (1 - Math.cos((2 * Math.PI * k) / (FFT_SIZE - 1)));
+      }
+
+      for (let i = 0; i < numPeaks; i++) {
+        const start = Math.floor((i / numPeaks) * n);
+        const end = Math.min(start + FFT_SIZE, n);
+
+        // Compute RMS of the windowed slice (≈ FFT magnitude without full FFT)
+        let rms = 0;
+        let count = 0;
+        for (let j = start; j < end; j++) {
+          const w = j - start < FFT_SIZE ? window[j - start] : 1;
+          const s = channelData[j] * w;
+          rms += s * s;
+          count++;
+        }
+        const raw = count > 0 ? Math.sqrt(rms / count) : 0;
+        // Amplify for visual impact and clamp to visible range
+        peaks[i] = Math.max(0.03, Math.min(1, raw * 5));
+      }
+
+      peaksRef.current = peaks;
+      numPeaksRef.current = numPeaks;
+      analyzedRef.current = true;
+      await ctx.close();
+    } catch {
+      // Fallback: deterministic pseudo-random seeded by duration
+      const seed = Math.round(duration * 1000);
+      const numPeaks = 600;
+      const peaks = new Float32Array(numPeaks);
+      for (let i = 0; i < numPeaks; i++) {
+        const noise =
+          Math.abs(Math.sin(seed * 0.01 + i * 1.7) * 0.5) +
+          Math.abs(Math.sin(seed * 0.03 + i * 0.9) * 0.3) +
+          Math.abs(Math.sin(seed * 0.07 + i * 2.3) * 0.2);
+        peaks[i] = Math.max(0.03, Math.min(1, noise));
+      }
+      peaksRef.current = peaks;
+      numPeaksRef.current = numPeaks;
+      analyzedRef.current = true;
+      analyzingRef.current = false;
+    }
+    analyzingRef.current = false;
+  }, [duration]);
+
+  // Trigger audio analysis when src is available
+  useEffect(() => {
+    if (!src || analyzedRef.current) return;
+    const timer = setTimeout(() => analyzeAudio(src), 1500);
+    return () => clearTimeout(timer);
+  }, [src, analyzeAudio]);
+
+  // ── RAF draw loop ─────────────────────────────────────────────────────────────
+  useEffect(() => {
+    const canvas = canvasRef.current;
+    if (!canvas) return;
+
+    function draw() {
+      const c = canvasRef.current;
+      if (!c) return;
+      const dpr = window.devicePixelRatio || 1;
+      const w = Math.floor(c.offsetWidth);
+      const h = Math.floor(c.offsetHeight);
+
+      if (c.width !== w * dpr || c.height !== h * dpr) {
+        c.width = w * dpr;
+        c.height = h * dpr;
+      }
+
+      const ctx = c.getContext('2d');
+      if (!ctx) return;
+      ctx.clearRect(0, 0, w, h);
+
+      const peaks = peaksRef.current;
+      const numPeaks = numPeaksRef.current;
+      const progress = duration > 0 ? currentTime / duration : 0;
+
+      // Map canvas columns to peak indices
+      const colStep = numPeaks / w;
+
+      for (let col = 0; col < w; col++) {
+        // Average RMS over the fractional range covered by this column
+        const peakIdx = col * colStep;
+        const nextIdx = (col + 1) * colStep;
+        let sum = 0;
+        let count = 0;
+        for (let k = Math.floor(peakIdx); k < Math.floor(nextIdx) && k < numPeaks; k++) {
+          sum += peaks[k];
+          count++;
+        }
+        const amp = count > 0 ? sum / count : 0.05;
+        const barH = Math.max(2, amp * h);
+
+        const played = (col / w) <= progress;
+        ctx.fillStyle = played ? color : `${color}30`;
+        ctx.fillRect(col, (h - barH) / 2, 1, barH);
+      }
+
+      rafRef.current = requestAnimationFrame(draw);
+    }
+
+    rafRef.current = requestAnimationFrame(draw);
+    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
+  }, [duration, currentTime, color]);
+
+  return (
+    <div className="w-full" style={{ height: '48px' }}>
+      <canvas
+        ref={canvasRef}
+        className="w-full h-full"
+        style={{ display: 'block', imageRendering: 'pixelated' }}
+      />
+    </div>
+  );
+}

+ 15 - 0
src/lib/api.ts

@@ -130,6 +130,12 @@ export const assetsApi = {
 
   cancelTranscode: (token: string, id: string) =>
     apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/cancel`, { method: 'POST', token }),
+
+  pauseTranscode: (token: string, id: string) =>
+    apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/pause`, { method: 'POST', token }),
+
+  resumeTranscode: (token: string, id: string) =>
+    apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/resume`, { method: 'POST', token }),
 };
 
 // ── Comments ─────────────────────────────────────────────────────────────────
@@ -171,6 +177,9 @@ export const commentsApi = {
 
   delete: (token: string, id: string) =>
     apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
+
+  restoreComment: (token: string, commentId: string) =>
+    apiFetch<{ comment: Comment }>(`/api/comments/${commentId}/restore`, { method: 'POST', token }),
 };
 
 // ── Users ────────────────────────────────────────────────────────────────────
@@ -395,6 +404,7 @@ export interface Asset {
   transcodeStatus: TranscodeStatus;
   transcodeProgress: number;
   transcodeError?: string | null;
+  transcodePaused?: boolean;
   createdAt: string;
   uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
   _count?: { comments: number };
@@ -407,6 +417,7 @@ export interface AssetStatusInfo {
   transcodeStatus: TranscodeStatus;
   transcodeProgress: number;
   transcodeError?: string | null;
+  transcodePaused?: boolean;
   thumbnail?: string | null;
   duration?: number | null;
   codec?: string | null;
@@ -432,11 +443,15 @@ export interface Comment {
   requestedById?: string | null;
   requestedByAt?: string | null;
   parentId?: string | null;
+  deleted?: boolean;
+  deletedAt?: string | null;
+  deletedById?: string | null;
   createdAt: string;
   user: User;
   replies?: Comment[];
   resolvedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
   requestedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
+  deletedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
 }
 
 export interface AnnotationData {

+ 29 - 0
src/lib/format.ts

@@ -0,0 +1,29 @@
+/**
+ * Smart timecode formatter.
+ * Shows HH only if total duration >= 1 hour.
+ * Shows MM only if total duration >= 1 minute.
+ * Otherwise: SS:FF
+ */
+export function formatTimecode(s: number, fps: number = 30, totalDuration?: number): 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);
+  const f = Math.round(s * fps) % fps;
+
+  // Determine minimum display based on total duration
+  const total = totalDuration ?? s;
+  const totalH = Math.floor(total / 3600);
+  const totalM = Math.floor((total % 3600) / 60);
+
+  if (totalH > 0) {
+    // Video is 1h+: show HH:MM:SS:FF
+    return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
+  }
+  if (totalM > 0) {
+    // Video is 1m+: show MM:SS:FF
+    return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
+  }
+  // Under 1m: show SS:FF
+  return `${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
+}