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

feat: guest comments, portrait video, and share token page

Phase 6 finalization — Guest Comments + 4K Support feature:
- Allow unregistered (guest) comments on shared video reviews
  with name prompt; Comment.userId becomes nullable with guestName
- AssetShareLink gains allowUnregisteredComments toggle
  wired through ShareModal create/edit forms and share.ts API
- VideoPlayer accepts isPortrait prop for 9:16 portrait videos
  (maxWidth: min(100%, 55vh), aspectRatio: 9/16)
- Share token page (/share/[token]) for public video review with
  guest comment form when allowUnregisteredComments is enabled
- Dockerfile.api now regenerates Prisma client from scratch
- docker-compose.yml Caddy port aligned to 8080
- CLAUDE.md documents dev/test environment workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kingkong преди 1 месец
родител
ревизия
4caaa7a7fb

+ 87 - 0
CLAUDE.md

@@ -1,4 +1,91 @@
 <!-- code-review-graph MCP tools -->
+
+## Môi trường
+
+| Môi trường | URL / Port | Mục đích | Quy tắc |
+|------------|-----------|---------|---------|
+| **local** | localhost | Phát triển, debug nhanh | Không commit trực tiếp vào test |
+| **dev** | dev-vid.k9tech.space | Integration test trước khi lên test | ✅ Dùng để verify trước khi merge |
+| **test** | (hiện tại) | Production-like validation | 🔒 Read-mostly, không test trực tiếp |
+
+## Dev Team Workflow
+
+### Luồng phát triển
+
+```
+Checkout từ main
+    ↓
+Tạo branch: feature/<ticket>-<mô-tả>
+    ↓
+Phát triển + chạy lint + typecheck + unit tests trên local
+    ↓
+Deploy lên dev để integration test
+    ↓
+Tạo PR / merge vào main
+    ↓
+Deploy lên test khi đã ổn định trên dev
+```
+
+### Git conventions
+
+Branch naming: `feature/<ticket>-<mô-tả-ngắn>`
+
+Commit message prefix:
+- `feat:` tính năng mới
+- `fix:` sửa bug
+- `refactor:` tái cấu trúc
+- `test:` thêm/sửa test
+- `docs:` cập nhật docs
+- `chore:` config, dependency
+
+### Trước khi merge/PR
+
+```bash
+git fetch origin && git rebase origin/main
+npm run lint && npm run typecheck && npm test
+```
+
+### Database migration — KHÔNG bao giờ chạy trực tiếp trên test
+
+1. Tạo migration file mới
+2. Apply lên **dev** trước
+3. Verify trên dev → chỉ khi ổn định mới apply lên test
+4. Backup DB trước khi thay đổi lớn
+5. Migration phải idempotent
+
+### Các lệnh thường dùng
+
+```bash
+# Deploy lên dev
+./scripts/deploy.sh dev <branch-name>
+
+# Xem worker logs trên dev
+docker logs -f vidreview-worker-dev
+
+# Check health dev
+curl https://dev-vid.k9tech.space/api/health
+
+# Reset stuck jobs trên dev
+docker restart vidreview-worker-dev
+```
+
+### Quy tắc quan trọng
+
+| ✅ NÊN | ❌ KHÔNG |
+|--------|----------|
+| Test trên dev trước | Test trực tiếp trên test |
+| Dùng `.env.local` cho local | Hardcode credentials |
+| Viết migration idempotent | Migration fail giữa chừng |
+| Backup DB trước thay đổi lớn | Sửa schema trên test không qua migration |
+| Có test cho logic mới | Deploy lên test sau giờ làm việc |
+
+### Incident response
+
+1. Báo team ngay
+2. Không tự ý restart services trên test
+3. Điều tra trên dev trước
+4. Fix → merge → deploy dev → verify → deploy test
+5. Post-mortem sau khi resolved
 ## MCP Tools: code-review-graph
 
 **IMPORTANT: This project has a knowledge graph. ALWAYS use the

+ 1 - 1
Dockerfile.api

@@ -9,7 +9,7 @@ WORKDIR /app
 COPY packages/api/package*.json ./
 COPY packages/api/prisma ./prisma
 COPY packages/api/tsconfig.json ./
-RUN npm install && npx prisma generate
+RUN npm install && rm -rf node_modules/@prisma/client && npx prisma generate
 
 # Copy source code
 COPY packages/api/src ./src

+ 2 - 6
docker-compose.yml

@@ -49,9 +49,7 @@ services:
 
   # ── API ─────────────────────────────────────────────────────────────────
   api:
-    build:
-      context: .
-      dockerfile: Dockerfile.api
+    image: vidreview-api:v0.2
     container_name: vidreview-api
     environment:
       DATABASE_URL: ${DATABASE_URL:?Required}
@@ -79,9 +77,7 @@ services:
 
   # ── Transcode Worker ─────────────────────────────────────────────────────
   worker:
-    build:
-      context: .
-      dockerfile: Dockerfile.api
+    image: vidreview-worker:v0.2
     container_name: vidreview-worker
     command: node src/worker/index.js
     environment:

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

