|
|
@@ -4,8 +4,9 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData } from '@/lib/api';
|
|
|
-import { Modal } from '@/components/ui/modal';
|
|
|
import { Avatar } from '@/components/ui/avatar';
|
|
|
+import { VideoPlayer } from '@/components/video-player/VideoPlayer';
|
|
|
+import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
|
|
|
|
|
@@ -16,10 +17,14 @@ const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass
|
|
|
REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
|
|
|
};
|
|
|
|
|
|
-function formatTime(s: number): string {
|
|
|
- const m = Math.floor(s / 60);
|
|
|
- const sec = Math.floor(s % 60);
|
|
|
- return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
|
+// HH:MM:SS:FF format
|
|
|
+function formatTimecode(seconds: number, fps: number = 30): string {
|
|
|
+ if (!seconds || isNaN(seconds)) return '00:00:00:00';
|
|
|
+ const h = Math.floor(seconds / 3600);
|
|
|
+ const m = Math.floor((seconds % 3600) / 60);
|
|
|
+ const s = Math.floor(seconds % 60);
|
|
|
+ const f = Math.floor((seconds % 1) * fps);
|
|
|
+ return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
|
|
|
}
|
|
|
|
|
|
export default function ReviewPage() {
|
|
|
@@ -33,17 +38,19 @@ export default function ReviewPage() {
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
|
const [pendingAnnotation, setPendingAnnotation] = useState<AnnotationData | null>(null);
|
|
|
- const [panelWidth, setPanelWidth] = useState(360);
|
|
|
+ const [panelWidth, setPanelWidth] = useState(380);
|
|
|
const [showApproval, setShowApproval] = useState(false);
|
|
|
const [updatingStatus, setUpdatingStatus] = useState(false);
|
|
|
const [newComment, setNewComment] = useState('');
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
- const [showResolveConfirm, setShowResolveConfirm] = useState<string | null>(null);
|
|
|
+ const [replyTo, setReplyTo] = useState<Comment | null>(null);
|
|
|
+ const [showResolved, setShowResolved] = useState(false);
|
|
|
const isDraggingRef = useRef(false);
|
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
- const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
|
|
|
|
|
|
+ const fps = asset?.fps ?? 30;
|
|
|
+
|
|
|
// Load asset + comments
|
|
|
const loadData = useCallback(async () => {
|
|
|
if (!token) return;
|
|
|
@@ -102,10 +109,20 @@ export default function ReviewPage() {
|
|
|
content: content.trim(),
|
|
|
timestamp,
|
|
|
annotation: annotation as AnnotationData | undefined,
|
|
|
+ parentId: replyTo?.id,
|
|
|
});
|
|
|
- setComments(prev => [...prev, comment]);
|
|
|
+ if (replyTo) {
|
|
|
+ setComments(prev => prev.map(c =>
|
|
|
+ c.id === replyTo.id
|
|
|
+ ? { ...c, replies: [...(c.replies ?? []), comment] }
|
|
|
+ : c
|
|
|
+ ));
|
|
|
+ } else {
|
|
|
+ setComments(prev => [...prev, comment]);
|
|
|
+ }
|
|
|
setNewComment('');
|
|
|
setPendingAnnotation(null);
|
|
|
+ setReplyTo(null);
|
|
|
} catch (err) {
|
|
|
alert(err instanceof Error ? err.message : 'Failed to add comment');
|
|
|
} finally {
|
|
|
@@ -118,7 +135,6 @@ export default function ReviewPage() {
|
|
|
try {
|
|
|
const { comment } = await commentsApi.resolve(token, commentId);
|
|
|
setComments(prev => prev.map(c => c.id === commentId ? comment : c));
|
|
|
- setShowResolveConfirm(null);
|
|
|
} catch {
|
|
|
alert('Failed to resolve comment');
|
|
|
}
|
|
|
@@ -129,7 +145,8 @@ export default function ReviewPage() {
|
|
|
if (!confirm('Delete this comment?')) return;
|
|
|
try {
|
|
|
await commentsApi.delete(token, commentId);
|
|
|
- setComments(prev => prev.filter(c => c.id !== commentId));
|
|
|
+ setComments(prev => prev.filter(c => c.id !== commentId && c.replies?.some(r => r.id !== commentId)));
|
|
|
+ setComments(prev => prev.map(c => c.id === commentId ? c : { ...c, replies: c.replies?.filter(r => r.id !== commentId) }));
|
|
|
} catch {
|
|
|
alert('Failed to delete comment');
|
|
|
}
|
|
|
@@ -153,17 +170,27 @@ export default function ReviewPage() {
|
|
|
setPendingAnnotation(annotation);
|
|
|
};
|
|
|
|
|
|
- const handleCommentClick = (comment: Comment) => {
|
|
|
- if (comment.timestamp && videoRef.current) {
|
|
|
- videoRef.current.currentTime = comment.timestamp;
|
|
|
- videoRef.current.play();
|
|
|
- }
|
|
|
- };
|
|
|
+ // Seek to comment WITHOUT auto-play
|
|
|
+ const handleCommentSeek = useCallback((comment: Comment) => {
|
|
|
+ setCurrentTime(comment.timestamp ?? 0);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleTimeUpdate = useCallback((time: number) => {
|
|
|
+ setCurrentTime(time);
|
|
|
+ }, []);
|
|
|
|
|
|
const status = asset?.status ?? 'PENDING_REVIEW';
|
|
|
const statusCfg = STATUS_CONFIG[status];
|
|
|
- const videoUrl = asset ? `${API_BASE}/uploads/${asset.filePath}` : '';
|
|
|
- const videoType = asset?.mimeType ?? 'video/mp4';
|
|
|
+
|
|
|
+ // Determine video URL: prefer HLS if available
|
|
|
+ const videoUrl = asset?.hlsPath
|
|
|
+ ? `${API_BASE}/uploads${asset.hlsPath}`
|
|
|
+ : asset
|
|
|
+ ? `${API_BASE}/uploads/${asset.filePath}`
|
|
|
+ : '';
|
|
|
+
|
|
|
+ const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
|
|
|
+ const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
|
|
|
|
|
|
if (loading) {
|
|
|
return (
|
|
|
@@ -180,7 +207,7 @@ export default function ReviewPage() {
|
|
|
if (!asset) return null;
|
|
|
|
|
|
return (
|
|
|
- <div className="h-screen flex flex-col overflow-hidden" style={{ background: '#070710' }}>
|
|
|
+ <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
|
{/* ── Top bar ──────────────────────────────────────────── */}
|
|
|
<header className="h-12 flex items-center px-4 gap-3 shrink-0"
|
|
|
@@ -190,7 +217,6 @@ export default function ReviewPage() {
|
|
|
zIndex: 50,
|
|
|
}}>
|
|
|
|
|
|
- {/* Back */}
|
|
|
<button
|
|
|
onClick={() => router.push(`/projects/${asset.projectId}`)}
|
|
|
className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
|
|
|
@@ -204,14 +230,12 @@ export default function ReviewPage() {
|
|
|
|
|
|
<div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
|
|
|
|
|
|
- {/* Title */}
|
|
|
<div className="flex-1 min-w-0">
|
|
|
<h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>
|
|
|
{asset.title}
|
|
|
</h1>
|
|
|
</div>
|
|
|
|
|
|
- {/* Project name */}
|
|
|
<span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
|
|
|
{asset.project?.name}
|
|
|
</span>
|
|
|
@@ -266,56 +290,25 @@ export default function ReviewPage() {
|
|
|
{/* Video area */}
|
|
|
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 min-w-0">
|
|
|
|
|
|
- {/* Video */}
|
|
|
- <div className="video-container flex-shrink-0" style={{ borderRadius: '12px' }}>
|
|
|
- <video
|
|
|
- ref={videoRef}
|
|
|
- src={videoUrl}
|
|
|
- className="w-full h-full object-contain"
|
|
|
- style={{ background: '#000', borderRadius: '12px' }}
|
|
|
- controls
|
|
|
- onTimeUpdate={e => setCurrentTime((e.target as HTMLVideoElement).currentTime)}
|
|
|
- />
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Timeline */}
|
|
|
- <div className="flex-shrink-0 px-1">
|
|
|
- <div className="flex items-center gap-3">
|
|
|
- <span className="text-xs font-mono w-10 text-right shrink-0" style={{ color: 'var(--text-muted)' }}>
|
|
|
- {formatTime(currentTime)}
|
|
|
- </span>
|
|
|
- <div className="flex-1 timeline-track" style={{ height: '4px', background: 'rgba(255,255,255,0.10)', borderRadius: '99px' }}>
|
|
|
- {/* Comment markers */}
|
|
|
- {comments.filter(c => c.timestamp != null).map(c => {
|
|
|
- const pct = asset.duration ? (c.timestamp! / asset.duration) * 100 : 0;
|
|
|
- return (
|
|
|
- <button
|
|
|
- key={c.id}
|
|
|
- onClick={() => handleCommentClick(c)}
|
|
|
- className="absolute w-2 h-2 rounded-full border border-white/20"
|
|
|
- style={{
|
|
|
- background: c.resolved ? '#6366F1' : '#F59E0B',
|
|
|
- left: `${Math.min(pct, 100)}%`,
|
|
|
- top: '50%',
|
|
|
- transform: 'translate(-50%, -50%)',
|
|
|
- zIndex: 2,
|
|
|
- }}
|
|
|
- title={c.content.slice(0, 50)}
|
|
|
- />
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- <span className="text-xs font-mono w-10 shrink-0" style={{ color: 'var(--text-muted)' }}>
|
|
|
- {asset.duration ? formatTime(asset.duration) : '0:00'}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ {/* Custom video player */}
|
|
|
+ <VideoPlayer
|
|
|
+ src={videoUrl}
|
|
|
+ mimeType={asset.mimeType}
|
|
|
+ fps={fps}
|
|
|
+ comments={allComments}
|
|
|
+ pendingAnnotation={pendingAnnotation}
|
|
|
+ onAnnotationCreated={handleAnnotationCreated}
|
|
|
+ onTimeUpdate={handleTimeUpdate}
|
|
|
+ onCommentClick={handleCommentSeek}
|
|
|
+ />
|
|
|
|
|
|
{/* Keyboard shortcuts */}
|
|
|
<div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
|
|
|
<span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
|
|
|
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek</span>
|
|
|
- <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> comment</span>
|
|
|
+ <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
|
|
|
+ <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>U</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>I</kbd> prev/next frame</span>
|
|
|
+ <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw annotation</span>
|
|
|
+ <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -340,25 +333,32 @@ export default function ReviewPage() {
|
|
|
<div className="px-4 py-3 flex items-center justify-between shrink-0"
|
|
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
<div className="flex items-center gap-2">
|
|
|
- <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
|
|
- Comments
|
|
|
- </h2>
|
|
|
+ <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
|
|
|
<span className="text-xs px-1.5 py-0.5 rounded-full"
|
|
|
style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
|
|
|
{comments.length}
|
|
|
</span>
|
|
|
</div>
|
|
|
- <div className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
- <svg className="w-3 h-3" 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>
|
|
|
- <span className="font-mono">{formatTime(currentTime)}</span>
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ {/* Timecode display */}
|
|
|
+ <span className="font-mono text-xs" style={{ color: '#818CF8' }}>
|
|
|
+ {formatTimecode(currentTime, fps)}
|
|
|
+ </span>
|
|
|
+ {/* Toggle resolved */}
|
|
|
+ <button
|
|
|
+ onClick={() => setShowResolved(v => !v)}
|
|
|
+ className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
|
|
|
+ style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
|
|
|
+ title="Toggle resolved comments"
|
|
|
+ >
|
|
|
+ {showResolved ? 'Hide resolved' : 'Show resolved'}
|
|
|
+ </button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{/* Comment list */}
|
|
|
<div className="flex-1 overflow-y-auto scroll-area">
|
|
|
- {comments.length === 0 ? (
|
|
|
+ {visibleComments.length === 0 ? (
|
|
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
|
|
<div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
|
|
|
style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
|
|
|
@@ -366,19 +366,21 @@ export default function ReviewPage() {
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
|
|
</svg>
|
|
|
</div>
|
|
|
- <p className="text-xs font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
|
|
|
+ <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
|
|
|
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
|
|
- Add a frame-accurate comment using the field below
|
|
|
+ Pause video, press <kbd className="px-1 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> to draw, then submit
|
|
|
</p>
|
|
|
</div>
|
|
|
) : (
|
|
|
- <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
|
|
|
- {comments.map(comment => (
|
|
|
+ <div>
|
|
|
+ {visibleComments.map(comment => (
|
|
|
<CommentItem
|
|
|
key={comment.id}
|
|
|
comment={comment}
|
|
|
currentUserId={user?.id ?? ''}
|
|
|
- onTimestampClick={handleCommentClick}
|
|
|
+ fps={fps}
|
|
|
+ onTimestampClick={handleCommentSeek}
|
|
|
+ onReply={() => { setReplyTo(comment); }}
|
|
|
onResolve={() => handleResolve(comment.id)}
|
|
|
onDelete={() => handleDeleteComment(comment.id)}
|
|
|
/>
|
|
|
@@ -387,18 +389,36 @@ export default function ReviewPage() {
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* New comment input */}
|
|
|
+ {/* New comment / reply input */}
|
|
|
<div className="shrink-0 p-3"
|
|
|
style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
|
|
|
+ {replyTo && (
|
|
|
+ <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
|
+ </svg>
|
|
|
+ Replying to {replyTo.user?.name}
|
|
|
+ <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
|
|
|
+ <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
<div className="flex items-center gap-1.5 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
<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 {formatTime(currentTime)}
|
|
|
+ {formatTimecode(currentTime, fps)}
|
|
|
{!!pendingAnnotation && (
|
|
|
- <span className="ml-1 text-xs" style={{ color: '#818CF8' }}>(with annotation)</span>
|
|
|
+ <span className="ml-1" style={{ color: '#818CF8' }}>(+ annotation)</span>
|
|
|
+ )}
|
|
|
+ {!!replyTo && (
|
|
|
+ <span className="ml-1" style={{ color: '#818CF8' }}>(reply)</span>
|
|
|
)}
|
|
|
</div>
|
|
|
+
|
|
|
<form
|
|
|
onSubmit={e => {
|
|
|
e.preventDefault();
|
|
|
@@ -414,7 +434,7 @@ export default function ReviewPage() {
|
|
|
className="input flex-1"
|
|
|
value={newComment}
|
|
|
onChange={e => setNewComment(e.target.value)}
|
|
|
- placeholder="Add a comment…"
|
|
|
+ placeholder={replyTo ? 'Write a reply…' : 'Add a comment…'}
|
|
|
rows={1}
|
|
|
style={{ resize: 'none', overflow: 'hidden' }}
|
|
|
onKeyDown={e => {
|
|
|
@@ -450,41 +470,43 @@ export default function ReviewPage() {
|
|
|
function CommentItem({
|
|
|
comment,
|
|
|
currentUserId,
|
|
|
+ fps,
|
|
|
onTimestampClick,
|
|
|
+ onReply,
|
|
|
onResolve,
|
|
|
onDelete,
|
|
|
}: {
|
|
|
comment: Comment;
|
|
|
currentUserId: string;
|
|
|
+ fps: number;
|
|
|
onTimestampClick: (c: Comment) => void;
|
|
|
+ onReply: () => void;
|
|
|
onResolve: () => void;
|
|
|
onDelete: () => void;
|
|
|
}) {
|
|
|
const isOwner = comment.userId === currentUserId;
|
|
|
const name = comment.user?.name ?? 'Unknown';
|
|
|
- const initials = name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase();
|
|
|
+ const isReply = !!comment.parentId;
|
|
|
|
|
|
return (
|
|
|
- <div className="p-4 animate-fade-in" style={{ opacity: comment.resolved ? 0.55 : 1 }}>
|
|
|
+ <div
|
|
|
+ className="p-4 animate-fade-in"
|
|
|
+ style={{ opacity: comment.resolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
|
|
|
+ >
|
|
|
<div className="flex gap-2.5">
|
|
|
<Avatar name={name} size="sm" />
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
{/* Meta */}
|
|
|
- <div className="flex items-center gap-2 mb-1">
|
|
|
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
|
<span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
|
|
|
{comment.timestamp != null && (
|
|
|
<button
|
|
|
onClick={() => onTimestampClick(comment)}
|
|
|
- className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors"
|
|
|
- style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8' }}
|
|
|
+ className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
|
|
|
+ style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
|
|
|
>
|
|
|
- {(() => {
|
|
|
- const s = comment.timestamp!;
|
|
|
- const m = Math.floor(s / 60);
|
|
|
- const sec = Math.floor(s % 60);
|
|
|
- return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
|
- })()}
|
|
|
+ {formatTimecode(comment.timestamp, fps)}
|
|
|
</button>
|
|
|
)}
|
|
|
{comment.resolved && (
|
|
|
@@ -498,14 +520,37 @@ function CommentItem({
|
|
|
</span>
|
|
|
</div>
|
|
|
|
|
|
+ {/* Annotation preview */}
|
|
|
+ {comment.annotation && (
|
|
|
+ <div className="mb-2 text-xs px-2 py-1 rounded inline-flex items-center gap-1"
|
|
|
+ style={{ background: 'rgba(99,102,241,0.08)', color: '#818CF8', border: '1px solid rgba(99,102,241,0.15)' }}>
|
|
|
+ <svg className="w-3 h-3" 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>
|
|
|
+ {comment.annotation.type} annotation
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
{/* Content */}
|
|
|
- <p className="text-xs leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
|
|
|
+ <p className="text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
|
|
|
{comment.content}
|
|
|
</p>
|
|
|
|
|
|
{/* Actions */}
|
|
|
{!comment.resolved && (
|
|
|
<div className="flex items-center gap-1">
|
|
|
+ {!isReply && (
|
|
|
+ <button
|
|
|
+ onClick={onReply}
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
+ style={{ color: 'var(--text-muted)' }}
|
|
|
+ title="Reply"
|
|
|
+ >
|
|
|
+ <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 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
<button
|
|
|
onClick={onResolve}
|
|
|
className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
@@ -530,6 +575,24 @@ function CommentItem({
|
|
|
)}
|
|
|
</div>
|
|
|
)}
|
|
|
+
|
|
|
+ {/* Replies */}
|
|
|
+ {comment.replies && comment.replies.length > 0 && (
|
|
|
+ <div className="mt-3 space-y-3">
|
|
|
+ {comment.replies.map(reply => (
|
|
|
+ <CommentItem
|
|
|
+ key={reply.id}
|
|
|
+ comment={reply}
|
|
|
+ currentUserId={currentUserId}
|
|
|
+ fps={fps}
|
|
|
+ onTimestampClick={onTimestampClick}
|
|
|
+ onReply={() => {}}
|
|
|
+ onResolve={onResolve}
|
|
|
+ onDelete={onDelete}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|