mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -2,13 +2,7 @@ import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Film } from "lucide-react";
|
||||
|
||||
interface VideoNotFoundProps {
|
||||
params: Promise<{ projectId: string; videoId: string }>;
|
||||
}
|
||||
|
||||
export default async function VideoNotFound({ params }: VideoNotFoundProps) {
|
||||
const { projectId } = await params;
|
||||
|
||||
export default function VideoNotFound() {
|
||||
return (
|
||||
<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">
|
||||
@@ -19,9 +13,6 @@ export default async function VideoNotFound({ params }: VideoNotFoundProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="default">
|
||||
<Link href={`/projects/${projectId}`}>Back to project</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/dashboard">Go to dashboard</Link>
|
||||
</Button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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,
|
||||
@@ -205,15 +206,19 @@ export default function VideoPage() {
|
||||
try {
|
||||
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`);
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = await res.json();
|
||||
const data = response.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);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('Error fetching video:', err);
|
||||
setError('Failed to load video');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -222,7 +227,9 @@ export default function VideoPage() {
|
||||
fetchVideo();
|
||||
}, [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 filteredComments = comments.filter((c) => showResolved || !c.isResolved);
|
||||
const duration = videoDuration || activeVersion?.duration || 0;
|
||||
@@ -524,6 +531,41 @@ export default function VideoPage() {
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -541,27 +583,51 @@ export default function VideoPage() {
|
||||
|
||||
if (res.ok) {
|
||||
const newComment = await res.json();
|
||||
// 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, { ...newComment, replies: [] }] }
|
||||
? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: [] } : c) }
|
||||
: v
|
||||
),
|
||||
};
|
||||
});
|
||||
setCommentText('');
|
||||
setSelectedTimestamp(null);
|
||||
setSelectedTagId(null);
|
||||
} 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) {
|
||||
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 {
|
||||
setIsSubmittingComment(false);
|
||||
}
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]);
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
|
||||
|
||||
// Voice recording handlers
|
||||
const startRecording = useCallback(async () => {
|
||||
@@ -751,14 +817,7 @@ export default function VideoPage() {
|
||||
|
||||
const handleResolveComment = useCallback(
|
||||
async (commentId: string, currentlyResolved: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isResolved: !currentlyResolved }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Optimistically toggle
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
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) {
|
||||
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]
|
||||
@@ -787,21 +890,21 @@ export default function VideoPage() {
|
||||
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
|
||||
if (!voiceData && !replyText.trim()) return;
|
||||
if (!activeVersion) return;
|
||||
setIsSubmittingReply(true);
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
const tempId = `temp-reply-${Date.now()}`;
|
||||
const parentComment = comments.find((c) => c.id === parentId);
|
||||
const optimisticReply = {
|
||||
id: tempId,
|
||||
content: voiceData ? replyText.trim() || null : replyText,
|
||||
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const newReply = await res.json();
|
||||
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 {
|
||||
@@ -812,7 +915,7 @@ export default function VideoPage() {
|
||||
...v,
|
||||
comments: v.comments.map((c) =>
|
||||
c.id === parentId
|
||||
? { ...c, replies: [...c.replies, newReply] }
|
||||
? { ...c, replies: [...c.replies, optimisticReply] }
|
||||
: c
|
||||
),
|
||||
}
|
||||
@@ -820,17 +923,96 @@ export default function VideoPage() {
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Clear input immediately
|
||||
setReplyText('');
|
||||
setReplyingTo(null);
|
||||
setReplyAudioBlob(null);
|
||||
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) {
|
||||
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 {
|
||||
setIsSubmittingReply(false);
|
||||
}
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime]);
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
|
||||
|
||||
// Voice recording for replies
|
||||
const startReplyRecording = useCallback(async () => {
|
||||
@@ -1056,7 +1238,16 @@ export default function VideoPage() {
|
||||
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) {
|
||||
|
||||
@@ -67,10 +67,11 @@ export default function NewProjectPage() {
|
||||
const res = await fetch('/api/workspaces');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setWorkspaces(data.workspaces);
|
||||
const workspacesData = data.workspaces || [];
|
||||
setWorkspaces(workspacesData);
|
||||
// Auto-select if only one workspace and none preselected
|
||||
if (!preselectedWorkspace && data.workspaces.length === 1) {
|
||||
setFormData(prev => ({ ...prev, workspaceId: data.workspaces[0].id }));
|
||||
if (!preselectedWorkspace && workspacesData.length === 1) {
|
||||
setFormData(prev => ({ ...prev, workspaceId: workspacesData[0].id }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { JetBrains_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
@@ -28,6 +29,7 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+222
-34
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
@@ -192,7 +193,8 @@ export default function WatchPage() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
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);
|
||||
@@ -205,7 +207,9 @@ export default function WatchPage() {
|
||||
fetchVideo();
|
||||
}, [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 filteredComments = comments.filter((c) => showResolved || !c.isResolved);
|
||||
const duration = activeVersion?.duration || 300;
|
||||
@@ -483,6 +487,41 @@ export default function WatchPage() {
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -500,27 +539,51 @@ export default function WatchPage() {
|
||||
|
||||
if (res.ok) {
|
||||
const newComment = await res.json();
|
||||
// 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, { ...newComment, replies: newComment.replies || [] }] }
|
||||
? { ...v, comments: v.comments.map(c => c.id === tempId ? { ...newComment, replies: newComment.replies || [] } : c) }
|
||||
: v
|
||||
),
|
||||
};
|
||||
});
|
||||
setCommentText('');
|
||||
setSelectedTimestamp(null);
|
||||
setSelectedTagId(null);
|
||||
} 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) {
|
||||
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 {
|
||||
setIsSubmittingComment(false);
|
||||
}
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]);
|
||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags]);
|
||||
|
||||
// Voice recording handlers
|
||||
const startRecording = useCallback(async () => {
|
||||
@@ -707,14 +770,7 @@ export default function WatchPage() {
|
||||
|
||||
const handleResolveComment = useCallback(
|
||||
async (commentId: string, currentlyResolved: boolean) => {
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isResolved: !currentlyResolved }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Optimistically toggle
|
||||
setVideo((prev) => {
|
||||
if (!prev) return prev;
|
||||
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) {
|
||||
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]
|
||||
@@ -743,21 +843,21 @@ export default function WatchPage() {
|
||||
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
|
||||
if (!voiceData && !replyText.trim()) return;
|
||||
if (!activeVersion) return;
|
||||
setIsSubmittingReply(true);
|
||||
try {
|
||||
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
const tempId = `temp-reply-${Date.now()}`;
|
||||
const parentComment = comments.find((c) => c.id === parentId);
|
||||
const optimisticReply = {
|
||||
id: tempId,
|
||||
content: voiceData ? replyText.trim() || null : replyText,
|
||||
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
|
||||
parentId,
|
||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||
...(isGuest && guestName && { guestName }),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const newReply = await res.json();
|
||||
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 {
|
||||
@@ -768,7 +868,7 @@ export default function WatchPage() {
|
||||
...v,
|
||||
comments: v.comments.map((c) =>
|
||||
c.id === parentId
|
||||
? { ...c, replies: [...c.replies, newReply] }
|
||||
? { ...c, replies: [...c.replies, optimisticReply] }
|
||||
: c
|
||||
),
|
||||
}
|
||||
@@ -776,17 +876,96 @@ export default function WatchPage() {
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Clear input immediately
|
||||
setReplyText('');
|
||||
setReplyingTo(null);
|
||||
setReplyAudioBlob(null);
|
||||
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) {
|
||||
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 {
|
||||
setIsSubmittingReply(false);
|
||||
}
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime]);
|
||||
}, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName]);
|
||||
|
||||
// Voice recording for replies
|
||||
const startReplyRecording = useCallback(async () => {
|
||||
@@ -953,7 +1132,16 @@ export default function WatchPage() {
|
||||
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) {
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^3.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6",
|
||||
@@ -1660,6 +1661,8 @@
|
||||
|
||||
"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-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
@@ -25,7 +25,7 @@ function createPrismaClient() {
|
||||
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,33 @@ const DIRECT_VIDEO_PATTERNS = [
|
||||
/\.(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 = {
|
||||
id: 'direct',
|
||||
name: 'Direct Upload',
|
||||
icon: 'Upload',
|
||||
|
||||
canHandle(url: string): boolean {
|
||||
// Check for common video extensions or our own domain
|
||||
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url));
|
||||
// Check for common video extensions and valid protocol
|
||||
return isValidVideoUrl(url);
|
||||
},
|
||||
|
||||
extractVideoId(url: string): string | null {
|
||||
// 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)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shadcn": "^3.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
Reference in New Issue
Block a user