@@ -79,6 +79,8 @@ model Asset {
   filePath        String
   thumbnail       String?
   hlsPath         String?
+  originalFilePath String?
+  maxResolution    String?
   duration        Float?
   fps             Float           @default(30)
   codec           String?
@@ -103,7 +105,8 @@ model Asset {
 model Comment {
   id            String         @id @default(cuid())
   assetId      String
-  userId       String
+  userId       String?
+  guestName    String?
   content      String
   timestamp    Float?
   annotations  Json?
@@ -121,7 +124,7 @@ model Comment {
   updatedAt    DateTime       @updatedAt
 
   asset       Asset     @relation(fields: [assetId], references: [id], onDelete: Cascade)
-  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  user        User?      @relation(fields: [userId], references: [id], onDelete: Cascade)
   parent      Comment?  @relation("Replies", fields: [parentId], references: [id], onDelete: Cascade)
   replies     Comment[] @relation("Replies")
   resolvedBy  User?     @relation("ResolvedBy", fields: [resolvedById], references: [id])
@@ -201,8 +204,9 @@ model AssetShareLink {
   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
+  allowDownload                Boolean  @default(false)
+  allowUnregisteredComments   Boolean  @default(false)
+  maxViews                    Int      @default(20)  // 0 or -1 = unlimited
   viewCount     Int      @default(0)
   createdById  String
   createdAt     DateTime @default(now())

+ 180 - 17
packages/api/src/routes/share.ts

@@ -37,7 +37,6 @@ async function canManageShareLinks(userId: string, globalRole: string, assetId:
 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);
@@ -63,6 +62,7 @@ router.get('/', authMiddleware, async (req, res) => {
         shareUrl: `${FRONTEND_URL}/share/${shareLink.token}`,
         hasPassword: !!shareLink.password,
         allowDownload: shareLink.allowDownload,
+        allowUnregisteredComments: shareLink.allowUnregisteredComments,
         maxViews: shareLink.maxViews,
         viewCount: shareLink.viewCount,
         createdAt: shareLink.createdAt,
@@ -75,13 +75,13 @@ router.get('/', authMiddleware, async (req, res) => {
 });
 
 // ── 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 {
+    const { assetId, password, allowDownload, allowUnregisteredComments, maxViews } = req.body as {
       assetId: string;
       password?: string;
       allowDownload?: boolean;
+      allowUnregisteredComments?: boolean;
       maxViews?: number;
     };
 
@@ -96,7 +96,6 @@ router.post('/', authMiddleware, async (req, res) => {
       return;
     }
 
-    // Generate unique token
     let token = randomBytes(16).toString('hex');
     let attempts = 0;
     while (attempts < 5) {
@@ -115,14 +114,16 @@ router.post('/', authMiddleware, async (req, res) => {
         token,
         password: hashedPassword,
         allowDownload: !!allowDownload,
+        allowUnregisteredComments: !!allowUnregisteredComments,
         maxViews: resolvedMaxViews,
         createdById: req.user!.userId,
       },
       select: {
         id: true,
         token: true,
-        password: true, // will be null or hash
+        password: true,
         allowDownload: true,
+        allowUnregisteredComments: true,
         maxViews: true,
         viewCount: true,
         createdById: true,
@@ -167,6 +168,8 @@ router.get('/:token', async (req, res) => {
             fps: true,
             hlsPath: true,
             filePath: true,
+            originalFilePath: true,
+            maxResolution: true,
             transcodeStatus: true,
           },
         },
@@ -178,9 +181,7 @@ router.get('/:token', async (req, res) => {
       return;
     }
 
-    // Check view limit (0 or -1 = unlimited)
-    const expired =
-      shareLink.maxViews > 0 && shareLink.viewCount >= shareLink.maxViews;
+    const expired = shareLink.maxViews > 0 && shareLink.viewCount >= shareLink.maxViews;
 
     if (expired) {
       res.status(410).json({
@@ -192,7 +193,6 @@ router.get('/:token', async (req, res) => {
       return;
     }
 
-    // Increment view count
     await prisma.assetShareLink.update({
       where: { id: shareLink.id },
       data: { viewCount: { increment: 1 } },
@@ -200,15 +200,16 @@ router.get('/:token', async (req, res) => {
 
     const hasPassword = !!shareLink.password;
     const videoReady = shareLink.asset.transcodeStatus === 'COMPLETED';
+    const has4K = !!shareLink.asset.originalFilePath;
 
-    // Return metadata only — actual stream URL only after password check
     res.json({
       id: shareLink.id,
       token: shareLink.token,
       hasPassword,
       allowDownload: shareLink.allowDownload,
+      allowUnregisteredComments: shareLink.allowUnregisteredComments,
       maxViews: shareLink.maxViews,
-      viewCount: shareLink.viewCount + 1, // +1 because we just incremented
+      viewCount: shareLink.viewCount + 1,
       asset: {
         id: shareLink.asset.id,
         title: shareLink.asset.title,
@@ -217,6 +218,8 @@ router.get('/:token', async (req, res) => {
         duration: shareLink.asset.duration,
         fps: shareLink.asset.fps,
         videoReady,
+        has4K,
+        maxResolution: shareLink.asset.maxResolution,
       },
     });
   } catch (err) {
@@ -226,7 +229,6 @@ router.get('/:token', async (req, res) => {
 });
 
 // ── 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);
@@ -240,6 +242,7 @@ router.post('/:token/access', async (req, res) => {
             id: true,
             hlsPath: true,
             filePath: true,
+            originalFilePath: true,
             mimeType: true,
             transcodeStatus: true,
           },
@@ -269,20 +272,30 @@ router.post('/:token/access', async (req, res) => {
       }
     }
 
-    // Increment view count after successful access
     await prisma.assetShareLink.update({
       where: { id: shareLink.id },
       data: { viewCount: { increment: 1 } },
     });
 
     const videoReady = shareLink.asset.transcodeStatus === 'COMPLETED';
+    const has4K = !!shareLink.asset.originalFilePath;
+
+    // Stream URL: always HLS if available (which is already 1080p for 4K source)
+    const streamUrl = videoReady && shareLink.asset.hlsPath
+      ? `/uploads${shareLink.asset.hlsPath}`
+      : `/uploads/${shareLink.asset.filePath}`;
+
+    // Original download URL: only if 4K original file exists
+    const originalDownloadUrl = has4K
+      ? `/uploads/${shareLink.asset.originalFilePath}`
+      : undefined;
 
     res.json({
-      streamUrl: videoReady && shareLink.asset.hlsPath
-        ? `/uploads${shareLink.asset.hlsPath}`
-        : `/uploads/${shareLink.asset.filePath}`,
+      streamUrl,
       mimeType: shareLink.asset.mimeType,
       allowDownload: shareLink.allowDownload,
+      has4K,
+      originalDownloadUrl,
     });
   } catch (err) {
     console.error('[share] POST /:token/access error:', err);
@@ -290,6 +303,152 @@ router.post('/:token/access', async (req, res) => {
   }
 });
 
+// ── GET /api/share/:token/comments ─────────────────────────────────────────────
+// Public — list comments for a share link (no auth required)
+router.get('/:token/comments', async (req, res) => {
+  try {
+    const token = str(req.params.token);
+    const { page = '1', limit = '50' } = req.query;
+
+    const shareLink = await prisma.assetShareLink.findUnique({
+      where: { token },
+      include: { asset: { select: { id: true } } },
+    });
+
+    if (!shareLink) {
+      res.status(404).json({ error: 'Share link not found' });
+      return;
+    }
+
+    const expired = shareLink.maxViews > 0 && shareLink.viewCount >= shareLink.maxViews;
+    if (expired) {
+      res.status(410).json({ error: 'View limit reached' });
+      return;
+    }
+
+    const pageNum = Math.max(1, parseInt(String(page), 10));
+    const limitNum = Math.min(100, Math.max(1, parseInt(String(limit), 10)));
+    const skip = (pageNum - 1) * limitNum;
+
+    const [comments, total] = await Promise.all([
+      prisma.comment.findMany({
+        where: { assetId: shareLink.asset.id, deleted: false, parentId: null },
+        skip,
+        take: limitNum,
+        include: {
+          user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+          replies: {
+            where: { deleted: false },
+            include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
+            orderBy: { createdAt: 'asc' },
+          },
+        },
+        orderBy: { timestamp: 'asc' },
+      }),
+      prisma.comment.count({ where: { assetId: shareLink.asset.id, deleted: false, parentId: null } }),
+    ]);
+
+    res.json({ comments, total, page: pageNum, limit: limitNum, totalPages: Math.ceil(total / limitNum) });
+  } catch (err) {
+    console.error('[share] GET /:token/comments error:', err);
+    res.status(500).json({ error: 'Internal server error' });
+  }
+});
+
+// ── POST /api/share/:token/comments ─────────────────────────────────────────────
+// Public — create a guest comment (no auth required)
+router.post('/:token/comments', async (req, res) => {
+  try {
+    const token = str(req.params.token);
+    const { guestName, content, timestamp, annotations, parentId } = req.body as {
+      guestName?: string;
+      content?: string;
+      timestamp?: number;
+      annotations?: unknown;
+      parentId?: string;
+    };
+
+    // Validate share link
+    const shareLink = await prisma.assetShareLink.findUnique({
+      where: { token },
+      include: { asset: { select: { id: true } } },
+    });
+
+    if (!shareLink) {
+      res.status(404).json({ error: 'Share link not found' });
+      return;
+    }
+
+    if (!shareLink.allowUnregisteredComments) {
+      res.status(403).json({ error: 'Guest comments are not enabled for this link' });
+      return;
+    }
+
+    const expired = shareLink.maxViews > 0 && shareLink.viewCount >= shareLink.maxViews;
+    if (expired) {
+      res.status(410).json({ error: 'View limit reached' });
+      return;
+    }
+
+    // Validate guestName
+    if (!guestName || typeof guestName !== 'string' || guestName.trim().length === 0) {
+      res.status(400).json({ error: 'guestName is required' });
+      return;
+    }
+    const trimmedName = guestName.trim();
+    if (trimmedName.length > 50) {
+      res.status(400).json({ error: 'guestName must be 50 characters or less' });
+      return;
+    }
+
+    // Validate content
+    if (!content || typeof content !== 'string' || content.trim().length === 0) {
+      res.status(400).json({ error: 'content is required' });
+      return;
+    }
+    const trimmedContent = content.trim();
+    if (trimmedContent.length > 5000) {
+      res.status(400).json({ error: 'content must be 5000 characters or less' });
+      return;
+    }
+
+    // Validate parent comment if provided
+    if (parentId) {
+      const parent = await prisma.comment.findFirst({
+        where: { id: parentId, assetId: shareLink.asset.id, deleted: false },
+      });
+      if (!parent) {
+        res.status(400).json({ error: 'Parent comment not found' });
+        return;
+      }
+    }
+
+    const comment = await prisma.comment.create({
+      data: {
+        assetId: shareLink.asset.id,
+        userId: null,
+        guestName: trimmedName,
+        content: trimmedContent,
+        timestamp: timestamp ?? undefined,
+        annotations: annotations ?? undefined,
+        parentId: parentId ?? undefined,
+      },
+      include: {
+        user: { select: { id: true, name: true, email: true, avatarUrl: true } },
+        replies: {
+          include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } },
+          orderBy: { createdAt: 'asc' },
+        },
+      },
+    });
+
+    res.status(201).json({ comment });
+  } catch (err) {
+    console.error('[share] POST /:token/comments 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); });
 
@@ -330,6 +489,7 @@ router.get('/:id', authMiddleware, async (req, res) => {
         shareUrl,
         hasPassword: !!shareLink.password,
         allowDownload: shareLink.allowDownload,
+        allowUnregisteredComments: shareLink.allowUnregisteredComments,
         maxViews: shareLink.maxViews,
         viewCount: shareLink.viewCount,
         createdAt: shareLink.createdAt,
@@ -345,9 +505,10 @@ router.get('/:id', authMiddleware, async (req, res) => {
 router.put('/:id', authMiddleware, async (req, res) => {
   try {
     const id = str(req.params.id);
-    const { password, allowDownload, maxViews } = req.body as {
+    const { password, allowDownload, allowUnregisteredComments, maxViews } = req.body as {
       password?: string;
       allowDownload?: boolean;
+      allowUnregisteredComments?: boolean;
       maxViews?: number;
     };
     const existing = await prisma.assetShareLink.findUnique({ where: { id } });
@@ -357,6 +518,7 @@ router.put('/:id', authMiddleware, async (req, res) => {
 
     const updateData: Record<string, unknown> = {};
     if (allowDownload !== undefined) updateData.allowDownload = allowDownload;
+    if (allowUnregisteredComments !== undefined) updateData.allowUnregisteredComments = allowUnregisteredComments;
     if (maxViews !== undefined) updateData.maxViews = maxViews <= 0 ? -1 : maxViews;
     if (password !== undefined) updateData.password = password ? await bcrypt.hash(password, 10) : null;
 
@@ -369,6 +531,7 @@ router.put('/:id', authMiddleware, async (req, res) => {
         shareUrl,
         hasPassword: !!updated.password,
         allowDownload: updated.allowDownload,
+        allowUnregisteredComments: updated.allowUnregisteredComments,
         maxViews: updated.maxViews,
         viewCount: updated.viewCount,
       },

+ 289 - 117
src/app/share/[token]/page.tsx

@@ -4,15 +4,17 @@ 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 { shareLinksApi, ShareLinkVerify, GuestComment } from '@/lib/api';
 import { formatTimecode } from '@/lib/format';
 
 const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
+const GUEST_NAME_KEY = (token: string) => `vidreview_guest_name_${token}`;
+const REFRESH_INTERVAL_MS = 30_000;
 
 export default function SharePage() {
   const params = useParams();
   const router = useRouter();
-  const { user, token } = useAuth();
+  const { user, token: authToken } = useAuth();
   const tokenParam = params.token as string;
 
   const [state, setState] = useState<'loading' | 'password' | 'ready' | 'expired' | 'error'>('loading');
@@ -21,19 +23,40 @@ export default function SharePage() {
   const [passwordError, setPasswordError] = useState<string | null>(null);
   const [submitting, setSubmitting] = useState(false);
   const [streamUrl, setStreamUrl] = useState<string | null>(null);
+  const [has4K, setHas4K] = useState(false);
+  const [originalDownloadUrl, setOriginalDownloadUrl] = useState<string | null>(null);
   const videoRef = useRef<HTMLVideoElement>(null);
 
+  // ── Guest comment state ──────────────────────────────────────────────────────
+  const [guestName, setGuestName] = useState('');
+  const [guestNameSubmitted, setGuestNameSubmitted] = useState(false);
+  const [comments, setComments] = useState<GuestComment[]>([]);
+  const [commentsTotal, setCommentsTotal] = useState(0);
+  const [commentLoading, setCommentLoading] = useState(false);
+  const [commentText, setCommentText] = useState('');
+  const [commentSubmitting, setCommentSubmitting] = useState(false);
+  const [commentError, setCommentError] = useState<string | null>(null);
+
+  // ── Load guest name from localStorage ───────────────────────────────────────
+  useEffect(() => {
+    const saved = localStorage.getItem(GUEST_NAME_KEY(tokenParam));
+    if (saved) {
+      setGuestName(saved);
+      setGuestNameSubmitted(true);
+    }
+  }, [tokenParam]);
+
+  // ── Verify share link ───────────────────────────────────────────────────────
   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) {
+        if (user && authToken) {
           try {
             const assetRes = await fetch(`${API_BASE}/api/assets/${info.asset.id}`, {
-              headers: { Authorization: `Bearer ${token}` },
+              headers: { Authorization: `Bearer ${authToken}` },
             });
             if (assetRes.ok) {
               router.replace(`/review/${info.asset.id}`);
@@ -58,7 +81,30 @@ export default function SharePage() {
     }
     verify();
   // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [tokenParam, user, token]);
+  }, [tokenParam, user, authToken]);
+
+  // ── Fetch comments when ready ────────────────────────────────────────────────
+  useEffect(() => {
+    if (state !== 'ready' || !linkInfo?.allowUnregisteredComments) return;
+    loadComments();
+    const interval = setInterval(loadComments, REFRESH_INTERVAL_MS);
+    return () => clearInterval(interval);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [state, tokenParam, linkInfo?.allowUnregisteredComments]);
+
+  async function loadComments() {
+    if (!linkInfo?.allowUnregisteredComments) return;
+    setCommentLoading(true);
+    try {
+      const data = await shareLinksApi.getComments(tokenParam);
+      setComments(data.comments);
+      setCommentsTotal(data.total);
+    } catch {
+      // Silently fail — comments are non-critical
+    } finally {
+      setCommentLoading(false);
+    }
+  }
 
   async function fetchAccess(pwd?: string) {
     setSubmitting(true);
@@ -66,6 +112,10 @@ export default function SharePage() {
     try {
       const data = await shareLinksApi.access(tokenParam, pwd);
       setStreamUrl(`${API_BASE}${data.streamUrl}`);
+      setHas4K(data.has4K);
+      if (data.originalDownloadUrl) {
+        setOriginalDownloadUrl(`${API_BASE}${data.originalDownloadUrl}`);
+      }
       setState('ready');
     } catch (e) {
       const msg = e instanceof Error ? e.message : '';
@@ -84,42 +134,116 @@ export default function SharePage() {
     fetchAccess(password);
   }
 
+  function handleGuestNameSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = guestName.trim();
+    if (!trimmed) return;
+    localStorage.setItem(GUEST_NAME_KEY(tokenParam), trimmed);
+    setGuestNameSubmitted(true);
+  }
+
+  async function handleCommentSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    if (!commentText.trim() || commentSubmitting) return;
+    setCommentSubmitting(true);
+    setCommentError(null);
+    try {
+      const data = await shareLinksApi.postGuestComment(tokenParam, {
+        guestName: guestName.trim(),
+        content: commentText.trim(),
+      });
+      setComments(prev => [...prev, data.comment]);
+      setCommentsTotal(prev => prev + 1);
+      setCommentText('');
+    } catch (err) {
+      setCommentError(err instanceof Error ? err.message : 'Failed to post comment');
+    } finally {
+      setCommentSubmitting(false);
+    }
+  }
+
   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 = !!streamUrl?.endsWith('.m3u8');
 
-  // Load HLS if needed
+  // Load HLS
   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,
-        backBufferLength: 30,
-        maxBufferLength: 30,
-        maxBufferSize: 50 * 1024 * 1024,
-        maxMaxBufferSize: 100 * 1024 * 1024,
-        startLevel: -1,
-      });
+      const hls = new Hls({ enableWorker: false, backBufferLength: 30, maxBufferLength: 30, maxBufferSize: 50 * 1024 * 1024, maxMaxBufferSize: 100 * 1024 * 1024, startLevel: -1 });
       hls.loadSource(streamUrl);
-      hls.attachMedia(video);
-      hls.on(Hls.Events.ERROR, (_event: string, data: { fatal: boolean; details: string }) => {
+      hls.attachMedia(videoRef.current);
+      hls.on(Hls.Events.ERROR, (_: string, data: { fatal: boolean; details: string }) => {
         if (data.fatal) console.error('[HLS] Fatal:', data.details);
       });
       return () => { hls.destroy(); };
-    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
-      video.src = streamUrl;
+    } else if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
+      videoRef.current.src = streamUrl;
     }
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [streamUrl, isHls]);
 
+  // ── Render helpers ───────────────────────────────────────────────────────────
+  const showGuestComments = linkInfo?.allowUnregisteredComments && guestNameSubmitted;
+  const canComment = showGuestComments && !user;
+
+  function CommentItem({ comment }: { comment: GuestComment }) {
+    const isGuest = comment.userId === null;
+    const displayName = isGuest ? (comment.guestName ?? 'Anonymous') : (comment.user?.name ?? 'Unknown');
+    const avatarColor = isGuest ? '#A78BFA' : '#6366F1';
+
+    return (
+      <div className="flex gap-3 py-3" style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
+        {/* Avatar */}
+        <div
+          className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
+          style={{ background: `${avatarColor}22`, color: avatarColor }}
+        >
+          {isGuest ? (
+            <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 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>
+          ) : (
+            displayName[0]?.toUpperCase() ?? '?'
+          )}
+        </div>
+        <div className="flex-1 min-w-0">
+          <div className="flex items-center gap-2 mb-0.5">
+            <span className="text-xs font-medium" style={{ color: isGuest ? '#A78BFA' : 'var(--text)' }}>
+              {displayName}
+            </span>
+            {isGuest && (
+              <span className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'rgba(167,139,250,0.12)', color: '#A78BFA' }}>
+                Guest
+              </span>
+            )}
+            {comment.timestamp != null && (
+              <span className="text-xs" style={{ color: 'var(--text-muted)' }}>
+                {formatTimecode(comment.timestamp, 30, comment.timestamp)}
+              </span>
+            )}
+          </div>
+          <p className="text-xs leading-relaxed" style={{ color: 'var(--text)' }}>{comment.content}</p>
+
+          {/* Replies */}
+          {comment.replies?.length > 0 && (
+            <div className="mt-2 pl-3" style={{ borderLeft: '2px solid rgba(255,255,255,0.08)' }}>
+              {comment.replies.map(reply => (
+                <CommentItem key={reply.id} comment={reply} />
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  // ── Loading / Error / Expired / Password states ─────────────────────────────
   if (state === 'loading') {
     return (
       <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
@@ -158,9 +282,7 @@ export default function SharePage() {
           </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.'}
+            {(linkInfo?.maxViews ?? 0) > 0 ? `This link has reached its view limit (${linkInfo?.maxViews} views).` : 'This share link has expired.'}
           </p>
         </div>
       </div>
@@ -172,39 +294,17 @@ export default function SharePage() {
       <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 }}
-            />
+            <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>
+          <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'}
+            <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>
@@ -212,37 +312,75 @@ export default function SharePage() {
     );
   }
 
-  // ready — full width video with info below
+  // ── Ready state ──────────────────────────────────────────────────────────────
   return (
-    <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
+    <div className="min-h-screen flex flex-col" 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
+        <h1 className="text-xs font-medium truncate flex-1" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
+
+        {/* 4K badge */}
+        {has4K && (
+          <span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded shrink-0"
+                style={{ background: 'rgba(251,191,36,0.10)', color: '#FBBF24', border: '1px solid rgba(251,191,36,0.20)' }}>
+            <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+              <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z" />
+            </svg>
+            4K
+          </span>
+        )}
+
+        {/* Download */}
+        {(linkInfo?.allowDownload && streamUrl) && (
+          <a href={originalDownloadUrl ?? 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)' }}
-          >
+            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
+            {has4K ? 'Download 4K' : 'Download'}
           </a>
         )}
-        {!user && (
-          <button
-            onClick={goToLogin}
+
+        {/* Guest name input or badge */}
+        {linkInfo?.allowUnregisteredComments && !user && (
+          guestNameSubmitted ? (
+            <div className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md 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 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>
+              {guestName}
+            </div>
+          ) : (
+            <form onSubmit={handleGuestNameSubmit} className="flex items-center gap-2 shrink-0">
+              <input
+                type="text"
+                value={guestName}
+                onChange={e => setGuestName(e.target.value)}
+                placeholder="Your name"
+                maxLength={50}
+                className="w-24 text-xs px-2 py-1 rounded-md"
+                style={{ background: 'rgba(167,139,250,0.10)', color: '#A78BFA', border: '1px solid rgba(167,139,250,0.30)', outline: 'none' }}
+              />
+              <button type="submit" disabled={!guestName.trim()}
+                className="text-xs px-2 py-1 rounded-md shrink-0 transition-all"
+                style={{ background: '#A78BFA', color: '#fff', opacity: guestName.trim() ? 1 : 0.5 }}>
+                Comment
+              </button>
+            </form>
+          )
+        )}
+
+        {/* Login link */}
+        {!user && !linkInfo?.allowUnregisteredComments && (
+          <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)' }}
-          >
+            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>
@@ -251,55 +389,89 @@ export default function SharePage() {
         )}
       </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' }} />
+      {/* Main content */}
+      <div className="flex-1 flex flex-col lg:flex-row">
+        {/* Video area */}
+        <div className="flex-1 flex items-center justify-center" style={{ background: '#000', minHeight: 0 }}>
+          {streamUrl ? (
+            <div className="w-full 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="flex flex-col items-center justify-center" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
+              <div className="w-8 h-8 rounded-full animate-spin mb-3" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
               <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Video is being processed…</p>
             </div>
+          ) : null}
+        </div>
+
+        {/* Comment panel */}
+        {showGuestComments && (
+          <div className="lg:w-96 w-full shrink-0 flex flex-col"
+               style={{ background: '#13141F', borderTop: '1px solid rgba(255,255,255,0.06)', maxHeight: '60vh', borderLeft: '1px solid rgba(255,255,255,0.06)' }}>
+            {/* Comment form */}
+            <form onSubmit={handleCommentSubmit} className="p-3 shrink-0" style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+              <textarea
+                value={commentText}
+                onChange={e => setCommentText(e.target.value)}
+                placeholder="Write a comment…"
+                rows={3}
+                maxLength={5000}
+                className="w-full text-xs rounded-lg px-3 py-2 resize-none"
+                style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text)', border: '1px solid rgba(255,255,255,0.08)', outline: 'none' }}
+              />
+              {commentError && <p className="text-xs mt-1" style={{ color: '#F87171' }}>{commentError}</p>}
+              <div className="flex justify-end mt-2">
+                <button type="submit" disabled={commentSubmitting || !commentText.trim()}
+                  className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg transition-all"
+                  style={{ background: '#A78BFA', color: '#fff', opacity: commentText.trim() && !commentSubmitting ? 1 : 0.5 }}>
+                  {commentSubmitting ? (
+                    <div className="w-3 h-3 rounded-full animate-spin" style={{ borderColor: '#fff', 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="M6 12H3m12 0h9m-9 0a9 9 0 01-9-9M3 12a9 9 0 019-9m0 0a9 9 0 019 9m-9-9v6" />
+                    </svg>
+                  )}
+                  Post
+                </button>
+              </div>
+            </form>
+
+            {/* Comment list */}
+            <div className="flex-1 overflow-y-auto px-3">
+              <div className="flex items-center justify-between py-2">
+                <span className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
+                  {commentsTotal} comment{commentsTotal !== 1 ? 's' : ''}
+                </span>
+                {commentLoading && (
+                  <div className="w-3 h-3 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '1.5px' }} />
+                )}
+              </div>
+              {comments.length === 0 && !commentLoading && (
+                <p className="text-xs text-center py-6" style={{ color: 'var(--text-muted)' }}>No comments yet. Be the first!</p>
+              )}
+              {comments.map(c => <CommentItem key={c.id} comment={c} />)}
+            </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>
+      {/* Info footer */}
+      <div className="shrink-0 px-4 py-3 flex items-center gap-4 text-xs" style={{ color: 'var(--text-muted)', borderTop: '1px solid rgba(255,255,255,0.04)' }}>
+        {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></>}
+        {linkInfo?.allowUnregisteredComments && !user && (
+          <><span>·</span><span style={{ color: '#A78BFA' }}>Guest comments enabled</span></>
+        )}
       </div>
     </div>
   );
-}
+}

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

@@ -19,6 +19,7 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
   const [requirePassword, setRequirePassword] = useState(false);
   const [password, setPassword] = useState('');
   const [allowDownload, setAllowDownload] = useState(false);
+  const [allowUnregisteredComments, setAllowUnregisteredComments] = useState(false);
   const [maxViews, setMaxViews] = useState(20);
   const [unlimited, setUnlimited] = useState(false);
 
@@ -36,6 +37,7 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
           setLink(data.shareLink);
           setRequirePassword(data.shareLink.hasPassword);
           setAllowDownload(data.shareLink.allowDownload);
+          setAllowUnregisteredComments(data.shareLink.allowUnregisteredComments);
           setMaxViews(data.shareLink.maxViews > 0 ? data.shareLink.maxViews : 20);
           setUnlimited(data.shareLink.maxViews <= 0);
         }
@@ -55,6 +57,7 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
       const data = await shareLinksApi.create(assetId, {
         password: requirePassword ? password : undefined,
         allowDownload,
+        allowUnregisteredComments,
         maxViews: unlimited ? -1 : maxViews,
       });
       setLink(data.shareLink);
@@ -73,6 +76,7 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
       const data = await shareLinksApi.update(link.id, {
         password: requirePassword ? password : undefined,
         allowDownload,
+        allowUnregisteredComments,
         maxViews: unlimited ? -1 : maxViews,
       });
       setLink(data.shareLink);
@@ -94,6 +98,7 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
       setRequirePassword(false);
       setPassword('');
       setAllowDownload(false);
+      setAllowUnregisteredComments(false);
       setMaxViews(20);
       setUnlimited(false);
     } catch (e) {
@@ -207,6 +212,15 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
                   <input type="checkbox" checked={allowDownload} onChange={e => setAllowDownload(e.target.checked)} className="accent-indigo-500" />
                   Allow download
                 </label>
+                <label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: 'var(--text)' }}>
+                  <input type="checkbox" checked={allowUnregisteredComments} onChange={e => setAllowUnregisteredComments(e.target.checked)} className="accent-indigo-500" />
+                  Allow guest comments
+                </label>
+                {allowUnregisteredComments && (
+                  <p className="text-xs pl-5" style={{ color: 'var(--text-muted)' }}>
+                    Guests can comment without signing up — they will be asked for their name.
+                  </p>
+                )}
                 <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">
@@ -266,6 +280,16 @@ export function ShareModal({ assetId, assetTitle, onClose }: ShareModalProps) {
                 Allow download
               </label>
 
+              <label className="flex items-center gap-2 text-xs cursor-pointer" style={{ color: 'var(--text)' }}>
+                <input type="checkbox" checked={allowUnregisteredComments} onChange={e => setAllowUnregisteredComments(e.target.checked)} className="accent-indigo-500" />
+                Allow guest comments
+              </label>
+              {allowUnregisteredComments && (
+                <p className="text-xs pl-5" style={{ color: 'var(--text-muted)' }}>
+                  Guests can comment without signing up — they will be asked for their name.
+                </p>
+              )}
+
               <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">

+ 5 - 1
src/components/video-player/VideoPlayer.tsx

@@ -70,6 +70,8 @@ interface Props {
    */
   thumbnailSrc?: string;
   thumbnailMimeType?: string;
+  /** Override portrait flag (if not provided, detects from video element after load) */
+  isPortrait?: boolean;
 }
 
 export function VideoPlayer({
@@ -100,6 +102,7 @@ export function VideoPlayer({
   thumbnailMimeType,
   onBufferChange,
   bufferStrategy = 'eager',
+  isPortrait: isPortraitProp,
 }: Props) {
   const internalVideoRef = useRef<HTMLVideoElement>(null);
   // Use external ref if provided, otherwise internal
@@ -570,7 +573,8 @@ export function VideoPlayer({
       {/* ── Video frame ──────────────────────────────────────────────── */}
       <div
         ref={containerRef}
-        className="relative bg-black rounded-xl overflow-hidden select-none group"
+        className={`relative bg-black rounded-xl overflow-hidden select-none group ${isPortraitProp ? 'mx-auto' : ''}`}
+        style={isPortraitProp ? { maxWidth: 'min(100%, 55vh)', aspectRatio: '9/16' } : undefined}
       >
         <video
           ref={videoRef}

+ 58 - 1
src/lib/api.ts

@@ -527,6 +527,7 @@ export interface ShareLink {
   shareUrl?: string;
   hasPassword: boolean;
   allowDownload: boolean;
+  allowUnregisteredComments: boolean;
   maxViews: number;       // -1 or 0 = unlimited
   viewCount: number;
   assetTitle?: string;
@@ -538,6 +539,7 @@ export interface ShareLinkVerify {
   token: string;
   hasPassword: boolean;
   allowDownload: boolean;
+  allowUnregisteredComments: boolean;
   maxViews: number;
   viewCount: number;
   asset: {
@@ -548,9 +550,45 @@ export interface ShareLinkVerify {
     duration?: number | null;
     fps?: number;
     videoReady: boolean;
+    has4K: boolean;
+    maxResolution?: string | null;
   };
 }
 
+export interface ShareAccessData {
+  streamUrl: string;
+  mimeType: string;
+  allowDownload: boolean;
+  has4K: boolean;
+  originalDownloadUrl?: string;
+}
+
+export interface GuestComment {
+  id: string;
+  assetId: string;
+  userId: string | null;
+  guestName: string | null;
+  content: string;
+  timestamp: number | null;
+  annotations: unknown | null;
+  resolved: boolean;
+  resolveStatus: string;
+  parentId: string | null;
+  deleted: boolean;
+  createdAt: string;
+  updatedAt: string;
+  user?: { id: string; name: string; email: string; avatarUrl: string | null } | null;
+  replies: GuestComment[];
+}
+
+export interface GuestCommentResponse {
+  comments: GuestComment[];
+  total: number;
+  page: number;
+  limit: number;
+  totalPages: number;
+}
+
 export const shareLinksApi = {
   // Get share link for an asset (by assetId — admin/owner only)
   getForAsset: (assetId: string) =>
@@ -560,6 +598,7 @@ export const shareLinksApi = {
   create: (assetId: string, data: {
     password?: string;
     allowDownload?: boolean;
+    allowUnregisteredComments?: boolean;
     maxViews?: number;
   }) =>
     apiFetch<{ shareLink: ShareLink }>('/api/share', {
@@ -571,6 +610,7 @@ export const shareLinksApi = {
   update: (id: string, data: {
     password?: string;
     allowDownload?: boolean;
+    allowUnregisteredComments?: boolean;
     maxViews?: number;
   }) =>
     apiFetch<{ shareLink: ShareLink }>(`/api/share/${id}`, {
@@ -588,10 +628,27 @@ export const shareLinksApi = {
 
   // Public: submit password and get stream URL
   access: (token: string, password?: string) =>
-    apiFetch<{ streamUrl: string; mimeType: string; allowDownload: boolean }>(`/api/share/${token}/access`, {
+    apiFetch<ShareAccessData>(`/api/share/${token}/access`, {
       method: 'POST',
       body: JSON.stringify({ password }),
     }),
+
+  // Public: list guest comments for a share link
+  getComments: (token: string, page = 1, limit = 50) =>
+    apiFetch<GuestCommentResponse>(`/api/share/${token}/comments?page=${page}&limit=${limit}`),
+
+  // Public: post a guest comment (no auth required)
+  postGuestComment: (token: string, data: {
+    guestName: string;
+    content: string;
+    timestamp?: number;
+    annotations?: unknown;
+    parentId?: string;
+  }) =>
+    apiFetch<{ comment: GuestComment }>(`/api/share/${token}/comments`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
 };
 
 // ── Folders ────────────────────────────────────────────────────────────────────