From 669f6fa9d2f1dca8096c0a95ebd82e5698e3643e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 7 Feb 2026 16:21:15 +0300 Subject: [PATCH] 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 --- .../videos/[videoId]/not-found.tsx | 11 +- .../[projectId]/videos/[videoId]/page.tsx | 257 +++++++++++++++--- app/(dashboard)/projects/new/page.tsx | 7 +- app/layout.tsx | 2 + app/watch/[videoId]/page.tsx | 248 +++++++++++++++-- bun.lock | 3 + lib/db.ts | 2 +- lib/video-providers/direct.ts | 20 +- package.json | 1 + 9 files changed, 471 insertions(+), 80 deletions(-) diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx index bd51e95..47e70f0 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/not-found.tsx @@ -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 (
@@ -19,9 +13,6 @@ export default async function VideoNotFound({ params }: VideoNotFoundProps) {

- diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx index 7db08d2..25f4896 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -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) { diff --git a/app/(dashboard)/projects/new/page.tsx b/app/(dashboard)/projects/new/page.tsx index ba17dfc..77ffc9d 100644 --- a/app/(dashboard)/projects/new/page.tsx +++ b/app/(dashboard)/projects/new/page.tsx @@ -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 { diff --git a/app/layout.tsx b/app/layout.tsx index 9cecc9a..c6f43e4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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} + diff --git a/app/watch/[videoId]/page.tsx b/app/watch/[videoId]/page.tsx index 251439d..8a83c29 100644 --- a/app/watch/[videoId]/page.tsx +++ b/app/watch/[videoId]/page.tsx @@ -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) { diff --git a/bun.lock b/bun.lock index 79c4290..59ed60c 100644 --- a/bun.lock +++ b/bun.lock @@ -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": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "sonner": ["sonner@2.0.7", "", { "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": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/lib/db.ts b/lib/db.ts index d1bf0b2..bf77023 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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'], }); } diff --git a/lib/video-providers/direct.ts b/lib/video-providers/direct.ts index 3a42dcc..36706b2 100644 --- a/lib/video-providers/direct.ts +++ b/lib/video-providers/direct.ts @@ -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; } diff --git a/package.json b/package.json index 1ab362c..d4d7f64 100644 --- a/package.json +++ b/package.json @@ -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"