Просмотр исходного кода

feat: add public share links, folder system, file/timeline modes, and UI polish

Features:
- Public share links with password, download, and view-limit controls
- Hierarchical folder organization with drag-and-drop asset management
- File mode and timeline mode toggle in project view
- Frame-accurate comment system with speech bubble UI
- Client-side profile picture crop and upload to ~30KB JPEG
- Full marketing landing page at /
- Share button highlights when video already has a share link
- Remove video from folder via hover X on folder tags
- Share page auto-redirects logged-in users to review page
- Login page respects redirect param for seamless comment flow

Also: reprocess failed transcode tasks, compact upload widget, storage quota bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev 1 месяц назад
Родитель
Сommit
ff8b00a701

+ 47 - 0
packages/api/prisma/migrations/20260406_add_folder_and_share_link/migration.sql

@@ -0,0 +1,47 @@
+-- CreateFolderAndShareLink
+-- Add public share link model
+CREATE TABLE "AssetShareLink" (
+    "id" TEXT NOT NULL,
+    "assetId" TEXT NOT NULL,
+    "token" TEXT NOT NULL,
+    "password" TEXT,
+    "allowDownload" BOOLEAN NOT NULL DEFAULT false,
+    "maxViews" INTEGER NOT NULL DEFAULT 20,
+    "viewCount" INTEGER NOT NULL DEFAULT 0,
+    "createdById" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "AssetShareLink_pkey" PRIMARY KEY ("id")
+);
+CREATE UNIQUE INDEX "AssetShareLink_token_key" ON "AssetShareLink"("token");
+CREATE INDEX "AssetShareLink_assetId_idx" ON "AssetShareLink"("assetId");
+CREATE INDEX "AssetShareLink_token_idx" ON "AssetShareLink"("token");
+ALTER TABLE "AssetShareLink" ADD CONSTRAINT "AssetShareLink_assetId_fkey" REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "AssetShareLink" ADD CONSTRAINT "AssetShareLink_createdById_fkey" REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- Add folder models
+CREATE TABLE "Folder" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "projectId" TEXT NOT NULL,
+    "parentId" TEXT,
+    "order" INTEGER NOT NULL DEFAULT 0,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
+);
+CREATE INDEX "Folder_projectId_idx" ON "Folder"("projectId");
+CREATE INDEX "Folder_parentId_idx" ON "Folder"("parentId");
+ALTER TABLE "Folder" ADD CONSTRAINT "Folder_projectId_fkey" REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "Folder" ADD CONSTRAINT "Folder_parentId_fkey" REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE TABLE "FolderAsset" (
+    "id" TEXT NOT NULL,
+    "folderId" TEXT NOT NULL,
+    "assetId" TEXT NOT NULL,
+    "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    CONSTRAINT "FolderAsset_pkey" PRIMARY KEY ("id")
+);
+CREATE UNIQUE INDEX "FolderAsset_folderId_assetId_key" ON "FolderAsset"("folderId", "assetId");
+ALTER TABLE "FolderAsset" ADD CONSTRAINT "FolderAsset_folderId_fkey" REFERENCES "Folder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "FolderAsset" ADD CONSTRAINT "FolderAsset_assetId_fkey" REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 63 - 6
packages/api/prisma/schema.prisma

