Răsfoiți Sursa

fix: display guest name correctly instead of "Unknown"

Root cause: guestName scalar field was placed inside Prisma's
user relation select block (guestName: true) — Prisma doesn't have
a guestName relation on User, so this silently dropped the field.
Fix:

- share.ts: removed erroneous guestName: true from user select
  in both GET and POST comment include blocks. Scalar fields are
  returned automatically at top level; no explicit select needed.

- api.ts: added optional guestName field to Comment interface.

- CommentPanel.tsx: CommentItem and replies now prefer
  comment.guestName over comment.user.name to show the actual
  guest-provided name. Avatar initials also use guestName.

- share/[token]/page.tsx: PanelComment interface now includes
  guestName field; toPanelComment passes it through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kingkong 1 lună în urmă
părinte
comite
9c716fb08a

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

@@ -337,12 +337,10 @@ router.get('/:token/comments', async (req, res) => {
         take: limitNum,
         include: {
           user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-          guestName: true,
           replies: {
             where: { deleted: false },
             include: {
               user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-              guestName: true,
             },
             orderBy: { createdAt: 'asc' },
           },
@@ -439,11 +437,9 @@ router.post('/:token/comments', async (req, res) => {
       },
       include: {
         user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-        guestName: true,
         replies: {
           include: {
             user: { select: { id: true, name: true, email: true, avatarUrl: true } },
-            guestName: true,
           },
           orderBy: { createdAt: 'asc' },
         },

+ 216 - 369
src/app/share/[token]/page.tsx

@@ -1,17 +1,54 @@
 'use client';
 
 import { useState, useEffect, useRef } from 'react';
-import { AnnotationCanvas, COLORS, Tool } from '@/components/video-player/AnnotationCanvas';
-import { AnnotationData } from '@/lib/api';
 import { useRouter } from 'next/navigation';
 import { useAuth } from '@/lib/auth-context';
 import { useParams } from 'next/navigation';
-import { shareLinksApi, ShareLinkVerify, GuestComment } from '@/lib/api';
-import { formatTimecode } from '@/lib/format';
+import { shareLinksApi, ShareLinkVerify, GuestComment, AnnotationData } from '@/lib/api';
+import { VideoPlayer } from '@/components/video-player/VideoPlayer';
+import { CommentPanel } from '@/components/comments/CommentPanel';
+import { Tool } from '@/components/video-player/AnnotationCanvas';
 
-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;
+const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
+
+// GuestComment from the API → shape that CommentPanel expects
+interface PanelComment {
+  id: string;
+  userId: string;
+  user: { id: string; name: string; email: string; avatarUrl: string | null };
+  guestName: string | null;
+  content: string;
+  timestamp: number | null;
+  annotations: AnnotationData[] | null;
+  resolved: boolean;
+  resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED';
+  parentId: string | null;
+  deleted: boolean;
+  createdAt: string;
+  replies: PanelComment[];
+}
+
+function toPanelComment(c: GuestComment): PanelComment {
+  return {
+    id: c.id,
+    userId: c.userId ?? 'guest',
+    user: c.userId
+      ? (c.user ?? { id: c.userId, name: 'Unknown', email: '', avatarUrl: null })
+      : { id: 'guest', name: c.guestName ?? 'Guest', email: '', avatarUrl: null },
+    guestName: c.guestName,
+    content: c.content,
+    timestamp: c.timestamp ?? null,
+    annotations: (c.annotations as AnnotationData[] | null) ?? null,
+    resolved: c.resolved,
+    resolveStatus: (c.resolveStatus as PanelComment['resolveStatus']) ?? 'UNRESOLVED',
+    parentId: c.parentId ?? null,
+    deleted: c.deleted,
+    createdAt: c.createdAt,
+    replies: (c.replies ?? []).map(r => toPanelComment(r)),
+  };
+}
 
 export default function SharePage() {
   const params = useParams();
@@ -29,44 +66,28 @@ export default function SharePage() {
   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 [comments, setComments] = useState<PanelComment[]>([]);
   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);
-  const [currentTime, setCurrentTime] = useState(0);
+
+  // Drawing state
   const [drawMode, setDrawMode] = useState(false);
   const [drawTool, setDrawTool] = useState<Tool>('arrow');
   const [drawColor, setDrawColor] = useState('#ef4444');
   const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
-  const videoContainerRef = useRef<HTMLDivElement>(null);
-  const [videoDims, setVideoDims] = useState({ width: 0, height: 0 });
 
-  // ── Video container resize observer ───────────────────────────────────────
-  useEffect(() => {
-    const el = videoContainerRef.current;
-    if (!el) return;
-    const update = () => setVideoDims({ width: el.offsetWidth, height: el.offsetHeight });
-    update();
-    const obs = new ResizeObserver(update);
-    obs.observe(el);
-    return () => obs.disconnect();
-  }, []);
-
-  // ── Load guest name from localStorage ───────────────────────────────────────
+  // Playback state
+  const [currentTime, setCurrentTime] = useState(0);
+
+  // Load guest name from localStorage
   useEffect(() => {
     const saved = localStorage.getItem(GUEST_NAME_KEY(tokenParam));
-    if (saved) {
-      setGuestName(saved);
-      setGuestNameSubmitted(true);
-    }
+    if (saved) { setGuestName(saved); setGuestNameSubmitted(true); }
   }, [tokenParam]);
 
-  // ── Verify share link ───────────────────────────────────────────────────────
+  // Verify share link
   useEffect(() => {
     async function verify() {
       try {
@@ -78,32 +99,23 @@ export default function SharePage() {
             const assetRes = await fetch(`${API_BASE}/api/assets/${info.asset.id}`, {
               headers: { Authorization: `Bearer ${authToken}` },
             });
-            if (assetRes.ok) {
-              router.replace(`/review/${info.asset.id}`);
-              return;
-            }
-          } catch { /* fall through to public view */ }
+            if (assetRes.ok) { router.replace(`/review/${info.asset.id}`); return; }
+          } catch { /* fall through */ }
         }
 
-        if (info.hasPassword) {
-          setState('password');
-        } else {
-          await fetchAccess();
-        }
+        if (info.hasPassword) { setState('password'); }
+        else { await fetchAccess(); }
       } catch (e) {
         const msg = e instanceof Error ? e.message : '';
-        if (msg.includes('410') || msg.includes('View limit')) {
-          setState('expired');
-        } else {
-          setState('error');
-        }
+        if (msg.includes('410') || msg.includes('View limit')) { setState('expired'); }
+        else { setState('error'); }
       }
     }
     verify();
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [tokenParam, user, authToken]);
 
-  // ── Fetch comments when ready ────────────────────────────────────────────────
+  // Fetch comments
   useEffect(() => {
     if (state !== 'ready' || !linkInfo?.allowUnregisteredComments) return;
     loadComments();
@@ -117,364 +129,237 @@ export default function SharePage() {
     setCommentLoading(true);
     try {
       const data = await shareLinksApi.getComments(tokenParam);
-      setComments(data.comments);
+      setComments(data.comments.map(toPanelComment));
       setCommentsTotal(data.total);
-    } catch {
-      // Silently fail — comments are non-critical
-    } finally {
-      setCommentLoading(false);
-    }
+    } catch { /* silent */ }
+    finally { setCommentLoading(false); }
   }
 
   async function fetchAccess(pwd?: string) {
-    setSubmitting(true);
-    setPasswordError(null);
+    setSubmitting(true); setPasswordError(null);
     try {
       const data = await shareLinksApi.access(tokenParam, pwd);
       setStreamUrl(`${API_BASE}${data.streamUrl}`);
       setHas4K(data.has4K);
-      if (data.originalDownloadUrl) {
-        setOriginalDownloadUrl(`${API_BASE}${data.originalDownloadUrl}`);
-      }
+      if (data.originalDownloadUrl) setOriginalDownloadUrl(`${API_BASE}${data.originalDownloadUrl}`);
       setState('ready');
     } catch (e) {
       const msg = e instanceof Error ? e.message : '';
-      if (msg.includes('401')) {
-        setPasswordError('Incorrect password');
-      } else {
-        setPasswordError(msg || 'Access denied');
-      }
-    } finally {
-      setSubmitting(false);
-    }
-  }
-
-  function handlePasswordSubmit(e: React.FormEvent) {
-    e.preventDefault();
-    fetchAccess(password);
+      setPasswordError(msg.includes('401') ? 'Incorrect password' : (msg || 'Access denied'));
+    } finally { setSubmitting(false); }
   }
 
-  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);
+  async function handleAddComment(payload: { content: string; timestamp?: number; parentId?: string }) {
+    if (!payload.content.trim() || !guestName.trim()) return;
+    setCommentSubmitting(true); setCommentError(null);
     try {
-      const data = await shareLinksApi.postGuestComment(tokenParam, {
+      const res = await shareLinksApi.postGuestComment(tokenParam, {
         guestName: guestName.trim(),
-        content: commentText.trim(),
-        timestamp: currentTime > 0 ? currentTime : undefined,
+        content: payload.content,
+        timestamp: payload.timestamp ?? (currentTime > 0 ? currentTime : undefined),
         annotations: pendingStrokes.length > 0 ? pendingStrokes : undefined,
+        parentId: payload.parentId,
       });
       setPendingStrokes([]);
-      setComments(prev => [...prev, data.comment]);
+      setComments(prev => [...prev, toPanelComment(res.comment)]);
       setCommentsTotal(prev => prev + 1);
       setCommentText('');
     } catch (err) {
       setCommentError(err instanceof Error ? err.message : 'Failed to post comment');
-    } finally {
-      setCommentSubmitting(false);
-    }
+    } finally { setCommentSubmitting(false); }
   }
 
+  function handleGuestNameSubmit(e: React.FormEvent) {
+    e.preventDefault();
+    const trimmed = guestName.trim();
+    if (!trimmed) return;
+    localStorage.setItem(GUEST_NAME_KEY(tokenParam), trimmed);
+    setGuestNameSubmitted(true);
+  }
+
+  const [commentText, setCommentText] = useState('');
+  const [commentSubmitting, setCommentSubmitting] = useState(false);
+  const [commentError, setCommentError] = useState<string | null>(null);
+
   function goToLogin() {
     const returnUrl = linkInfo ? `/review/${linkInfo.asset.id}` : '/login';
     router.push(`/login?redirect=${encodeURIComponent(returnUrl)}`);
   }
 
+  const showGuestComments = linkInfo?.allowUnregisteredComments && guestNameSubmitted;
   const isHls = !!streamUrl?.endsWith('.m3u8');
-
-  // Load HLS
-  useEffect(() => {
-    if (!streamUrl || !isHls || !videoRef.current) return;
-    // 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 });
-      hls.loadSource(streamUrl);
-      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 (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
-      videoRef.current.src = streamUrl;
+  const fps = linkInfo?.asset.fps ?? 30;
+
+  // Compute visible annotations at current time
+  const visibleAnnotations: { annotation: AnnotationData; timestamp: number }[] = [];
+  if (currentTime > 0) {
+    const frameRange = 3 / fps;
+    for (const c of comments) {
+      if (c.annotations && c.timestamp != null && Math.abs(currentTime - c.timestamp) <= frameRange) {
+        for (const ann of c.annotations) {
+          visibleAnnotations.push({ annotation: ann, timestamp: c.timestamp });
+        }
+      }
     }
-  // 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)' }}>
-        <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
-          <div className="w-5 h-5 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
-          <span>Loading shared video…</span>
-        </div>
+    return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+      <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
+        <div className="w-5 h-5 rounded-full animate-spin" style={{ borderColor: '#6366F1', borderTopColor: 'transparent', borderWidth: '2px' }} />
+        <span>Loading shared video…</span>
       </div>
-    );
+    </div>;
   }
 
   if (state === 'error') {
-    return (
-      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
-        <div className="text-center max-w-sm">
-          <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.10)', border: '1px solid rgba(239,68,68,0.20)' }}>
-            <svg className="w-6 h-6" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-              <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
-            </svg>
-          </div>
-          <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Not Found</h1>
-          <p className="text-sm" style={{ color: 'var(--text-muted)' }}>This share link is invalid or has been revoked.</p>
+    return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+      <div className="text-center max-w-sm">
+        <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.10)', border: '1px solid rgba(239,68,68,0.20)' }}>
+          <svg className="w-6 h-6" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
+          </svg>
         </div>
+        <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Not Found</h1>
+        <p className="text-sm" style={{ color: 'var(--text-muted)' }}>This share link is invalid or has been revoked.</p>
       </div>
-    );
+    </div>;
   }
 
   if (state === 'expired') {
-    return (
-      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
-        <div className="text-center max-w-sm">
-          <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.20)' }}>
-            <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-              <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
-            </svg>
-          </div>
-          <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
-          <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
-            {(linkInfo?.maxViews ?? 0) > 0 ? `This link has reached its view limit (${linkInfo?.maxViews} views).` : 'This share link has expired.'}
-          </p>
+    return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+      <div className="text-center max-w-sm">
+        <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center" style={{ background: 'rgba(251,191,36,0.10)', border: '1px solid rgba(251,191,36,0.20)' }}>
+          <svg className="w-6 h-6" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+            <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
+          </svg>
         </div>
+        <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>Link Expired</h1>
+        <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
+          {(linkInfo?.maxViews ?? 0) > 0 ? `This link has reached its view limit (${linkInfo?.maxViews} views).` : 'This share link has expired.'}
+        </p>
       </div>
-    );
+    </div>;
   }
 
   if (state === 'password') {
-    return (
-      <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
-        <div className="text-center max-w-sm w-full px-4">
-          {linkInfo?.asset.thumbnail && (
-            <img src={`${API_BASE}/uploads/${linkInfo.asset.thumbnail}`} alt={linkInfo.asset.title}
-              className="w-full rounded-xl mb-6 aspect-video object-cover" style={{ maxHeight: 200 }} />
-          )}
-          <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
-          <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>This video is password protected.</p>
-          <form onSubmit={handlePasswordSubmit} className="space-y-3">
-            <input type="password" value={password} onChange={e => { setPassword(e.target.value); setPasswordError(null); }}
-              placeholder="Enter password" className="input w-full" autoFocus />
-            {passwordError && <p className="text-xs" style={{ color: '#F87171' }}>{passwordError}</p>}
-            <button type="submit" disabled={submitting || !password} className="btn btn-primary btn-md w-full">
-              {submitting ? <div className="w-4 h-4 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} /> : 'View Video'}
-            </button>
-          </form>
-        </div>
+    return <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
+      <div className="text-center max-w-sm w-full px-4">
+        {linkInfo?.asset.thumbnail && (
+          <img src={`${API_BASE}/uploads/${linkInfo.asset.thumbnail}`} alt={linkInfo.asset.title}
+            className="w-full rounded-xl mb-6 aspect-video object-cover" style={{ maxHeight: 200 }} />
+        )}
+        <h1 className="text-lg font-semibold mb-2" style={{ color: 'var(--text)' }}>{linkInfo?.asset.title}</h1>
+        <p className="text-sm mb-6" style={{ color: 'var(--text-muted)' }}>This video is password protected.</p>
+        <form onSubmit={e => { e.preventDefault(); fetchAccess(password); }} className="space-y-3">
+          <input type="password" value={password} onChange={e => { setPassword(e.target.value); setPasswordError(null); }}
+            placeholder="Enter password" className="input w-full" autoFocus />
+          {passwordError && <p className="text-xs" style={{ color: '#F87171' }}>{passwordError}</p>}
+          <button type="submit" disabled={submitting || !password} className="btn btn-primary btn-md w-full">
+            {submitting ? <div className="w-4 h-4 rounded-full animate-spin" style={{ borderColor: '#fff', borderTopColor: 'transparent', borderWidth: '2px' }} /> : 'View Video'}
+          </button>
+        </form>
       </div>
-    );
+    </div>;
   }
 
-  // ── Ready state ──────────────────────────────────────────────────────────────
   return (
-    <div className="min-h-screen flex flex-col" style={{ background: 'var(--bg)' }}>
+    <div className="flex flex-col h-screen" style={{ background: 'var(--bg)' }}>
       {/* Header */}
       <header className="h-12 flex items-center px-4 gap-3 shrink-0"
-              style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
+        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>
 
-        {/* 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>
+            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)' }}>
-            <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>
+            <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>
             {has4K ? 'Download 4K' : 'Download'}
           </a>
         )}
 
-        {/* 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>
+              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' }}
-              />
+              <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>
+                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"
+          <button onClick={goToLogin} className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md transition-all shrink-0"
             style={{ color: '#A78BFA', background: 'rgba(167,139,250,0.10)' }}>
-            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-              <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
-            </svg>
+            <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" /></svg>
             Login to comment
           </button>
         )}
       </header>
 
       {/* 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 }}>
+      <div className="flex-1 flex overflow-hidden">
+        {/* Video */}
+        <div className="flex-1 flex items-center justify-center overflow-hidden" style={{ background: '#000', minWidth: 0 }}>
           {streamUrl ? (
-            <div className="w-full max-w-6xl mx-auto" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
-              <div ref={videoContainerRef} className="relative w-full h-full">
-                {isHls ? (
-                  <video ref={videoRef} className="w-full h-full" controls playsInline
-                    onTimeUpdate={e => setCurrentTime(e.currentTarget.currentTime)} />
-                ) : (
-                  <video ref={videoRef} src={streamUrl} className="w-full h-full" controls playsInline
-                    onTimeUpdate={e => setCurrentTime(e.currentTarget.currentTime)} />
-                )}
-                {/* Annotation drawing canvas */}
-                <AnnotationCanvas
-                  isActive={drawMode}
-                  tool={drawTool}
-                  color={drawColor}
-                  width={videoDims.width}
-                  height={videoDims.height}
-                  pendingStrokes={pendingStrokes}
-                  onStrokeComplete={(stroke) => setPendingStrokes(prev => [...prev, stroke])}
-                />
-                {/* Draw toolbar */}
-                {drawMode && (
-                  <div className="absolute top-2 right-2 z-30 flex flex-wrap gap-1.5 p-2 rounded-xl"
-                    style={{ background: 'rgba(15,17,28,0.97)', border: '1px solid rgba(167,139,250,0.3)', backdropFilter: 'blur(12px)' }}>
-                    {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
-                      <button key={t} onClick={() => setDrawTool(t)}
-                        className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
-                          drawTool === t ? 'bg-indigo-600 text-white' : 'bg-white/10 text-white/80 hover:bg-white/20'
-                        }`}>{t.charAt(0).toUpperCase() + t.slice(1)}</button>
-                    ))}
-                    <div className="w-px h-5 bg-white/10" />
-                    {COLORS.map(c => (
-                      <button key={c.value} onClick={() => setDrawColor(c.value)}
-                        className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
-                          drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
-                        }`} style={{ backgroundColor: c.value }} />
-                    ))}
-                    <div className="w-px h-5 bg-white/10" />
-                    <button onClick={() => { setDrawMode(false); setPendingStrokes([]); }}
-                      className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors">Clear</button>
-                    <button onClick={() => setDrawMode(false)}
-                      className="text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors">Done</button>
-                  </div>
-                )}
-                {/* Draw mode toggle */}
-                {!drawMode && (
-                  <button onClick={() => { videoRef.current?.pause(); setDrawMode(true); }}
-                    className="absolute top-2 right-2 z-20 p-1.5 rounded-lg transition-colors"
-                    style={{ background: 'rgba(0,0,0,0.5)', color: 'rgba(255,255,255,0.8)' }}
-                    title="Draw annotation">
-                    <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                      <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
-                    </svg>
-                  </button>
-                )}
-              </div>
-            </div>
+            <VideoPlayer
+              src={streamUrl}
+              mimeType="video/mp4"
+              fps={fps}
+              comments={comments as any}
+              visibleAnnotations={visibleAnnotations}
+              drawMode={drawMode}
+              drawTool={drawTool}
+              drawColor={drawColor}
+              onDrawModeChange={setDrawMode}
+              onDrawToolChange={setDrawTool}
+              onDrawColorChange={setDrawColor}
+              pendingStrokes={pendingStrokes}
+              onStrokeComplete={stroke => setPendingStrokes(prev => [...prev, stroke])}
+              onTimeUpdate={setCurrentTime}
+              onCommentClick={(comment) => {
+                if (comment.timestamp != null && videoRef.current) {
+                  videoRef.current.currentTime = comment.timestamp;
+                }
+              }}
+              onPrevComment={() => {
+                const tsComments = comments.filter(c => c.timestamp != null);
+                const prev = [...tsComments].reverse().find(c => (c.timestamp ?? 0) < currentTime - 0.5 / fps);
+                if (prev && prev.timestamp != null && videoRef.current) {
+                  videoRef.current.currentTime = prev.timestamp;
+                }
+              }}
+              onNextComment={() => {
+                const next = comments.find(c => (c.timestamp ?? 0) > currentTime + 0.5 / fps);
+                if (next && next.timestamp != null && videoRef.current) {
+                  videoRef.current.currentTime = next.timestamp;
+                }
+              }}
+              videoRef={videoRef}
+            />
           ) : !linkInfo?.asset.videoReady ? (
-            <div className="flex flex-col items-center justify-center" style={{ aspectRatio: '16/9', maxHeight: '85vh' }}>
+            <div className="flex flex-col items-center justify-center">
               <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>
@@ -483,75 +368,37 @@ export default function SharePage() {
 
         {/* 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>}
-              {currentTime > 0 && (
-                <div className="text-xs mb-2 flex items-center gap-1" style={{ color: '#A78BFA' }}>
-                  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
-                    <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
-                  </svg>
-                  Comment at {formatTimecode(currentTime, 30, currentTime)}{pendingStrokes.length > 0 && ` (${pendingStrokes.length} annotation${pendingStrokes.length > 1 ? 's' : ''})`}
-                </div>
-              )}
-              <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 className="w-96 shrink-0 overflow-hidden"
+            style={{ background: '#13141F', borderLeft: '1px solid rgba(255,255,255,0.06)' }}>
+            <CommentPanel
+              comments={comments as any}
+              currentUserId="guest"
+              currentUserRole="EDITOR"
+              isProjectAdmin={false}
+              currentTime={currentTime}
+              pendingAnnotation={pendingStrokes.length > 0 ? pendingStrokes : undefined}
+              onAddComment={handleAddComment}
+              onResolve={async () => {}}
+              onRequestResolve={async () => {}}
+              onDelete={async () => {}}
+              onCommentClick={(comment) => {
+                if (comment.timestamp != null && videoRef.current) {
+                  videoRef.current.currentTime = comment.timestamp;
+                }
+              }}
+            />
           </div>
         )}
       </div>
 
-      {/* Info footer */}
+      {/* 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>
-        )}
+        {linkInfo?.asset.duration && <span>{linkInfo.asset.duration}</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></>
-        )}
+        {linkInfo?.allowUnregisteredComments && !user && <><span>·</span><span style={{ color: '#A78BFA' }}>Guest comments enabled</span></>}
       </div>
     </div>
   );
-}
+}

+ 4 - 4
src/components/comments/CommentPanel.tsx

@@ -256,11 +256,11 @@ function CommentItem({
   return (
     <div className={itemClass}>
       <div className="flex items-start gap-2.5">
-        <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
+        <Avatar name={comment.guestName ?? comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
         <div className="flex-1 min-w-0">
           {/* Meta */}
           <div className="flex items-center gap-2 flex-wrap">
-            <span className="text-sm font-medium text-gray-900">{comment.user?.name ?? 'Unknown'}</span>
+            <span className="text-sm font-medium text-gray-900">{comment.guestName ?? comment.user?.name ?? 'Unknown'}</span>
             {comment.timestamp != null && onTimestampClick && (
               <button
                 onClick={onTimestampClick}
@@ -306,9 +306,9 @@ function CommentItem({
             <div className="mt-2 ml-2 border-l-2 border-gray-100 pl-3 space-y-2">
               {comment.replies.map(reply => (
                 <div key={reply.id} className="flex items-start gap-2">
-                  <Avatar name={reply.user?.name ?? 'U'} src={reply.user?.avatarUrl} size="sm" />
+                  <Avatar name={reply.guestName ?? reply.user?.name ?? 'U'} src={reply.user?.avatarUrl} size="sm" />
                   <div>
-                    <span className="text-xs font-medium text-gray-800">{reply.user?.name ?? 'Unknown'}</span>
+                    <span className="text-xs font-medium text-gray-800">{reply.guestName ?? reply.user?.name ?? 'Unknown'}</span>
                     <p className="text-sm text-gray-600 mt-0.5">{reply.content}</p>
                   </div>
                 </div>

+ 1 - 0
src/lib/api.ts

@@ -501,6 +501,7 @@ export interface Comment {
   id: string;
   assetId: string;
   userId: string;
+  guestName?: string | null;
   content: string;
   timestamp?: number | null;
   annotations?: AnnotationData[] | null;