feat: Add optimistic UI for video comments

- Implement optimistic updates for comment creation, deletion, resolution, tag, and content changes
- Enhance user experience by providing immediate feedback for comment actions
- Integrate `sonner` toasts for success and error notifications
- Improve video data fetching error handling and logging
- Refine active video version selection logic for robustness
- Simplify video detail not-found page, removing dynamic project link
This commit is contained in:
Yusuf İpek
2026-02-07 16:21:15 +03:00
parent 373aab964c
commit 669f6fa9d2
9 changed files with 471 additions and 80 deletions
@@ -2,13 +2,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Film } from "lucide-react"; import { Film } from "lucide-react";
interface VideoNotFoundProps { export default function VideoNotFound() {
params: Promise<{ projectId: string; videoId: string }>;
}
export default async function VideoNotFound({ params }: VideoNotFoundProps) {
const { projectId } = await params;
return ( return (
<div className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 p-4"> <div className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 p-4">
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
@@ -19,9 +13,6 @@ export default async function VideoNotFound({ params }: VideoNotFoundProps) {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button asChild variant="default">
<Link href={`/projects/${projectId}`}>Back to project</Link>
</Button>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href="/dashboard">Go to dashboard</Link> <Link href="/dashboard">Go to dashboard</Link>
</Button> </Button>
@@ -3,6 +3,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { import {
ArrowLeft, ArrowLeft,
Play, Play,
@@ -205,15 +206,19 @@ export default function VideoPage() {
try { try {
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`);
if (!res.ok) { if (!res.ok) {
setError('Failed to load video'); const errorText = await res.text();
console.error('Failed to load video:', res.status, errorText);
setError(`Failed to load video: ${res.status} ${errorText}`);
setLoading(false); setLoading(false);
return; return;
} }
const data = await res.json(); const response = await res.json();
const data = response.data;
setVideo(data); setVideo(data);
const active = data.versions.find((v: Version) => v.isActive) || data.versions[0]; const active = data.versions?.find((v: Version) => v.isActive) || data.versions?.[0];
if (active) setActiveVersionId(active.id); if (active) setActiveVersionId(active.id);
} catch { } catch (err) {
console.error('Error fetching video:', err);
setError('Failed to load video'); setError('Failed to load video');
} finally { } finally {
setLoading(false); setLoading(false);
@@ -222,7 +227,9 @@ export default function VideoPage() {
fetchVideo(); fetchVideo();
}, [projectId, videoId]); }, [projectId, videoId]);
const activeVersion = video?.versions.find((v) => v.id === activeVersionId); const activeVersion = video?.versions?.find((v) => v.id === activeVersionId) ||
video?.versions?.find((v) => v.isActive) ||
video?.versions?.[0];
const comments = activeVersion?.comments || []; const comments = activeVersion?.comments || [];
const filteredComments = comments.filter((c) => showResolved || !c.isResolved); const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
const duration = videoDuration || activeVersion?.duration || 0; const duration = videoDuration || activeVersion?.duration || 0;
@@ -524,6 +531,41 @@ export default function VideoPage() {
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !commentText.trim()) return; if (!voiceData && !commentText.trim()) return;
if (!activeVersion) 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); setIsSubmittingComment(true);
try { try {
@@ -541,27 +583,51 @@ export default function VideoPage() {
if (res.ok) { if (res.ok) {
const newComment = await res.json(); const newComment = await res.json();
// Replace temp comment with real one
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ...v, comments: [...v.comments, { ...newComment, replies: [] }] } ? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: [] } : c) }
: v : v
), ),
}; };
}); });
setCommentText(''); } else {
setSelectedTimestamp(null); // Remove optimistic comment on failure
setSelectedTagId(null); 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) { } catch (err) {
console.error('Failed to add comment:', 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 { } finally {
setIsSubmittingComment(false); setIsSubmittingComment(false);
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
// Voice recording handlers // Voice recording handlers
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
@@ -751,14 +817,7 @@ export default function VideoPage() {
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
try { // Optimistically toggle
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isResolved: !currentlyResolved }),
});
if (res.ok) {
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@@ -775,9 +834,53 @@ export default function VideoPage() {
), ),
}; };
}); });
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) { } catch (err) {
console.error('Failed to resolve comment:', 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');
} }
}, },
[activeVersionId] [activeVersionId]
@@ -787,21 +890,21 @@ export default function VideoPage() {
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!voiceData && !replyText.trim()) return; if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return; if (!activeVersion) return;
setIsSubmittingReply(true);
try { const tempId = `temp-reply-${Date.now()}`;
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const parentComment = comments.find((c) => c.id === parentId);
method: 'POST', const optimisticReply = {
headers: { 'Content-Type': 'application/json' }, id: tempId,
body: JSON.stringify({
content: voiceData ? replyText.trim() || null : replyText, content: voiceData ? replyText.trim() || null : replyText,
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, voiceUrl: voiceData?.url ?? null,
parentId, voiceDuration: voiceData?.duration ?? null,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), createdAt: new Date().toISOString(),
...(isGuest && guestName && { guestName }), author: isGuest ? null : { id: 'current-user', name: null, image: null },
}), guestName: isGuest ? guestName : null,
}); tag: null,
if (res.ok) { };
const newReply = await res.json();
// Optimistically add reply
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@@ -812,7 +915,7 @@ export default function VideoPage() {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === parentId c.id === parentId
? { ...c, replies: [...c.replies, newReply] } ? { ...c, replies: [...c.replies, optimisticReply] }
: c : c
), ),
} }
@@ -820,17 +923,96 @@ export default function VideoPage() {
), ),
}; };
}); });
// Clear input immediately
setReplyText(''); setReplyText('');
setReplyingTo(null); setReplyingTo(null);
setReplyAudioBlob(null); setReplyAudioBlob(null);
setReplyRecordingTime(0); setReplyRecordingTime(0);
setIsSubmittingReply(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 newReply = await res.json();
// 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) { } catch (err) {
console.error('Failed to reply:', 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 { } finally {
setIsSubmittingReply(false); setIsSubmittingReply(false);
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime]); }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
// Voice recording for replies // Voice recording for replies
const startReplyRecording = useCallback(async () => { const startReplyRecording = useCallback(async () => {
@@ -1056,7 +1238,16 @@ export default function VideoPage() {
if (version.providerId === 'vimeo') { if (version.providerId === 'vimeo') {
return `https://player.vimeo.com/video/${version.videoId}`; 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; return version.originalUrl;
} catch {
return '';
}
}; };
if (loading) { if (loading) {
+4 -3
View File
@@ -67,10 +67,11 @@ export default function NewProjectPage() {
const res = await fetch('/api/workspaces'); const res = await fetch('/api/workspaces');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setWorkspaces(data.workspaces); const workspacesData = data.workspaces || [];
setWorkspaces(workspacesData);
// Auto-select if only one workspace and none preselected // Auto-select if only one workspace and none preselected
if (!preselectedWorkspace && data.workspaces.length === 1) { if (!preselectedWorkspace && workspacesData.length === 1) {
setFormData(prev => ({ ...prev, workspaceId: data.workspaces[0].id })); setFormData(prev => ({ ...prev, workspaceId: workspacesData[0].id }));
} }
} }
} catch { } catch {
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { JetBrains_Mono } from "next/font/google"; import { JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
@@ -28,6 +29,7 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
{children} {children}
<Toaster />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+222 -34
View File
@@ -3,6 +3,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { toast } from 'sonner';
import { import {
ArrowLeft, ArrowLeft,
Play, Play,
@@ -192,7 +193,8 @@ export default function WatchPage() {
setLoading(false); setLoading(false);
return; return;
} }
const data = await res.json(); const response = await res.json();
const data = response.data;
setVideo(data); setVideo(data);
const active = data.versions.find((v: Version) => v.isActive) || data.versions[0]; const active = data.versions.find((v: Version) => v.isActive) || data.versions[0];
if (active) setActiveVersionId(active.id); if (active) setActiveVersionId(active.id);
@@ -205,7 +207,9 @@ export default function WatchPage() {
fetchVideo(); fetchVideo();
}, [videoId]); }, [videoId]);
const activeVersion = video?.versions.find((v) => v.id === activeVersionId); const activeVersion = video?.versions?.find((v) => v.id === activeVersionId) ||
video?.versions?.find((v) => v.isActive) ||
video?.versions?.[0];
const comments = activeVersion?.comments || []; const comments = activeVersion?.comments || [];
const filteredComments = comments.filter((c) => showResolved || !c.isResolved); const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
const duration = activeVersion?.duration || 300; const duration = activeVersion?.duration || 300;
@@ -483,6 +487,41 @@ export default function WatchPage() {
const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => { const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !commentText.trim()) return; if (!voiceData && !commentText.trim()) return;
if (!activeVersion) 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); setIsSubmittingComment(true);
try { try {
@@ -500,27 +539,51 @@ export default function WatchPage() {
if (res.ok) { if (res.ok) {
const newComment = await res.json(); const newComment = await res.json();
// Replace temp comment with real one
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ...v, comments: [...v.comments, { ...newComment, replies: newComment.replies || [] }] } ? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: newComment.replies || [] } : c) }
: v : v
), ),
}; };
}); });
setCommentText(''); } else {
setSelectedTimestamp(null); // Remove optimistic comment on failure
setSelectedTagId(null); 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) { } catch (err) {
console.error('Failed to add comment:', 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 { } finally {
setIsSubmittingComment(false); setIsSubmittingComment(false);
} }
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]); }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
// Voice recording handlers // Voice recording handlers
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
@@ -707,14 +770,7 @@ export default function WatchPage() {
const handleResolveComment = useCallback( const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => { async (commentId: string, currentlyResolved: boolean) => {
try { // Optimistically toggle
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isResolved: !currentlyResolved }),
});
if (res.ok) {
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@@ -731,9 +787,53 @@ export default function WatchPage() {
), ),
}; };
}); });
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) { } catch (err) {
console.error('Failed to resolve comment:', 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');
} }
}, },
[activeVersionId] [activeVersionId]
@@ -743,21 +843,21 @@ export default function WatchPage() {
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => { const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!voiceData && !replyText.trim()) return; if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return; if (!activeVersion) return;
setIsSubmittingReply(true);
try { const tempId = `temp-reply-${Date.now()}`;
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, { const parentComment = comments.find((c) => c.id === parentId);
method: 'POST', const optimisticReply = {
headers: { 'Content-Type': 'application/json' }, id: tempId,
body: JSON.stringify({
content: voiceData ? replyText.trim() || null : replyText, content: voiceData ? replyText.trim() || null : replyText,
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime, voiceUrl: voiceData?.url ?? null,
parentId, voiceDuration: voiceData?.duration ?? null,
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), createdAt: new Date().toISOString(),
...(isGuest && guestName && { guestName }), author: isGuest ? null : { id: 'current-user', name: null, image: null },
}), guestName: isGuest ? guestName : null,
}); tag: null,
if (res.ok) { };
const newReply = await res.json();
// Optimistically add reply
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@@ -768,7 +868,7 @@ export default function WatchPage() {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === parentId c.id === parentId
? { ...c, replies: [...c.replies, newReply] } ? { ...c, replies: [...c.replies, optimisticReply] }
: c : c
), ),
} }
@@ -776,17 +876,96 @@ export default function WatchPage() {
), ),
}; };
}); });
// Clear input immediately
setReplyText(''); setReplyText('');
setReplyingTo(null); setReplyingTo(null);
setReplyAudioBlob(null); setReplyAudioBlob(null);
setReplyRecordingTime(0); setReplyRecordingTime(0);
setIsSubmittingReply(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 newReply = await res.json();
// 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) { } catch (err) {
console.error('Failed to reply:', 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 { } finally {
setIsSubmittingReply(false); setIsSubmittingReply(false);
} }
}, [replyText, activeVersion, activeVersionId, comments, currentTime]); }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
// Voice recording for replies // Voice recording for replies
const startReplyRecording = useCallback(async () => { const startReplyRecording = useCallback(async () => {
@@ -953,7 +1132,16 @@ export default function WatchPage() {
if (version.providerId === 'vimeo') { if (version.providerId === 'vimeo') {
return `https://player.vimeo.com/video/${version.videoId}`; 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; return version.originalUrl;
} catch {
return '';
}
}; };
if (loading) { if (loading) {
+3
View File
@@ -26,6 +26,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^3.8.3", "shadcn": "^3.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -1660,6 +1661,8 @@
"sisteransi": ["[email protected]", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "sisteransi": ["[email protected]", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"sonner": ["[email protected]", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+1 -1
View File
@@ -25,7 +25,7 @@ function createPrismaClient() {
return new PrismaClient({ return new PrismaClient({
adapter, adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
}); });
} }
+17 -3
View File
@@ -5,19 +5,33 @@ const DIRECT_VIDEO_PATTERNS = [
/\.(mp4|webm|ogg|mov)(\?.*)?$/i, /\.(mp4|webm|ogg|mov)(\?.*)?$/i,
]; ];
// Security: Validate URL protocol to prevent XSS
function isValidVideoUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Only allow http and https protocols
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url));
} catch {
return false;
}
}
export const directProvider: VideoProvider = { export const directProvider: VideoProvider = {
id: 'direct', id: 'direct',
name: 'Direct Upload', name: 'Direct Upload',
icon: 'Upload', icon: 'Upload',
canHandle(url: string): boolean { canHandle(url: string): boolean {
// Check for common video extensions or our own domain // Check for common video extensions and valid protocol
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url)); return isValidVideoUrl(url);
}, },
extractVideoId(url: string): string | null { extractVideoId(url: string): string | null {
// For direct uploads, the "videoId" is the full URL // For direct uploads, the "videoId" is the full URL
// In production, this would be a storage key/path // Security: Validate URL before returning
if (this.canHandle(url)) { if (this.canHandle(url)) {
return url; return url;
} }
+1
View File
@@ -36,6 +36,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"shadcn": "^3.8.3", "shadcn": "^3.8.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.3.6" "zod": "^4.3.6"