|
@@ -3,7 +3,7 @@
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
-import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData } from '@/lib/api';
|
|
|
|
|
|
|
+import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
|
|
|
import { Avatar } from '@/components/ui/avatar';
|
|
import { Avatar } from '@/components/ui/avatar';
|
|
|
import { VideoPlayer } from '@/components/video-player/VideoPlayer';
|
|
import { VideoPlayer } from '@/components/video-player/VideoPlayer';
|
|
|
import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
import { Tool } from '@/components/video-player/AnnotationCanvas';
|
|
@@ -19,6 +19,15 @@ const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass
|
|
|
REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
|
|
REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
|
|
|
|
|
+ PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
|
|
|
|
|
+ UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
|
|
|
|
|
+ PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
|
|
|
|
|
+ COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
|
|
|
|
|
+ FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
|
|
|
|
|
+ UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
function formatTimecode(seconds: number, fps: number = 30): string {
|
|
function formatTimecode(seconds: number, fps: number = 30): string {
|
|
|
if (!seconds || isNaN(seconds)) return '00:00:00:00';
|
|
if (!seconds || isNaN(seconds)) return '00:00:00:00';
|
|
|
const h = Math.floor(seconds / 3600);
|
|
const h = Math.floor(seconds / 3600);
|
|
@@ -66,6 +75,31 @@ export default function ReviewPage() {
|
|
|
|
|
|
|
|
const fps = asset?.fps ?? 30;
|
|
const fps = asset?.fps ?? 30;
|
|
|
|
|
|
|
|
|
|
+ // Derive the current user's project role
|
|
|
|
|
+ const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
|
|
|
|
|
+ const isProjectAdmin = currentUserRole === 'ADMIN';
|
|
|
|
|
+ const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
|
|
|
|
|
+
|
|
|
|
|
+ // ── Poll for transcode progress ───────────────────────────────────────────
|
|
|
|
|
+ const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
|
|
|
|
|
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (isTranscoding) {
|
|
|
|
|
+ if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pollRef.current) return;
|
|
|
|
|
+ pollRef.current = setInterval(async () => {
|
|
|
|
|
+ if (!token) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { asset: updated } = await assetsApi.getStatus(token, assetId);
|
|
|
|
|
+ setAsset(prev => prev ? { ...prev, ...updated } : prev);
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ }, 2000);
|
|
|
|
|
+ return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
|
|
|
|
+ }, [token, assetId, isTranscoding]);
|
|
|
|
|
+
|
|
|
// Load asset + comments
|
|
// Load asset + comments
|
|
|
const loadData = useCallback(async () => {
|
|
const loadData = useCallback(async () => {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
@@ -146,13 +180,23 @@ export default function ReviewPage() {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const handleResolve = async (commentId: string) => {
|
|
|
|
|
|
|
+ const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
|
try {
|
|
try {
|
|
|
- const { comment } = await commentsApi.resolve(token, commentId);
|
|
|
|
|
|
|
+ const { comment } = await commentsApi.resolve(token, commentId, action);
|
|
|
setComments(prev => prev.map(c => c.id === commentId ? comment : c));
|
|
setComments(prev => prev.map(c => c.id === commentId ? comment : c));
|
|
|
- } catch {
|
|
|
|
|
- alert('Failed to resolve comment');
|
|
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to update comment');
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handleRequestResolve = async (commentId: string) => {
|
|
|
|
|
+ if (!token) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { comment } = await commentsApi.requestResolve(token, commentId);
|
|
|
|
|
+ setComments(prev => prev.map(c => c.id === commentId ? comment : c));
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to request resolve');
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -171,22 +215,13 @@ export default function ReviewPage() {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// ── Annotation actions ─────────────────────────────────────────────────────
|
|
// ── Annotation actions ─────────────────────────────────────────────────────
|
|
|
- // User clicks "Add annotation" on a comment
|
|
|
|
|
|
|
+ // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
|
|
|
const handleAddAnnotationClick = (comment: Comment) => {
|
|
const handleAddAnnotationClick = (comment: Comment) => {
|
|
|
const existingCount = comment.annotations?.length ?? 0;
|
|
const existingCount = comment.annotations?.length ?? 0;
|
|
|
if (existingCount >= MAX_ANNOTATIONS) {
|
|
if (existingCount >= MAX_ANNOTATIONS) {
|
|
|
alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
|
|
alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- // Seek to comment timestamp if it exists
|
|
|
|
|
- if (comment.timestamp != null) {
|
|
|
|
|
- const videoEl = document.querySelector('video') as HTMLVideoElement | null;
|
|
|
|
|
- if (videoEl) {
|
|
|
|
|
- videoEl.pause();
|
|
|
|
|
- videoEl.currentTime = comment.timestamp;
|
|
|
|
|
- }
|
|
|
|
|
- setCurrentTime(comment.timestamp);
|
|
|
|
|
- }
|
|
|
|
|
setPendingStrokes([]);
|
|
setPendingStrokes([]);
|
|
|
setAnnotatingComment(comment);
|
|
setAnnotatingComment(comment);
|
|
|
setDrawMode(true);
|
|
setDrawMode(true);
|
|
@@ -203,37 +238,23 @@ export default function ReviewPage() {
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // Save pending strokes — use handleAddComment with replyTo set if annotating a comment
|
|
|
|
|
- const handleSaveAnnotations = (content: string, timestamp?: number) => {
|
|
|
|
|
|
|
+ // Save pending strokes as annotation on the parent comment (no separate reply)
|
|
|
|
|
+ const handleSaveAnnotations = () => {
|
|
|
const strokes = pendingStrokesRef.current;
|
|
const strokes = pendingStrokesRef.current;
|
|
|
const parent = annotatingCommentRef.current;
|
|
const parent = annotatingCommentRef.current;
|
|
|
|
|
+ if (!token || !parent || strokes.length === 0) {
|
|
|
|
|
+ setPendingStrokes([]);
|
|
|
|
|
+ setDrawMode(false);
|
|
|
|
|
+ setAnnotatingComment(null);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ setSubmitting(true);
|
|
|
setPendingStrokes([]);
|
|
setPendingStrokes([]);
|
|
|
setDrawMode(false);
|
|
setDrawMode(false);
|
|
|
setAnnotatingComment(null);
|
|
setAnnotatingComment(null);
|
|
|
- if (parent) {
|
|
|
|
|
- setReplyTo(parent);
|
|
|
|
|
- setNewComment(content.trim() || '(annotation)');
|
|
|
|
|
- // Also call handleAddComment with strokes and parentId
|
|
|
|
|
- const parentId = parent.id;
|
|
|
|
|
- if (!token) return;
|
|
|
|
|
- setSubmitting(true);
|
|
|
|
|
- commentsApi.create(token, assetId, {
|
|
|
|
|
- content: content.trim() || '(annotation)',
|
|
|
|
|
- timestamp,
|
|
|
|
|
- annotations: strokes,
|
|
|
|
|
- parentId,
|
|
|
|
|
- }).then(({ comment }) => {
|
|
|
|
|
- setComments(prev => prev.map(c =>
|
|
|
|
|
- c.id === parentId
|
|
|
|
|
- ? { ...c, replies: [...(c.replies ?? []), comment] }
|
|
|
|
|
- : c
|
|
|
|
|
- ));
|
|
|
|
|
- setReplyTo(null);
|
|
|
|
|
- setNewComment('');
|
|
|
|
|
- }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save')).finally(() => setSubmitting(false));
|
|
|
|
|
- } else {
|
|
|
|
|
- handleAddComment(content.trim() || '(annotation)', timestamp, strokes);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
|
|
|
|
|
+ setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
|
|
|
|
|
+ }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// Discard pending strokes
|
|
// Discard pending strokes
|
|
@@ -285,6 +306,8 @@ export default function ReviewPage() {
|
|
|
const status = asset?.status ?? 'PENDING_REVIEW';
|
|
const status = asset?.status ?? 'PENDING_REVIEW';
|
|
|
const statusCfg = STATUS_CONFIG[status];
|
|
const statusCfg = STATUS_CONFIG[status];
|
|
|
|
|
|
|
|
|
|
+ const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
|
|
|
|
|
+
|
|
|
const videoUrl = asset?.hlsPath
|
|
const videoUrl = asset?.hlsPath
|
|
|
? `${API_BASE}/uploads${asset.hlsPath}`
|
|
? `${API_BASE}/uploads${asset.hlsPath}`
|
|
|
: asset
|
|
: asset
|
|
@@ -294,8 +317,8 @@ export default function ReviewPage() {
|
|
|
const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
|
|
const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
|
|
|
const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
|
|
const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
|
|
|
|
|
|
|
|
- // Flatten all comment annotations with their timestamps for the player to display
|
|
|
|
|
- const visibleAnnotations = allComments.flatMap(c =>
|
|
|
|
|
|
|
+ // Only main comments (not replies) have annotations that should show on the video
|
|
|
|
|
+ const visibleAnnotations = visibleComments.flatMap(c =>
|
|
|
(c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
|
|
(c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
|
|
|
);
|
|
);
|
|
|
|
|
|
|
@@ -403,11 +426,78 @@ export default function ReviewPage() {
|
|
|
onDrawModeChange={setDrawMode}
|
|
onDrawModeChange={setDrawMode}
|
|
|
onDrawToolChange={setDrawTool}
|
|
onDrawToolChange={setDrawTool}
|
|
|
onDrawColorChange={setDrawColor}
|
|
onDrawColorChange={setDrawColor}
|
|
|
|
|
+ pendingStrokes={pendingStrokes}
|
|
|
onStrokeComplete={handleStrokeComplete}
|
|
onStrokeComplete={handleStrokeComplete}
|
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
|
onCommentClick={handleCommentSeek}
|
|
onCommentClick={handleCommentSeek}
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
|
|
+ {/* Transcode status overlay — shown when video is not ready */}
|
|
|
|
|
+ {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
|
|
|
|
|
+ <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
|
|
|
|
|
+ style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
|
|
|
|
|
+ {transcodeCfg.spinner ? (
|
|
|
|
|
+ <div className="w-8 h-8 rounded-full animate-spin shrink-0"
|
|
|
|
|
+ style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
|
|
|
|
|
+ ) : asset.transcodeStatus === 'FAILED' ? (
|
|
|
|
|
+ <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
|
|
|
|
+ style={{ background: 'rgba(248,113,113,0.15)' }}>
|
|
|
|
|
+ <svg className="w-4 h-4" 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>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
|
|
|
|
+ style={{ background: 'rgba(251,191,36,0.15)' }}>
|
|
|
|
|
+ <svg className="w-4 h-4" style={{ color: '#FBBF24' }} 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>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="flex items-center gap-2 mb-1">
|
|
|
|
|
+ <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
|
|
|
|
|
+ {transcodeCfg.label}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
|
|
|
|
|
+ <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
|
|
|
|
|
+ {asset.transcodeProgress}%
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {asset.transcodeStatus === 'PROCESSING' && (
|
|
|
|
|
+ <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="h-full rounded-full transition-all duration-500"
|
|
|
|
|
+ style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
|
|
|
|
|
+ <p className="text-xs mt-1" style={{ color: '#F87171' }}>
|
|
|
|
|
+ {asset.transcodeError}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
|
|
|
|
|
+ <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
|
|
|
|
|
+ {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
|
|
|
|
|
+ <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
|
|
|
|
|
+ Converting from {asset.codec.toUpperCase()} → H.264/AAC
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {asset.transcodeStatus === 'UPLOADING' && (
|
|
|
|
|
+ <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
|
|
|
|
|
+ Video uploaded — queued for processing
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Keyboard shortcuts */}
|
|
{/* Keyboard shortcuts */}
|
|
|
<div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
|
|
<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)' }}>Space</kbd> play/pause</span>
|
|
@@ -461,7 +551,7 @@ export default function ReviewPage() {
|
|
|
</svg>
|
|
</svg>
|
|
|
<span className="text-xs flex-1" style={{ color: '#818CF8' }}>
|
|
<span className="text-xs flex-1" style={{ color: '#818CF8' }}>
|
|
|
{annotatingComment
|
|
{annotatingComment
|
|
|
- ? `Drawing on comment by ${annotatingComment.user?.name} — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
|
|
|
|
|
|
|
+ ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
|
|
|
: `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
|
|
: `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
|
|
|
</span>
|
|
</span>
|
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex items-center gap-1.5">
|
|
@@ -473,12 +563,9 @@ export default function ReviewPage() {
|
|
|
Undo all
|
|
Undo all
|
|
|
</button>
|
|
</button>
|
|
|
<button
|
|
<button
|
|
|
- onClick={() => {
|
|
|
|
|
- const text = window.prompt('Add a note (optional):') ?? '';
|
|
|
|
|
- handleSaveAnnotations(text, currentTime);
|
|
|
|
|
- }}
|
|
|
|
|
- disabled={submitting}
|
|
|
|
|
- className="text-xs px-2 py-0.5 rounded transition-colors"
|
|
|
|
|
|
|
+ onClick={handleSaveAnnotations}
|
|
|
|
|
+ disabled={submitting || pendingStrokes.length === 0}
|
|
|
|
|
+ className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
|
|
|
style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
|
|
style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
|
|
|
>
|
|
>
|
|
|
{submitting ? 'Saving…' : 'Save'}
|
|
{submitting ? 'Saving…' : 'Save'}
|
|
@@ -510,10 +597,14 @@ export default function ReviewPage() {
|
|
|
comment={comment}
|
|
comment={comment}
|
|
|
currentUserId={user?.id ?? ''}
|
|
currentUserId={user?.id ?? ''}
|
|
|
fps={fps}
|
|
fps={fps}
|
|
|
|
|
+ canComment={canComment}
|
|
|
|
|
+ isProjectAdmin={isProjectAdmin}
|
|
|
onTimestampClick={handleCommentSeek}
|
|
onTimestampClick={handleCommentSeek}
|
|
|
onReply={() => { setReplyTo(comment); }}
|
|
onReply={() => { setReplyTo(comment); }}
|
|
|
- onResolve={() => handleResolve(comment.id)}
|
|
|
|
|
- onDelete={() => handleDeleteComment(comment.id)}
|
|
|
|
|
|
|
+ onResolve={(action) => handleResolve(comment.id, action)}
|
|
|
|
|
+ onRequestResolve={() => handleRequestResolve(comment.id)}
|
|
|
|
|
+ onDeleteSelf={() => handleDeleteComment(comment.id)}
|
|
|
|
|
+ onDelete={(id) => handleDeleteComment(id)}
|
|
|
onAddAnnotation={() => handleAddAnnotationClick(comment)}
|
|
onAddAnnotation={() => handleAddAnnotationClick(comment)}
|
|
|
onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
|
|
onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
|
|
|
/>
|
|
/>
|
|
@@ -546,7 +637,7 @@ export default function ReviewPage() {
|
|
|
<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" />
|
|
<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>
|
|
</svg>
|
|
|
{pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
|
|
{pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
|
|
|
- {annotatingComment ? ` → will be saved as reply to "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
|
|
|
|
|
|
|
+ {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
|
|
|
<button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
|
|
<button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
@@ -606,9 +697,13 @@ function CommentItem({
|
|
|
comment,
|
|
comment,
|
|
|
currentUserId,
|
|
currentUserId,
|
|
|
fps,
|
|
fps,
|
|
|
|
|
+ canComment,
|
|
|
|
|
+ isProjectAdmin,
|
|
|
onTimestampClick,
|
|
onTimestampClick,
|
|
|
onReply,
|
|
onReply,
|
|
|
onResolve,
|
|
onResolve,
|
|
|
|
|
+ onRequestResolve,
|
|
|
|
|
+ onDeleteSelf,
|
|
|
onDelete,
|
|
onDelete,
|
|
|
onAddAnnotation,
|
|
onAddAnnotation,
|
|
|
onDeleteAnnotation,
|
|
onDeleteAnnotation,
|
|
@@ -616,23 +711,35 @@ function CommentItem({
|
|
|
comment: Comment;
|
|
comment: Comment;
|
|
|
currentUserId: string;
|
|
currentUserId: string;
|
|
|
fps: number;
|
|
fps: number;
|
|
|
|
|
+ canComment: boolean | undefined;
|
|
|
|
|
+ isProjectAdmin: boolean;
|
|
|
onTimestampClick: (c: Comment) => void;
|
|
onTimestampClick: (c: Comment) => void;
|
|
|
onReply: () => void;
|
|
onReply: () => void;
|
|
|
- onResolve: () => void;
|
|
|
|
|
- onDelete: () => void;
|
|
|
|
|
|
|
+ onResolve: (action: 'approve' | 'reject') => void;
|
|
|
|
|
+ onRequestResolve: () => void;
|
|
|
|
|
+ onDeleteSelf: () => void;
|
|
|
|
|
+ onDelete: (id: string) => void;
|
|
|
onAddAnnotation: () => void;
|
|
onAddAnnotation: () => void;
|
|
|
onDeleteAnnotation: (annotations: AnnotationData[]) => void;
|
|
onDeleteAnnotation: (annotations: AnnotationData[]) => void;
|
|
|
}) {
|
|
}) {
|
|
|
const isOwner = comment.userId === currentUserId;
|
|
const isOwner = comment.userId === currentUserId;
|
|
|
|
|
+ const isCommentAuthor = comment.userId === currentUserId;
|
|
|
const name = comment.user?.name ?? 'Unknown';
|
|
const name = comment.user?.name ?? 'Unknown';
|
|
|
const isReply = !!comment.parentId;
|
|
const isReply = !!comment.parentId;
|
|
|
const annotations = comment.annotations ?? [];
|
|
const annotations = comment.annotations ?? [];
|
|
|
const canAddMore = annotations.length < MAX_ANNOTATIONS;
|
|
const canAddMore = annotations.length < MAX_ANNOTATIONS;
|
|
|
|
|
|
|
|
|
|
+ // Resolve state machine
|
|
|
|
|
+ const isResolved = comment.resolveStatus === 'RESOLVED';
|
|
|
|
|
+ const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
|
|
|
|
|
+ const canApprove = isCommentAuthor || isProjectAdmin;
|
|
|
|
|
+ const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
|
|
|
|
|
+ const canReopen = isResolved && canApprove;
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
className="p-4 animate-fade-in"
|
|
className="p-4 animate-fade-in"
|
|
|
- style={{ opacity: comment.resolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
|
|
|
|
|
|
|
+ style={{ opacity: isResolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
|
|
|
>
|
|
>
|
|
|
<div className="flex gap-2.5">
|
|
<div className="flex gap-2.5">
|
|
|
<Avatar name={name} size="sm" />
|
|
<Avatar name={name} size="sm" />
|
|
@@ -650,10 +757,21 @@ function CommentItem({
|
|
|
{formatTimecode(comment.timestamp, fps)}
|
|
{formatTimecode(comment.timestamp, fps)}
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
- {comment.resolved && (
|
|
|
|
|
|
|
+ {isPending && (
|
|
|
|
|
+ <span className="text-xs px-1.5 py-0.5 rounded"
|
|
|
|
|
+ style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
|
|
|
|
|
+ Pending approval
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {isResolved && (
|
|
|
<span className="text-xs px-1.5 py-0.5 rounded"
|
|
<span className="text-xs px-1.5 py-0.5 rounded"
|
|
|
style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
|
|
style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
|
|
|
- Resolved
|
|
|
|
|
|
|
+ Approved
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {isResolved && comment.resolvedBy && (
|
|
|
|
|
+ <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
|
|
|
|
|
+ by {comment.resolvedBy.name}
|
|
|
</span>
|
|
</span>
|
|
|
)}
|
|
)}
|
|
|
<span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
|
|
<span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
|
|
@@ -725,19 +843,90 @@ function CommentItem({
|
|
|
</svg>
|
|
</svg>
|
|
|
</button>
|
|
</button>
|
|
|
)}
|
|
)}
|
|
|
- <button
|
|
|
|
|
- onClick={onResolve}
|
|
|
|
|
- className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
|
|
- style={{ color: comment.resolved ? '#86EFAC' : '#6366F1' }}
|
|
|
|
|
- title={comment.resolved ? 'Unresolve' : 'Mark as resolved'}
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
|
|
+
|
|
|
|
|
+ {/* Resolve / approval workflow buttons */}
|
|
|
|
|
+ {!isReply && !isResolved && !isPending && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ {canRequest ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={onRequestResolve}
|
|
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
|
|
+ style={{ color: '#6366F1' }}
|
|
|
|
|
+ title="Request resolve approval"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Request resolve
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <span
|
|
|
|
|
+ className="text-xs px-2 py-1 opacity-30"
|
|
|
|
|
+ style={{ color: '#6366F1' }}
|
|
|
|
|
+ title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Request resolve
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {isPending && canApprove && !isReply && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => onResolve('approve')}
|
|
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
|
|
+ style={{ color: '#86EFAC' }}
|
|
|
|
|
+ title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Approve
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => onResolve('reject')}
|
|
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
|
|
+ style={{ color: '#FCA5A5' }}
|
|
|
|
|
+ title="Reject resolve request"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Reject
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {isPending && !canApprove && !isReply && (
|
|
|
|
|
+ <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 inline-block mr-0.5" 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>
|
|
|
|
|
+ Awaiting approval
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {canReopen && !isReply && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => onResolve('reject')}
|
|
|
|
|
+ className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
|
|
+ style={{ color: '#86EFAC' }}
|
|
|
|
|
+ title="Reopen comment"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ Reopen
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{isOwner && (
|
|
{isOwner && (
|
|
|
<button
|
|
<button
|
|
|
- onClick={onDelete}
|
|
|
|
|
|
|
+ onClick={onDeleteSelf}
|
|
|
className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
className="text-xs px-2 py-1 rounded-md transition-colors"
|
|
|
style={{ color: 'var(--text-subtle)' }}
|
|
style={{ color: 'var(--text-subtle)' }}
|
|
|
title="Delete comment"
|
|
title="Delete comment"
|
|
@@ -753,17 +942,11 @@ function CommentItem({
|
|
|
{comment.replies && comment.replies.length > 0 && (
|
|
{comment.replies && comment.replies.length > 0 && (
|
|
|
<div className="mt-3 space-y-3">
|
|
<div className="mt-3 space-y-3">
|
|
|
{comment.replies.map(reply => (
|
|
{comment.replies.map(reply => (
|
|
|
- <CommentItem
|
|
|
|
|
|
|
+ <ReplyItem
|
|
|
key={reply.id}
|
|
key={reply.id}
|
|
|
comment={reply}
|
|
comment={reply}
|
|
|
- currentUserId={currentUserId}
|
|
|
|
|
- fps={fps}
|
|
|
|
|
- onTimestampClick={onTimestampClick}
|
|
|
|
|
- onReply={() => {}}
|
|
|
|
|
- onResolve={onResolve}
|
|
|
|
|
- onDelete={onDelete}
|
|
|
|
|
- onAddAnnotation={() => {}}
|
|
|
|
|
- onDeleteAnnotation={onDeleteAnnotation}
|
|
|
|
|
|
|
+ isOwner={reply.userId === currentUserId}
|
|
|
|
|
+ onDelete={() => onDelete(reply.id)}
|
|
|
/>
|
|
/>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
@@ -773,3 +956,44 @@ function CommentItem({
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// ── ReplyItem ──────────────────────────────────────────────────────────────
|
|
|
|
|
+// Replies have no resolve, no annotation, no timestamp — just content + delete
|
|
|
|
|
+function ReplyItem({
|
|
|
|
|
+ comment,
|
|
|
|
|
+ isOwner,
|
|
|
|
|
+ onDelete,
|
|
|
|
|
+}: {
|
|
|
|
|
+ comment: Comment;
|
|
|
|
|
+ isOwner: boolean;
|
|
|
|
|
+ onDelete: (id: string) => void;
|
|
|
|
|
+}) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex gap-2.5 animate-fade-in">
|
|
|
|
|
+ <Avatar name={comment.user?.name ?? 'U'} size="sm" />
|
|
|
|
|
+ <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: 'var(--text)' }}>
|
|
|
|
|
+ {comment.user?.name ?? 'Unknown'}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
|
|
|
|
|
+ {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ {comment.content}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ {isOwner && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => onDelete(comment.id)}
|
|
|
|
|
+ className="text-xs mt-1 transition-colors"
|
|
|
|
|
+ style={{ color: 'var(--text-subtle)' }}
|
|
|
|
|
+ title="Delete reply"
|
|
|
|
|
+ >
|
|
|
|
|
+ Delete
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|