Explorar o código

feat: project delete, responsive UI, draw defaults, and video detail improvements

Backend:
- DELETE /api/projects/:id: owner can now delete a project — cascades to all assets, files deleted from disk
- Include uploader info in asset list/get responses
- Prisma schema synced to include all production fields (uploaderId, transcodeStatus, etc.)

Frontend (projects page):
- Single-column layout on all screen sizes
- Sort by: recent activity (updatedAt), date created, name A–Z + direction toggle
- Toggle "Group by role" splits list into My Projects / Shared with Me
- Owner name shown on non-owned projects

Frontend (project detail):
- Uploader name, filename, and post date shown on every video card
- Delete project button in header (owner only) with confirmation modal
- Confirm dialog warns about all videos that will be permanently deleted
- Responsive padding (px-4 on mobile, px-8 on desktop)

Video player:
- Pause: no overlay tint, no play button — click or Space to resume playback

Annotation / draw mode:
- Default tool changed from pen to arrow
- Touch events added (iPhone/iPad support with preventDefault)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev hai 1 mes
pai
achega
a20ad6513c

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

@@ -103,6 +103,7 @@ router.get('/', async (req: Request, res: Response) => {
     const assets = await prisma.asset.findMany({
       where: { projectId },
       include: {
+        uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
         _count: { select: { comments: true } },
       },
       orderBy: { createdAt: 'desc' },
@@ -160,6 +161,7 @@ router.get('/:id', async (req: Request, res: Response) => {
         ...(isAdmin ? {} : { project: { members: { some: { userId: req.user!.userId } } } }),
       },
       include: {
+        uploader: { select: { id: true, name: true, email: true, avatarUrl: true } },
         project: {
           include: {
             members: {

+ 39 - 6
packages/api/src/routes/projects.ts

@@ -1,4 +1,6 @@
 import { Router, Request, Response } from 'express';
+import path from 'path';
+import fs from 'fs';
 import { prisma } from '../lib/prisma';
 import { authMiddleware } from '../lib/auth';
 
@@ -31,7 +33,7 @@ router.get('/', async (req: Request, res: Response) => {
         },
         _count: { select: { assets: true } },
       },
-      orderBy: { createdAt: 'desc' },
+      orderBy: { updatedAt: 'desc' },
     });
 
     const result = projects.map(p => {
@@ -125,7 +127,10 @@ router.get('/:id', async (req: Request, res: Response) => {
       return;
     }
 
-    res.json({ project });
+    // Add myRole to response
+    const myMembership = project.members.find(m => m.userId === req.user!.userId);
+    const isOwner = project.ownerId === req.user!.userId;
+    res.json({ project: { ...project, myRole: isOwner ? 'OWNER' : myMembership?.role ?? null } });
   } catch (err) {
     console.error('Get project error:', err);
     res.status(500).json({ error: 'Internal server error' });
@@ -170,15 +175,43 @@ router.put('/:id', async (req: Request, res: Response) => {
 // DELETE /api/projects/:id
 router.delete('/:id', async (req: Request, res: Response) => {
   try {
-    const membership = await prisma.projectMember.findFirst({
-      where: { projectId: str(req.params.id), userId: req.user!.userId, role: 'ADMIN' },
+    const project = await prisma.project.findUnique({
+      where: { id: str(req.params.id) },
+      include: {
+        assets: { select: { id: true, filePath: true, thumbnail: true, hlsPath: true } },
+      },
     });
 
-    if (!membership) {
-      res.status(403).json({ error: 'Forbidden — must be admin' });
+    if (!project) {
+      res.status(404).json({ error: 'Project not found' });
       return;
     }
 
+    const isAdmin = req.user!.globalRole === 'ADMIN';
+    const isOwner = project.ownerId === req.user!.userId;
+
+    if (!isAdmin && !isOwner) {
+      res.status(403).json({ error: 'Forbidden — only the project owner can delete this project' });
+      return;
+    }
+
+    const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
+
+    // Delete all asset files from disk
+    for (const asset of project.assets) {
+      const fullPath = path.join(UPLOAD_DIR, asset.filePath);
+      if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath);
+      if (asset.thumbnail) {
+        const thumbPath = path.join(UPLOAD_DIR, asset.thumbnail);
+        if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
+      }
+      if (asset.hlsPath) {
+        const hlsDir = path.join(UPLOAD_DIR, 'hls', asset.id);
+        if (fs.existsSync(hlsDir)) fs.rmSync(hlsDir, { recursive: true, force: true });
+      }
+    }
+
+    // Cascade delete via Prisma: assets → comments (cascades) → members → invitations → project
     await prisma.project.delete({ where: { id: str(req.params.id) } });
     res.json({ message: 'Project deleted' });
   } catch (err) {

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

@@ -31,6 +31,7 @@ router.get('/', async (req: Request, res: Response) => {
         // total storage used = sum of assets in projects this user owns
         projects: {
           select: {
+            id: true,
             assets: {
               select: { fileSize: true },
             },

+ 109 - 36
prisma/schema.prisma

@@ -11,63 +11,90 @@ datasource db {
 }
 
 model User {
-  id          String   @id @default(cuid())
-  email       String   @unique
-  name        String
-  password    String
-  avatarUrl   String?
-  role        Role     @default(REVIEWER)
-  createdAt   DateTime @default(now())
-  updatedAt   DateTime @updatedAt
-
-  memberships ProjectMember[]
-  comments    Comment[]
+  id            String    @id @default(cuid())
+  email         String    @unique
+  name          String
+  password      String
+  avatarUrl     String?
+  globalRole    GlobalRole @default(MEMBER)
+  active        Boolean   @default(true)
+  storageQuota  Int        @default(524288000) // 500 MB in bytes
+  storageUsed   Int        @default(0)         // bytes consumed
+  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[]
 }
 
 model Project {
   id          String   @id @default(cuid())
   name        String
   description String?
+  ownerId     String
   createdAt   DateTime @default(now())
   updatedAt   DateTime @updatedAt
 
-  assets Asset[]
-  members ProjectMember[]
-  owner   ProjectMember?
+  assets      Asset[]
+  members     ProjectMember[] @relation("ProjectMembers")
+  invitations Invitation[]
+  owner       User     @relation("ProjectOwner", fields: [ownerId], references: [id])
+}
+
+model SiteSetting {
+  id    String @id @default(cuid())
+  name  String @unique
+  value String
 }
 
 model ProjectMember {
-  id        String @id @default(cuid())
+  id        String   @id @default(cuid())
   userId    String
   projectId String
-  role      Role  @default(REVIEWER)
+  role      Role     @default(REVIEWER)
   joinedAt  DateTime @default(now())
+  invitedBy String?    // userId who sent the invite
 
-  user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
-  project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+  user    User    @relation("ProjectMembers", fields: [userId], references: [id], onDelete: Cascade)
+  project Project @relation("ProjectMembers", fields: [projectId], references: [id], onDelete: Cascade)
 
   @@unique([userId, projectId])
+  @@index([projectId])
+  @@index([userId])
 }
 
 model Asset {
-  id        String      @id @default(cuid())
-  projectId String
-  title     String
-  filename  String
-  filePath  String
-  thumbnail String?
-  duration  Float?
-  mimeType  String
-  status    AssetStatus @default(PENDING_REVIEW)
-  createdAt DateTime    @default(now())
-  updatedAt DateTime    @updatedAt
+  id              String          @id @default(cuid())
+  projectId       String
+  uploaderId      String?         // null for legacy assets before this feature
+  title           String
+  filename        String
+  filePath        String
+  thumbnail       String?
+  hlsPath         String?
+  duration        Float?
+  fps             Float           @default(30)
+  codec           String?
+  mimeType        String
+  fileSize        Int              @default(0)   // raw video file size in bytes
+  status          AssetStatus     @default(PENDING_REVIEW)
+  transcodeStatus TranscodeStatus @default(PENDING)
+  transcodeProgress Int            @default(0)
+  transcodeError  String?
+  createdAt       DateTime        @default(now())
+  updatedAt       DateTime        @updatedAt
 
   project  Project  @relation(fields: [projectId], references: [id], onDelete: Cascade)
+  uploader User?    @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
   comments Comment[]
 }
 
 model Comment {
-  id           String         @id @default(cuid())
+  id            String         @id @default(cuid())
   assetId      String
   userId       String
   content      String
@@ -91,12 +118,6 @@ model Comment {
   requestedBy User?    @relation("RequestedBy", fields: [requestedById], references: [id])
 }
 
-enum ResolveStatus {
-  UNRESOLVED
-  PENDING_APPROVAL
-  RESOLVED
-}
-
 enum Role {
   ADMIN
   EDITOR
@@ -104,9 +125,61 @@ enum Role {
   VIEWER
 }
 
+enum GlobalRole {
+  ADMIN        // system-wide admin: manage users, all projects, quotas
+  MEMBER       // registered user: create own projects, invite members
+  PROJECT_USER // invited user: no project creation, workspace visibility only via invite
+}
+
+enum InvitationStatus {
+  PENDING
+  ACCEPTED
+  EXPIRED
+  REVOKED
+}
+
+model Invitation {
+  id         String           @id @default(cuid())
+  email     String            // invitee email
+  projectId String?           // null = workspace invite (creates MEMBER); set = project invite (creates PROJECT_USER)
+  type      InvitationType   @default(PROJECT)
+  role      Role              @default(REVIEWER)
+  token     String            @unique
+  status    InvitationStatus  @default(PENDING)
+  invitedBy String?           // userId who sent the invite
+  expiresAt DateTime
+  createdAt DateTime         @default(now())
+
+  project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
+
+  @@index([projectId])
+  @@index([email])
+  @@index([token])
+}
+
+enum InvitationType {
+  WORKSPACE  // admin invites MEMBER — no project attached, user registers as MEMBER
+  PROJECT   // admin/project member invites PROJECT_USER — attached to a project
+}
+
 enum AssetStatus {
   PENDING_REVIEW
   CHANGES_REQUESTED
   APPROVED
   REJECTED
 }
+
+enum ResolveStatus {
+  UNRESOLVED       // no request made
+  PENDING_APPROVAL // someone requested resolve, awaiting approval
+  RESOLVED         // approved and closed
+}
+
+enum TranscodeStatus {
+  PENDING
+  UPLOADING
+  PROCESSING
+  COMPLETED
+  FAILED
+  UNSUPPORTED_CODEC
+}

+ 96 - 4
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -78,10 +78,29 @@ export default function ProjectDetailPage() {
   const canManage = members.some(m =>
     m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
   );
+  const isOwner = project?.ownerId === user?.id;
   const isAdmin = members.some(m =>
     m.user.id === user?.id && m.role === 'ADMIN'
   );
 
+  // ── Delete project ──────────────────────────────────────────────────────────
+  const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
+  const [deletingProject, setDeletingProject] = useState(false);
+
+  const handleDeleteProject = async () => {
+    if (!token) return;
+    setDeletingProject(true);
+    try {
+      await projectsApi.delete(token, projectId);
+      router.push('/projects');
+    } catch (err) {
+      alert(err instanceof Error ? err.message : 'Failed to delete project');
+    } finally {
+      setDeletingProject(false);
+      setConfirmDeleteProject(false);
+    }
+  };
+
   const loadAll = useCallback(async () => {
     if (!token) return;
     try {
@@ -333,7 +352,7 @@ export default function ProjectDetailPage() {
     <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
 
       {/* Header */}
-      <header className="sticky top-0 z-10 px-8 py-4 flex items-center gap-5 shrink-0"
+      <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-3 md:gap-5 shrink-0"
               style={{
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
@@ -410,13 +429,28 @@ export default function ProjectDetailPage() {
           ))}
         </div>
 
-        <div className="text-xs px-2.5 py-1 rounded-full"
+        <div className="text-xs px-2 py-1.5 md:px-2.5 md:py-1 rounded-full shrink-0"
              style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
           {assets.length} video{assets.length !== 1 ? 's' : ''}
         </div>
+
+        {/* Delete project — owner only */}
+        {isOwner && (
+          <button
+            onClick={() => setConfirmDeleteProject(true)}
+            className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all shrink-0"
+            style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
+            title="Delete project"
+          >
+            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
+            </svg>
+            <span className="hidden sm:inline">Delete</span>
+          </button>
+        )}
       </header>
 
-      <div className="px-8 py-6">
+      <div className="px-4 md:px-8 py-4 md:py-6">
 
         {/* ── Videos Tab ───────────────────────────────────────────────────── */}
         {activeTab === 'videos' && (
@@ -593,7 +627,7 @@ export default function ProjectDetailPage() {
 
                     {/* Info */}
                     <div className="p-4">
-                      <div className="flex items-start justify-between gap-2 mb-2">
+                      <div className="flex items-start justify-between gap-2 mb-1.5">
                         <h3 className="text-sm font-medium truncate flex-1 transition-colors"
                             style={{ color: 'var(--text)' }}>
                           {asset.title}
@@ -603,6 +637,20 @@ export default function ProjectDetailPage() {
                         </span>
                       </div>
 
+                      {/* Uploader + date */}
+                      <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
+                        <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+                          <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
+                        </svg>
+                        <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
+                        <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
+                        <span className="shrink-0 text-[10px]">
+                          {new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+                        </span>
+                        <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
+                        <span className="shrink-0 text-[10px] truncate">{asset.filename}</span>
+                      </div>
+
                       {/* Transcode status row */}
                       {asset.transcodeStatus !== 'COMPLETED' && (
                         <div className="mb-2 flex items-center gap-1.5">
@@ -1048,6 +1096,50 @@ export default function ProjectDetailPage() {
           </div>
         </div>
       )}
+
+      {/* Delete project confirm modal */}
+      {confirmDeleteProject && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center"
+             style={{ background: 'rgba(0,0,0,0.7)' }}>
+          <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
+            <div className="flex items-center gap-3 mb-4">
+              <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
+                   style={{ background: 'rgba(248,113,113,0.15)' }}>
+                <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                  <path strokeLinecap="round" strokeLinejoin="round" d="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>
+              </div>
+              <div>
+                <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>
+                  Delete "{project?.name}"?
+                </h3>
+                <p className="text-xs mt-0.5" style={{ color: '#F87171' }}>
+                  {assets.length} video{assets.length !== 1 ? 's' : ''} will be permanently deleted
+                </p>
+              </div>
+            </div>
+            <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
+              This will permanently delete the project, all videos, comments, and assets. This action cannot be undone.
+            </p>
+            <div className="flex gap-3 justify-end">
+              <button
+                onClick={() => setConfirmDeleteProject(false)}
+                disabled={deletingProject}
+                className="btn btn-secondary btn-md"
+              >
+                Cancel
+              </button>
+              <button
+                onClick={handleDeleteProject}
+                disabled={deletingProject}
+                className="btn btn-danger btn-md"
+              >
+                {deletingProject ? 'Deleting…' : 'Delete Project'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 173 - 46
src/app/(dashboard)/projects/page.tsx

@@ -1,6 +1,6 @@
 'use client';
 
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
 import { useAuth } from '@/lib/auth-context';
 import { projectsApi, Project } from '@/lib/api';
 import { Modal } from '@/components/ui/modal';
@@ -8,6 +8,9 @@ import { Avatar } from '@/components/ui/avatar';
 import { useRouter } from 'next/navigation';
 import Link from 'next/link';
 
+type SortKey = 'createdAt' | 'updatedAt' | 'name';
+type SortDir = 'asc' | 'desc';
+
 export default function ProjectsPage() {
   const { user, token } = useAuth();
   const router = useRouter();
@@ -22,6 +25,11 @@ export default function ProjectsPage() {
   const [inviteRole, setInviteRole] = useState('REVIEWER');
   const [inviting, setInviting] = useState(false);
 
+  // Sort & group state
+  const [sortKey, setSortKey] = useState<SortKey>('updatedAt');
+  const [sortDir, setSortDir] = useState<SortDir>('desc');
+  const [groupByRole, setGroupByRole] = useState(false);
+
   const loadProjects = useCallback(async () => {
     if (!token) return;
     try {
@@ -36,6 +44,27 @@ export default function ProjectsPage() {
 
   useEffect(() => { loadProjects(); }, [loadProjects]);
 
+  // ── Sort & group ─────────────────────────────────────────────────────────────
+  const { myProjects, otherProjects } = useMemo(() => {
+    const sorted = [...projects].sort((a, b) => {
+      let aVal: string, bVal: string;
+      if (sortKey === 'name') {
+        aVal = a.name.toLowerCase();
+        bVal = b.name.toLowerCase();
+      } else {
+        aVal = a[sortKey] ?? '';
+        bVal = b[sortKey] ?? '';
+      }
+      if (aVal < bVal) return sortDir === 'asc' ? -1 : 1;
+      if (aVal > bVal) return sortDir === 'asc' ? 1 : -1;
+      return 0;
+    });
+
+    const mine = sorted.filter(p => p.myRole === 'OWNER' || p.ownerId === user?.id);
+    const other = sorted.filter(p => p.ownerId !== user?.id);
+    return { myProjects: mine, otherProjects: other };
+  }, [projects, sortKey, sortDir, user?.id]);
+
   const handleCreate = async (e: React.FormEvent) => {
     e.preventDefault();
     if (!token || !createName.trim()) return;
@@ -73,21 +102,76 @@ export default function ProjectsPage() {
     <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
 
       {/* ── Header ─────────────────────────────────────────────── */}
-      <header className="sticky top-0 z-10 px-8 py-4 flex items-center justify-between shrink-0"
+      <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 shrink-0"
               style={{
                 background: 'rgba(10,11,20,0.80)',
                 backdropFilter: 'blur(12px)',
                 borderBottom: '1px solid rgba(255,255,255,0.06)',
               }}>
-        <div>
-          <h1 className="text-xl font-semibold" style={{ color: 'var(--text)' }}>Projects</h1>
-          <p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
+        <div className="flex-1 min-w-0">
+          <h1 className="text-lg md:text-xl font-semibold" style={{ color: 'var(--text)' }}>Projects</h1>
+          <p className="text-xs md:text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>
             {loading ? '…' : `${projects.length} project${projects.length !== 1 ? 's' : ''}`}
           </p>
         </div>
+
+        {/* Sort + group controls */}
+        <div className="flex items-center gap-2 flex-wrap">
+          {/* Group toggle */}
+          <button
+            onClick={() => setGroupByRole(g => !g)}
+            className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all"
+            style={{
+              background: groupByRole ? 'rgba(99,102,241,0.15)' : 'rgba(255,255,255,0.04)',
+              color: groupByRole ? '#A5B4FC' : 'var(--text-muted)',
+              border: groupByRole ? '1px solid rgba(99,102,241,0.3)' : '1px solid rgba(255,255,255,0.06)',
+            }}
+          >
+            <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="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>
+            Group by role
+          </button>
+
+          {/* Sort select */}
+          <select
+            value={sortKey}
+            onChange={e => setSortKey(e.target.value as SortKey)}
+            className="text-xs px-2 py-1.5 rounded-lg appearance-none"
+            style={{
+              background: 'rgba(255,255,255,0.04)',
+              color: 'var(--text-muted)',
+              border: '1px solid rgba(255,255,255,0.06)',
+              cursor: 'pointer',
+            }}
+          >
+            <option value="updatedAt">Recent activity</option>
+            <option value="createdAt">Date created</option>
+            <option value="name">Name (A–Z)</option>
+          </select>
+
+          {/* Sort direction */}
+          <button
+            onClick={() => setSortDir(d => d === 'asc' ? 'desc' : 'asc')}
+            className="p-1.5 rounded-lg"
+            style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}
+            title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
+          >
+            {sortDir === 'asc' ? (
+              <svg className="w-3.5 h-3.5" style={{ color: 'var(--text-muted)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0v-8.25m0 8.25h-8.25" />
+              </svg>
+            ) : (
+              <svg className="w-3.5 h-3.5" style={{ color: 'var(--text-muted)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+                <path strokeLinecap="round" strokeLinejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0V6.75m0 6.75h-8.25" />
+              </svg>
+            )}
+          </button>
+        </div>
+
         <button
           onClick={() => setShowCreate(true)}
-          className="btn btn-primary btn-md flex items-center gap-2"
+          className="btn btn-primary btn-md flex items-center gap-2 shrink-0"
         >
           <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="M12 4.5v15m7.5-7.5h-15" />
@@ -97,16 +181,16 @@ export default function ProjectsPage() {
       </header>
 
       {/* ── Content ─────────────────────────────────────────────── */}
-      <div className="px-8 py-6">
+      <div className="px-4 md:px-8 py-4 md:py-6">
 
         {/* Loading skeletons */}
         {loading && (
-          <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
-            {[1,2,3,4,5,6].map(i => (
-              <div key={i} className="card p-5 h-36"
+          <div className="space-y-3">
+            {[1,2,3,4].map(i => (
+              <div key={i} className="card p-4"
                    style={{ animation: `fadeIn 0.2s ease-out ${i * 60}ms both` }}>
-                <div className="skeleton h-4 w-3/4 mb-3 rounded-md" />
-                <div className="skeleton h-3 w-full mb-5 rounded-md" />
+                <div className="skeleton h-4 w-3/4 mb-2 rounded-md" />
+                <div className="skeleton h-3 w-full mb-3 rounded-md" />
                 <div className="skeleton h-3 w-1/3 rounded-md" />
               </div>
             ))}
@@ -137,17 +221,62 @@ export default function ProjectsPage() {
           </div>
         )}
 
-        {/* Project grid */}
+        {/* Project list — single column */}
         {!loading && projects.length > 0 && (
-          <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
-            {projects.map((project, i) => (
-              <ProjectCard
-                key={project.id}
-                project={project}
-                index={i}
-                onInvite={() => setInviteModal({ projectId: project.id, name: project.name })}
-              />
-            ))}
+          <div className="space-y-3">
+            {groupByRole ? (
+              <>
+                {myProjects.length > 0 && (
+                  <section>
+                    <div className="flex items-center gap-2 mb-2">
+                      <span className="text-[11px] font-semibold uppercase tracking-wider px-2 py-1 rounded"
+                            style={{ background: 'rgba(99,102,241,0.12)', color: '#818CF8' }}>
+                        My Projects ({myProjects.length})
+                      </span>
+                    </div>
+                    <div className="space-y-3">
+                      {myProjects.map((project, i) => (
+                        <ProjectCard
+                          key={project.id}
+                          project={project}
+                          index={i}
+                          onInvite={() => setInviteModal({ projectId: project.id, name: project.name })}
+                        />
+                      ))}
+                    </div>
+                  </section>
+                )}
+                {otherProjects.length > 0 && (
+                  <section className="mt-6">
+                    <div className="flex items-center gap-2 mb-2">
+                      <span className="text-[11px] font-semibold uppercase tracking-wider px-2 py-1 rounded"
+                            style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
+                        Shared with Me ({otherProjects.length})
+                      </span>
+                    </div>
+                    <div className="space-y-3">
+                      {otherProjects.map((project, i) => (
+                        <ProjectCard
+                          key={project.id}
+                          project={project}
+                          index={i}
+                          onInvite={() => setInviteModal({ projectId: project.id, name: project.name })}
+                        />
+                      ))}
+                    </div>
+                  </section>
+                )}
+              </>
+            ) : (
+              projects.map((project, i) => (
+                <ProjectCard
+                  key={project.id}
+                  project={project}
+                  index={i}
+                  onInvite={() => setInviteModal({ projectId: project.id, name: project.name })}
+                />
+              ))
+            )}
           </div>
         )}
       </div>
@@ -247,24 +376,28 @@ export default function ProjectsPage() {
   );
 }
 
-function ProjectCard({ project, index, onInvite }: {
+function ProjectCard({ project, index, onInvite, showOwner = false }: {
   project: Project;
   index: number;
   onInvite: () => void;
+  showOwner?: boolean;
 }) {
   const assetCount = project._count?.assets ?? 0;
   const memberCount = project.members?.length ?? 0;
 
+  // Owner name — find the owner member record
+  const owner = project.members?.find(m => m.user.id === project.ownerId);
+
   return (
     <Link
       href={`/projects/${project.id}`}
-      className="card block p-5 group"
+      className="card block p-4 group"
       style={{ animationDelay: `${index * 50}ms`, animation: `slideUp 0.25s ease-out ${index * 50}ms both` }}
     >
       {/* Header row */}
-      <div className="flex items-start justify-between mb-4">
+      <div className="flex items-start justify-between mb-3">
         <div className="flex items-center gap-3 min-w-0">
-          <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors"
+          <div className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
                style={{ background: 'rgba(99,102,241,0.12)', border: '1px solid rgba(99,102,241,0.18)' }}>
             <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
               <path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h1.5A1.125 1.125 0 005.625 18.375m-2.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0112 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375m18.75 0c.621 0 1.125.504 1.125 1.125m-18.75 0v1.5c0 .621-.504 1.125-1.125 1.125M12 10.875v-1.5m0 4.5c0 .621.504 1.125 1.125 1.125m0-4.5c0-.621-.504-1.125-1.125-1.125m0-4.5c0 .621.504 1.125 1.125 1.125m0-4.5c0 .621.504 1.125 1.125 1.125M12 10.875c0 .621-.504 1.125-1.125 1.125M12 10.875v1.5m0-4.5c0 .621.504 1.125 1.125 1.125m0-4.5v4.5" />
@@ -290,6 +423,17 @@ function ProjectCard({ project, index, onInvite }: {
                 </span>
               )}
             </div>
+            <div className="flex items-center gap-2 text-[11px] mt-0.5" style={{ color: 'var(--text-muted)' }}>
+              {showOwner && owner && (
+                <>
+                  <span>{owner.user.name}</span>
+                  <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
+                </>
+              )}
+              <span>{assetCount} video{assetCount !== 1 ? 's' : ''}</span>
+              <span className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
+              <span>{memberCount} member{memberCount !== 1 ? 's' : ''}</span>
+            </div>
             {project.description && (
               <p className="text-xs mt-0.5 truncate" style={{ color: 'var(--text-muted)' }}>
                 {project.description}
@@ -299,7 +443,7 @@ function ProjectCard({ project, index, onInvite }: {
         </div>
 
         {/* Arrow indicator */}
-        <div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ml-2 transition-all"
+        <div className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ml-2 transition-all min-w-[32px] min-h-[32px]"
              style={{ background: 'rgba(255,255,255,0.04)' }}>
           <svg className="w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5"
                style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -308,25 +452,8 @@ function ProjectCard({ project, index, onInvite }: {
         </div>
       </div>
 
-      {/* Stats */}
-      <div className="flex items-center gap-3 text-xs mb-4" style={{ color: 'var(--text-muted)' }}>
-        <div className="flex items-center gap-1.5">
-          <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="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>
-          <span>{assetCount} video{assetCount !== 1 ? 's' : ''}</span>
-        </div>
-        <div className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
-        <div className="flex items-center gap-1.5">
-          <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="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
-          </svg>
-          <span>{memberCount} member{memberCount !== 1 ? 's' : ''}</span>
-        </div>
-      </div>
-
       {/* Footer */}
-      <div className="flex items-center justify-between pt-3.5" style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
+      <div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid rgba(255,255,255,0.06)' }}>
         {/* Member avatars */}
         <div className="flex -space-x-1.5">
           {(project.members ?? []).slice(0, 5).map(m => (
@@ -351,7 +478,7 @@ function ProjectCard({ project, index, onInvite }: {
         {/* Invite button */}
         <button
           onClick={e => { e.preventDefault(); onInvite(); }}
-          className="text-xs font-medium px-2.5 py-1 rounded-md transition-all"
+          className="text-xs font-medium px-3 py-1.5 rounded-lg transition-all min-h-[36px] min-w-[36px]"
           style={{ color: '#818CF8', background: 'rgba(99,102,241,0.08)' }}
         >
           + Invite

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

@@ -57,7 +57,7 @@ export default function ReviewPage() {
 
   // Drawing state — lifted to page level
   const [drawMode, setDrawMode] = useState(false);
-  const [drawTool, setDrawTool] = useState<Tool>('pen');
+  const [drawTool, setDrawTool] = useState<Tool>('arrow');
   const [drawColor, setDrawColor] = useState('#ef4444');
   const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
   // The comment we're annotating (null = annotating the main video, not a specific comment)

+ 45 - 1
src/components/video-player/AnnotationCanvas.tsx

@@ -137,8 +137,15 @@ export function AnnotationCanvas({
   }, [width, height, pendingStrokes]);
 
   // ── Normalise mouse / touch position ────────────────────────────────────────
-  function pos(e: React.MouseEvent<HTMLCanvasElement>): [number, number] {
+  function pos(e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>): [number, number] {
     const rect = canvasRef.current!.getBoundingClientRect();
+    if ('touches' in e) {
+      const touch = e.touches[0] ?? e.changedTouches[0];
+      return [
+        (touch.clientX - rect.left) / rect.width,
+        (touch.clientY - rect.top) / rect.height,
+      ];
+    }
     return [
       (e.clientX - rect.left) / rect.width,
       (e.clientY - rect.top) / rect.height,
@@ -178,6 +185,39 @@ export function AnnotationCanvas({
     redraw();
   };
 
+  // ── Touch handlers ────────────────────────────────────────────────────────────
+  const onTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
+    if (!isActive) return;
+    e.preventDefault();
+    e.stopPropagation();
+    const [x, y] = pos(e);
+    isDrawingRef.current = true;
+    drawRef.current = { type: tool, color, startX: x, startY: y, points: [[x, y]] };
+  };
+
+  const onTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
+    if (!isDrawingRef.current || !drawRef.current) return;
+    e.preventDefault();
+    e.stopPropagation();
+    const [x, y] = pos(e);
+    const pts = drawRef.current.points;
+    drawRef.current = { ...drawRef.current, points: [...pts, [x, y]] };
+    redraw();
+  };
+
+  const onTouchEnd = (e: React.TouchEvent<HTMLCanvasElement>) => {
+    if (!isDrawingRef.current || !drawRef.current) return;
+    e.preventDefault();
+    e.stopPropagation();
+    isDrawingRef.current = false;
+    const [x, y] = pos(e);
+    const pts = [...drawRef.current.points, [x, y] as [number, number]];
+    drawRef.current = { ...drawRef.current, points: pts };
+    onStrokeComplete(toAnnotation(drawRef.current));
+    drawRef.current = null;
+    redraw();
+  };
+
   return (
     <canvas
       ref={canvasRef}
@@ -185,12 +225,16 @@ export function AnnotationCanvas({
       style={{
         cursor: isActive ? 'crosshair' : 'default',
         pointerEvents: isActive ? 'auto' : 'none',
+        touchAction: 'none',
       }}
       onClick={e => { if (isActive) e.stopPropagation(); }}
       onMouseDown={onDown}
       onMouseMove={onMove}
       onMouseUp={onUp}
       onMouseLeave={onUp}
+      onTouchStart={onTouchStart}
+      onTouchMove={onTouchMove}
+      onTouchEnd={onTouchEnd}
     />
   );
 }

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

@@ -296,15 +296,10 @@ export function VideoPlayer({
       {/* Big play button overlay */}
       {!playing && !drawMode && (
         <button
-          className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
+          className="absolute inset-0 flex items-center justify-center z-20"
           onClick={togglePlay}
-        >
-          <div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
-            <svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M8 5v14l11-7z" />
-            </svg>
-          </div>
-        </button>
+          aria-label="Play video"
+        />
       )}
 
       {/* Controls overlay */}

+ 2 - 0
src/lib/api.ts

@@ -320,6 +320,7 @@ export interface Project {
   description?: string | null;
   ownerId: string;
   createdAt: string;
+  updatedAt: string;
   /** Current user's role in this project: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' | null */
   myRole: string | null;
   members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>;
@@ -395,6 +396,7 @@ export interface Asset {
   transcodeProgress: number;
   transcodeError?: string | null;
   createdAt: string;
+  uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
   _count?: { comments: number };
 }