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 { 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,6 +817,24 @@ export default function VideoPage() {
const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => {
// Optimistically toggle
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
),
}
: v
),
};
});
try {
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
@@ -758,7 +842,8 @@ export default function VideoPage() {
body: JSON.stringify({ isResolved: !currentlyResolved }),
});
if (res.ok) {
if (!res.ok) {
// Rollback on failure
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -766,18 +851,36 @@ export default function VideoPage() {
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
),
}
...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,64 @@ export default function VideoPage() {
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return;
const tempId = `temp-reply-${Date.now()}`;
const parentComment = comments.find((c) => c.id === parentId);
const optimisticReply = {
id: tempId,
content: voiceData ? replyText.trim() || null : replyText,
voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null,
createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: null, image: null },
guestName: isGuest ? guestName : null,
tag: null,
};
// Optimistically add reply
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: [...c.replies, optimisticReply] }
: c
),
}
: v
),
};
});
// Clear input immediately
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
setIsSubmittingReply(true);
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: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
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 {
@@ -809,28 +955,64 @@ export default function VideoPage() {
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: [...c.replies, newReply] }
: c
),
}
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: c.replies.map(r => r.id === tempId ? newReply : r) }
: c
),
}
: v
),
};
});
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
} 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}`;
}
return version.originalUrl;
// 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) {
+4 -3
View File
@@ -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 {
+2
View File
@@ -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>
+218 -30
View File
@@ -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,6 +770,24 @@ export default function WatchPage() {
const handleResolveComment = useCallback(
async (commentId: string, currentlyResolved: boolean) => {
// Optimistically toggle
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
),
}
: v
),
};
});
try {
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
@@ -714,7 +795,8 @@ export default function WatchPage() {
body: JSON.stringify({ isResolved: !currentlyResolved }),
});
if (res.ok) {
if (!res.ok) {
// Rollback on failure
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -722,18 +804,36 @@ export default function WatchPage() {
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
),
}
...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,64 @@ export default function WatchPage() {
const handleReplyComment = useCallback(async (parentId: string, voiceData?: { url: string; duration: number }) => {
if (!voiceData && !replyText.trim()) return;
if (!activeVersion) return;
const tempId = `temp-reply-${Date.now()}`;
const parentComment = comments.find((c) => c.id === parentId);
const optimisticReply = {
id: tempId,
content: voiceData ? replyText.trim() || null : replyText,
voiceUrl: voiceData?.url ?? null,
voiceDuration: voiceData?.duration ?? null,
createdAt: new Date().toISOString(),
author: isGuest ? null : { id: 'current-user', name: null, image: null },
guestName: isGuest ? guestName : null,
tag: null,
};
// Optimistically add reply
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: [...c.replies, optimisticReply] }
: c
),
}
: v
),
};
});
// Clear input immediately
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
setIsSubmittingReply(true);
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: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
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 {
@@ -765,28 +908,64 @@ export default function WatchPage() {
versions: prev.versions.map((v) =>
v.id === activeVersionId
? {
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: [...c.replies, newReply] }
: c
),
}
...v,
comments: v.comments.map((c) =>
c.id === parentId
? { ...c, replies: c.replies.map(r => r.id === tempId ? newReply : r) }
: c
),
}
: v
),
};
});
setReplyText('');
setReplyingTo(null);
setReplyAudioBlob(null);
setReplyRecordingTime(0);
} 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}`;
}
return version.originalUrl;
// 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) {