浏览代码

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 月之前
父节点
当前提交
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())
   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 {
@@ -43,6 +44,7 @@ model Project {
   members     ProjectMember[] @relation("ProjectMembers")
   invitations Invitation[]
   owner       User     @relation("ProjectOwner", fields: [ownerId], references: [id])
+  folders     Folder[]
 }
 
 model SiteSetting {
@@ -92,6 +94,8 @@ model Asset {
   project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
   uploader User?    @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
   comments Comment[]
+  shareLinks AssetShareLink[]
+  folderAssets FolderAsset[]
 }
 
 model Comment {
@@ -187,3 +191,56 @@ enum TranscodeStatus {
   FAILED
   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 invitationRoutes from './routes/invitations';
 import settingsRoutes from './routes/settings';
+import shareRoutes from './routes/share';
+import folderRoutes from './routes/folders';
 
 const app = express();
 const PORT = process.env.API_PORT || 3001;
@@ -46,6 +48,8 @@ app.use('/api/comments', commentRoutes);
 app.use('/api/users', userRoutes);
 app.use('/api/invitations', invitationRoutes);
 app.use('/api/settings', settingsRoutes);
+app.use('/api/share', shareRoutes);
+app.use('/api/folders', folderRoutes);
 
 // ── 404 handler ─────────────────────────────────────────────────────────────
 app.use((_req, res) => {

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

@@ -105,6 +105,7 @@ router.get('/', async (req: Request, res: Response) => {
       include: {
         uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
         _count: { select: { comments: true } },
+        shareLinks: { select: { id: true }, take: 1 },
       },
       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 bcrypt from 'bcryptjs';
+import multer from 'multer';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
 import { prisma } from '../lib/prisma';
 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)
 router.put('/:id/role', async (req: Request, res: Response) => {
   try {

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

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

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

@@ -3,17 +3,18 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useParams, useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
-import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
+import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
 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 { useDropzone } from 'react-dropzone';
 
 async function safeCopy(text: string): Promise<void> {
   if (typeof window === 'undefined') return;
   if (navigator.clipboard?.writeText) {
     try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
   } else {
-    // Fallback: create a temp input so we can use execCommand on insecure contexts
     const el = document.createElement('textarea');
     el.value = text;
     el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
@@ -62,6 +63,75 @@ function groupByDay(assets: Asset[]): [string, Asset[]][] {
   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() {
   const params = useParams();
   const projectId = params.projectId as string;
@@ -72,8 +142,13 @@ export default function ProjectDetailPage() {
   const [members, setMembers] = useState<any[]>([]);
   const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
   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 [uploading, setUploading] = useState(false);
+  const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
   const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
 
   // Invite form state (single shared form)
@@ -108,6 +183,41 @@ export default function ProjectDetailPage() {
     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 ──────────────────────────────────────────────────────────
   const [confirmDeleteProject, setConfirmDeleteProject] = 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 () => {
     if (!token) return;
     try {
@@ -149,6 +270,7 @@ export default function ProjectDetailPage() {
   }, [token, projectId, router, canManage]);
 
   useEffect(() => { loadAll(); }, [loadAll]);
+  useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
 
   // ── Invite member ──────────────────────────────────────────────────────────
   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 { invitations } = await invitationsApi.list(token, projectId);
       setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
-      // API returns full URL now (e.g. http://localhost:3000/invite/xxx)
       await safeCopy(inviteUrl);
       setCreatedLink(inviteUrl);
       setInviteEmail('');
@@ -257,6 +378,7 @@ export default function ProjectDetailPage() {
     setTimeout(() => setCopiedInviteId(null), 2000);
   };
 
+  // ── Upload ─────────────────────────────────────────────────────────────────
   const handleDrop = async (acceptedFiles: File[]) => {
     if (!token || acceptedFiles.length === 0) return;
     setUploading(true);
@@ -276,46 +398,13 @@ export default function ProjectDetailPage() {
     setUploading(false);
   };
 
-  const { getRootProps, getInputProps, isDragActive } = useDropzone({
+  const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
     onDrop: handleDrop,
     accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
     multiple: true,
     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
   const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
 
@@ -327,6 +416,21 @@ export default function ProjectDetailPage() {
     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 () => {
     if (!token || !confirmDelete) return;
     setDeletingId(confirmDelete.id);
@@ -340,6 +444,7 @@ export default function ProjectDetailPage() {
       setDeletingId(null);
     }
   };
+
   useEffect(() => {
     const processingAssets = assets.filter(a =>
       ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
@@ -348,7 +453,7 @@ export default function ProjectDetailPage() {
       if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
       return;
     }
-    if (pollingRef.current) return; // already polling
+    if (pollingRef.current) return;
 
     pollingRef.current = setInterval(async () => {
       if (!token) return;
@@ -376,8 +481,25 @@ export default function ProjectDetailPage() {
   return (
     <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 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={{
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
@@ -421,12 +543,32 @@ export default function ProjectDetailPage() {
           )}
         </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"
              style={{ background: 'rgba(255,255,255,0.04)' }}>
           {[
             { 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 },
           ].map(({ tab, label, count }) => (
             <button key={tab}
@@ -467,11 +609,6 @@ export default function ProjectDetailPage() {
           ))}
         </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 */}
         {isOwner && (
           <button
@@ -492,141 +629,307 @@ export default function ProjectDetailPage() {
         {/* ── Videos Tab ───────────────────────────────────────────────────── */}
         {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" />
                       </svg>
                     </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)' }}>
-                      MP4, MOV, WebM — up to 500MB each
+                      Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
                     </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>
+                    )}
+
+                    {/* Videos in this folder */}
+                    {filteredAssets.length > 0 && (
                       <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
                             key={asset.id}
                             asset={asset}
                             canManage={canManage}
-                            showHour={showHour}
+                            showHour={false}
                             onPlay={() => router.push(`/review/${asset.id}`)}
                             onDelete={() => handleDeleteAsset(asset.id, asset.title)}
                             onCancel={async (id) => {
                               if (!token) return;
                               try {
                                 await assetsApi.cancelTranscode(token, id);
-                                setAssets(prev => prev.map(a => a.id === id ? {
-                                  ...a,
-                                  transcodeStatus: 'PENDING',
-                                  transcodeProgress: 0,
-                                  transcodeError: null,
-                                  hlsPath: null,
-                                  transcodePaused: false,
-                                } : a));
-                              } catch (err) {
-                                alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
-                              }
+                                setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
+                              } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
                             }}
                             onPause={async (id) => {
                               if (!token) return;
-                              try {
-                                await assetsApi.pauseTranscode(token, id);
-                                setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a));
-                              } catch (err) {
-                                alert(err instanceof Error ? err.message : 'Failed to pause transcode');
-                              }
+                              try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
+                              catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
                             }}
                             onResume={async (id) => {
                               if (!token) return;
-                              try {
-                                await assetsApi.resumeTranscode(token, id);
-                                setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a));
-                              } catch (err) {
-                                alert(err instanceof Error ? err.message : 'Failed to resume transcode');
-                              }
+                              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}
+                            folderNames={getAssetFolderNames(assetFolders, asset.id)}
+                            onShare={setSharingAssetId}
+                            isShared={!!asset.isShared}
+                            onRemoveFromFolder={handleRemoveFromFolder}
                           />
                         ))}
                       </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>
           </>
         )}
 
@@ -642,35 +945,25 @@ export default function ProjectDetailPage() {
                 if (!token) return;
                 try {
                   await assetsApi.cancelTranscode(token, id);
-                  setAssets(prev => prev.map(a => a.id === id ? {
-                    ...a,
-                    transcodeStatus: 'PENDING',
-                    transcodeProgress: 0,
-                    transcodeError: null,
-                    hlsPath: null,
-                    transcodePaused: false,
-                  } : a));
-                } catch (err) {
-                  alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
-                }
+                  setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
+                } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
               }}
               onPause={async (id) => {
                 if (!token) return;
-                try {
-                  await assetsApi.pauseTranscode(token, id);
-                  setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a));
-                } catch (err) {
-                  alert(err instanceof Error ? err.message : 'Failed to pause transcode');
-                }
+                try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
+                catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
               }}
               onResume={async (id) => {
+                if (!token) return;
+                try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
+                catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
+              }}
+              onReprocess={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');
-                }
+                  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>
@@ -680,7 +973,7 @@ export default function ProjectDetailPage() {
         {activeTab === 'members' && (
           <div className="max-w-3xl animate-fade-in">
 
-            {/* Invite form — single form, shared email + role */}
+            {/* Invite form */}
             {canManage && (
               <div className="card p-5 mb-6">
                 <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
@@ -716,7 +1009,6 @@ export default function ProjectDetailPage() {
                         ))}
                       </select>
                     </div>
-                    {/* Both buttons share the same email + role from this single form */}
                     <button
                       type="button"
                       disabled={inviting || !inviteEmail.trim()}
@@ -737,13 +1029,12 @@ export default function ProjectDetailPage() {
                       type="submit"
                       disabled={inviting || !inviteEmail.trim()}
                       className="btn btn-primary btn-md"
-                      title="Send invite — link is included automatically"
+                      title="Send invite"
                     >
                       {inviting ? 'Sending…' : 'Send Invite'}
                     </button>
                   </form>
 
-                  {/* Created link feedback */}
                   {createdLink && (
                     <div className="rounded-lg p-3 animate-scale-in"
                          style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
@@ -759,12 +1050,8 @@ export default function ProjectDetailPage() {
                     </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>
             )}
@@ -791,13 +1078,11 @@ export default function ProjectDetailPage() {
                       <div key={m.id}
                            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"
                              style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
                           {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
                         </div>
 
-                        {/* Info */}
                         <div className="flex-1 min-w-0">
                           <div className="flex items-center gap-2">
                             <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
@@ -808,12 +1093,10 @@ export default function ProjectDetailPage() {
                           <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
                         </div>
 
-                        {/* Joined date */}
                         <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
                           {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
                         </span>
 
-                        {/* Role */}
                         {editingRoleId === m.id ? (
                           <div className="flex items-center gap-2 shrink-0">
                             <select
@@ -826,21 +1109,12 @@ export default function ProjectDetailPage() {
                                 <option key={v} value={v}>{l}</option>
                               ))}
                             </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}>
                                 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                               </svg>
                             </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}>
                                 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
                               </svg>
@@ -904,8 +1178,6 @@ export default function ProjectDetailPage() {
                     {pendingInvites.map(inv => (
                       <div key={inv.id}
                            className="flex items-center gap-4 px-5 py-4">
-
-                        {/* Icon */}
                         <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
                              style={{ background: 'rgba(99,102,241,0.08)' }}>
                           <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -913,7 +1185,6 @@ export default function ProjectDetailPage() {
                           </svg>
                         </div>
 
-                        {/* Info */}
                         <div className="flex-1 min-w-0">
                           <div className="flex items-center gap-2">
                             <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
@@ -928,7 +1199,6 @@ export default function ProjectDetailPage() {
                           </div>
                         </div>
 
-                        {/* Actions */}
                         <div className="flex items-center gap-1.5 shrink-0">
                           <button
                             onClick={() => handleCopyLink(inv)}
@@ -976,6 +1246,14 @@ export default function ProjectDetailPage() {
         )}
       </div>
 
+      {/* Share modal */}
+      {sharingAssetId && (
+        <ShareModal
+          assetId={sharingAssetId}
+          onClose={() => setSharingAssetId(null)}
+        />
+      )}
+
       {/* Delete asset confirm modal */}
       {confirmDelete && (
         <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.
             </p>
             <div className="flex gap-3 justify-end">
-              <button
-                onClick={() => setConfirmDelete(null)}
-                disabled={!!deletingId}
-                className="btn btn-secondary btn-md"
-              >
-                Cancel
-              </button>
-              <button
-                onClick={confirmDeleteAsset}
-                disabled={!!deletingId}
-                className="btn btn-danger btn-md"
-              >
+              <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
+              <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
                 {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
               </button>
             </div>
@@ -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.
             </p>
             <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'}
               </button>
             </div>

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

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

+ 13 - 0
src/app/globals.css

@@ -403,3 +403,16 @@ body {
 
 /* ── Smooth scroll helper ───────────────────────────────────────── */
 .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 { assetsApi, commentsApi, AssetWithComments, Asset, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
 import { Avatar } from '@/components/ui/avatar';
+import { ShareModal } from '@/components/share/ShareModal';
 import { VideoPlayer } from '@/components/video-player/VideoPlayer';
 import { Tool } from '@/components/video-player/AnnotationCanvas';
 import { formatTimecode } from '@/lib/format';
@@ -47,6 +48,7 @@ export default function ReviewPage() {
   const [submitting, setSubmitting] = useState(false);
   const [replyTo, setReplyTo] = useState<Comment | null>(null);
   const [showResolved, setShowResolved] = useState(false);
+  const [showShareModal, setShowShareModal] = useState(false);
 
   // Drawing state — lifted to page level
   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)' }} />
 
+        {/* 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 */}
         <button
           onClick={() => {
@@ -1170,6 +1187,15 @@ export default function ReviewPage() {
         </div>
         )}
       </div>
+
+      {/* Share modal */}
+      {showShareModal && asset && (
+        <ShareModal
+          assetId={asset.id}
+          assetTitle={asset.title}
+          onClose={() => setShowShareModal(false)}
+        />
+      )}
     </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;
   onPause: (id: string) => void;
   onResume: (id: string) => void;
+  onReprocess: (id: string) => void;
 }
 
 const STATUS_CONFIG: Record<TranscodeStatus, {
@@ -42,6 +43,7 @@ function TranscodeTaskRow({
   onCancel,
   onPause,
   onResume,
+  onReprocess,
 }: {
   asset: Asset;
   canManage: boolean;
@@ -49,11 +51,13 @@ function TranscodeTaskRow({
   onCancel: (id: string) => void;
   onPause: (id: string) => void;
   onResume: (id: string) => void;
+  onReprocess: (id: string) => void;
 }) {
   const cfg = STATUS_CONFIG[asset.transcodeStatus] ?? STATUS_CONFIG.PENDING;
   const isActive = ['PENDING', 'UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus);
   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;
 
   return (
@@ -146,6 +150,23 @@ function TranscodeTaskRow({
       {/* Actions */}
       {canManage && (
         <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 && (
             <>
               {/* 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 filtered = assets.filter(a => {
@@ -311,6 +332,7 @@ export function TranscodeTasksPanel({ assets, canManage, onDelete, onCancel, onP
               onCancel={onCancel}
               onPause={onPause}
               onResume={onResume}
+              onReprocess={onReprocess}
             />
           ))}
         </div>

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

@@ -12,6 +12,14 @@ interface Props {
   onPause: (id: string) => void;
   onResume: (id: string) => void;
   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> = {
@@ -53,13 +61,36 @@ function formatTime(d: Date, showHour: boolean): string {
   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 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 (
     <div
       className="card overflow-hidden group"
       style={{ animation: `slideUp 0.25s ease-out ${animationDelay}ms both` }}
+      draggable={canManage}
+      onDragStart={canManage ? handleDragStart : undefined}
     >
       {/* Thumbnail */}
       <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>
         </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 */}
         <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
           <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
             <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
           </svg>
           <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
-          <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
-          <span className="shrink-0 text-[10px] truncate">{asset.filename}</span>
         </div>
 
         {/* Transcode status */}
@@ -222,6 +275,22 @@ export function AssetCard({ asset, canManage, showHour, onPlay, onDelete, onCanc
             </svg>
           </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 && (
             <button
               onClick={e => { e.stopPropagation(); onDelete(); }}

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

@@ -6,41 +6,31 @@ import { formatTimecode } from '@/lib/format';
 interface Props {
   comment: Comment;
   fps?: number;
-  /** CSS left position (%) within the video frame */
+  /** CSS left position (%) within the video frame — used to position the bubble tail */
   left?: number;
   onDismiss: () => void;
 }
 
 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 (
     <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={{
-        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',
         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 */}
       <div
         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>
   );
 }
+

+ 137 - 0
src/lib/api.ts

@@ -203,6 +203,23 @@ export const usersApi = {
       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) =>
     apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, {
       method: 'PUT',
@@ -410,6 +427,8 @@ export interface Asset {
   createdAt: string;
   uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
   _count?: { comments: number };
+  /** True if this asset has at least one public share link */
+  isShared?: boolean;
 }
 
 export interface AssetStatusInfo {
@@ -463,3 +482,121 @@ export interface AnnotationData {
   text?: string;
   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 }),
+};