diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx index e4c0373..0ce3181 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -1,2302 +1,18 @@ 'use client'; -import { useState, useRef, useCallback, useEffect } from 'react'; -import Link from 'next/link'; -import { useParams, useRouter } from 'next/navigation'; -import { toast } from 'sonner'; -import { - ArrowLeft, - Play, - Pause, - Volume2, - VolumeX, - SkipBack, - SkipForward, - Gauge, - MessageSquare, - Mic, - Send, - Clock, - CheckCircle2, - Circle, - ChevronDown, - MoreVertical, - Plus, - Loader2, - Link as LinkIcon, - AlertCircle, - GitCompareArrows, - Reply, - Pencil, - Trash2, - X, - ArrowUpRight, - Tag, -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; -import { Textarea } from '@/components/ui/textarea'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Separator } from '@/components/ui/separator'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { cn } from '@/lib/utils'; -import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers'; - -interface Version { - id: string; - versionNumber: number; - versionLabel: string | null; - providerId: string; - videoId: string; - originalUrl: string; - title: string | null; - thumbnailUrl: string | null; - duration: number | null; - isActive: boolean; - _count: { comments: number }; -} - -interface CommentTag { - id: string; - name: string; - color: string; -} - -interface Comment { - id: string; - content: string | null; - timestamp: number; - voiceUrl: string | null; - voiceDuration: number | null; - isResolved: boolean; - createdAt: string; - author: { id: string; name: string | null; image: string | null } | null; - guestName: string | null; - tag: CommentTag | null; - replies: { - id: string; - content: string | null; - voiceUrl: string | null; - voiceDuration: number | null; - createdAt: string; - author: { id: string; name: string | null; image: string | null } | null; - guestName: string | null; - tag: CommentTag | null; - }[]; -} - -interface VideoData { - id: string; - title: string; - description: string | null; - projectId: string; - project: { - name: string; - ownerId: string; - members: { role: string }[]; - }; - versions: (Version & { comments: Comment[] })[]; - isAuthenticated: boolean; -} - -function formatTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; -} - -const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; +import { useParams } from 'next/navigation'; +import { VideoPageContent } from '@/components/video-page-content'; export default function VideoPage() { const params = useParams(); - const router = useRouter(); const projectId = params.projectId as string; const videoId = params.videoId as string; - const iframeRef = useRef(null); - const playerRef = useRef(null); - const timelineRef = useRef(null); - const [video, setVideo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [activeVersionId, setActiveVersionId] = useState(null); - const [isReady, setIsReady] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [videoDuration, setVideoDuration] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [isMuted, setIsMuted] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [playbackSpeed, setPlaybackSpeed] = useState(1); - - const [commentText, setCommentText] = useState(''); - const [isSubmittingComment, setIsSubmittingComment] = useState(false); - const [isRecording, setIsRecording] = useState(false); - const [recordingTime, setRecordingTime] = useState(0); - const [audioBlob, setAudioBlob] = useState(null); - const [isUploadingAudio, setIsUploadingAudio] = useState(false); - const [playingVoiceId, setPlayingVoiceId] = useState(null); - const [voiceProgress, setVoiceProgress] = useState(0); - const [voiceCurrentTime, setVoiceCurrentTime] = useState(0); - const [voicePlaybackRate, setVoicePlaybackRate] = useState(1); - const mediaRecorderRef = useRef(null); - const audioChunksRef = useRef([]); - const recordingTimerRef = useRef | null>(null); - const audioPlayerRef = useRef(null); - const voiceRafRef = useRef(null); - const voiceKnownDurationRef = useRef(0); - const [selectedTimestamp, setSelectedTimestamp] = useState(null); - const [showResolved, setShowResolved] = useState(false); - - // Reply/Edit/Delete state - const [replyingTo, setReplyingTo] = useState(null); - const [replyText, setReplyText] = useState(''); - const [isSubmittingReply, setIsSubmittingReply] = useState(false); - const [isReplyRecording, setIsReplyRecording] = useState(false); - const [replyRecordingTime, setReplyRecordingTime] = useState(0); - const [replyAudioBlob, setReplyAudioBlob] = useState(null); - const [isUploadingReplyAudio, setIsUploadingReplyAudio] = useState(false); - const replyMediaRecorderRef = useRef(null); - const replyAudioChunksRef = useRef([]); - const replyRecordingTimerRef = useRef | null>(null); - const [editingCommentId, setEditingCommentId] = useState(null); - const [editText, setEditText] = useState(''); - const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); - const [deletingCommentId, setDeletingCommentId] = useState(null); - const isMutatingRef = useRef(false); - - // Guest name (for unauthenticated users on public projects) - const [guestName, setGuestName] = useState(''); - useEffect(() => { - const saved = localStorage.getItem('openframe_guest_name'); - if (saved) setGuestName(saved); - }, []); - const isGuest = video ? !video.isAuthenticated : false; - - // New version dialog - const [showVersionDialog, setShowVersionDialog] = useState(false); - const [newVersionUrl, setNewVersionUrl] = useState(''); - const [newVersionLabel, setNewVersionLabel] = useState(''); - const [newVersionSource, setNewVersionSource] = useState(null); - const [newVersionUrlError, setNewVersionUrlError] = useState(''); - const [isCreatingVersion, setIsCreatingVersion] = useState(false); - - // Comment tags state - const [availableTags, setAvailableTags] = useState([]); - const [selectedTagId, setSelectedTagId] = useState(null); - - // Fetch video data - useEffect(() => { - async function fetchVideo() { - try { - const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); - if (!res.ok) { - const errorText = await res.text(); - console.error('Failed to load video:', res.status, errorText); - setError(`Failed to load video: ${res.status} ${errorText}`); - setLoading(false); - return; - } - const response = await res.json(); - const data = response.data; - setVideo(data); - const active = data.versions?.find((v: Version) => v.isActive) || data.versions?.[0]; - if (active) setActiveVersionId(active.id); - } catch (err) { - console.error('Error fetching video:', err); - setError('Failed to load video'); - } finally { - setLoading(false); - } - } - fetchVideo(); - }, [projectId, videoId]); - - const activeVersion = video?.versions?.find((v) => v.id === activeVersionId) || - video?.versions?.find((v) => v.isActive) || - video?.versions?.[0]; - const comments = activeVersion?.comments || []; - const filteredComments = comments.filter((c) => showResolved || !c.isResolved); - const duration = videoDuration || activeVersion?.duration || 0; - - // Fetch tags for the project - useEffect(() => { - async function fetchTags() { - try { - const res = await fetch(`/api/projects/${projectId}/tags`); - if (res.ok) { - const data = await res.json(); - const tags = data.data || []; - setAvailableTags(tags); - // Auto-select first tag (Feedback) as default - if (tags.length > 0 && !selectedTagId) { - setSelectedTagId(tags[0].id); - } - } - } catch { - // Silent fail - tags are optional - } - } - fetchTags(); - }, [projectId]); - - // Load YouTube iframe API script once - useEffect(() => { - if (window.YT) return; - const tag = document.createElement('script'); - tag.src = 'https://www.youtube.com/iframe_api'; - const firstScriptTag = document.getElementsByTagName('script')[0]; - firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag); - }, []); - - // Initialize / reinitialize YouTube player when version changes - useEffect(() => { - if (!activeVersion || activeVersion.providerId !== 'youtube') return; - - // Reset state for new version - setIsReady(false); - setCurrentTime(0); - setVideoDuration(0); - setIsPlaying(false); - setPlaybackSpeed(1); - - // Destroy previous player if it exists - if (playerRef.current) { - try { playerRef.current.destroy(); } catch { /* ignore */ } - playerRef.current = null; - } - - const initPlayer = () => { - if (!iframeRef.current) return; - playerRef.current = new YT.Player(iframeRef.current, { - events: { - onReady: (event: YT.PlayerEvent) => { - setIsReady(true); - const dur = event.target.getDuration(); - if (dur > 0) setVideoDuration(dur); - }, - onStateChange: (event: YT.OnStateChangeEvent) => { - setIsPlaying(event.data === YT.PlayerState.PLAYING); - // Update duration when playback starts (more reliable) - if (event.data === YT.PlayerState.PLAYING) { - const dur = event.target.getDuration(); - if (dur > 0) setVideoDuration(dur); - } - }, - }, - }); - }; - - // Wait a tick for the iframe to update its src before binding - const timeout = setTimeout(() => { - if (window.YT?.Player) { - initPlayer(); - } else { - window.onYouTubeIframeAPIReady = initPlayer; - } - }, 100); - - return () => { - clearTimeout(timeout); - window.onYouTubeIframeAPIReady = undefined; - }; - }, [activeVersionId]); - - // Update current time periodically - useEffect(() => { - if (!isReady || !playerRef.current) return; - - const interval = setInterval(() => { - if (playerRef.current?.getCurrentTime && !isDragging) { - setCurrentTime(playerRef.current.getCurrentTime()); - } - }, 250); - - return () => clearInterval(interval); - }, [isReady, isDragging]); - - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Ignore if user is typing in an input/textarea - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } - - switch (e.code) { - case 'Space': - case 'KeyK': - e.preventDefault(); - if (playerRef.current) { - if (isPlaying) { - playerRef.current.pauseVideo(); - } else { - playerRef.current.playVideo(); - } - } - break; - case 'ArrowLeft': - e.preventDefault(); - if (playerRef.current?.seekTo) { - const newTime = Math.max(0, currentTime - 5); - playerRef.current.seekTo(newTime, true); - setCurrentTime(newTime); - } - break; - case 'ArrowRight': - e.preventDefault(); - if (playerRef.current?.seekTo) { - const newTime = Math.min(duration, currentTime + 5); - playerRef.current.seekTo(newTime, true); - setCurrentTime(newTime); - } - break; - case 'ArrowUp': - e.preventDefault(); - { - const speeds = SPEED_OPTIONS; - const currentIndex = speeds.indexOf(playbackSpeed); - if (currentIndex < speeds.length - 1) { - const newSpeed = speeds[currentIndex + 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } - break; - case 'ArrowDown': - e.preventDefault(); - { - const speeds = SPEED_OPTIONS; - const currentIndex = speeds.indexOf(playbackSpeed); - if (currentIndex > 0) { - const newSpeed = speeds[currentIndex - 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } - break; - case 'Comma': // < key (Shift+,) - if (e.shiftKey) { - e.preventDefault(); - const speeds = SPEED_OPTIONS; - const currentIndex = speeds.indexOf(playbackSpeed); - if (currentIndex > 0) { - const newSpeed = speeds[currentIndex - 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } - break; - case 'Period': // > key (Shift+.) - if (e.shiftKey) { - e.preventDefault(); - const speeds = SPEED_OPTIONS; - const currentIndex = speeds.indexOf(playbackSpeed); - if (currentIndex < speeds.length - 1) { - const newSpeed = speeds[currentIndex + 1]; - setPlaybackSpeed(newSpeed); - playerRef.current?.setPlaybackRate(newSpeed); - } - } - break; - case 'KeyM': - e.preventDefault(); - if (playerRef.current) { - if (isMuted) { - playerRef.current.unMute(); - } else { - playerRef.current.mute(); - } - setIsMuted(!isMuted); - } - break; - case 'KeyJ': - e.preventDefault(); - if (playerRef.current?.seekTo) { - const newTime = Math.max(0, currentTime - 10); - playerRef.current.seekTo(newTime, true); - setCurrentTime(newTime); - } - break; - case 'KeyL': - e.preventDefault(); - if (playerRef.current?.seekTo) { - const newTime = Math.min(duration, currentTime + 10); - playerRef.current.seekTo(newTime, true); - setCurrentTime(newTime); - } - break; - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isPlaying, currentTime, duration, isMuted, playbackSpeed]); - - const handlePlayPause = useCallback(() => { - if (!playerRef.current) return; - if (isPlaying) { - playerRef.current.pauseVideo(); - } else { - playerRef.current.playVideo(); - } - }, [isPlaying]); - - const handleSeekToTimestamp = useCallback((timestamp: number) => { - setCurrentTime(timestamp); - if (playerRef.current?.seekTo) { - playerRef.current.seekTo(timestamp, true); - } - }, []); - - const handleMuteToggle = useCallback(() => { - if (!playerRef.current) return; - if (isMuted) { - playerRef.current.unMute(); - } else { - playerRef.current.mute(); - } - setIsMuted(!isMuted); - }, [isMuted]); - - const handleSkip = useCallback( - (seconds: number) => { - const newTime = Math.max(0, Math.min(duration, currentTime + seconds)); - handleSeekToTimestamp(newTime); - }, - [currentTime, duration, handleSeekToTimestamp] - ); - - const handleSpeedChange = useCallback( - (speed: number) => { - setPlaybackSpeed(speed); - playerRef.current?.setPlaybackRate(speed); - }, - [] - ); - - const handleTimelineClick = useCallback( - (e: React.MouseEvent) => { - if (!timelineRef.current) return; - const rect = timelineRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const percentage = Math.max(0, Math.min(1, x / rect.width)); - const newTime = percentage * duration; - handleSeekToTimestamp(newTime); - }, - [duration, handleSeekToTimestamp] - ); - - const handleTimelineMouseDown = useCallback( - (e: React.MouseEvent) => { - setIsDragging(true); - handleTimelineClick(e); - }, - [handleTimelineClick] - ); - - const handleTimelineMouseMove = useCallback( - (e: React.MouseEvent) => { - if (!isDragging || !timelineRef.current) return; - const rect = timelineRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const percentage = Math.max(0, Math.min(1, x / rect.width)); - setCurrentTime(percentage * duration); - }, - [isDragging, duration] - ); - - const handleTimelineMouseUp = useCallback(() => { - if (isDragging) { - handleSeekToTimestamp(currentTime); - setIsDragging(false); - } - }, [isDragging, currentTime, handleSeekToTimestamp]); - - const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { - if (!voiceData && !commentText.trim()) return; - if (!activeVersion) return; - - const tempId = `temp-${Date.now()}`; - const optimisticComment: Comment = { - id: tempId, - content: voiceData ? commentText.trim() || null : commentText, - timestamp: selectedTimestamp ?? currentTime, - voiceUrl: voiceData?.url ?? null, - voiceDuration: voiceData?.duration ?? null, - isResolved: false, - createdAt: new Date().toISOString(), - author: isGuest ? null : { id: 'current-user', name: null, image: null }, - guestName: isGuest ? guestName : null, - tag: availableTags.find(t => t.id === selectedTagId) || null, - replies: [], - }; - - // Optimistically add comment - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { ...v, comments: [...v.comments, optimisticComment] } - : v - ), - }; - }); - - // Clear input immediately for better UX - setCommentText(''); - setSelectedTimestamp(null); - setSelectedTagId(availableTags.length > 0 ? availableTags[0].id : null); - setAudioBlob(null); - - setIsSubmittingComment(true); - isMutatingRef.current = true; - - try { - const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: voiceData ? commentText.trim() || null : commentText, - timestamp: selectedTimestamp ?? currentTime, - ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), - ...(isGuest && guestName && { guestName }), - ...(selectedTagId && { tagId: selectedTagId }), - }), - }); - - if (res.ok) { - const response = await res.json(); - const newComment = response.data; - // Replace temp comment with real one - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: [] } : c) } - : v - ), - }; - }); - } else { - // Remove optimistic comment on failure - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { ...v, comments: v.comments.filter(c => c.id !== tempId) } - : v - ), - }; - }); - toast.error('Failed to add comment'); - } - } catch (err) { - // Remove optimistic comment on error - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { ...v, comments: v.comments.filter(c => c.id !== tempId) } - : v - ), - }; - }); - toast.error('Failed to add comment'); - } finally { - setIsSubmittingComment(false); - isMutatingRef.current = false; - } - }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]); - - // Voice recording handlers - const startRecording = useCallback(async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mediaRecorder = new MediaRecorder(stream, { - mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' - : 'audio/webm', - }); - - audioChunksRef.current = []; - mediaRecorderRef.current = mediaRecorder; - - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) { - audioChunksRef.current.push(e.data); - } - }; - - mediaRecorder.onstop = () => { - const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); - setAudioBlob(blob); - stream.getTracks().forEach((track) => track.stop()); - if (recordingTimerRef.current) { - clearInterval(recordingTimerRef.current); - recordingTimerRef.current = null; - } - }; - - mediaRecorder.start(100); - setIsRecording(true); - setRecordingTime(0); - recordingTimerRef.current = setInterval(() => { - setRecordingTime((prev) => prev + 0.1); - }, 100); - } catch (err) { - console.error('Failed to start recording:', err); - } - }, []); - - const stopRecording = useCallback(() => { - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { - mediaRecorderRef.current.stop(); - } - setIsRecording(false); - }, []); - - const cancelRecording = useCallback(() => { - if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { - mediaRecorderRef.current.stop(); - } - setIsRecording(false); - setAudioBlob(null); - setRecordingTime(0); - }, []); - - const submitVoiceComment = useCallback(async () => { - if (!audioBlob || !activeVersion) return; - setIsUploadingAudio(true); - - try { - // Upload audio to R2 - const formData = new FormData(); - formData.append('audio', audioBlob, 'recording.webm'); - - const uploadRes = await fetch('/api/upload/audio', { - method: 'POST', - body: formData, - }); - - if (!uploadRes.ok) { - throw new Error('Failed to upload audio'); - } - - const uploadData = await uploadRes.json(); - const { url } = uploadData.data; - - // Submit comment with voice URL - await handleAddComment({ url, duration: recordingTime }); - setAudioBlob(null); - setRecordingTime(0); - } catch (err) { - console.error('Failed to submit voice comment:', err); - } finally { - setIsUploadingAudio(false); - } - }, [audioBlob, activeVersion, recordingTime, handleAddComment]); - - // Voice playback - const stopVoiceTracking = useCallback(() => { - if (voiceRafRef.current) { - cancelAnimationFrame(voiceRafRef.current); - voiceRafRef.current = null; - } - }, []); - - const startVoiceTracking = useCallback(() => { - stopVoiceTracking(); - const tick = () => { - const audio = audioPlayerRef.current; - if (audio) { - const dur = isFinite(audio.duration) && audio.duration > 0 - ? audio.duration - : voiceKnownDurationRef.current; - if (dur > 0) { - setVoiceProgress((audio.currentTime / dur) * 100); - setVoiceCurrentTime(audio.currentTime); - } - } - voiceRafRef.current = requestAnimationFrame(tick); - }; - voiceRafRef.current = requestAnimationFrame(tick); - }, [stopVoiceTracking]); - - const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => { - if (playingVoiceId === commentId) { - if (audioPlayerRef.current) { - audioPlayerRef.current.pause(); - audioPlayerRef.current = null; - } - stopVoiceTracking(); - setPlayingVoiceId(null); - setVoiceProgress(0); - setVoiceCurrentTime(0); - return; - } - - if (audioPlayerRef.current) { - audioPlayerRef.current.pause(); - } - stopVoiceTracking(); - - voiceKnownDurationRef.current = knownDuration || 0; - const audio = new Audio(voiceUrl); - audio.playbackRate = voicePlaybackRate; - audioPlayerRef.current = audio; - setPlayingVoiceId(commentId); - setVoiceProgress(0); - setVoiceCurrentTime(0); - - audio.onplay = () => { - startVoiceTracking(); - }; - - audio.onended = () => { - stopVoiceTracking(); - setPlayingVoiceId(null); - setVoiceProgress(0); - setVoiceCurrentTime(0); - audioPlayerRef.current = null; - }; - - audio.onerror = () => { - stopVoiceTracking(); - setPlayingVoiceId(null); - setVoiceProgress(0); - setVoiceCurrentTime(0); - audioPlayerRef.current = null; - }; - - audio.play(); - }, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]); - - const toggleVoiceSpeed = useCallback(() => { - setVoicePlaybackRate((prev) => { - const next = prev === 1 ? 2 : 1; - if (audioPlayerRef.current) { - audioPlayerRef.current.playbackRate = next; - } - return next; - }); - }, []); - - // Cleanup audio on unmount - useEffect(() => { - return () => { - if (audioPlayerRef.current) { - audioPlayerRef.current.pause(); - audioPlayerRef.current = null; - } - stopVoiceTracking(); - if (recordingTimerRef.current) { - clearInterval(recordingTimerRef.current); - } - }; - }, []); - - const handleResolveComment = useCallback( - async (commentId: string, currentlyResolved: boolean) => { - isMutatingRef.current = true; - // Optimistically toggle - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === commentId ? { ...c, isResolved: !c.isResolved } : c - ), - } - : v - ), - }; - }); - - try { - const res = await fetch(`/api/comments/${commentId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ isResolved: !currentlyResolved }), - }); - - if (!res.ok) { - // Rollback on failure - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === commentId ? { ...c, isResolved: currentlyResolved } : c - ), - } - : v - ), - }; - }); - toast.error('Failed to update comment'); - } - } catch (err) { - // Rollback on error - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === commentId ? { ...c, isResolved: currentlyResolved } : c - ), - } - : v - ), - }; - }); - toast.error('Failed to update comment'); - } finally { - isMutatingRef.current = false; - } - }, - [activeVersionId] - ); - - // Reply to a comment - const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { - if (!voiceData && !replyText.trim()) return; - if (!activeVersion) return; - - const tempId = `temp-reply-${Date.now()}`; - const parentComment = comments.find((c) => c.id === parentId); - const optimisticReply = { - id: tempId, - content: voiceData ? replyText.trim() || null : replyText, - voiceUrl: voiceData?.url ?? null, - voiceDuration: voiceData?.duration ?? null, - createdAt: new Date().toISOString(), - author: isGuest ? null : { id: 'current-user', name: null, image: null }, - guestName: isGuest ? guestName : null, - tag: null, - }; - - // Optimistically add reply - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === parentId - ? { ...c, replies: [...c.replies, optimisticReply] } - : c - ), - } - : v - ), - }; - }); - - // Clear input immediately - setReplyText(''); - setReplyingTo(null); - setReplyAudioBlob(null); - setReplyRecordingTime(0); - - setIsSubmittingReply(true); - isMutatingRef.current = true; - - try { - const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: voiceData ? replyText.trim() || null : replyText, - timestamp: parentComment?.timestamp ?? currentTime, - parentId, - ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), - ...(isGuest && guestName && { guestName }), - }), - }); - - if (res.ok) { - const response = await res.json(); - const newReply = response.data; - // Replace temp reply with real one - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === parentId - ? { ...c, replies: c.replies.map(r => r.id === tempId ? newReply : r) } - : c - ), - } - : v - ), - }; - }); - } else { - // Remove optimistic reply on failure - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === parentId - ? { ...c, replies: c.replies.filter(r => r.id !== tempId) } - : c - ), - } - : v - ), - }; - }); - toast.error('Failed to add reply'); - } - } catch (err) { - // Remove optimistic reply on error - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => - c.id === parentId - ? { ...c, replies: c.replies.filter(r => r.id !== tempId) } - : c - ), - } - : v - ), - }; - }); - toast.error('Failed to add reply'); - } finally { - setIsSubmittingReply(false); - isMutatingRef.current = false; - } - }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]); - - // Voice recording for replies - const startReplyRecording = useCallback(async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mediaRecorder = new MediaRecorder(stream, { - mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' - : 'audio/webm', - }); - replyAudioChunksRef.current = []; - replyMediaRecorderRef.current = mediaRecorder; - mediaRecorder.ondataavailable = (e) => { - if (e.data.size > 0) replyAudioChunksRef.current.push(e.data); - }; - mediaRecorder.onstop = () => { - const blob = new Blob(replyAudioChunksRef.current, { type: 'audio/webm' }); - setReplyAudioBlob(blob); - stream.getTracks().forEach((track) => track.stop()); - if (replyRecordingTimerRef.current) { - clearInterval(replyRecordingTimerRef.current); - replyRecordingTimerRef.current = null; - } - }; - mediaRecorder.start(100); - setIsReplyRecording(true); - setReplyRecordingTime(0); - replyRecordingTimerRef.current = setInterval(() => { - setReplyRecordingTime((prev) => prev + 0.1); - }, 100); - } catch (err) { - console.error('Failed to start reply recording:', err); - } - }, []); - - const stopReplyRecording = useCallback(() => { - if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') { - replyMediaRecorderRef.current.stop(); - } - setIsReplyRecording(false); - }, []); - - const cancelReplyRecording = useCallback(() => { - if (replyMediaRecorderRef.current && replyMediaRecorderRef.current.state !== 'inactive') { - replyMediaRecorderRef.current.stop(); - } - setIsReplyRecording(false); - setReplyAudioBlob(null); - setReplyRecordingTime(0); - }, []); - - const submitVoiceReply = useCallback(async (parentId: string) => { - if (!replyAudioBlob || !activeVersion) return; - setIsUploadingReplyAudio(true); - try { - const formData = new FormData(); - formData.append('audio', replyAudioBlob, 'recording.webm'); - const uploadRes = await fetch('/api/upload/audio', { method: 'POST', body: formData }); - if (!uploadRes.ok) throw new Error('Failed to upload audio'); - const uploadData = await uploadRes.json(); - const { url } = uploadData.data; - await handleReplyComment(parentId, { url, duration: replyRecordingTime }); - } catch (err) { - console.error('Failed to submit voice reply:', err); - } finally { - setIsUploadingReplyAudio(false); - } - }, [replyAudioBlob, activeVersion, replyRecordingTime, handleReplyComment]); - - // Edit a comment - const handleEditComment = useCallback(async (commentId: string) => { - if (!editText.trim()) return; - setIsSubmittingEdit(true); - isMutatingRef.current = true; - try { - const res = await fetch(`/api/comments/${commentId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: editText }), - }); - if (res.ok) { - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments.map((c) => { - if (c.id === commentId) return { ...c, content: editText.trim() }; - return { - ...c, - replies: c.replies.map((r) => - r.id === commentId ? { ...r, content: editText.trim() } : r - ), - }; - }), - } - : v - ), - }; - }); - setEditingCommentId(null); - setEditText(''); - } - } catch (err) { - console.error('Failed to edit comment:', err); - } finally { - setIsSubmittingEdit(false); - isMutatingRef.current = false; - } - }, [editText, activeVersionId]); - - // Delete a comment - const handleDeleteComment = useCallback(async (commentId: string) => { - setDeletingCommentId(commentId); - isMutatingRef.current = true; - - // Optimistically remove from UI - const previousVideo = video; - setVideo((prev) => { - if (!prev) return prev; - return { - ...prev, - versions: prev.versions.map((v) => - v.id === activeVersionId - ? { - ...v, - comments: v.comments - .filter((c) => c.id !== commentId) - .map((c) => ({ - ...c, - replies: c.replies.filter((r) => r.id !== commentId), - })), - } - : v - ), - }; - }); - - try { - const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' }); - if (!res.ok) { - setVideo(previousVideo); - } - } catch (err) { - console.error('Failed to delete comment:', err); - setVideo(previousVideo); - } finally { - setDeletingCommentId(null); - isMutatingRef.current = false; - } - }, [activeVersionId, video]); - - // Poll for new comments every 10 seconds - useEffect(() => { - if (!activeVersion) return; - const interval = setInterval(async () => { - try { - if (isMutatingRef.current) return; - - const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`, { cache: 'no-store' }); - if (res.ok) { - const data = await res.json(); - if (!isMutatingRef.current) { - setVideo(data.data); - } - } - } catch { /* silent */ } - }, 10000); - return () => clearInterval(interval); - }, [activeVersion, projectId, videoId]); - - // New version URL handler - const handleNewVersionUrlChange = (url: string) => { - setNewVersionUrl(url); - setNewVersionUrlError(''); - if (!url.trim()) { - setNewVersionSource(null); - return; - } - const source = parseVideoUrl(url); - if (source) { - setNewVersionSource(source); - } else { - setNewVersionSource(null); - if (url.length > 10) setNewVersionUrlError('Unsupported URL'); - } - }; - - const handleCreateVersion = async () => { - if (!newVersionSource) return; - setIsCreatingVersion(true); - - try { - const meta = await fetchVideoMetadata(newVersionSource); - const thumbnailUrl = getThumbnailUrl(newVersionSource, 'large'); - - const res = await fetch(`/api/projects/${projectId}/videos/${videoId}/versions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - videoUrl: newVersionSource.originalUrl, - providerId: newVersionSource.providerId, - providerVideoId: newVersionSource.videoId, - versionLabel: newVersionLabel.trim() || null, - thumbnailUrl, - duration: meta?.duration || null, - setActive: true, - }), - }); - - if (res.ok) { - const videoRes = await fetch(`/api/projects/${projectId}/videos/${videoId}`); - if (videoRes.ok) { - const data = await videoRes.json(); - setVideo(data); - const active = data.versions.find((v: Version) => v.isActive) || data.versions[0]; - if (active) setActiveVersionId(active.id); - } - setShowVersionDialog(false); - setNewVersionUrl(''); - setNewVersionLabel(''); - setNewVersionSource(null); - } - } catch (err) { - console.error('Failed to create version:', err); - } finally { - setIsCreatingVersion(false); - } - }; - - const getEmbedUrl = (version: Version) => { - if (version.providerId === 'youtube') { - return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; - } - if (version.providerId === 'vimeo') { - return `https://player.vimeo.com/video/${version.videoId}`; - } - // Security: Only allow http/https URLs to prevent XSS via javascript: URIs - try { - const url = new URL(version.originalUrl); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return ''; - } - return version.originalUrl; - } catch { - return ''; - } - }; - - if (loading) { - return ( -
-
-
-
-
- - -
- - -
-
-
- - -
-
-
-
-
- - - - - -
- -
-
- -
-
-
-
-
- - - -
- -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
- - -
- -
- - -
- ))} -
-
- -
- - -
-
-
-
-
- ); - } - - if (error || !video || !activeVersion) { - return ( -
-
-

{error || 'Video not found'}

- -
-
- ); - } - - const embedUrl = getEmbedUrl(activeVersion); - return ( -
isDragging && handleTimelineMouseUp()} - > -
- {/* Video Area */} -
- {/* Compact Header Bar */} -
-
- - - Back - - -
- {video.title} - • {video.project.name} -
-
- - {/* Version Selector + Add Version */} -
- - - - - - {video.versions.map((version) => ( - setActiveVersionId(version.id)} - > - - v{version.versionNumber} - - {version.versionLabel || `Version ${version.versionNumber}`} - - {version._count.comments} comments - - - ))} - - - - - - - - - {video.versions.length >= 2 && ( - - )} - - - Add New Version - - Upload a new version of this video. The new version will become active. - - -
-
- -
- - handleNewVersionUrlChange(e.target.value)} - className="pl-10" - disabled={isCreatingVersion} - /> -
- {newVersionUrlError && ( -

- - {newVersionUrlError} -

- )} - {newVersionSource && ( -

- - {newVersionSource.providerId.charAt(0).toUpperCase() + - newVersionSource.providerId.slice(1)}{' '} - video detected -

- )} -
-
- - setNewVersionLabel(e.target.value)} - disabled={isCreatingVersion} - /> -
- -
-
-
-
-
- - {/* Video Player - click to play/pause, YouTube controls hidden */} -
-
-