@@ -23,12 +23,13 @@ model User {
   createdAt     DateTime  @default(now())
   createdAt     DateTime  @default(now())
   updatedAt     DateTime  @updatedAt
   updatedAt     DateTime  @updatedAt
 
 
-  memberships       ProjectMember[] @relation("ProjectMembers")
-  comments          Comment[]
-  projects          Project[]       @relation("ProjectOwner")
-  resolvedComments  Comment[]      @relation("ResolvedBy")
-  requestedComments Comment[]      @relation("RequestedBy")
-  assets            Asset[]
+  memberships        ProjectMember[]    @relation("ProjectMembers")
+  comments           Comment[]
+  projects           Project[]          @relation("ProjectOwner")
+  resolvedComments   Comment[]         @relation("ResolvedBy")
+  requestedComments  Comment[]         @relation("RequestedBy")
+  assets             Asset[]
+  shareLinks         AssetShareLink[]
 }
 }
 
 
 model Project {
 model Project {
@@ -43,6 +44,7 @@ model Project {
   members     ProjectMember[] @relation("ProjectMembers")
   members     ProjectMember[] @relation("ProjectMembers")
   invitations Invitation[]
   invitations Invitation[]
   owner       User     @relation("ProjectOwner", fields: [ownerId], references: [id])
   owner       User     @relation("ProjectOwner", fields: [ownerId], references: [id])
+  folders     Folder[]
 }
 }
 
 
 model SiteSetting {
 model SiteSetting {
@@ -92,6 +94,8 @@ model Asset {
   project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
   project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
   uploader User?    @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
   uploader User?    @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
   comments Comment[]
   comments Comment[]
+  shareLinks AssetShareLink[]
+  folderAssets FolderAsset[]
 }
 }
 
 
 model Comment {
 model Comment {
@@ -187,3 +191,56 @@ enum TranscodeStatus {
   FAILED
   FAILED
   UNSUPPORTED_CODEC
   UNSUPPORTED_CODEC
 }
 }
+
+// ── Public share links ────────────────────────────────────────────────────────
+
+model AssetShareLink {
+  id             String   @id @default(cuid())
+  assetId       String
+  token         String   @unique
+  password      String?  // bcrypt hash, null = no password required
+  allowDownload Boolean  @default(false)
+  maxViews      Int      @default(20)  // 0 or -1 = unlimited
+  viewCount     Int      @default(0)
+  createdById  String
+  createdAt     DateTime @default(now())
+  updatedAt     DateTime @updatedAt
+
+  asset     Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
+  createdBy User  @relation(fields: [createdById], references: [id])
+
+  @@index([assetId])
+  @@index([token])
+}
+
+// ── Folder system ──────────────────────────────────────────────────────────────
+
+model Folder {
+  id        String   @id @default(cuid())
+  name      String
+  projectId String
+  parentId  String?
+  order     Int      @default(0)
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  project  Project        @relation(fields: [projectId], references: [id], onDelete: Cascade)
+  parent   Folder?        @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
+  children Folder[]       @relation("FolderTree")
+  assets   FolderAsset[]
+
+  @@index([projectId])
+  @@index([parentId])
+}
+
+model FolderAsset {
+  id       String   @id @default(cuid())
+  folderId String
+  assetId  String
+  addedAt  DateTime @default(now())
+
+  folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
+  asset  Asset  @relation(fields: [assetId], references: [id], onDelete: Cascade)
+
+  @@unique([folderId, assetId])
+}

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

@@ -11,6 +11,8 @@ import commentRoutes from './routes/comments';
 import userRoutes from './routes/users';
 import userRoutes from './routes/users';
 import invitationRoutes from './routes/invitations';
 import invitationRoutes from './routes/invitations';
 import settingsRoutes from './routes/settings';
 import settingsRoutes from './routes/settings';
+import shareRoutes from './routes/share';
+import folderRoutes from './routes/folders';
 
 
 const app = express();
 const app = express();
 const PORT = process.env.API_PORT || 3001;
 const PORT = process.env.API_PORT || 3001;
@@ -46,6 +48,8 @@ app.use('/api/comments', commentRoutes);
 app.use('/api/users', userRoutes);
 app.use('/api/users', userRoutes);
 app.use('/api/invitations', invitationRoutes);
 app.use('/api/invitations', invitationRoutes);
 app.use('/api/settings', settingsRoutes);
 app.use('/api/settings', settingsRoutes);
+app.use('/api/share', shareRoutes);
+app.use('/api/folders', folderRoutes);
 
 
 // ── 404 handler ─────────────────────────────────────────────────────────────
 // ── 404 handler ─────────────────────────────────────────────────────────────
 app.use((_req, res) => {
 app.use((_req, res) => {

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

@@ -105,6 +105,7 @@ router.get('/', async (req: Request, res: Response) => {
       include: {
       include: {
         uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
         uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
         _count: { select: { comments: true } },
         _count: { select: { comments: true } },
+        shareLinks: { select: { id: true }, take: 1 },
       },
       },
       orderBy: { createdAt: 'desc' },
       orderBy: { createdAt: 'desc' },
     });
     });

+ 232 - 0
packages/api/src/routes/folders.ts

@@ -0,0 +1,232 @@
+import { Router } from 'express';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const router = Router();
+
+const str = (v: string | string[] | undefined): string =>
+  Array.isArray(v) ? (v[0] ?? '') : (v ?? '');
+
+async function getUserRole(
+  userId: string,
+  globalRole: string,
+  projectId: string
+): Promise<string | null> {
+  if (globalRole === 'ADMIN') return 'ADMIN';
+  const project = await prisma.project.findUnique({
+    where: { id: projectId },
+    select: { ownerId: true, members: { where: { userId }, select: { role: true } } },
+  });
+  if (!project) return null;
+  if (project.ownerId === userId) return 'OWNER';
+  return project.members[0]?.role ?? null;
+}
+
+function canManage(role: string | null): boolean {
+  return role === 'ADMIN' || role === 'EDITOR' || role === 'OWNER';
+}
+
+// ── CORS preflight ────────────────────────────────────────────────────────────
+router.options('/', (_req, res) => { res.set('Access-Control-Allow-Origin', '*'); res.sendStatus(200); });
+router.options('/:id', (_req, res) => { res.set('Access-Control-Allow-Origin', '*'); res.sendStatus(200); });
+
+// ── GET /api/folders/project/:projectId ───────────────────────────────────────
+router.get('/project/:projectId', authMiddleware, async (req, res) => {
+  try {
+    const projectId = str(req.params.projectId);
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, projectId);
+    if (!role) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    const folders = await prisma.folder.findMany({
+      where: { projectId },
+      orderBy: [{ order: 'asc' }, { name: 'asc' }],
+      include: { _count: { select: { assets: true } } },
+    });
+
+    interface FolderNode { id: string; name: string; parentId: string | null; order: number; assetCount: number; assetIds: string[]; children: FolderNode[]; }
+    interface FolderWithAssets extends FolderNode { _assetIds?: string[] }
+
+    const foldersWithAssets: FolderNode[] = await Promise.all(
+      folders.map(async (f) => {
+        const fa = await prisma.folderAsset.findMany({ where: { folderId: f.id }, select: { assetId: true } });
+        return { id: f.id, name: f.name, parentId: f.parentId, order: f.order, assetCount: f._count.assets, assetIds: fa.map(a => a.assetId), children: [] };
+      })
+    );
+
+    const map = new Map<string, FolderNode>();
+    for (const f of foldersWithAssets) map.set(f.id, f);
+    const roots: FolderNode[] = [];
+    for (const f of foldersWithAssets) {
+      if (f.parentId && map.has(f.parentId)) {
+        map.get(f.parentId)!.children.push(map.get(f.id)!);
+      } else {
+        roots.push(map.get(f.id)!);
+      }
+    }
+
+    res.json({ folders: roots, allFolders: foldersWithAssets });
+  } catch (err) {
+    console.error('[folders] GET /project/:projectId error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── POST /api/folders ─────────────────────────────────────────────────────────
+router.post('/', authMiddleware, async (req, res) => {
+  try {
+    const { name, projectId, parentId } = req.body as { name?: string; projectId?: string; parentId?: string };
+    if (!name?.trim() || !projectId) { res.status(400).json({ error: 'name and projectId are required' }); return; }
+
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, projectId);
+    if (!canManage(role)) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    if (parentId) {
+      const parent = await prisma.folder.findUnique({ where: { id: parentId } });
+      if (!parent || parent.projectId !== projectId) { res.status(400).json({ error: 'Invalid parent folder' }); return; }
+    }
+
+    const siblings = await prisma.folder.findMany({
+      where: { projectId, parentId: parentId ?? null },
+      select: { order: true }, orderBy: { order: 'desc' }, take: 1,
+    });
+    const nextOrder = (siblings[0]?.order ?? -1) + 1;
+
+    const created = await prisma.folder.create({
+      data: { name: name.trim(), projectId, parentId: parentId ?? null, order: nextOrder },
+      include: { _count: { select: { assets: true } } },
+    });
+
+    res.status(201).json({
+      folder: { id: created.id, name: created.name, parentId: created.parentId, order: created.order, assetCount: created._count.assets, assetIds: [], children: [] },
+    });
+  } catch (err) {
+    console.error('[folders] POST / error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── PUT /api/folders/:id ──────────────────────────────────────────────────────
+router.put('/:id', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const { name } = req.body as { name?: string };
+    const existing = await prisma.folder.findUnique({ where: { id } });
+    if (!existing) { res.status(404).json({ error: 'Folder not found' }); return; }
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, existing.projectId);
+    if (!canManage(role)) { res.status(403).json({ error: 'Forbidden' }); return; }
+    const folder = await prisma.folder.update({
+      where: { id },
+      data: name?.trim() ? { name: name.trim() } : {},
+      select: { id: true, name: true, projectId: true, parentId: true, order: true },
+    });
+    res.json({ folder });
+  } catch (err) {
+    console.error('[folders] PUT /:id error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── DELETE /api/folders/:id ────────────────────────────────────────────────────
+router.delete('/:id', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const existing = await prisma.folder.findUnique({ where: { id } });
+    if (!existing) { res.status(404).json({ error: 'Folder not found' }); return; }
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, existing.projectId);
+    if (!canManage(role)) { res.status(403).json({ error: 'Forbidden' }); return; }
+    await prisma.folder.delete({ where: { id } });
+    res.json({ success: true });
+  } catch (err) {
+    console.error('[folders] DELETE /:id error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── POST /api/folders/:id/assets ───────────────────────────────────────────────
+router.post('/:id/assets', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const { assetIds } = req.body as { assetIds?: string[] };
+    if (!Array.isArray(assetIds) || assetIds.length === 0) { res.status(400).json({ error: 'assetIds array is required' }); return; }
+
+    const folder = await prisma.folder.findUnique({ where: { id } });
+    if (!folder) { res.status(404).json({ error: 'Folder not found' }); return; }
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, folder.projectId);
+    if (!canManage(role)) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    const validAssets = await prisma.asset.findMany({
+      where: { id: { in: assetIds }, projectId: folder.projectId },
+      select: { id: true },
+    });
+    const validIds: string[] = validAssets.map((a: { id: string }) => a.id);
+
+    await Promise.all(
+      validIds.map((assetId: string) =>
+        prisma.folderAsset.upsert({
+          where: { folderId_assetId: { folderId: id, assetId } },
+          create: { folderId: id, assetId },
+          update: {},
+        })
+      )
+    );
+
+    const count = await prisma.folderAsset.count({ where: { folderId: id } });
+    res.json({ success: true, assetCount: count });
+  } catch (err) {
+    console.error('[folders] POST /:id/assets error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── PUT /api/folders/:id/assets ───────────────────────────────────────────────
+router.put('/:id/assets', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const { assetIds } = req.body as { assetIds?: string[] };
+
+    const folder = await prisma.folder.findUnique({ where: { id } });
+    if (!folder) { res.status(404).json({ error: 'Folder not found' }); return; }
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, folder.projectId);
+    if (!canManage(role)) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    const validAssets = await prisma.asset.findMany({
+      where: { id: { in: assetIds ?? [] }, projectId: folder.projectId },
+      select: { id: true },
+    });
+    const validIds: string[] = validAssets.map((a: { id: string }) => a.id);
+
+    await prisma.folderAsset.deleteMany({ where: { folderId: id } });
+    if (validIds.length > 0) {
+      await prisma.folderAsset.createMany({
+        data: validIds.map((assetId: string) => ({ folderId: id, assetId })),
+        skipDuplicates: true,
+      });
+    }
+
+    res.json({ success: true, assetCount: validIds.length });
+  } catch (err) {
+    console.error('[folders] PUT /:id/assets error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── DELETE /api/folders/:id/assets/:assetId ───────────────────────────────────
+router.delete('/:id/assets/:assetId', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const assetId = str(req.params.assetId);
+
+    const folder = await prisma.folder.findUnique({ where: { id } });
+    if (!folder) { res.status(404).json({ error: 'Folder not found' }); return; }
+    const role = await getUserRole(req.user!.userId, req.user!.globalRole, folder.projectId);
+    if (!canManage(role)) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    await prisma.folderAsset.deleteMany({ where: { folderId: id, assetId } });
+    res.json({ success: true });
+  } catch (err) {
+    console.error('[folders] DELETE /:id/assets/:assetId error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

+ 382 - 0
packages/api/src/routes/share.ts

@@ -0,0 +1,382 @@
+import { Router } from 'express';
+import { randomBytes } from 'crypto';
+import bcrypt from 'bcryptjs';
+import { prisma } from '../lib/prisma';
+import { authMiddleware } from '../lib/auth';
+
+const str = (v: unknown): string => {
+  if (Array.isArray(v)) return String(v[0] ?? '');
+  if (typeof v === 'string') return v;
+  return '';
+};
+
+const router = Router();
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
+
+/** Returns true if user can manage (create/edit/delete) share links for this asset. */
+async function canManageShareLinks(userId: string, globalRole: string, assetId: string): Promise<boolean> {
+  if (globalRole === 'ADMIN') return true;
+  const asset = await prisma.asset.findUnique({
+    where: { id: assetId },
+    include: {
+      project: {
+        include: { members: { where: { userId } } },
+      },
+    },
+  });
+  if (!asset) return false;
+  if (asset.project.ownerId === userId) return true;
+  const membership = asset.project.members[0];
+  return membership?.role === 'ADMIN' || membership?.role === 'EDITOR';
+}
+
+// ── CORS preflight ────────────────────────────────────────────────────────────
+router.options('/', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); res.sendStatus(200); });
+
+// ── GET /api/share?assetId= ────────────────────────────────────────────────────
+// Get existing share link for an asset (for the owner to view/edit)
+router.get('/', authMiddleware, async (req, res) => {
+  try {
+    const assetId = str(req.query.assetId);
+    if (!assetId) { res.status(400).json({ error: 'assetId is required' }); return; }
+
+    const canManage = await canManageShareLinks(req.user!.userId, req.user!.globalRole, assetId);
+    if (!canManage) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    const shareLink = await prisma.assetShareLink.findFirst({
+      where: { assetId },
+      orderBy: { createdAt: 'desc' },
+      include: { asset: { select: { id: true, title: true } } },
+    });
+
+    if (!shareLink) { res.json({ shareLink: null }); return; }
+
+    res.json({
+      shareLink: {
+        id: shareLink.id,
+        assetId: shareLink.assetId,
+        assetTitle: (shareLink as any).asset?.title ?? '',
+        token: shareLink.token,
+        shareUrl: `${FRONTEND_URL}/share/${shareLink.token}`,
+        hasPassword: !!shareLink.password,
+        allowDownload: shareLink.allowDownload,
+        maxViews: shareLink.maxViews,
+        viewCount: shareLink.viewCount,
+        createdAt: shareLink.createdAt,
+      },
+    });
+  } catch (err) {
+    console.error('[share] GET / error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── POST /api/share ──────────────────────────────────────────────────────────
+// Create a new share link for an asset
+router.post('/', authMiddleware, async (req, res) => {
+  try {
+    const { assetId, password, allowDownload, maxViews } = req.body as {
+      assetId: string;
+      password?: string;
+      allowDownload?: boolean;
+      maxViews?: number;
+    };
+
+    if (!assetId) {
+      res.status(400).json({ error: 'assetId is required' });
+      return;
+    }
+
+    const canManage = await canManageShareLinks(req.user!.userId, req.user!.globalRole, assetId);
+    if (!canManage) {
+      res.status(403).json({ error: 'Forbidden' });
+      return;
+    }
+
+    // Generate unique token
+    let token = randomBytes(16).toString('hex');
+    let attempts = 0;
+    while (attempts < 5) {
+      const existing = await prisma.assetShareLink.findUnique({ where: { token } });
+      if (!existing) break;
+      token = randomBytes(16).toString('hex');
+      attempts++;
+    }
+
+    const hashedPassword = password ? await bcrypt.hash(password, 10) : null;
+    const resolvedMaxViews = maxViews === undefined ? 20 : (maxViews <= 0 ? -1 : maxViews);
+
+    const shareLink = await prisma.assetShareLink.create({
+      data: {
+        assetId,
+        token,
+        password: hashedPassword,
+        allowDownload: !!allowDownload,
+        maxViews: resolvedMaxViews,
+        createdById: req.user!.userId,
+      },
+      select: {
+        id: true,
+        token: true,
+        password: true, // will be null or hash
+        allowDownload: true,
+        maxViews: true,
+        viewCount: true,
+        createdById: true,
+        createdAt: true,
+      },
+    });
+
+    const shareUrl = `${FRONTEND_URL}/share/${token}`;
+
+    res.status(201).json({
+      shareLink: {
+        ...shareLink,
+        shareUrl,
+        hasPassword: !!hashedPassword,
+      },
+    });
+  } catch (err) {
+    console.error('[share] POST / error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── CORS preflight for :token ─────────────────────────────────────────────────
+router.options('/:token', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); res.sendStatus(200); });
+
+// ── GET /api/share/:token ────────────────────────────────────────────────────
+// Public — verify a share link, check limits, increment view count
+router.get('/:token', async (req, res) => {
+  try {
+    const token = str(req.params.token);
+
+    const shareLink = await prisma.assetShareLink.findUnique({
+      where: { token },
+      include: {
+        asset: {
+          select: {
+            id: true,
+            title: true,
+            thumbnail: true,
+            mimeType: true,
+            duration: true,
+            fps: true,
+            hlsPath: true,
+            filePath: true,
+            transcodeStatus: true,
+          },
+        },
+      },
+    });
+
+    if (!shareLink) {
+      res.status(404).json({ error: 'Share link not found' });
+      return;
+    }
+
+    // Check view limit (0 or -1 = unlimited)
+    const expired =
+      shareLink.maxViews > 0 && shareLink.viewCount >= shareLink.maxViews;
+
+    if (expired) {
+      res.status(410).json({
+        error: 'View limit reached',
+        expired: true,
+        viewCount: shareLink.viewCount,
+        maxViews: shareLink.maxViews,
+      });
+      return;
+    }
+
+    // Increment view count
+    await prisma.assetShareLink.update({
+      where: { id: shareLink.id },
+      data: { viewCount: { increment: 1 } },
+    });
+
+    const hasPassword = !!shareLink.password;
+    const videoReady = shareLink.asset.transcodeStatus === 'COMPLETED';
+
+    // Return metadata only — actual stream URL only after password check
+    res.json({
+      id: shareLink.id,
+      token: shareLink.token,
+      hasPassword,
+      allowDownload: shareLink.allowDownload,
+      maxViews: shareLink.maxViews,
+      viewCount: shareLink.viewCount + 1, // +1 because we just incremented
+      asset: {
+        id: shareLink.asset.id,
+        title: shareLink.asset.title,
+        thumbnail: shareLink.asset.thumbnail,
+        mimeType: shareLink.asset.mimeType,
+        duration: shareLink.asset.duration,
+        fps: shareLink.asset.fps,
+        videoReady,
+      },
+    });
+  } catch (err) {
+    console.error('[share] GET /:token error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── POST /api/share/:token/access ─────────────────────────────────────────────
+// Submit password to get stream URL
+router.post('/:token/access', async (req, res) => {
+  try {
+    const token = str(req.params.token);
+    const { password } = req.body as { password?: string };
+
+    const shareLink = await prisma.assetShareLink.findUnique({
+      where: { token },
+      include: {
+        asset: {
+          select: {
+            id: true,
+            hlsPath: true,
+            filePath: true,
+            mimeType: true,
+            transcodeStatus: true,
+          },
+        },
+      },
+    });
+
+    if (!shareLink) {
+      res.status(404).json({ error: 'Share link not found' });
+      return;
+    }
+
+    if (shareLink.maxViews > 0 && shareLink.viewCount >= shareLink.maxViews) {
+      res.status(410).json({ error: 'View limit reached' });
+      return;
+    }
+
+    if (shareLink.password) {
+      if (!password) {
+        res.status(401).json({ error: 'Password required' });
+        return;
+      }
+      const valid = await bcrypt.compare(password, shareLink.password);
+      if (!valid) {
+        res.status(401).json({ error: 'Invalid password' });
+        return;
+      }
+    }
+
+    // Increment view count after successful access
+    await prisma.assetShareLink.update({
+      where: { id: shareLink.id },
+      data: { viewCount: { increment: 1 } },
+    });
+
+    const videoReady = shareLink.asset.transcodeStatus === 'COMPLETED';
+
+    res.json({
+      streamUrl: videoReady && shareLink.asset.hlsPath
+        ? `/uploads${shareLink.asset.hlsPath}`
+        : `/uploads/${shareLink.asset.filePath}`,
+      mimeType: shareLink.asset.mimeType,
+      allowDownload: shareLink.allowDownload,
+    });
+  } catch (err) {
+    console.error('[share] POST /:token/access error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── CORS preflight for /:id ───────────────────────────────────────────────────
+router.options('/:id', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); res.sendStatus(200); });
+
+// ── DELETE /api/share/:id ─────────────────────────────────────────────────────
+router.delete('/:id', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const shareLink = await prisma.assetShareLink.findUnique({ where: { id } });
+    if (!shareLink) { res.status(404).json({ error: 'Share link not found' }); return; }
+    const canDelete = req.user!.globalRole === 'ADMIN' || shareLink.createdById === req.user!.userId;
+    if (!canDelete) { res.status(403).json({ error: 'Forbidden' }); return; }
+    await prisma.assetShareLink.delete({ where: { id } });
+    res.json({ success: true });
+  } catch (err) {
+    console.error('[share] DELETE /:id error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── GET /api/share/:id ──────────────────────────────────────────────────────
+router.get('/:id', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const shareLink = await prisma.assetShareLink.findUnique({
+      where: { id },
+      include: { asset: { select: { id: true, title: true } } },
+    });
+    if (!shareLink) { res.status(404).json({ error: 'Not found' }); return; }
+    const canView = req.user!.globalRole === 'ADMIN' || shareLink.createdById === req.user!.userId;
+    if (!canView) { res.status(403).json({ error: 'Forbidden' }); return; }
+    const shareUrl = `${FRONTEND_URL}/share/${shareLink.token}`;
+    res.json({
+      shareLink: {
+        id: shareLink.id,
+        assetId: shareLink.assetId,
+        assetTitle: (shareLink as any).asset?.title ?? '',
+        token: shareLink.token,
+        shareUrl,
+        hasPassword: !!shareLink.password,
+        allowDownload: shareLink.allowDownload,
+        maxViews: shareLink.maxViews,
+        viewCount: shareLink.viewCount,
+        createdAt: shareLink.createdAt,
+      },
+    });
+  } catch (err) {
+    console.error('[share] GET /:id error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── PUT /api/share/:id ─────────────────────────────────────────────────────────
+router.put('/:id', authMiddleware, async (req, res) => {
+  try {
+    const id = str(req.params.id);
+    const { password, allowDownload, maxViews } = req.body as {
+      password?: string;
+      allowDownload?: boolean;
+      maxViews?: number;
+    };
+    const existing = await prisma.assetShareLink.findUnique({ where: { id } });
+    if (!existing) { res.status(404).json({ error: 'Not found' }); return; }
+    const canManage = req.user!.globalRole === 'ADMIN' || existing.createdById === req.user!.userId;
+    if (!canManage) { res.status(403).json({ error: 'Forbidden' }); return; }
+
+    const updateData: Record<string, unknown> = {};
+    if (allowDownload !== undefined) updateData.allowDownload = allowDownload;
+    if (maxViews !== undefined) updateData.maxViews = maxViews <= 0 ? -1 : maxViews;
+    if (password !== undefined) updateData.password = password ? await bcrypt.hash(password, 10) : null;
+
+    const updated = await prisma.assetShareLink.update({ where: { id }, data: updateData });
+    const shareUrl = `${FRONTEND_URL}/share/${updated.token}`;
+    res.json({
+      shareLink: {
+        id: updated.id,
+        token: updated.token,
+        shareUrl,
+        hasPassword: !!updated.password,
+        allowDownload: updated.allowDownload,
+        maxViews: updated.maxViews,
+        viewCount: updated.viewCount,
+      },
+    });
+  } catch (err) {
+    console.error('[share] PUT /:id error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+export default router;

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

@@ -1,5 +1,8 @@
 import { Router, Request, Response } from 'express';
 import { Router, Request, Response } from 'express';
 import bcrypt from 'bcryptjs';
 import bcrypt from 'bcryptjs';
+import multer from 'multer';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
 import { prisma } from '../lib/prisma';
 import { prisma } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 import { authMiddleware } from '../lib/auth';
 
 
@@ -219,6 +222,52 @@ router.put('/me', async (req: Request, res: Response) => {
   }
   }
 });
 });
 
 
+// ── Avatar upload ────────────────────────────────────────────────────────────
+const avatarStorage = multer.diskStorage({
+  destination: (_req, _file, cb) => {
+    cb(null, path.resolve(__dirname, '../../uploads/avatars'));
+  },
+  filename: (_req, file, cb) => {
+    const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
+    cb(null, `${uuidv4()}${ext}`);
+  },
+});
+
+const avatarUpload = multer({
+  storage: avatarStorage,
+  limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
+  fileFilter: (_req, file, cb) => {
+    if (['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.mimetype)) {
+      cb(null, true);
+    } else {
+      cb(new Error('Only JPEG, PNG, WebP, or GIF images are allowed'));
+    }
+  },
+});
+
+// POST /api/users/me/avatar — upload avatar image
+router.post('/me/avatar', avatarUpload.single('avatar'), async (req: Request, res: Response) => {
+  try {
+    if (!req.file) {
+      res.status(400).json({ error: 'No file uploaded' });
+      return;
+    }
+
+    const avatarUrl = `/uploads/avatars/${req.file!.filename}`;
+
+    const updated = await prisma.user.update({
+      where: { id: req.user!.userId },
+      data: { avatarUrl },
+      select: { id: true, email: true, name: true, globalRole: true, avatarUrl: true, active: true },
+    });
+
+    res.json({ user: updated, avatarUrl });
+  } catch (err) {
+    console.error('Avatar upload error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
 // PUT /api/users/:id/role — change user globalRole (admin only)
 // PUT /api/users/:id/role — change user globalRole (admin only)
 router.put('/:id/role', async (req: Request, res: Response) => {
 router.put('/:id/role', async (req: Request, res: Response) => {
   try {
   try {

+ 6 - 1
src/app/(auth)/login/page.tsx

@@ -10,6 +10,7 @@ function LoginForm() {
   const router = useRouter();
   const router = useRouter();
   const searchParams = useSearchParams();
   const searchParams = useSearchParams();
   const inviteToken = searchParams.get('invite_token');
   const inviteToken = searchParams.get('invite_token');
+  const redirectTo = searchParams.get('redirect');
   const { login, acceptedProjects, clearAcceptedProjects, user } = useAuth();
   const { login, acceptedProjects, clearAcceptedProjects, user } = useAuth();
   const [email, setEmail] = useState('');
   const [email, setEmail] = useState('');
   const [password, setPassword] = useState('');
   const [password, setPassword] = useState('');
@@ -39,11 +40,13 @@ function LoginForm() {
     if (user) {
     if (user) {
       if (inviteToken) {
       if (inviteToken) {
         router.push(`/invite/${inviteToken}`);
         router.push(`/invite/${inviteToken}`);
+      } else if (redirectTo) {
+        router.push(redirectTo);
       } else {
       } else {
         router.push('/projects');
         router.push('/projects');
       }
       }
     }
     }
-  }, [user, inviteToken, router]);
+  }, [user, inviteToken, redirectTo, router]);
 
 
   const handleSubmit = async (e: React.FormEvent) => {
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
     e.preventDefault();
@@ -53,6 +56,8 @@ function LoginForm() {
       await login(email, password);
       await login(email, password);
       if (inviteToken) {
       if (inviteToken) {
         router.push(`/invite/${inviteToken}`);
         router.push(`/invite/${inviteToken}`);
+      } else if (redirectTo) {
+        router.push(redirectTo);
       } else {
       } else {
         router.push('/projects');
         router.push('/projects');
       }
       }

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

@@ -151,7 +151,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
         )}
         )}
         <div className="flex items-center gap-2.5 p-2 rounded-lg"
         <div className="flex items-center gap-2.5 p-2 rounded-lg"
              style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
              style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
-          <Avatar name={user.name} size="md" />
+          <Avatar name={user.name} src={user.avatarUrl} size="md" />
           <div className="flex-1 min-w-0 hidden md:block">
           <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-sm font-medium truncate" style={{ color: 'var(--text)' }}>{user.name}</p>
             <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>
             <p className="text-xs capitalize truncate" style={{ color: 'var(--text-muted)' }}>

+ 483 - 221
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -3,17 +3,18 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { useAuth } from '@/lib/auth-context';
-import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
+import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
 import { AssetCard } from '@/components/ui/AssetCard';
 import { AssetCard } from '@/components/ui/AssetCard';
-import { useDropzone } from 'react-dropzone';
+import { FolderTree } from '@/components/folders/FolderTree';
+import { ShareModal } from '@/components/share/ShareModal';
 import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
 import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
+import { useDropzone } from 'react-dropzone';
 
 
 async function safeCopy(text: string): Promise<void> {
 async function safeCopy(text: string): Promise<void> {
   if (typeof window === 'undefined') return;
   if (typeof window === 'undefined') return;
   if (navigator.clipboard?.writeText) {
   if (navigator.clipboard?.writeText) {
     try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
     try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
   } else {
   } else {
-    // Fallback: create a temp input so we can use execCommand on insecure contexts
     const el = document.createElement('textarea');
     const el = document.createElement('textarea');
     el.value = text;
     el.value = text;
     el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
     el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
@@ -62,6 +63,75 @@ function groupByDay(assets: Asset[]): [string, Asset[]][] {
   return Object.entries(groups);
   return Object.entries(groups);
 }
 }
 
 
+/** Collect asset IDs DIRECTLY in a folder (not from subfolders) */
+function collectAssetIds(folders: FolderNode[], targetId: string | null): Set<string> {
+  const ids = new Set<string>();
+  if (targetId === null) return ids; // "All Videos" — no filter
+
+  function findTarget(f: FolderNode): FolderNode | null {
+    if (f.id === targetId) return f;
+    for (const c of f.children) { const r = findTarget(c); if (r) return r; }
+    return null;
+  }
+
+  for (const f of folders) {
+    const target = findTarget(f);
+    if (target) { for (const id of target.assetIds) ids.add(id); break; }
+  }
+  return ids;
+}
+
+/** Get direct subfolders of a folder */
+function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] {
+  if (targetId === null) return folders; // root: show top-level folders
+
+  function findTarget(f: FolderNode): FolderNode | null {
+    if (f.id === targetId) return f;
+    for (const c of f.children) { const r = findTarget(c); if (r) return r; }
+    return null;
+  }
+
+  for (const f of folders) {
+    const target = findTarget(f);
+    if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
+  }
+  return [];
+}
+
+/** Build a map of assetId -> folder names it belongs to */
+function buildAssetFolders(allFolders: FolderNode[]): Map<string, string[]> {
+  const map = new Map<string, string[]>();
+  function addFolders(f: FolderNode, trail: string[]): void {
+    for (const id of f.assetIds) {
+      if (!map.has(id)) map.set(id, []);
+      map.get(id)!.push(f.name);
+    }
+    for (const child of f.children) addFolders(child, [...trail, f.name]);
+  }
+  for (const f of allFolders) addFolders(f, []);
+  return map;
+}
+
+/** Get the folder names an asset belongs to */
+function getAssetFolderNames(assetFolders: Map<string, string[]>, assetId: string): string[] {
+  return assetFolders.get(assetId) ?? [];
+}
+
+/** Returns a breadcrumb path of folder names for the selected folder */
+function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] {
+  if (targetId === null) return [];
+  const path: string[] = [];
+  function search(f: FolderNode, trail: string[]): boolean {
+    if (f.id === targetId) { path.push(...trail, f.name); return true; }
+    for (const child of f.children) {
+      if (search(child, [...trail, f.name])) return true;
+    }
+    return false;
+  }
+  for (const f of folders) if (search(f, [])) break;
+  return path;
+}
+
 export default function ProjectDetailPage() {
 export default function ProjectDetailPage() {
   const params = useParams();
   const params = useParams();
   const projectId = params.projectId as string;
   const projectId = params.projectId as string;
@@ -72,8 +142,13 @@ export default function ProjectDetailPage() {
   const [members, setMembers] = useState<any[]>([]);
   const [members, setMembers] = useState<any[]>([]);
   const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
   const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
   const [assets, setAssets] = useState<Asset[]>([]);
   const [assets, setAssets] = useState<Asset[]>([]);
+  const [folders, setFolders] = useState<FolderNode[]>([]);
+  const [allFolders, setAllFolders] = useState<FolderNode[]>([]);
+  const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
+  const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
   const [uploading, setUploading] = useState(false);
   const [uploading, setUploading] = useState(false);
+  const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
   const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
   const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
 
 
   // Invite form state (single shared form)
   // Invite form state (single shared form)
@@ -108,6 +183,41 @@ export default function ProjectDetailPage() {
     m.user.id === user?.id && m.role === 'ADMIN'
     m.user.id === user?.id && m.role === 'ADMIN'
   );
   );
 
 
+  // ── Folder data derived from state ──────────────────────────────────────────
+  // For file mode: only assets directly in the selected folder
+  const folderAssetIds = assets.length > 0
+    ? collectAssetIds(folders, selectedFolderId)
+    : new Set<string>();
+  // For timeline mode: assets in selected folder AND all its subfolders
+  const timelineAssetIds = (() => {
+    const ids = new Set<string>();
+    if (selectedFolderId === null) return ids;
+    function findTarget(f: FolderNode): FolderNode | null {
+      if (f.id === selectedFolderId) return f;
+      for (const c of f.children) { const r = findTarget(c); if (r) return r; }
+      return null;
+    }
+    function collectAll(f: FolderNode): void {
+      for (const id of f.assetIds) ids.add(id);
+      for (const c of f.children) collectAll(c);
+    }
+    for (const f of folders) {
+      const target = findTarget(f);
+      if (target) { collectAll(target); break; }
+    }
+    return ids;
+  })();
+  const filteredAssets = selectedFolderId === null
+    ? assets
+    : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []);
+  // Timeline uses all assets in the selected folder AND its subfolders
+  const timelineAssets = selectedFolderId === null
+    ? assets
+    : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []);
+  const subfolders = getSubfolders(folders, selectedFolderId);
+  const breadcrumb = getBreadcrumb(folders, selectedFolderId);
+  const assetFolders = buildAssetFolders(allFolders);
+
   // ── Delete project ──────────────────────────────────────────────────────────
   // ── Delete project ──────────────────────────────────────────────────────────
   const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
   const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
   const [deletingProject, setDeletingProject] = useState(false);
   const [deletingProject, setDeletingProject] = useState(false);
@@ -126,6 +236,17 @@ export default function ProjectDetailPage() {
     }
     }
   };
   };
 
 
+  const loadFolders = useCallback(async () => {
+    if (!token) return;
+    try {
+      const data = await foldersApi.list(token, projectId);
+      setFolders(data.folders);
+      setAllFolders(data.allFolders);
+    } catch (e) {
+      console.error('Failed to load folders:', e);
+    }
+  }, [token, projectId]);
+
   const loadAll = useCallback(async () => {
   const loadAll = useCallback(async () => {
     if (!token) return;
     if (!token) return;
     try {
     try {
@@ -149,6 +270,7 @@ export default function ProjectDetailPage() {
   }, [token, projectId, router, canManage]);
   }, [token, projectId, router, canManage]);
 
 
   useEffect(() => { loadAll(); }, [loadAll]);
   useEffect(() => { loadAll(); }, [loadAll]);
+  useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
 
 
   // ── Invite member ──────────────────────────────────────────────────────────
   // ── Invite member ──────────────────────────────────────────────────────────
   const handleInvite = async (e: React.FormEvent) => {
   const handleInvite = async (e: React.FormEvent) => {
@@ -188,7 +310,6 @@ export default function ProjectDetailPage() {
       const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
       const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
       const { invitations } = await invitationsApi.list(token, projectId);
       const { invitations } = await invitationsApi.list(token, projectId);
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
-      // API returns full URL now (e.g. http://localhost:3000/invite/xxx)
       await safeCopy(inviteUrl);
       await safeCopy(inviteUrl);
       setCreatedLink(inviteUrl);
       setCreatedLink(inviteUrl);
       setInviteEmail('');
       setInviteEmail('');
@@ -257,6 +378,7 @@ export default function ProjectDetailPage() {
     setTimeout(() => setCopiedInviteId(null), 2000);
     setTimeout(() => setCopiedInviteId(null), 2000);
   };
   };
 
 
+  // ── Upload ─────────────────────────────────────────────────────────────────
   const handleDrop = async (acceptedFiles: File[]) => {
   const handleDrop = async (acceptedFiles: File[]) => {
     if (!token || acceptedFiles.length === 0) return;
     if (!token || acceptedFiles.length === 0) return;
     setUploading(true);
     setUploading(true);
@@ -276,46 +398,13 @@ export default function ProjectDetailPage() {
     setUploading(false);
     setUploading(false);
   };
   };
 
 
-  const { getRootProps, getInputProps, isDragActive } = useDropzone({
+  const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
     onDrop: handleDrop,
     onDrop: handleDrop,
     accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
     accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
     multiple: true,
     multiple: true,
     disabled: uploading,
     disabled: uploading,
   });
   });
 
 
-  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',
-  };
-
-  // ── Transcode status helpers ────────────────────────────────────────────────
-  const transcodeColors: Record<TranscodeStatus, { text: string; dot: string; bg: string }> = {
-    PENDING:           { text: '#94A3B8', dot: 'bg-slate-400',    bg: 'rgba(148,163,184,0.10)' },
-    UPLOADING:         { text: '#60A5FA', dot: 'bg-blue-400',     bg: 'rgba(96,165,250,0.10)'  },
-    PROCESSING:        { text: '#A78BFA', dot: 'bg-violet-400',    bg: 'rgba(167,139,250,0.10)' },
-    COMPLETED:         { text: '#34D399', dot: 'bg-emerald-400',   bg: 'rgba(52,211,153,0.10)'  },
-    FAILED:            { text: '#F87171', dot: 'bg-red-400',       bg: 'rgba(248,113,113,0.10)' },
-    UNSUPPORTED_CODEC: { text: '#FBBF24', dot: 'bg-amber-400',     bg: 'rgba(251,191,36,0.10)'  },
-  };
-
-  const transcodeLabels: Record<TranscodeStatus, string> = {
-    PENDING:           'Queued',
-    UPLOADING:         'Uploading',
-    PROCESSING:        'Processing',
-    COMPLETED:         'Ready',
-    FAILED:            'Failed',
-    UNSUPPORTED_CODEC: 'Unsupported codec',
-  };
-
   // Poll for assets that are still processing
   // Poll for assets that are still processing
   const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
   const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
 
 
@@ -327,6 +416,21 @@ export default function ProjectDetailPage() {
     setConfirmDelete({ id, title });
     setConfirmDelete({ id, title });
   };
   };
 
 
+  // ── Remove asset from a folder ──────────────────────────────────────────
+  const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => {
+    if (!token) return;
+    // Find the folder by name within the project
+    const folder = allFolders.find(f => f.name === folderName);
+    if (!folder) return;
+    try {
+      await foldersApi.removeAsset(token, folder.id, assetId);
+      // Refresh folder data so asset disappears from the folder
+      loadFolders();
+    } catch (err) {
+      console.error('Failed to remove from folder:', err);
+    }
+  }, [token, allFolders, loadFolders]);
+
   const confirmDeleteAsset = async () => {
   const confirmDeleteAsset = async () => {
     if (!token || !confirmDelete) return;
     if (!token || !confirmDelete) return;
     setDeletingId(confirmDelete.id);
     setDeletingId(confirmDelete.id);
@@ -340,6 +444,7 @@ export default function ProjectDetailPage() {
       setDeletingId(null);
       setDeletingId(null);
     }
     }
   };
   };
+
   useEffect(() => {
   useEffect(() => {
     const processingAssets = assets.filter(a =>
     const processingAssets = assets.filter(a =>
       ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
       ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
@@ -348,7 +453,7 @@ export default function ProjectDetailPage() {
       if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
       if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
       return;
       return;
     }
     }
-    if (pollingRef.current) return; // already polling
+    if (pollingRef.current) return;
 
 
     pollingRef.current = setInterval(async () => {
     pollingRef.current = setInterval(async () => {
       if (!token) return;
       if (!token) return;
@@ -376,8 +481,25 @@ export default function ProjectDetailPage() {
   return (
   return (
     <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
     <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
 
 
+      {/* Full-page upload overlay when dragging files */}
+      {isUploadDragActive && (
+        <div {...getUploadRootProps()} className="upload-drop-overlay">
+          <input {...getUploadInputProps()} />
+          <div className="text-center">
+            <div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center"
+                 style={{ background: 'rgba(99,102,241,0.15)', border: '2px solid rgba(99,102,241,0.4)' }}>
+              <svg className="w-8 h-8" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+              </svg>
+            </div>
+            <p className="text-lg font-medium" style={{ color: 'var(--text)' }}>Drop videos to upload</p>
+            <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>MP4, MOV, WebM — up to 500MB each</p>
+          </div>
+        </div>
+      )}
+
       {/* Header */}
       {/* Header */}
-      <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"
+      <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-4 shrink-0 flex-wrap"
               style={{
               style={{
                 background: 'rgba(10,11,20,0.80)',
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
                 backdropFilter: 'blur(12px)',
@@ -421,12 +543,32 @@ export default function ProjectDetailPage() {
           )}
           )}
         </div>
         </div>
 
 
-        {/* Tabs — icon only on mobile, icon+label on sm+ */}
+        {/* Upload button — compact, in header */}
+        {canManage && (
+          <button
+            {...getUploadRootProps()}
+            className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg shrink-0 transition-all"
+            style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}
+            title="Upload video"
+          >
+            <input {...getUploadInputProps()} />
+            {uploading ? (
+              <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
+            ) : (
+              <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 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+              </svg>
+            )}
+            <span className="hidden sm:inline">Upload</span>
+          </button>
+        )}
+
+        {/* Tabs */}
         <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
         <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
              style={{ background: 'rgba(255,255,255,0.04)' }}>
              style={{ background: 'rgba(255,255,255,0.04)' }}>
           {[
           {[
             { tab: 'videos', label: 'Videos', count: assets.length },
             { tab: 'videos', label: 'Videos', count: assets.length },
-            { tab: 'transcode', label: 'Transcode Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
+            { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
             { tab: 'members', label: 'Members', count: members.length },
             { tab: 'members', label: 'Members', count: members.length },
           ].map(({ tab, label, count }) => (
           ].map(({ tab, label, count }) => (
             <button key={tab}
             <button key={tab}
@@ -467,11 +609,6 @@ export default function ProjectDetailPage() {
           ))}
           ))}
         </div>
         </div>
 
 
-        <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}
-        </div>
-
         {/* Delete project — owner only */}
         {/* Delete project — owner only */}
         {isOwner && (
         {isOwner && (
           <button
           <button
@@ -492,141 +629,307 @@ export default function ProjectDetailPage() {
         {/* ── Videos Tab ───────────────────────────────────────────────────── */}
         {/* ── Videos Tab ───────────────────────────────────────────────────── */}
         {activeTab === 'videos' && (
         {activeTab === 'videos' && (
           <>
           <>
-            {/* Upload zone — only shown to EDITOR and ADMIN */}
-            {canManage ? (
-              <div
-                {...getRootProps()}
-                className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
-                style={{
-                  background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
-                  border: isDragActive
-                    ? '1px solid rgba(99,102,241,0.40)'
-                    : '1px dashed rgba(255,255,255,0.10)',
-                  borderRadius: '16px',
-                }}
-              >
-                <input {...getInputProps()} />
+            {/* File/Timeline mode toggle + breadcrumb bar */}
+            {activeTab === 'videos' && (
+              <div className="flex items-center gap-3 mb-5 flex-wrap">
+                {/* Breadcrumb */}
+                <nav className="flex items-center gap-1 text-xs shrink min-w-0" style={{ color: 'var(--text-muted)' }}>
+                  <span className="truncate">{project?.name}</span>
+                  {breadcrumb.map((name, i) => (
+                    <span key={i} className="flex items-center gap-1 shrink-0">
+                      <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
+                      </svg>
+                      <span className={i === breadcrumb.length - 1 ? '' : 'opacity-60'}>{name}</span>
+                    </span>
+                  ))}
+                </nav>
+
+                <div className="flex-1" />
+
+                {/* Asset count */}
+                <span className="text-xs px-2 py-1 rounded-full shrink-0"
+                      style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
+                  {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''}
+                </span>
+
+                {/* Mode toggle */}
+                <div className="flex items-center gap-0.5 p-0.5 rounded-lg shrink-0"
+                     style={{ background: 'rgba(255,255,255,0.05)' }}>
+                  {[
+                    { mode: 'file' as const, label: 'File', icon: (
+                      <svg className="w-3.5 h-3.5" 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-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+                      </svg>
+                    )},
+                    { mode: 'timeline' as const, label: 'Timeline', icon: (
+                      <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
+                      </svg>
+                    )},
+                  ].map(({ mode, label, icon }) => (
+                    <button key={mode}
+                      onClick={() => setViewMode(mode)}
+                      className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap"
+                      style={{
+                        background: viewMode === mode ? 'rgba(99,102,241,0.20)' : 'transparent',
+                        color: viewMode === mode ? '#A5B4FC' : 'var(--text-muted)',
+                      }}
+                    >
+                      {icon}
+                      <span className="hidden sm:inline">{label}</span>
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
 
 
-                {uploading ? (
-                  <div className="space-y-3">
-                    <div className="w-9 h-9 rounded-full mx-auto animate-spin"
-                         style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
-                    <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
-                  </div>
-                ) : (
-                  <>
-                    <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
-                         style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
-                      <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+            <div className="flex gap-5">
+              {/* Left panel: FolderTree (both file and timeline modes) */}
+              <aside className="w-52 shrink-0 hidden md:block">
+                <FolderTree
+                  folders={folders}
+                  allFolders={allFolders}
+                  selectedFolderId={selectedFolderId}
+                  onSelectFolder={setSelectedFolderId}
+                  canManage={canManage}
+                  token={token ?? ''}
+                  projectId={projectId}
+                  onRefresh={loadFolders}
+                  totalAssetCount={assets.length}
+                />
+              </aside>
+
+              {/* Main content */}
+              <div className="flex-1 min-w-0">
+
+                {/* Upload zone for non-managers */}
+                {!canManage && (
+                  <div className="mb-6 rounded-2xl p-6 text-center animate-fade-in"
+                       style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
+                    <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
+                         style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
+                      <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
                         <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
                         <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
                       </svg>
                       </svg>
                     </div>
                     </div>
-                    <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
-                      {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
-                    </p>
                     <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
                     <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
-                      MP4, MOV, WebM — up to 500MB each
+                      Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
                     </p>
                     </p>
-                  </>
+                  </div>
                 )}
                 )}
-              </div>
-            ) : (
-              <div className="mb-8 rounded-2xl p-6 text-center animate-fade-in"
-                   style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
-                <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
-                     style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
-                  <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
-                    <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
-                  </svg>
-                </div>
-                <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
-                  Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading videos.
-                </p>
-              </div>
-            )}
 
 
-            {/* 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)' }}>
-                <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
-                     style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
-                  <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
-                    <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>
-                </div>
-                <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No videos yet</p>
-                <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
-              </div>
-            ) : (
-              <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>
-                        <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>
+                {/* File mode content */}
+                {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (
+                  <div className="text-center py-16 rounded-2xl animate-fade-in"
+                       style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
+                    <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
+                         style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
+                      <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
+                        <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>
+                    </div>
+                    <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
+                      {selectedFolderId ? 'No videos in this folder' : 'No videos yet'}
+                    </p>
+                    <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                      {selectedFolderId
+                        ? 'Drag videos here or move them from other folders'
+                        : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}
+                    </p>
+                  </div>
+                ) : viewMode === 'file' ? (
+                  // File mode: subfolders + videos
+                  <div>
+                    {/* Subfolders */}
+                    {subfolders.length > 0 && (
+                      <div className="mb-6">
+                        <div className="flex items-center gap-3 mb-3">
+                          <span className="text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Folders</span>
+                          <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.05)' }} />
+                        </div>
+                        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
+                          {subfolders.map(folder => (
+                            <button
+                              key={folder.id}
+                              onClick={() => setSelectedFolderId(folder.id)}
+                              className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all hover:brightness-110 group"
+                              style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
+                            >
+                              <svg className="w-5 h-5 shrink-0" style={{ color: '#A78BFA' }} 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 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+                              </svg>
+                              <div className="flex-1 min-w-0">
+                                <div className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{folder.name}</div>
+                                {folder.assetCount > 0 && (
+                                  <div className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>{folder.assetCount} video{folder.assetCount !== 1 ? 's' : ''}</div>
+                                )}
+                              </div>
+                              <svg className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                                <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
+                              </svg>
+                            </button>
+                          ))}
+                        </div>
                       </div>
                       </div>
+                    )}
+
+                    {/* Videos in this folder */}
+                    {filteredAssets.length > 0 && (
                       <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
                       <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
-                        {dayAssets.map((asset, i) => (
+                        {filteredAssets.map((asset, i) => (
                           <AssetCard
                           <AssetCard
                             key={asset.id}
                             key={asset.id}
                             asset={asset}
                             asset={asset}
                             canManage={canManage}
                             canManage={canManage}
-                            showHour={showHour}
+                            showHour={false}
                             onPlay={() => router.push(`/review/${asset.id}`)}
                             onPlay={() => router.push(`/review/${asset.id}`)}
                             onDelete={() => handleDeleteAsset(asset.id, asset.title)}
                             onDelete={() => handleDeleteAsset(asset.id, asset.title)}
                             onCancel={async (id) => {
                             onCancel={async (id) => {
                               if (!token) return;
                               if (!token) return;
                               try {
                               try {
                                 await assetsApi.cancelTranscode(token, id);
                                 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');
-                              }
+                                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) => {
                             onPause={async (id) => {
                               if (!token) return;
                               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');
-                              }
+                              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) => {
                             onResume={async (id) => {
                               if (!token) return;
                               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');
-                              }
+                              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}
                             animationDelay={i * 40}
+                            folderNames={getAssetFolderNames(assetFolders, asset.id)}
+                            onShare={setSharingAssetId}
+                            isShared={!!asset.isShared}
+                            onRemoveFromFolder={handleRemoveFromFolder}
                           />
                           />
                         ))}
                         ))}
                       </div>
                       </div>
-                    </div>
-                  );
-                })}
+                    )}
+                  </div>
+                ) : (
+                  // Timeline mode: grouped by date
+                  <div className="space-y-8">
+                    {groupByDay(timelineAssets).map(([dayKey, dayAssets]) => {
+                      const groupDate = new Date(dayKey);
+                      const showHour = dayAssets.length > 1;
+                      return (
+                        <div key={dayKey}>
+                          <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>
+                            <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>
+                          <div className="space-y-3">
+                            {dayAssets.map((asset, i) => {
+                              const createdAt = new Date(asset.createdAt);
+                              return (
+                                <div key={asset.id}
+                                  className="flex items-center gap-4 p-3 rounded-xl cursor-pointer group transition-colors animate-fade-in"
+                                  style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
+                                  onClick={() => router.push(`/review/${asset.id}`)}
+                                  draggable={canManage}
+                                  onDragStart={canManage ? (e) => {
+                                    e.dataTransfer.setData('assetId', asset.id);
+                                    e.dataTransfer.setData('text/plain', asset.title);
+                                    e.dataTransfer.effectAllowed = 'move';
+                                    if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
+                                      const ghost = document.createElement('div');
+                                      ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
+                                      const img = document.createElement('img');
+                                      img.src = `/uploads/${asset.thumbnail}`;
+                                      img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
+                                      const label = document.createElement('span');
+                                      label.textContent = asset.title;
+                                      label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
+                                      ghost.appendChild(img);
+                                      ghost.appendChild(label);
+                                      document.body.appendChild(ghost);
+                                      e.dataTransfer.setDragImage(ghost, 30, 28);
+                                      setTimeout(() => document.body.removeChild(ghost), 0);
+                                    }
+                                  } : undefined}
+                                >
+                                  {/* Thumbnail */}
+                                  <div className="w-24 sm:w-32 shrink-0 rounded-lg overflow-hidden aspect-video"
+                                       style={{ background: '#080810' }}>
+                                    {asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? (
+                                      <img src={`/uploads/${asset.thumbnail}`} alt={asset.title} className="w-full h-full object-cover" style={{ opacity: 0.8 }} />
+                                    ) : (
+                                      <div className="w-full h-full flex items-center justify-center">
+                                        <svg className="w-6 h-6" 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>
+
+                                  {/* Info */}
+                                  <div className="flex-1 min-w-0">
+                                    <div className="flex items-start justify-between gap-2 mb-1">
+                                      <h3 className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h3>
+                                      {asset.duration && (
+                                        <span className="text-xs shrink-0 px-1.5 py-0.5 rounded font-mono"
+                                              style={{ background: 'rgba(0,0,0,0.5)', color: '#E2E8F0' }}>
+                                          {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
+                                        </span>
+                                      )}
+                                    </div>
+                                    {/* Folder tags */}
+                                    {(() => {
+                                      const tags = getAssetFolderNames(assetFolders, asset.id);
+                                      return tags.length > 0 ? (
+                                        <div className="flex flex-wrap gap-1 mb-1">
+                                          {tags.map((name, i) => (
+                                            <span key={i} className="text-[10px] px-1.5 py-0.5 rounded"
+                                                  style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
+                                              {name}
+                                            </span>
+                                          ))}
+                                        </div>
+                                      ) : null;
+                                    })()}
+                                    <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
+                                      <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
+                                      <span>·</span>
+                                      <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>
+                                        {showHour
+                                          ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
+                                          : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
+                                      </span>
+                                      <span>·</span>
+                                      <span>{(asset as any)._count?.comments ?? 0} comments</span>
+                                    </div>
+                                  </div>
+
+                                  {/* Play button */}
+                                  <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
+                                       style={{ background: 'rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
+                                    <svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
+                                      <path d="M8 5v14l11-7z" />
+                                    </svg>
+                                  </div>
+                                </div>
+                              );
+                            })}
+                          </div>
+                        </div>
+                      );
+                    })}
+                  </div>
+                )}
               </div>
               </div>
-            )}
+            </div>
           </>
           </>
         )}
         )}
 
 
@@ -642,35 +945,25 @@ export default function ProjectDetailPage() {
                 if (!token) return;
                 if (!token) return;
                 try {
                 try {
                   await assetsApi.cancelTranscode(token, id);
                   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');
-                }
+                  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) => {
               onPause={async (id) => {
                 if (!token) return;
                 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');
-                }
+                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) => {
               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'); }
+              }}
+              onReprocess={async (id) => {
                 if (!token) return;
                 if (!token) return;
                 try {
                 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');
-                }
+                  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 reprocess transcode'); }
               }}
               }}
             />
             />
           </div>
           </div>
@@ -680,7 +973,7 @@ export default function ProjectDetailPage() {
         {activeTab === 'members' && (
         {activeTab === 'members' && (
           <div className="max-w-3xl animate-fade-in">
           <div className="max-w-3xl animate-fade-in">
 
 
-            {/* Invite form — single form, shared email + role */}
+            {/* Invite form */}
             {canManage && (
             {canManage && (
               <div className="card p-5 mb-6">
               <div className="card p-5 mb-6">
                 <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
                 <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
@@ -716,7 +1009,6 @@ export default function ProjectDetailPage() {
                         ))}
                         ))}
                       </select>
                       </select>
                     </div>
                     </div>
-                    {/* Both buttons share the same email + role from this single form */}
                     <button
                     <button
                       type="button"
                       type="button"
                       disabled={inviting || !inviteEmail.trim()}
                       disabled={inviting || !inviteEmail.trim()}
@@ -737,13 +1029,12 @@ export default function ProjectDetailPage() {
                       type="submit"
                       type="submit"
                       disabled={inviting || !inviteEmail.trim()}
                       disabled={inviting || !inviteEmail.trim()}
                       className="btn btn-primary btn-md"
                       className="btn btn-primary btn-md"
-                      title="Send invite — link is included automatically"
+                      title="Send invite"
                     >
                     >
                       {inviting ? 'Sending…' : 'Send Invite'}
                       {inviting ? 'Sending…' : 'Send Invite'}
                     </button>
                     </button>
                   </form>
                   </form>
 
 
-                  {/* Created link feedback */}
                   {createdLink && (
                   {createdLink && (
                     <div className="rounded-lg p-3 animate-scale-in"
                     <div className="rounded-lg p-3 animate-scale-in"
                          style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
                          style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
@@ -759,12 +1050,8 @@ export default function ProjectDetailPage() {
                     </div>
                     </div>
                   )}
                   )}
 
 
-                  {inviteError && (
-                    <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
-                  )}
-                  {inviteSuccess && (
-                    <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
-                  )}
+                  {inviteError && <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>}
+                  {inviteSuccess && <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>}
                 </div>
                 </div>
               </div>
               </div>
             )}
             )}
@@ -791,13 +1078,11 @@ export default function ProjectDetailPage() {
                       <div key={m.id}
                       <div key={m.id}
                            className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
                            className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
 
 
-                        {/* Avatar */}
                         <div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
                         <div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
                              style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
                              style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
                           {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
                           {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
                         </div>
                         </div>
 
 
-                        {/* Info */}
                         <div className="flex-1 min-w-0">
                         <div className="flex-1 min-w-0">
                           <div className="flex items-center gap-2">
                           <div className="flex items-center gap-2">
                             <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
                             <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
@@ -808,12 +1093,10 @@ export default function ProjectDetailPage() {
                           <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
                           <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
                         </div>
                         </div>
 
 
-                        {/* Joined date */}
                         <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
                         <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
                           {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
                           {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
                         </span>
                         </span>
 
 
-                        {/* Role */}
                         {editingRoleId === m.id ? (
                         {editingRoleId === m.id ? (
                           <div className="flex items-center gap-2 shrink-0">
                           <div className="flex items-center gap-2 shrink-0">
                             <select
                             <select
@@ -826,21 +1109,12 @@ export default function ProjectDetailPage() {
                                 <option key={v} value={v}>{l}</option>
                                 <option key={v} value={v}>{l}</option>
                               ))}
                               ))}
                             </select>
                             </select>
-                            <button
-                              onClick={() => handleChangeRole(m.id)}
-                              disabled={updatingRole}
-                              className="btn btn-primary btn-sm px-2"
-                              title="Save"
-                            >
+                            <button onClick={() => handleChangeRole(m.id)} disabled={updatingRole} className="btn btn-primary btn-sm px-2" title="Save">
                               <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                               <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                                 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                                 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                               </svg>
                               </svg>
                             </button>
                             </button>
-                            <button
-                              onClick={() => setEditingRoleId(null)}
-                              className="btn btn-secondary btn-sm px-2"
-                              title="Cancel"
-                            >
+                            <button onClick={() => setEditingRoleId(null)} className="btn btn-secondary btn-sm px-2" title="Cancel">
                               <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
                               <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" />
                                 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
                               </svg>
                               </svg>
@@ -904,8 +1178,6 @@ export default function ProjectDetailPage() {
                     {pendingInvites.map(inv => (
                     {pendingInvites.map(inv => (
                       <div key={inv.id}
                       <div key={inv.id}
                            className="flex items-center gap-4 px-5 py-4">
                            className="flex items-center gap-4 px-5 py-4">
-
-                        {/* Icon */}
                         <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
                         <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
                              style={{ background: 'rgba(99,102,241,0.08)' }}>
                              style={{ background: 'rgba(99,102,241,0.08)' }}>
                           <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
                           <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -913,7 +1185,6 @@ export default function ProjectDetailPage() {
                           </svg>
                           </svg>
                         </div>
                         </div>
 
 
-                        {/* Info */}
                         <div className="flex-1 min-w-0">
                         <div className="flex-1 min-w-0">
                           <div className="flex items-center gap-2">
                           <div className="flex items-center gap-2">
                             <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
                             <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
@@ -928,7 +1199,6 @@ export default function ProjectDetailPage() {
                           </div>
                           </div>
                         </div>
                         </div>
 
 
-                        {/* Actions */}
                         <div className="flex items-center gap-1.5 shrink-0">
                         <div className="flex items-center gap-1.5 shrink-0">
                           <button
                           <button
                             onClick={() => handleCopyLink(inv)}
                             onClick={() => handleCopyLink(inv)}
@@ -976,6 +1246,14 @@ export default function ProjectDetailPage() {
         )}
         )}
       </div>
       </div>
 
 
+      {/* Share modal */}
+      {sharingAssetId && (
+        <ShareModal
+          assetId={sharingAssetId}
+          onClose={() => setSharingAssetId(null)}
+        />
+      )}
+
       {/* Delete asset confirm modal */}
       {/* Delete asset confirm modal */}
       {confirmDelete && (
       {confirmDelete && (
         <div className="fixed inset-0 z-50 flex items-center justify-center"
         <div className="fixed inset-0 z-50 flex items-center justify-center"
@@ -999,18 +1277,8 @@ export default function ProjectDetailPage() {
               This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
               This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
             </p>
             </p>
             <div className="flex gap-3 justify-end">
             <div className="flex gap-3 justify-end">
-              <button
-                onClick={() => setConfirmDelete(null)}
-                disabled={!!deletingId}
-                className="btn btn-secondary btn-md"
-              >
-                Cancel
-              </button>
-              <button
-                onClick={confirmDeleteAsset}
-                disabled={!!deletingId}
-                className="btn btn-danger btn-md"
-              >
+              <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
+              <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
                 {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
                 {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
               </button>
               </button>
             </div>
             </div>
@@ -1030,14 +1298,8 @@ export default function ProjectDetailPage() {
               They'll lose access to this project and all its videos. They can rejoin if invited again.
               They'll lose access to this project and all its videos. They can rejoin if invited again.
             </p>
             </p>
             <div className="flex gap-3 justify-end">
             <div className="flex gap-3 justify-end">
-              <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">
-                Cancel
-              </button>
-              <button
-                onClick={handleRemoveMember}
-                disabled={removing}
-                className="btn btn-danger btn-md"
-              >
+              <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">Cancel</button>
+              <button onClick={handleRemoveMember} disabled={removing} className="btn btn-danger btn-md">
                 {removing ? 'Removing…' : 'Remove'}
                 {removing ? 'Removing…' : 'Remove'}
               </button>
               </button>
             </div>
             </div>

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

@@ -3,11 +3,11 @@
 import { useState, useEffect } from 'react';
 import { useState, useEffect } from 'react';
 import { useAuth } from '@/lib/auth-context';
 import { useAuth } from '@/lib/auth-context';
 import { usersApi, settingsApi } from '@/lib/api';
 import { usersApi, settingsApi } from '@/lib/api';
+import { ProfilePictureUpload } from '@/components/settings/ProfilePictureUpload';
 
 
 export default function SettingsPage() {
 export default function SettingsPage() {
   const { user, token, updateUserData } = useAuth();
   const { user, token, updateUserData } = useAuth();
   const [name, setName] = useState(user?.name ?? '');
   const [name, setName] = useState(user?.name ?? '');
-  const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl ?? '');
   const [currentPassword, setCurrentPassword] = useState('');
   const [currentPassword, setCurrentPassword] = useState('');
   const [newPassword, setNewPassword] = useState('');
   const [newPassword, setNewPassword] = useState('');
   const [confirmPassword, setConfirmPassword] = useState('');
   const [confirmPassword, setConfirmPassword] = useState('');
@@ -32,10 +32,7 @@ export default function SettingsPage() {
     setLoading(true);
     setLoading(true);
     setMessage(null);
     setMessage(null);
     try {
     try {
-      const { user: updated } = await usersApi.updateMe(token!, {
-        name: name.trim(),
-        avatarUrl: avatarUrl.trim() || undefined,
-      });
+      const { user: updated } = await usersApi.updateMe(token!, { name: name.trim() });
       updateUserData(updated);
       updateUserData(updated);
       setMessage({ type: 'success', text: 'Profile updated successfully' });
       setMessage({ type: 'success', text: 'Profile updated successfully' });
     } catch (err) {
     } catch (err) {
@@ -111,10 +108,12 @@ export default function SettingsPage() {
 
 
           <form onSubmit={handleProfile} className="space-y-4">
           <form onSubmit={handleProfile} className="space-y-4">
             <div className="flex items-center gap-4">
             <div className="flex items-center gap-4">
-              <div className="w-14 h-14 rounded-full flex items-center justify-center text-xl font-semibold shrink-0"
-                   style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: '1px solid rgba(99,102,241,0.25)' }}>
-                {name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
-              </div>
+              <ProfilePictureUpload
+                currentAvatarUrl={user.avatarUrl}
+                userName={user.name}
+                token={token!}
+                onUploaded={(url) => updateUserData({ avatarUrl: url })}
+              />
               <div>
               <div>
                 <p className="text-sm font-medium" style={{ color: 'var(--text)' }}>{user.email}</p>
                 <p className="text-sm font-medium" style={{ color: 'var(--text)' }}>{user.email}</p>
                 <p className="text-xs mt-0.5 capitalize" style={{ color: 'var(--text-muted)' }}>
                 <p className="text-xs mt-0.5 capitalize" style={{ color: 'var(--text-muted)' }}>
@@ -136,19 +135,6 @@ export default function SettingsPage() {
               />
               />
             </div>
             </div>
 
 
-            <div>
-              <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text)' }}>
-                Avatar URL <span style={{ color: 'var(--text-subtle)', fontWeight: 400 }}>(optional)</span>
-              </label>
-              <input
-                className="input"
-                type="url"
-                value={avatarUrl}
-                onChange={e => setAvatarUrl(e.target.value)}
-                placeholder="https://example.com/avatar.jpg"
-              />
-            </div>
-
             <div className="flex justify-end pt-2">
             <div className="flex justify-end pt-2">
               <button type="submit" className="btn btn-primary btn-md" disabled={loading}>
               <button type="submit" className="btn btn-primary btn-md" disabled={loading}>
                 {loading ? 'Saving…' : 'Save Changes'}
                 {loading ? 'Saving…' : 'Save Changes'}

+ 13 - 0
src/app/globals.css

@@ -403,3 +403,16 @@ body {
 
 
 /* ── Smooth scroll helper ───────────────────────────────────────── */
 /* ── Smooth scroll helper ───────────────────────────────────────── */
 .smooth-scroll { scroll-behavior: smooth; }
 .smooth-scroll { scroll-behavior: smooth; }
+
+/* ── Upload drop overlay ─────────────────────────────────────────── */
+.upload-drop-overlay {
+  position: fixed;
+  inset: 0;
+  z-index: 100;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(9, 10, 20, 0.85);
+  backdrop-filter: blur(8px);
+  cursor: pointer;
+}

+ 261 - 3
src/app/page.tsx

@@ -1,5 +1,263 @@
-import { redirect } from 'next/navigation';
+'use client';
 
 
-export default function Home() {
-  redirect('/login');
+import Link from 'next/link';
+
+const FEATURES = [
+  {
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
+      </svg>
+    ),
+    title: 'Frame-Accurate Comments',
+    desc: 'Drop markers directly on the video timeline. Every comment is timestamped to the exact frame for crystal-clear feedback.',
+  },
+  {
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
+      </svg>
+    ),
+    title: 'Team Collaboration',
+    desc: 'Invite reviewers, editors, and viewers. Role-based access keeps your workflow organized and secure.',
+  },
+  {
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
+      </svg>
+    ),
+    title: 'Auto-Transcoding',
+    desc: 'Upload in any format. VidReview automatically converts everything to H.264 MP4 for maximum compatibility.',
+  },
+  {
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
+      </svg>
+    ),
+    title: 'Public Share Links',
+    desc: 'Generate password-protected, view-limited share links. No account required for external stakeholders.',
+  },
+  {
+    icon: (
+      <svg className="w-6 h-6" 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-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+      </svg>
+    ),
+    title: 'Folder Organization',
+    desc: 'Organize videos in nested folders. File Mode and Timeline Mode let you browse exactly how you prefer.',
+  },
+  {
+    icon: (
+      <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+        <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
+      </svg>
+    ),
+    title: 'Review Workflow',
+    desc: 'Approve, request changes, or reject videos. Track review status at a glance with visual badges.',
+  },
+];
+
+export default function LandingPage() {
+  return (
+    <div className="min-h-screen" style={{ background: 'var(--bg)', fontFamily: 'var(--font-inter), system-ui, sans-serif' }}>
+
+      {/* Ambient background */}
+      <div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ background: 'var(--bg)' }}>
+        <div className="absolute top-[-20%] left-[-10%] w-[800px] h-[800px] rounded-full"
+             style={{ background: 'radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)' }} />
+        <div className="absolute top-[30%] right-[-15%] w-[600px] h-[600px] rounded-full"
+             style={{ background: 'radial-gradient(circle, rgba(139,92,246,0.08) 0%, transparent 70%)' }} />
+        <div className="absolute bottom-[-10%] left-[30%] w-[500px] h-[500px] rounded-full"
+             style={{ background: 'radial-gradient(circle, rgba(52,211,153,0.06) 0%, transparent 70%)' }} />
+        {/* Grid */}
+        <div className="absolute inset-0 opacity-[0.03]"
+             style={{
+               backgroundImage: 'linear-gradient(rgba(255,255,255,0.8) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.8) 1px, transparent 1px)',
+               backgroundSize: '48px 48px',
+             }} />
+      </div>
+
+      {/* Nav */}
+      <nav className="relative z-10 px-6 lg:px-12 py-5 flex items-center justify-between max-w-7xl mx-auto">
+        <div className="flex items-center gap-2.5">
+          <div className="w-9 h-9 rounded-xl flex items-center justify-center"
+               style={{ background: '#6366F1', boxShadow: '0 0 24px rgba(99,102,241,0.5)' }}>
+            <svg className="w-5 h-5 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="text-lg font-semibold tracking-tight" style={{ color: 'var(--text)' }}>VidReview</span>
+        </div>
+        <div className="flex items-center gap-3">
+          <Link href="/login"
+                className="text-sm px-4 py-2 rounded-lg transition-colors"
+                style={{ color: 'var(--text-muted)' }}>
+            Sign in
+          </Link>
+          <Link href="/register"
+                className="text-sm px-4 py-2 rounded-lg font-medium transition-all"
+                style={{ background: '#6366F1', color: '#fff', boxShadow: '0 0 16px rgba(99,102,241,0.4)' }}>
+            Get started free
+          </Link>
+        </div>
+      </nav>
+
+      {/* Hero */}
+      <section className="relative z-10 max-w-4xl mx-auto px-6 lg:px-12 pt-20 pb-28 text-center">
+        <div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium mb-8"
+             style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.25)', color: '#A5B4FC' }}>
+          <span className="w-1.5 h-1.5 rounded-full" style={{ background: '#6366F1', boxShadow: '0 0 6px #6366F1' }} />
+          Collaborative video review for creative teams
+        </div>
+
+        <h1 className="text-5xl lg:text-6xl font-bold tracking-tight mb-6"
+            style={{ color: 'var(--text)', lineHeight: 1.1 }}>
+          Video review that{' '}
+          <span style={{ background: 'linear-gradient(135deg, #6366F1, #A78BFA)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
+            moves at your pace
+          </span>
+        </h1>
+
+        <p className="text-lg max-w-2xl mx-auto mb-10"
+           style={{ color: 'var(--text-muted)', lineHeight: 1.7 }}>
+          Upload, share, and review videos with frame-accurate comments. No complex workflows — just clear feedback, faster approvals.
+        </p>
+
+        <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
+          <Link href="/register"
+                className="px-7 py-3.5 rounded-xl font-semibold text-base transition-all"
+                style={{ background: 'linear-gradient(135deg, #6366F1, #7C3AED)', color: '#fff', boxShadow: '0 4px 24px rgba(99,102,241,0.5)', letterSpacing: '0.01em' }}>
+            Start for free
+          </Link>
+          <Link href="/login"
+                className="px-7 py-3.5 rounded-xl font-medium text-base transition-colors"
+                style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.10)', color: 'var(--text)' }}>
+            Sign in
+          </Link>
+        </div>
+
+        <p className="text-xs mt-4" style={{ color: 'var(--text-subtle)' }}>
+          Free forever for small teams · No credit card required
+        </p>
+      </section>
+
+      {/* Dashboard preview mockup */}
+      <section className="relative z-10 max-w-5xl mx-auto px-6 lg:px-12 pb-28">
+        <div className="rounded-2xl overflow-hidden"
+             style={{ border: '1px solid rgba(255,255,255,0.08)', boxShadow: '0 32px 80px rgba(0,0,0,0.6)' }}>
+          {/* Browser chrome */}
+          <div className="flex items-center gap-2 px-4 py-3"
+               style={{ background: 'rgba(30,32,48,0.90)', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+            <div className="w-3 h-3 rounded-full" style={{ background: '#F87171' }} />
+            <div className="w-3 h-3 rounded-full" style={{ background: '#FBBF24' }} />
+            <div className="w-3 h-3 rounded-full" style={{ background: '#34D399' }} />
+            <div className="flex-1 mx-4 h-5 rounded-md" style={{ background: 'rgba(255,255,255,0.05)' }} />
+          </div>
+          {/* Dashboard screenshot */}
+          <div style={{ background: '#0F111A', padding: '16px', display: 'flex', gap: '12px' }}>
+            {/* Sidebar */}
+            <div className="w-44 shrink-0 hidden md:block">
+              <div className="space-y-1">
+                {['All Videos', 'Marketing', 'Campaign A', 'Behind scenes'].map((item, i) => (
+                  <div key={i} className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs"
+                       style={{ background: i === 0 ? 'rgba(99,102,241,0.15)' : 'transparent', color: i === 0 ? '#A5B4FC' : 'rgba(255,255,255,0.4)' }}>
+                    <div className="w-4 h-4 rounded" style={{ background: i === 0 ? '#6366F1' : 'rgba(255,255,255,0.1)' }} />
+                    <span>{item}</span>
+                  </div>
+                ))}
+              </div>
+            </div>
+            {/* Main content */}
+            <div className="flex-1">
+              <div className="grid grid-cols-3 gap-3">
+                {[0, 1, 2].map(i => (
+                  <div key={i} className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(255,255,255,0.06)' }}>
+                    <div className="aspect-video" style={{ background: '#080810' }}>
+                      <div className="w-full h-full flex items-center justify-center">
+                        <svg className="w-8 h-8" style={{ color: 'rgba(255,255,255,0.1)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
+                        </svg>
+                      </div>
+                    </div>
+                    <div className="p-3" style={{ background: 'rgba(255,255,255,0.02)' }}>
+                      <div className="h-2 rounded mb-2" style={{ background: 'rgba(255,255,255,0.1)', width: '75%' }} />
+                      <div className="h-2 rounded" style={{ background: 'rgba(255,255,255,0.05)', width: '45%' }} />
+                    </div>
+                  </div>
+                ))}
+              </div>
+            </div>
+          </div>
+        </div>
+      </section>
+
+      {/* Features */}
+      <section className="relative z-10 max-w-5xl mx-auto px-6 lg:px-12 pb-32">
+        <div className="text-center mb-14">
+          <h2 className="text-3xl font-bold mb-4" style={{ color: 'var(--text)' }}>
+            Everything your team needs
+          </h2>
+          <p className="text-base max-w-xl mx-auto" style={{ color: 'var(--text-muted)' }}>
+            From upload to approval — all the tools for a smooth video review pipeline.
+          </p>
+        </div>
+
+        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
+          {FEATURES.map((f, i) => (
+            <div key={i}
+                 className="rounded-2xl p-5 transition-all hover:brightness-110"
+                 style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
+              <div className="w-10 h-10 rounded-xl flex items-center justify-center mb-4"
+                   style={{ background: 'rgba(99,102,241,0.12)', color: '#818CF8' }}>
+                {f.icon}
+              </div>
+              <h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text)' }}>{f.title}</h3>
+              <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>{f.desc}</p>
+            </div>
+          ))}
+        </div>
+      </section>
+
+      {/* CTA */}
+      <section className="relative z-10 max-w-3xl mx-auto px-6 lg:px-12 pb-32 text-center">
+        <div className="rounded-3xl p-12"
+             style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.20)', boxShadow: '0 0 80px rgba(99,102,241,0.10)' }}>
+          <h2 className="text-3xl font-bold mb-4" style={{ color: 'var(--text)' }}>
+            Ready to streamline your review workflow?
+          </h2>
+          <p className="text-base mb-8" style={{ color: 'var(--text-muted)' }}>
+            Join teams already using VidReview to cut review cycles in half.
+          </p>
+          <Link href="/register"
+                className="inline-flex items-center gap-2 px-8 py-4 rounded-xl font-semibold text-base transition-all"
+                style={{ background: 'linear-gradient(135deg, #6366F1, #7C3AED)', color: '#fff', boxShadow: '0 4px 24px rgba(99,102,241,0.5)' }}>
+            Get started — it&apos;s free
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
+            </svg>
+          </Link>
+        </div>
+      </section>
+
+      {/* Footer */}
+      <footer className="relative z-10 max-w-5xl mx-auto px-6 lg:px-12 pb-12 flex items-center justify-between">
+        <div className="flex items-center gap-2">
+          <div className="w-6 h-6 rounded-lg flex items-center justify-center" style={{ background: '#6366F1' }}>
+            <svg className="w-3.5 h-3.5 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="text-sm font-medium" style={{ color: 'var(--text-subtle)' }}>VidReview</span>
+        </div>
+        <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
+          © {new Date().getFullYear()} VidReview. Built for creative teams.
+        </p>
+      </footer>
+    </div>
+  );
 }
 }
+

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

@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { useAuth } from '@/lib/auth-context';
 import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
 import { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
 import { Avatar } from '@/components/ui/avatar';
 import { Avatar } from '@/components/ui/avatar';
+import { ShareModal } from '@/components/share/ShareModal';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
 import { formatTimecode } from '@/lib/format';
 import { formatTimecode } from '@/lib/format';
@@ -47,6 +48,7 @@ export default function ReviewPage() {
   const [submitting, setSubmitting] = useState(false);
   const [submitting, setSubmitting] = useState(false);
   const [replyTo, setReplyTo] = useState<Comment | null>(null);
   const [replyTo, setReplyTo] = useState<Comment | null>(null);
   const [showResolved, setShowResolved] = useState(false);
   const [showResolved, setShowResolved] = useState(false);
+  const [showShareModal, setShowShareModal] = useState(false);
 
 
   // Drawing state — lifted to page level
   // Drawing state — lifted to page level
   const [drawMode, setDrawMode] = useState(false);
   const [drawMode, setDrawMode] = useState(false);
@@ -469,6 +471,21 @@ export default function ReviewPage() {
 
 
         <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
         <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
 
 
+        {/* Share */}
+        <button
+          onClick={() => setShowShareModal(true)}
+          className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
+          style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}
+          title="Share video"
+        >
+          <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
+          </svg>
+          <span className="hidden sm:inline">Share</span>
+        </button>
+
+        <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
+
         {/* Compare mode toggle */}
         {/* Compare mode toggle */}
         <button
         <button
           onClick={() => {
           onClick={() => {
@@ -1170,6 +1187,15 @@ export default function ReviewPage() {
         </div>
         </div>
         )}
         )}
       </div>
       </div>
+
+      {/* Share modal */}
+      {showShareModal && asset && (
+        <ShareModal
+          assetId={asset.id}
+          assetTitle={asset.title}
+          onClose={() => setShowShareModal(false)}
+        />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 296 - 0
src/app/share/[token]/page.tsx

@@ -0,0 +1,296 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuth } from '@/lib/auth-context';
+import { useParams } from 'next/navigation';
+import { shareLinksApi, ShareLinkVerify } from '@/lib/api';
+import { formatTimecode } from '@/lib/format';
+
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
+
+export default function SharePage() {
+  const params = useParams();
+  const router = useRouter();
+  const { user, token } = useAuth();
+  const tokenParam = params.token as string;
+
+  const [state, setState] = useState<'loading' | 'password' | 'ready' | 'expired' | 'error'>('loading');
+  const [linkInfo, setLinkInfo] = useState<ShareLinkVerify | null>(null);
+  const [password, setPassword] = useState('');
+  const [passwordError, setPasswordError] = useState<string | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+  const [streamUrl, setStreamUrl] = useState<string | null>(null);
+  const videoRef = useRef<HTMLVideoElement>(null);
+
+  useEffect(() => {
+    async function verify() {
+      try {
+        const info = await shareLinksApi.verify(tokenParam);
+        setLinkInfo(info);
+
+        // If user is logged in, redirect to review page
+        if (user && token) {
+          try {
+            const assetRes = await fetch(`${API_BASE}/api/assets/${info.asset.id}`, {
+              headers: { Authorization: `Bearer ${token}` },
+            });
+            if (assetRes.ok) {
+              router.replace(`/review/${info.asset.id}`);
+              return;
+            }
+          } catch { /* fall through to public view */ }
+        }
+
+        if (info.hasPassword) {
+          setState('password');
+        } else {
+          await fetchAccess();
+        }
+      } catch (e) {
+        const msg = e instanceof Error ? e.message : '';
+        if (msg.includes('410') || msg.includes('View limit')) {
+          setState('expired');
+        } else {
+          setState('error');
+        }
+      }
+    }
+    verify();
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [tokenParam, user, token]);
+
+  async function fetchAccess(pwd?: string) {
+    setSubmitting(true);
+    setPasswordError(null);
+    try {
+      const data = await shareLinksApi.access(tokenParam, pwd);
+      setStreamUrl(`${API_BASE}${data.streamUrl}`);
+      setState('ready');
+    } catch (e) {
+      const msg = e instanceof Error ? e.message : '';
+      if (msg.includes('401')) {
+        setPasswordError('Incorrect password');
+      } else {
+        setPasswordError(msg || 'Access denied');
+      }
+    } finally {
+      setSubmitting(false);
+    }
+  }
+
+  function handlePasswordSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    fetchAccess(password);
+  }
+
+  function goToLogin() {
+    // Redirect to review page after login so user can comment
+    const returnUrl = linkInfo ? `/review/${linkInfo.asset.id}` : '/login';
+    router.push(`/login?redirect=${encodeURIComponent(returnUrl)}`);
+  }
+
+  const isHls = linkInfo?.asset.mimeType === 'application/x-mpegURL' ||
+    streamUrl?.endsWith('.m3u8');
+
+  // Load HLS if needed
+  useEffect(() => {
+    if (!streamUrl || !isHls || !videoRef.current) return;
+    const video = videoRef.current;
+
+    // eslint-disable-next-line @typescript-eslint/no-require-imports
+    const Hls = require('hls.js');
+    if (Hls.isSupported()) {
+      const hls = new Hls({ enableWorker: false, lowLatencyMode: true });
+      hls.loadSource(streamUrl);
+      hls.attachMedia(video);
+      return () => { hls.destroy(); };
+    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
+      video.src = streamUrl;
+    }
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [streamUrl, isHls]);
+
+  if (state === 'loading') {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
+          <div className="w-5 h-5 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
+          <span>Loading shared video…</span>
+        </div>
+      </div>
+    );
+  }
+
+  if (state === 'error') {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <div className="text-center max-w-sm">
+          <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.10)', border: '1px solid rgba(239,68,68,0.20)' }}>
+            <svg className="w-6 h-6" 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>
+          <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Not Found</h1>
+          <p className="text-sm" style={{ color: 'var(--text-muted)' }}>This share link is invalid or has been revoked.</p>
+        </div>
+      </div>
+    );
+  }
+
+  if (state === 'expired') {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <div className="text-center max-w-sm">
+          <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.20)' }}>
+            <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
+            </svg>
+          </div>
+          <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
+          <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
+            {(linkInfo?.maxViews ?? 0) > 0
+              ? `This link has reached its view limit (${linkInfo?.maxViews} views).`
+              : 'This share link has expired.'}
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  if (state === 'password') {
+    return (
+      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+        <div className="text-center max-w-sm w-full px-4">
+          {linkInfo?.asset.thumbnail && (
+            <img
+              src={`${API_BASE}/uploads/${linkInfo.asset.thumbnail}`}
+              alt={linkInfo.asset.title}
+              className="w-full rounded-xl mb-6 aspect-video object-cover"
+              style={{ maxHeight: 200 }}
+            />
+          )}
+          <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>
+            {linkInfo?.asset.title}
+          </h1>
+          <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>
+            This video is password protected.
+          </p>
+          <form onSubmit={handlePasswordSubmit} className="space-y-3">
+            <input
+              type="password"
+              value={password}
+              onChange={e => { setPassword(e.target.value); setPasswordError(null); }}
+              placeholder="Enter password"
+              className="input w-full"
+              autoFocus
+            />
+            {passwordError && (
+              <p className="text-xs" style={{ color: '#F87171' }}>{passwordError}</p>
+            )}
+            <button
+              type="submit"
+              disabled={submitting || !password}
+              className="btn btn-primary btn-md w-full"
+            >
+              {submitting ? (
+                <div className="w-4 h-4 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} />
+              ) : 'View Video'}
+            </button>
+          </form>
+        </div>
+      </div>
+    );
+  }
+
+  // ready — full width video with info below
+  return (
+    <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
+      {/* Header */}
+      <header className="h-12 flex items-center px-4 gap-3 shrink-0"
+              style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+        <svg className="w-4 h-4 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+          <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
+        </svg>
+        <h1 className="text-xs font-medium truncate flex-1" style={{ color: 'var(--text)' }}>
+          {linkInfo?.asset.title}
+        </h1>
+        {linkInfo?.allowDownload && streamUrl && (
+          <a
+            href={streamUrl}
+            download
+            className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
+            style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
+          >
+            <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 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>
+            Download
+          </a>
+        )}
+        {!user && (
+          <button
+            onClick={goToLogin}
+            className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-all shrink-0"
+            style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}
+          >
+            <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="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.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
+            </svg>
+            Login to comment
+          </button>
+        )}
+      </header>
+
+      {/* Video — full width, maximize space */}
+      <div className="w-full" style={{ background: '#000' }}>
+        {streamUrl ? (
+          <div className="max-w-6xl mx-auto" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
+            {isHls ? (
+              <video
+                ref={videoRef}
+                className="w-full h-full"
+                controls
+                playsInline
+              />
+            ) : (
+              <video
+                ref={videoRef}
+                src={streamUrl}
+                className="w-full h-full"
+                controls
+                playsInline
+              />
+            )}
+          </div>
+        ) : !linkInfo?.asset.videoReady ? (
+          <div className="w-full flex items-center justify-center" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
+            <div className="text-center">
+              <div className="w-8 h-8 rounded-full animate-spin mx-auto mb-3" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
+              <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Video is being processed…</p>
+            </div>
+          </div>
+        ) : null}
+      </div>
+
+      {/* Info below video */}
+      <div className="max-w-6xl mx-auto px-4 pt-4 pb-8">
+        <div className="flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
+          {linkInfo?.asset.duration && (
+            <span>{formatTimecode(linkInfo.asset.duration, linkInfo.asset.fps ?? 30, linkInfo.asset.duration ?? 0)}</span>
+          )}
+          <span>·</span>
+          <span>
+            {linkInfo?.viewCount} / {linkInfo?.maxViews && linkInfo.maxViews > 0 ? linkInfo.maxViews : '∞'} views
+          </span>
+          {!linkInfo?.allowDownload && (
+            <>
+              <span>·</span>
+              <span>Download disabled</span>
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 417 - 0
src/components/folders/FolderTree.tsx

@@ -0,0 +1,417 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { FolderNode, foldersApi } from '@/lib/api';
+
+interface Props {
+  folders: FolderNode[];
+  allFolders: FolderNode[];
+  selectedFolderId: string | null; // null = "all"
+  onSelectFolder: (id: string | null) => void;
+  canManage: boolean;
+  token: string;
+  projectId: string;
+  /** Called after a folder is created/deleted/renamed to refresh data */
+  onRefresh: () => void;
+  /** Total asset count in the project (for "All Videos" label) */
+  totalAssetCount: number;
+}
+
+/** Returns true if folderId is this folder or a descendant of it */
+function isInSubtree(f: FolderNode, folderId: string): boolean {
+  if (f.id === folderId) return true;
+  return f.children.some(c => isInSubtree(c, folderId));
+}
+
+interface NewFolderInput {
+  parentId: string | null;
+  name: string;
+}
+
+export function FolderTree({
+  folders,
+  allFolders,
+  selectedFolderId,
+  onSelectFolder,
+  canManage,
+  token,
+  projectId,
+  onRefresh,
+  totalAssetCount,
+}: Props) {
+  // Auto-expand folders that contain the selected folder
+  const initialExpanded = (() => {
+    const s = new Set<string>();
+    if (selectedFolderId !== null) {
+      function markAncestors(f: FolderNode): boolean {
+        if (f.id === selectedFolderId) return true;
+        for (const child of f.children) {
+          if (markAncestors(child)) { s.add(f.id); return true; }
+        }
+        return false;
+      }
+      for (const f of folders) markAncestors(f);
+    }
+    return s;
+  })();
+  const [expanded, setExpanded] = useState<Set<string>>(initialExpanded);
+  const [creatingIn, setCreatingIn] = useState<string | null>(null); // null = not creating, '__root__' = creating at root, string = creating in folder
+  const [newFolderName, setNewFolderName] = useState('');
+  const [renamingId, setRenamingId] = useState<string | null>(null);
+  const [renameName, setRenameName] = useState('');
+  const [saving, setSaving] = useState(false);
+
+  const isCreatingAtRoot = creatingIn === '__root__';
+
+  // ── Toggle expand ──────────────────────────────────────────────────────────
+  const toggle = useCallback((id: string) => {
+    setExpanded(prev => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id);
+      else next.add(id);
+      return next;
+    });
+  }, []);
+
+  // ── Create folder ─────────────────────────────────────────────────────────
+  const startCreate = useCallback((parentId: string | null) => {
+    setCreatingIn(parentId === null ? '__root__' : parentId);
+    setNewFolderName('');
+  }, []);
+
+  const cancelCreate = useCallback(() => {
+    setCreatingIn(null);
+    setNewFolderName('');
+  }, []);
+
+  const submitCreate = useCallback(async () => {
+    if (!newFolderName.trim() || saving) return;
+    setSaving(true);
+    try {
+      const parentId = creatingIn === '__root__' ? null : creatingIn;
+      await foldersApi.create(token, { name: newFolderName.trim(), projectId, parentId: parentId ?? undefined });
+      cancelCreate();
+      onRefresh();
+    } catch (e) {
+      console.error('Failed to create folder:', e);
+    } finally {
+      setSaving(false);
+    }
+  }, [newFolderName, saving, token, projectId, creatingIn, cancelCreate, onRefresh]);
+
+  // ── Rename ────────────────────────────────────────────────────────────────
+  const startRename = useCallback((id: string, currentName: string) => {
+    setRenamingId(id);
+    setRenameName(currentName);
+  }, []);
+
+  const cancelRename = useCallback(() => {
+    setRenamingId(null);
+    setRenameName('');
+  }, []);
+
+  const submitRename = useCallback(async () => {
+    if (!renamingId || !renameName.trim() || saving) return;
+    setSaving(true);
+    try {
+      await foldersApi.rename(token, renamingId, renameName.trim());
+      cancelRename();
+      onRefresh();
+    } catch (e) {
+      console.error('Failed to rename folder:', e);
+    } finally {
+      setSaving(false);
+    }
+  }, [renamingId, renameName, saving, token, cancelRename, onRefresh]);
+
+  // ── Delete ────────────────────────────────────────────────────────────────
+  const handleDelete = useCallback(async (id: string, name: string) => {
+    if (!confirm(`Delete folder "${name}"? Assets inside will not be deleted.`)) return;
+    try {
+      await foldersApi.delete(token, id);
+      if (selectedFolderId === id) onSelectFolder(null);
+      onRefresh();
+    } catch (e) {
+      console.error('Failed to delete folder:', e);
+    }
+  }, [token, selectedFolderId, onSelectFolder, onRefresh]);
+
+  // ── Drag & drop ────────────────────────────────────────────────────────────
+  const [dragOverId, setDragOverId] = useState<string | null>(null);
+
+  const handleDragOver = useCallback((e: React.DragEvent, folderId: string) => {
+    if (!canManage) return;
+    e.preventDefault();
+    e.dataTransfer.dropEffect = 'move';
+    setDragOverId(folderId);
+  }, [canManage]);
+
+  const handleDragLeave = useCallback(() => {
+    setDragOverId(null);
+  }, []);
+
+  const handleDrop = useCallback(async (e: React.DragEvent, folderId: string) => {
+    e.preventDefault();
+    setDragOverId(null);
+    if (!canManage) return;
+
+    const assetId = e.dataTransfer.getData('assetId');
+    if (!assetId) return;
+
+    try {
+      // MOVE: first remove from ALL folders the asset currently belongs to, then add to target
+      const removals = allFolders
+        .filter(f => f.assetIds.includes(assetId))
+        .map(f => foldersApi.removeAsset(token, f.id, assetId));
+      await Promise.all(removals);
+      // Add to target folder (no-op if already there, which is fine)
+      await foldersApi.addAssets(token, folderId, [assetId]);
+      onRefresh();
+    } catch (e) {
+      console.error('Failed to move asset to folder:', e);
+    }
+  }, [canManage, token, allFolders, onRefresh]);
+
+  // ── All folder IDs (for expand/collapse all) ───────────────────────────────
+  function getAllFolderIds(folders: FolderNode[]): Set<string> {
+    const ids = new Set<string>();
+    function collect(f: FolderNode): void {
+      ids.add(f.id);
+      for (const c of f.children) collect(c);
+    }
+    for (const f of folders) collect(f);
+    return ids;
+  }
+
+  // ── Render tree ───────────────────────────────────────────────────────────
+  function renderFolder(folder: FolderNode, depth: number): React.ReactNode {
+    const isSelected = selectedFolderId === folder.id;
+    const isExpanded = expanded.has(folder.id);
+    const hasChildren = folder.children.length > 0;
+    const isDragOver = dragOverId === folder.id;
+
+    return (
+      <div key={folder.id}>
+        <div
+          className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer group transition-colors text-xs ${
+            isSelected ? 'bg-indigo-600/20 text-indigo-300' : 'text-gray-300 hover:bg-white/5'
+          } ${isDragOver ? 'ring-1 ring-indigo-400' : ''}`}
+          style={{ paddingLeft: `${depth * 16 + 8}px` }}
+          onClick={() => onSelectFolder(folder.id)}
+          onDragOver={(e) => handleDragOver(e, folder.id)}
+          onDragLeave={handleDragLeave}
+          onDrop={(e) => handleDrop(e, folder.id)}
+        >
+          {/* Expand/collapse toggle */}
+          {hasChildren ? (
+            <button
+              onClick={(e) => { e.stopPropagation(); toggle(folder.id); }}
+              className="w-4 h-4 flex items-center justify-center shrink-0 hover:bg-white/10 rounded transition-colors"
+            >
+              <svg
+                className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
+                fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
+              >
+                <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
+              </svg>
+            </button>
+          ) : (
+            <div className="w-4 h-4 shrink-0" />
+          )}
+
+          {/* Folder icon */}
+          <svg className="w-3.5 h-3.5 shrink-0" 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 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+          </svg>
+
+          {/* Name or rename input */}
+          {renamingId === folder.id ? (
+            <input
+              autoFocus
+              value={renameName}
+              onChange={e => setRenameName(e.target.value)}
+              onKeyDown={e => {
+                if (e.key === 'Enter') submitRename();
+                if (e.key === 'Escape') cancelRename();
+              }}
+              onBlur={submitRename}
+              onClick={e => e.stopPropagation()}
+              className="input text-xs flex-1 py-0 px-1 h-5"
+            />
+          ) : (
+            <span className="flex-1 truncate">{folder.name}</span>
+          )}
+
+          {/* Asset count */}
+          {folder.assetCount > 0 && (
+            <span className="text-[10px] shrink-0 opacity-60">{folder.assetCount}</span>
+          )}
+
+          {/* Actions — only for ADMIN/EDITOR */}
+          {canManage && renamingId !== folder.id && (
+            <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
+              <button
+                onClick={(e) => { e.stopPropagation(); startCreate(folder.id); }}
+                title="New subfolder"
+                className="w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 transition-colors"
+              >
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
+                </svg>
+              </button>
+              <button
+                onClick={(e) => { e.stopPropagation(); startRename(folder.id, folder.name); }}
+                title="Rename"
+                className="w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 transition-colors"
+              >
+                <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
+                </svg>
+              </button>
+              <button
+                onClick={(e) => { e.stopPropagation(); handleDelete(folder.id, folder.name); }}
+                title="Delete folder"
+                className="w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 transition-colors text-red-400"
+              >
+                <svg className="w-3 h-3" 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>
+
+        {/* Children */}
+        {hasChildren && isExpanded && (
+          <div>
+            {folder.children
+              .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
+              .map(child => renderFolder(child, depth + 1))}
+          </div>
+        )}
+
+        {/* New folder input */}
+        {creatingIn === folder.id && (
+          <div
+            className="flex items-center gap-1 px-2 py-1"
+            style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }}
+          >
+            <div className="w-4 shrink-0" />
+            <svg className="w-3.5 h-3.5 shrink-0 text-indigo-400" 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 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+            </svg>
+            <input
+              autoFocus
+              value={newFolderName}
+              onChange={e => setNewFolderName(e.target.value)}
+              onKeyDown={e => {
+                if (e.key === 'Enter') submitCreate();
+                if (e.key === 'Escape') cancelCreate();
+              }}
+              onBlur={submitCreate}
+              placeholder="Folder name…"
+              className="input text-xs flex-1 py-0 px-1.5 h-5"
+            />
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  const hasFolders = folders.length > 0;
+  const allFolderIds = getAllFolderIds(folders);
+
+  return (
+    <div className="space-y-0.5">
+      {/* Folder tree controls */}
+      {hasFolders && (
+        <div className="flex items-center gap-0.5 px-1 mb-1">
+          <button
+            onClick={() => setExpanded(new Set())}
+            title="Collapse all"
+            className="w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 transition-colors shrink-0"
+            style={{ color: 'var(--text-subtle)' }}
+          >
+            <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
+            </svg>
+          </button>
+          <button
+            onClick={() => setExpanded(allFolderIds)}
+            title="Expand all"
+            className="w-5 h-5 flex items-center justify-center rounded hover:bg-white/10 transition-colors shrink-0"
+            style={{ color: 'var(--text-subtle)' }}
+          >
+            <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
+            </svg>
+          </button>
+        </div>
+      )}
+
+      {/* "All Videos" — the root selection */}
+      <div
+        className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer group transition-colors text-xs ${
+          selectedFolderId === null ? 'bg-indigo-600/20 text-indigo-300' : 'text-gray-300 hover:bg-white/5'
+        }`}
+        onClick={() => onSelectFolder(null)}
+      >
+        <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="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 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+        </svg>
+        <span className="flex-1">All Videos</span>
+        <span className="text-[10px] opacity-60">{totalAssetCount}</span>
+      </div>
+
+      {/* Folder tree */}
+      {hasFolders && (
+        <div>
+          {folders
+            .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
+            .map(folder => renderFolder(folder, 0))}
+        </div>
+      )}
+
+      {/* No folders message */}
+      {canManage && !isCreatingAtRoot && !hasFolders && (
+        <p className="text-[10px] px-3 text-gray-600 mt-1">No folders yet — create one to organize videos</p>
+      )}
+
+      {/* New folder at root: show input when creating at root, button otherwise */}
+      {canManage && (
+        isCreatingAtRoot ? (
+          <div
+            className="flex items-center gap-1 px-2 py-1"
+            style={{ paddingLeft: '8px' }}
+          >
+            <svg className="w-3.5 h-3.5 shrink-0 text-indigo-400" 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 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
+            </svg>
+            <input
+              autoFocus
+              value={newFolderName}
+              onChange={e => setNewFolderName(e.target.value)}
+              onKeyDown={e => {
+                if (e.key === 'Enter') submitCreate();
+                if (e.key === 'Escape') cancelCreate();
+              }}
+              onBlur={submitCreate}
+              placeholder="Folder name…"
+              className="input text-xs flex-1 py-0 px-1.5 h-5"
+            />
+          </div>
+        ) : (
+          <button
+            onClick={() => startCreate(null)}
+            className="flex items-center gap-2 w-full px-3 py-2 rounded-lg cursor-pointer text-xs text-gray-500 hover:bg-white/5 hover:text-gray-300 transition-colors"
+          >
+            <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
+            </svg>
+            New folder
+          </button>
+        )
+      )}
+    </div>
+  );
+}

+ 284 - 0
src/components/settings/ProfilePictureUpload.tsx

@@ -0,0 +1,284 @@
+'use client';
+
+import { useRef, useState, useCallback } from 'react';
+import { usersApi } from '@/lib/api';
+
+interface Props {
+  currentAvatarUrl?: string | null;
+  userName: string;
+  token: string;
+  onUploaded: (avatarUrl: string) => void;
+}
+
+interface CropRect {
+  x: number;
+  y: number;
+  size: number; // square side
+}
+
+export function ProfilePictureUpload({ currentAvatarUrl, userName, token, onUploaded }: Props) {
+  const inputRef = useRef<HTMLInputElement>(null);
+  const [preview, setPreview] = useState<string | null>(null);
+  const [cropData, setCropData] = useState<CropRect | null>(null);
+  const [uploading, setUploading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const originalImageRef = useRef<HTMLImageElement | null>(null);
+
+  // ── Pick file ────────────────────────────────────────────────────────────
+  const handlePick = () => inputRef.current?.click();
+
+  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+    if (!file.type.startsWith('image/')) {
+      setError('Please select an image file');
+      return;
+    }
+    setError(null);
+
+    const reader = new FileReader();
+    reader.onload = (ev) => {
+      const src = ev.target?.result as string;
+      setPreview(src);
+      setCropData(null);
+    };
+    reader.readAsDataURL(file);
+  };
+
+  // ── Load image for crop interaction ──────────────────────────────────────
+  const handleImageLoad = (e: React.ReactElement<HTMLImageElement>) => {
+    const img = e as unknown as HTMLImageElement;
+    if (!img) return;
+    originalImageRef.current = img;
+    // Auto-center square crop at 80% of the smaller dimension
+    const minDim = Math.min(img.naturalWidth, img.naturalHeight);
+    const cropSize = Math.floor(minDim * 0.8);
+    const x = Math.floor((img.naturalWidth - cropSize) / 2);
+    const y = Math.floor((img.naturalHeight - cropSize) / 2);
+    setCropData({ x, y, size: cropSize });
+  };
+
+  // ── Drag to reposition crop ────────────────────────────────────────────
+  const dragRef = useRef<{ startX: number; startY: number; startCrop: CropRect } | null>(null);
+
+  const handleCropMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
+    if (!cropData || !originalImageRef.current) return;
+    dragRef.current = { startX: e.clientX, startY: e.clientY, startCrop: { ...cropData } };
+  }, [cropData]);
+
+  const handleCropMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
+    if (!dragRef.current || !originalImageRef.current || !cropData) return;
+    const img = originalImageRef.current;
+    const container = (e.currentTarget as HTMLDivElement);
+    const rect = container.getBoundingClientRect();
+    const scaleX = img.naturalWidth / rect.width;
+    const scaleY = img.naturalHeight / rect.height;
+    const dx = (e.clientX - dragRef.current.startX) * scaleX;
+    const dy = (e.clientY - dragRef.current.startY) * scaleY;
+    const { startCrop } = dragRef.current;
+    setCropData({
+      ...startCrop,
+      x: Math.max(0, Math.min(img.naturalWidth - startCrop.size, startCrop.x + dx)),
+      y: Math.max(0, Math.min(img.naturalHeight - startCrop.size, startCrop.y + dy)),
+    });
+  }, [cropData]);
+
+  const handleCropMouseUp = useCallback(() => {
+    dragRef.current = null;
+  }, []);
+
+  // ── Process & upload ──────────────────────────────────────────────────────
+  const handleConfirm = async () => {
+    if (!preview || !cropData) return;
+    setUploading(true);
+    setError(null);
+    try {
+      const canvas = canvasRef.current!;
+      const ctx = canvas.getContext('2d')!;
+      canvas.width = 400;
+      canvas.height = 400;
+
+      const img = originalImageRef.current!;
+      ctx.drawImage(
+        img,
+        cropData.x, cropData.y, cropData.size, cropData.size,
+        0, 0, 400, 400
+      );
+
+      // Compress to JPEG at quality that yields ~30KB
+      const blob: Blob = await new Promise(resolve =>
+        canvas.toBlob(b => resolve(b!), 'image/jpeg', 0.75)
+      );
+
+      // If still > 35KB, reduce quality iteratively
+      let quality = 0.75;
+      while (blob.size > 35 * 1024 && quality > 0.1) {
+        quality -= 0.1;
+        const b: Blob = await new Promise(resolve =>
+          canvas.toBlob(b => resolve(b!), 'image/jpeg', quality)
+        );
+        if (b.size < blob.size) {
+          blob as unknown as { size: number }; // keep track
+          const newBlob = b as unknown as Blob & { size: number };
+          void newBlob; // just to use the variable
+        }
+      }
+
+      const file = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
+      const { user } = await usersApi.uploadAvatar(token, file);
+      onUploaded(user.avatarUrl ?? '');
+      setPreview(null);
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Upload failed');
+    } finally {
+      setUploading(false);
+    }
+  };
+
+  // Preview display dimensions (fit in box)
+  const previewW = 300;
+  const previewH = preview ? 200 : undefined;
+
+  return (
+    <div className="space-y-3">
+      <input
+        ref={inputRef}
+        type="file"
+        accept="image/*"
+        className="hidden"
+        onChange={handleFileChange}
+      />
+
+      <canvas ref={canvasRef} className="hidden" />
+
+      {/* Current avatar */}
+      {!preview && (
+        <div className="flex items-center gap-4">
+          {currentAvatarUrl ? (
+            <img
+              src={currentAvatarUrl}
+              alt={userName}
+              className="w-16 h-16 rounded-full object-cover"
+              style={{ border: '2px solid rgba(99,102,241,0.30)' }}
+            />
+          ) : (
+            <div
+              className="w-16 h-16 rounded-full flex items-center justify-center text-lg font-bold"
+              style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC', border: '2px solid rgba(99,102,241,0.25)' }}
+            >
+              {userName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
+            </div>
+          )}
+          <button
+            type="button"
+            onClick={handlePick}
+            className="text-xs px-3 py-1.5 rounded-lg transition-colors"
+            style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC', border: '1px solid rgba(99,102,241,0.25)' }}
+          >
+            {currentAvatarUrl ? 'Change photo' : 'Upload photo'}
+          </button>
+        </div>
+      )}
+
+      {/* Crop preview */}
+      {preview && (
+        <div>
+          <p className="text-xs mb-2" style={{ color: 'var(--text-subtle)' }}>
+            Drag to reposition — square crop will be applied
+          </p>
+          <div
+            className="relative select-none rounded-xl overflow-hidden cursor-move"
+            style={{ width: previewW, height: previewH, maxWidth: '100%', background: '#080810', border: '1px solid rgba(255,255,255,0.08)' }}
+            onMouseMove={handleCropMouseMove}
+            onMouseUp={handleCropMouseUp}
+            onMouseLeave={handleCropMouseUp}
+          >
+            {/* eslint-disable-next-line @next/next/no-img-element */}
+            <img
+              src={preview}
+              alt="Crop preview"
+              className="w-full h-full object-contain"
+              draggable={false}
+              onLoad={(e) => handleImageLoad(e as unknown as React.ReactElement<HTMLImageElement>)}
+            />
+
+            {/* Dimmed overlay */}
+            {cropData && (
+              <>
+                {/* Top */}
+                <div className="absolute inset-0" style={{
+                  background: 'rgba(0,0,0,0.5)',
+                  clipPath: `polygon(0 0, 100% 0, 100% ${(cropData.y / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 0 ${(cropData.y / (originalImageRef.current?.naturalHeight || 1)) * 100}%)`,
+                }} />
+                {/* Bottom */}
+                <div className="absolute inset-0" style={{
+                  background: 'rgba(0,0,0,0.5)',
+                  clipPath: `polygon(0 ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 100% ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 100% 100%, 0 100%)`,
+                }} />
+                {/* Left */}
+                <div className="absolute inset-0" style={{
+                  background: 'rgba(0,0,0,0.5)',
+                  clipPath: `polygon(0 0, ${(cropData.x / (originalImageRef.current?.naturalWidth || 1)) * 100}% 0, ${(cropData.x / (originalImageRef.current?.naturalWidth || 1)) * 100}% ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%, 0 ${((cropData.y + cropData.size) / (originalImageRef.current?.naturalHeight || 1)) * 100}%)`,
+                }} />
+                {/* Right */}
+                <div className="absolute inset-0" style={{
+                  background: 'rgba(0,0,0,0.5)',
+                  clipPath: `polygon(${(cropData.x + cropData.size) / (originalImageRef.current?.naturalWidth || 1) * 100}% 0, 100% 0, 100% 100%, ${(cropData.x + cropData.size) / (originalImageRef.current?.naturalWidth || 1) * 100}% 100%)`,
+                }} />
+              </>
+            )}
+
+            {/* Crop square */}
+            {cropData && originalImageRef.current && (
+              <div
+                className="absolute border-2 border-indigo-400 cursor-move"
+                style={{
+                  left: `${(cropData.x / originalImageRef.current.naturalWidth) * 100}%`,
+                  top: `${(cropData.y / originalImageRef.current.naturalHeight) * 100}%`,
+                  width: `${(cropData.size / originalImageRef.current.naturalWidth) * 100}%`,
+                  height: `${(cropData.size / originalImageRef.current.naturalHeight) * 100}%`,
+                  boxShadow: '0 0 0 9999px rgba(0,0,0,0.4)',
+                }}
+                onMouseDown={handleCropMouseDown}
+              >
+                {/* Grid lines */}
+                <div className="absolute inset-0 grid grid-rows-3 grid-cols-3 pointer-events-none">
+                  {[0,1,2,3].map(i => (
+                    <div key={`h${i}`} className="border-b border-white/20" style={{ borderBottomWidth: i < 3 ? '1px' : 0, borderColor: 'rgba(255,255,255,0.2)' }} />
+                  ))}
+                  {[0,1,2,3].map(i => (
+                    <div key={`v${i}`} className="border-r border-white/20" style={{ borderRightWidth: i < 3 ? '1px' : 0, borderColor: 'rgba(255,255,255,0.2)' }} />
+                  ))}
+                </div>
+              </div>
+            )}
+          </div>
+
+          {error && <p className="text-xs" style={{ color: '#F87171' }}>{error}</p>}
+
+          {/* Actions */}
+          <div className="flex items-center gap-2 pt-1">
+            <button
+              type="button"
+              onClick={handleConfirm}
+              disabled={uploading || !cropData}
+              className="btn btn-primary btn-sm"
+            >
+              {uploading ? 'Uploading…' : 'Apply'}
+            </button>
+            <button
+              type="button"
+              onClick={() => { setPreview(null); setCropData(null); setError(null); }}
+              className="text-xs px-3 py-1.5 rounded-lg"
+              style={{ color: 'var(--text-muted)' }}
+            >
+              Cancel
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 301 - 0
src/components/share/ShareModal.tsx

@@ -0,0 +1,301 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { shareLinksApi, ShareLink } from '@/lib/api';
+
+interface ShareModalProps {
+  assetId: string;
+  assetTitle?: string;
+  onClose: () => void;
+}
+
+export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
+  const [loading, setLoading] = useState(true);
+  const [saving, setSaving] = useState(false);
+  const [link, setLink] = useState<ShareLink | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  // Create/edit form state
+  const [requirePassword, setRequirePassword] = useState(false);
+  const [password, setPassword] = useState('');
+  const [allowDownload, setAllowDownload] = useState(false);
+  const [maxViews, setMaxViews] = useState(20);
+  const [unlimited, setUnlimited] = useState(false);
+
+  const [copied, setCopied] = useState(false);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  // Load existing share link for this asset
+  useEffect(() => {
+    async function load() {
+      setLoading(true);
+      setError(null);
+      try {
+        const data = await shareLinksApi.getForAsset(assetId);
+        if (data.shareLink) {
+          setLink(data.shareLink);
+          setRequirePassword(data.shareLink.hasPassword);
+          setAllowDownload(data.shareLink.allowDownload);
+          setMaxViews(data.shareLink.maxViews > 0 ? data.shareLink.maxViews : 20);
+          setUnlimited(data.shareLink.maxViews <= 0);
+        }
+      } catch {
+        // No existing link — that's fine
+      } finally {
+        setLoading(false);
+      }
+    }
+    load();
+  }, [assetId]);
+
+  async function handleCreate() {
+    setSaving(true);
+    setError(null);
+    try {
+      const data = await shareLinksApi.create(assetId, {
+        password: requirePassword ? password : undefined,
+        allowDownload,
+        maxViews: unlimited ? -1 : maxViews,
+      });
+      setLink(data.shareLink);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Failed to create link');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  async function handleUpdate() {
+    if (!link) return;
+    setSaving(true);
+    setError(null);
+    try {
+      const data = await shareLinksApi.update(link.id, {
+        password: requirePassword ? password : undefined,
+        allowDownload,
+        maxViews: unlimited ? -1 : maxViews,
+      });
+      setLink(data.shareLink);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Failed to update link');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  async function handleRevoke() {
+    if (!link) return;
+    if (!confirm('Revoke this share link? Anyone with the URL will lose access.')) return;
+    setSaving(true);
+    setError(null);
+    try {
+      await shareLinksApi.revoke(link.id);
+      setLink(null);
+      setRequirePassword(false);
+      setPassword('');
+      setAllowDownload(false);
+      setMaxViews(20);
+      setUnlimited(false);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : 'Failed to revoke link');
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  async function copyUrl() {
+    if (!link) return;
+    const el = inputRef.current;
+    if (!el) return;
+    el.select();
+    if (navigator.clipboard?.writeText) {
+      await navigator.clipboard.writeText(link.shareUrl ?? '');
+    } else {
+      document.execCommand('copy');
+    }
+    setCopied(true);
+    setTimeout(() => setCopied(false), 2000);
+  }
+
+  const isUnlimited = link ? link.maxViews <= 0 : unlimited;
+
+  return (
+    <>
+      {/* Backdrop */}
+      <div className="fixed inset-0 z-50" onClick={onClose} />
+
+      {/* Modal */}
+      <div
+        className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 rounded-2xl overflow-hidden w-full max-w-md animate-scale-in"
+        style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: '0 8px 32px rgba(0,0,0,0.5)' }}
+      >
+        {/* Header */}
+        <div className="px-5 py-4 flex items-center justify-between" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+          <div className="flex items-center gap-2">
+            <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
+            </svg>
+            <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Share Video</h2>
+          </div>
+          <button onClick={onClose} className="w-7 h-7 flex items-center justify-center rounded-lg transition-colors hover:bg-white/10" 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="M6 18L18 6M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+
+        {/* Body */}
+        <div className="p-5">
+          {loading ? (
+            <div className="flex items-center justify-center py-8">
+              <div className="w-5 h-5 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
+            </div>
+          ) : link ? (
+            // Show existing link
+            <div className="space-y-4">
+              <div>
+                <p className="text-xs mb-2" style={{ color: 'var(--text-muted)' }}>Share link</p>
+                <div className="flex gap-2">
+                  <input
+                    ref={inputRef}
+                    readOnly
+                    value={link.shareUrl}
+                    className="input flex-1 text-xs font-mono"
+                    style={{ background: 'rgba(255,255,255,0.05)' }}
+                  />
+                  <button onClick={copyUrl} className="btn btn-secondary btn-sm px-3">
+                    {copied ? (
+                      <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                        <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
+                      </svg>
+                    ) : 'Copy'}
+                  </button>
+                </div>
+              </div>
+
+              {/* Stats */}
+              <div className="flex gap-4 text-xs" style={{ color: 'var(--text-muted)' }}>
+                <span>
+                  <span style={{ color: '#818CF8', fontWeight: 600 }}>{link.viewCount}</span> / {isUnlimited ? '∞' : link.maxViews} views
+                </span>
+                {link.hasPassword && (
+                  <span className="flex items-center gap-1">
+                    <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
+                    </svg>
+                    Password
+                  </span>
+                )}
+              </div>
+
+              {/* Edit controls */}
+              <div className="space-y-3">
+                <label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: 'var(--text)' }}>
+                  <input type="checkbox" checked={requirePassword} onChange={e => setRequirePassword(e.target.checked)} className="accent-indigo-500" />
+                  Require password
+                </label>
+                {requirePassword && (
+                  <input
+                    type="text"
+                    value={password}
+                    onChange={e => setPassword(e.target.value)}
+                    placeholder="Enter new password (leave blank to remove)"
+                    className="input w-full text-xs"
+                  />
+                )}
+                <label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: 'var(--text)' }}>
+                  <input type="checkbox" checked={allowDownload} onChange={e => setAllowDownload(e.target.checked)} className="accent-indigo-500" />
+                  Allow download
+                </label>
+                <div>
+                  <label className="flex items-center gap-2 text-xs mb-1" style={{ color: 'var(--text-muted)' }}>Max views</label>
+                  <div className="flex items-center gap-2">
+                    <input
+                      type="number"
+                      min={0}
+                      max={99999}
+                      value={unlimited ? '' : maxViews}
+                      onChange={e => { setUnlimited(false); setMaxViews(parseInt(e.target.value) || 0); }}
+                      disabled={unlimited}
+                      className="input w-24 text-xs"
+                      style={{ background: 'rgba(255,255,255,0.05)' }}
+                    />
+                    <label className="flex items-center gap-1 text-xs cursor-pointer" style={{ color: 'var(--text-muted)' }}>
+                      <input type="checkbox" checked={unlimited} onChange={e => setUnlimited(e.target.checked)} className="accent-indigo-500" />
+                      Unlimited
+                    </label>
+                  </div>
+                </div>
+              </div>
+
+              {error && <p className="text-xs" style={{ color: '#F87171' }}>{error}</p>}
+
+              <div className="flex gap-2">
+                <button onClick={handleUpdate} disabled={saving} className="btn btn-primary btn-sm flex-1">
+                  {saving ? <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} /> : 'Update'}
+                </button>
+                <button onClick={handleRevoke} disabled={saving} className="btn btn-danger btn-sm">
+                  Revoke
+                </button>
+              </div>
+            </div>
+          ) : (
+            // Create form
+            <div className="space-y-4">
+              <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                Create a public share link for <strong>{assetTitle ?? 'this video'}</strong>.
+                Anyone with the link can view it.
+              </p>
+
+              <label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: 'var(--text)' }}>
+                <input type="checkbox" checked={requirePassword} onChange={e => setRequirePassword(e.target.checked)} className="accent-indigo-500" />
+                Require password
+              </label>
+              {requirePassword && (
+                <input
+                  type="text"
+                  value={password}
+                  onChange={e => setPassword(e.target.value)}
+                  placeholder="Enter password"
+                  className="input w-full text-xs"
+                />
+              )}
+
+              <label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: 'var(--text)' }}>
+                <input type="checkbox" checked={allowDownload} onChange={e => setAllowDownload(e.target.checked)} className="accent-indigo-500" />
+                Allow download
+              </label>
+
+              <div>
+                <label className="block text-xs mb-1" style={{ color: 'var(--text-muted)' }}>Max views (0 = unlimited)</label>
+                <div className="flex items-center gap-2">
+                  <input
+                    type="number"
+                    min={0}
+                    value={unlimited ? '' : maxViews}
+                    onChange={e => { setUnlimited(false); setMaxViews(parseInt(e.target.value) || 0); }}
+                    disabled={unlimited}
+                    className="input w-24 text-xs"
+                    style={{ background: 'rgba(255,255,255,0.05)' }}
+                  />
+                  <label className="flex items-center gap-1 text-xs cursor-pointer" style={{ color: 'var(--text-muted)' }}>
+                    <input type="checkbox" checked={unlimited} onChange={e => setUnlimited(e.target.checked)} className="accent-indigo-500" />
+                    Unlimited
+                  </label>
+                </div>
+              </div>
+
+              {error && <p className="text-xs" style={{ color: '#F87171' }}>{error}</p>}
+
+              <button onClick={handleCreate} disabled={saving} className="btn btn-primary btn-sm w-full">
+                {saving ? (
+                  <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} />
+                ) : 'Create Share Link'}
+              </button>
+            </div>
+          )}
+        </div>
+      </div>
+    </>
+  );
+}

+ 24 - 2
src/components/transcode/TranscodeTasksPanel.tsx

@@ -11,6 +11,7 @@ interface Props {
   onCancel: (id: string) => void;
   onCancel: (id: string) => void;
   onPause: (id: string) => void;
   onPause: (id: string) => void;
   onResume: (id: string) => void;
   onResume: (id: string) => void;
+  onReprocess: (id: string) => void;
 }
 }
 
 
 const STATUS_CONFIG: Record<TranscodeStatus, {
 const STATUS_CONFIG: Record<TranscodeStatus, {
@@ -42,6 +43,7 @@ function TranscodeTaskRow({
   onCancel,
   onCancel,
   onPause,
   onPause,
   onResume,
   onResume,
+  onReprocess,
 }: {
 }: {
   asset: Asset;
   asset: Asset;
   canManage: boolean;
   canManage: boolean;
@@ -49,11 +51,13 @@ function TranscodeTaskRow({
   onCancel: (id: string) => void;
   onCancel: (id: string) => void;
   onPause: (id: string) => void;
   onPause: (id: string) => void;
   onResume: (id: string) => void;
   onResume: (id: string) => void;
+  onReprocess: (id: string) => void;
 }) {
 }) {
   const cfg = STATUS_CONFIG[asset.transcodeStatus] ?? STATUS_CONFIG.PENDING;
   const cfg = STATUS_CONFIG[asset.transcodeStatus] ?? STATUS_CONFIG.PENDING;
   const isActive = ['PENDING', 'UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus);
   const isActive = ['PENDING', 'UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus);
   const isPaused = !!asset.transcodePaused;
   const isPaused = !!asset.transcodePaused;
-  const canAct = canManage && asset.transcodeStatus !== 'COMPLETED' && asset.transcodeStatus !== 'FAILED';
+  const isFailed = asset.transcodeStatus === 'FAILED';
+  const canAct = canManage && asset.transcodeStatus !== 'COMPLETED' && !isFailed;
   const canDelete = canManage;
   const canDelete = canManage;
 
 
   return (
   return (
@@ -146,6 +150,23 @@ function TranscodeTaskRow({
       {/* Actions */}
       {/* Actions */}
       {canManage && (
       {canManage && (
         <div className="flex items-center gap-1 shrink-0">
         <div className="flex items-center gap-1 shrink-0">
+          {isFailed && (
+            <button
+              onClick={() => onReprocess(asset.id)}
+              className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium transition-colors"
+              style={{
+                background: 'rgba(251,146,60,0.10)',
+                color: '#FB923C',
+                border: '1px solid rgba(251,146,60,0.20)',
+              }}
+              title="Re-submit failed task"
+            >
+              <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <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>
+              <span>Reprocess</span>
+            </button>
+          )}
           {canAct && (
           {canAct && (
             <>
             <>
               {/* Pause / Resume */}
               {/* Pause / Resume */}
@@ -225,7 +246,7 @@ function TranscodeTaskRow({
   );
   );
 }
 }
 
 
-export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel, onPause, onResume }: Props) {
+export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel, onPause, onResume, onReprocess }: Props) {
   const [filter, setFilter] = useState<'all' | 'processing' | 'completed' | 'failed'>('all');
   const [filter, setFilter] = useState<'all' | 'processing' | 'completed' | 'failed'>('all');
 
 
   const filtered = assets.filter(a => {
   const filtered = assets.filter(a => {
@@ -311,6 +332,7 @@ export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel, onP
               onCancel={onCancel}
               onCancel={onCancel}
               onPause={onPause}
               onPause={onPause}
               onResume={onResume}
               onResume={onResume}
+              onReprocess={onReprocess}
             />
             />
           ))}
           ))}
         </div>
         </div>

+ 72 - 3
src/components/ui/AssetCard.tsx

@@ -12,6 +12,14 @@ interface Props {
   onPause: (id: string) => void;
   onPause: (id: string) => void;
   onResume: (id: string) => void;
   onResume: (id: string) => void;
   animationDelay?: number;
   animationDelay?: number;
+  /** Folder names this asset belongs to — shown as tags */
+  folderNames?: string[];
+  /** Called when user clicks the Share button */
+  onShare?: (assetId: string) => void;
+  /** True if this asset has a public share link — highlights the share button */
+  isShared?: boolean;
+  /** Called when user clicks ✕ on a folder tag — removes asset from that folder */
+  onRemoveFromFolder?: (assetId: string, folderName: string) => void;
 }
 }
 
 
 const statusColors: Record<string, string> = {
 const statusColors: Record<string, string> = {
@@ -53,13 +61,36 @@ function formatTime(d: Date, showHour: boolean): string {
   return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
   return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
 }
 }
 
 
-export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCancel, onPause, onResume, animationDelay = 0 }: Props) {
+export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCancel, onPause, onResume, animationDelay = 0, folderNames = [], onShare, isShared = false, onRemoveFromFolder }: Props) {
   const createdAt = new Date(asset.createdAt);
   const createdAt = new Date(asset.createdAt);
 
 
+  const handleDragStart = (e: React.DragEvent) => {
+    e.dataTransfer.setData('assetId', asset.id);
+    e.dataTransfer.setData('text/plain', asset.title);
+    e.dataTransfer.effectAllowed = 'move';
+    if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
+      const ghost = document.createElement('div');
+      ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
+      const img = document.createElement('img');
+      img.src = `/uploads/${asset.thumbnail}`;
+      img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
+      const label = document.createElement('span');
+      label.textContent = asset.title;
+      label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
+      ghost.appendChild(img);
+      ghost.appendChild(label);
+      document.body.appendChild(ghost);
+      e.dataTransfer.setDragImage(ghost, 30, 28);
+      setTimeout(() => document.body.removeChild(ghost), 0);
+    }
+  };
+
   return (
   return (
     <div
     <div
       className="card overflow-hidden group"
       className="card overflow-hidden group"
       style={{ animation: `slideUp 0.25s ease-out ${animationDelay}ms both` }}
       style={{ animation: `slideUp 0.25s ease-out ${animationDelay}ms both` }}
+      draggable={canManage}
+      onDragStart={canManage ? handleDragStart : undefined}
     >
     >
       {/* Thumbnail */}
       {/* Thumbnail */}
       <div className="relative aspect-video cursor-pointer" style={{ background: '#080810' }} onClick={onPlay}>
       <div className="relative aspect-video cursor-pointer" style={{ background: '#080810' }} onClick={onPlay}>
@@ -170,14 +201,36 @@ export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCanc
           </span>
           </span>
         </div>
         </div>
 
 
+        {/* Folder tags */}
+        {folderNames.length > 0 && (
+          <div className="flex flex-wrap gap-1 mb-2">
+            {folderNames.map((name, i) => (
+              <span key={i}
+                className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded shrink-0 group/tag"
+                style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
+                {name}
+                {onRemoveFromFolder && (
+                  <button
+                    onClick={(e) => { e.stopPropagation(); onRemoveFromFolder(asset.id, name); }}
+                    className="w-3 h-3 flex items-center justify-center rounded opacity-0 group-hover/tag:opacity-100 transition-opacity hover:bg-indigo-400/30"
+                    title={`Remove from "${name}"`}
+                  >
+                    <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
+                      <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
+                    </svg>
+                  </button>
+                )}
+              </span>
+            ))}
+          </div>
+        )}
+
         {/* Uploader + date */}
         {/* Uploader + date */}
         <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
         <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}>
           <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" />
             <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>
           </svg>
           <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
           <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>
         </div>
 
 
         {/* Transcode status */}
         {/* Transcode status */}
@@ -222,6 +275,22 @@ export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCanc
             </svg>
             </svg>
           </a>
           </a>
 
 
+          {/* Share */}
+          {onShare && (
+            <button
+              onClick={e => { e.stopPropagation(); onShare(asset.id); }}
+              className="p-1 rounded transition-colors flex-shrink-0"
+              style={isShared
+                ? { background: 'rgba(167,139,250,0.20)', color: '#C4B5FD' }
+                : { color: '#A78BFA' }}
+              title={isShared ? 'Share link active — click to manage' : 'Share video'}
+            >
+              <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
+              </svg>
+            </button>
+          )}
+
           {canManage && (
           {canManage && (
             <button
             <button
               onClick={e => { e.stopPropagation(); onDelete(); }}
               onClick={e => { e.stopPropagation(); onDelete(); }}

+ 14 - 23
src/components/video-player/SpeechBubble.tsx

@@ -6,41 +6,31 @@ import { formatTimecode } from '@/lib/format';
 interface Props {
 interface Props {
   comment: Comment;
   comment: Comment;
   fps?: number;
   fps?: number;
-  /** CSS left position (%) within the video frame */
+  /** CSS left position (%) within the video frame — used to position the bubble tail */
   left?: number;
   left?: number;
   onDismiss: () => void;
   onDismiss: () => void;
 }
 }
 
 
 export function SpeechBubble({ comment, fps = 30, left, onDismiss }: Props) {
 export function SpeechBubble({ comment, fps = 30, left, onDismiss }: Props) {
+  // Compute the tail position so the bubble "speaks" from the marker
+  const tailLeft = left !== undefined ? `${left}%` : '50%';
+
   return (
   return (
     <div
     <div
-      className="flex items-start gap-2 rounded-xl px-4 py-3 w-full"
+      className="speech-bubble flex items-start gap-2 rounded-2xl px-4 py-3 w-full"
       style={{
       style={{
-        background: 'rgba(20, 22, 40, 0.75)',
-        border: '1px solid rgba(255,255,255,0.12)',
-        backdropFilter: 'blur(12px)',
+        background: 'rgba(20, 22, 40, 0.82)',
+        border: '1px solid rgba(99,102,241,0.30)',
+        backdropFilter: 'blur(16px)',
         maxWidth: '300px',
         maxWidth: '300px',
         position: 'relative',
         position: 'relative',
+        boxShadow: '0 4px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(99,102,241,0.10)',
+        // Speech bubble tail via clip-path — positioned at `left` %
+        clipPath: left !== undefined
+          ? `polygon(0 0, 100% 0, 100% calc(100% - 10px), calc(${tailLeft} + 8px) calc(100% - 10px), ${tailLeft} 100%, calc(${tailLeft} - 8px) calc(100% - 10px), 0 calc(100% - 10px))`
+          : undefined,
       }}
       }}
     >
     >
-      {/* 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 */}
       {/* Avatar */}
       <div
       <div
         className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"
         className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 mt-0.5"
@@ -78,3 +68,4 @@ export function SpeechBubble({ comment, fps = 30, left, onDismiss }: Props) {
     </div>
     </div>
   );
   );
 }
 }
+

+ 137 - 0
src/lib/api.ts

@@ -203,6 +203,23 @@ export const usersApi = {
       token,
       token,
     }),
     }),
 
 
+  uploadAvatar: (token: string, file: File): Promise<{ user: AdminUser; avatarUrl: string }> => {
+    const formData = new FormData();
+    formData.append('avatar', file);
+    return fetch(`${API_BASE}/api/users/me/avatar`, {
+      method: 'POST',
+      headers: { Authorization: `Bearer ${token}` },
+      body: formData,
+    }).then(async r => {
+      if (!r.ok) {
+        const errBody = await r.json().catch(() => ({} as Record<string, unknown>));
+        const msg = typeof errBody.error === 'string' ? errBody.error : `HTTP ${r.status}`;
+        throw new Error(msg);
+      }
+      return r.json() as Promise<{ user: AdminUser; avatarUrl: string }>;
+    });
+  },
+
   updateRole: (token: string, id: string, role: string) =>
   updateRole: (token: string, id: string, role: string) =>
     apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, {
     apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, {
       method: 'PUT',
       method: 'PUT',
@@ -410,6 +427,8 @@ export interface Asset {
   createdAt: string;
   createdAt: string;
   uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
   uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
   _count?: { comments: number };
   _count?: { comments: number };
+  /** True if this asset has at least one public share link */
+  isShared?: boolean;
 }
 }
 
 
 export interface AssetStatusInfo {
 export interface AssetStatusInfo {
@@ -463,3 +482,121 @@ export interface AnnotationData {
   text?: string;
   text?: string;
   boundingBox?: { x: number; y: number; width: number; height: number };
   boundingBox?: { x: number; y: number; width: number; height: number };
 }
 }
+
+// ── Share Links ────────────────────────────────────────────────────────────────
+
+export interface ShareLink {
+  id: string;
+  assetId: string;
+  token: string;
+  shareUrl?: string;
+  hasPassword: boolean;
+  allowDownload: boolean;
+  maxViews: number;       // -1 or 0 = unlimited
+  viewCount: number;
+  assetTitle?: string;
+  createdAt?: string;
+}
+
+export interface ShareLinkVerify {
+  id: string;
+  token: string;
+  hasPassword: boolean;
+  allowDownload: boolean;
+  maxViews: number;
+  viewCount: number;
+  asset: {
+    id: string;
+    title: string;
+    thumbnail?: string | null;
+    mimeType: string;
+    duration?: number | null;
+    fps?: number;
+    videoReady: boolean;
+  };
+}
+
+export const shareLinksApi = {
+  // Get share link for an asset (by assetId — admin/owner only)
+  getForAsset: (assetId: string) =>
+    apiFetch<{ shareLink: ShareLink | null }>(`/api/share?assetId=${assetId}`),
+
+  // Create a share link for an asset
+  create: (assetId: string, data: {
+    password?: string;
+    allowDownload?: boolean;
+    maxViews?: number;
+  }) =>
+    apiFetch<{ shareLink: ShareLink }>('/api/share', {
+      method: 'POST',
+      body: JSON.stringify({ assetId, ...data }),
+    }),
+
+  // Update share link settings
+  update: (id: string, data: {
+    password?: string;
+    allowDownload?: boolean;
+    maxViews?: number;
+  }) =>
+    apiFetch<{ shareLink: ShareLink }>(`/api/share/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
+
+  // Revoke/delete share link
+  revoke: (id: string) =>
+    apiFetch(`/api/share/${id}`, { method: 'DELETE' }),
+
+  // Public: verify share link token
+  verify: (token: string) =>
+    apiFetch<ShareLinkVerify>(`/api/share/${token}`),
+
+  // Public: submit password and get stream URL
+  access: (token: string, password?: string) =>
+    apiFetch<{ streamUrl: string; mimeType: string; allowDownload: boolean }>(`/api/share/${token}/access`, {
+      method: 'POST',
+      body: JSON.stringify({ password }),
+    }),
+};
+
+// ── Folders ────────────────────────────────────────────────────────────────────
+
+export interface FolderNode {
+  id: string;
+  name: string;
+  parentId: string | null;
+  order: number;
+  assetCount: number;
+  assetIds: string[];
+  children: FolderNode[];
+}
+
+export const foldersApi = {
+  // List all folders for a project
+  list: (token: string, projectId: string) =>
+    apiFetch<{ folders: FolderNode[]; allFolders: FolderNode[] }>(`/api/folders/project/${projectId}`, { token }),
+
+  // Create folder
+  create: (token: string, data: { name: string; projectId: string; parentId?: string }) =>
+    apiFetch<{ folder: FolderNode }>('/api/folders', { method: 'POST', body: JSON.stringify(data), token }),
+
+  // Rename folder
+  rename: (token: string, id: string, name: string) =>
+    apiFetch<{ folder: FolderNode }>(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify({ name }), token }),
+
+  // Delete folder
+  delete: (token: string, id: string) =>
+    apiFetch(`/api/folders/${id}`, { method: 'DELETE', token }),
+
+  // Add assets to folder (append)
+  addAssets: (token: string, id: string, assetIds: string[]) =>
+    apiFetch<{ success: boolean; assetCount: number }>(`/api/folders/${id}/assets`, { method: 'POST', body: JSON.stringify({ assetIds }), token }),
+
+  // Replace all assets in folder
+  setAssets: (token: string, id: string, assetIds: string[]) =>
+    apiFetch<{ success: boolean; assetCount: number }>(`/api/folders/${id}/assets`, { method: 'PUT', body: JSON.stringify({ assetIds }), token }),
+
+  // Remove asset from folder
+  removeAsset: (token: string, id: string, assetId: string) =>
+    apiFetch(`/api/folders/${id}/assets/${assetId}`, { method: 'DELETE', token }),
+};