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 { 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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user