feat: Implement video version management API, enable comment tag editing, and enhance video duration display to include hours.

This commit is contained in:
Yusuf İpek
2026-02-10 14:48:15 +03:00
parent d42db4484b
commit ca65cf8f58
7 changed files with 522 additions and 175 deletions
@@ -33,8 +33,13 @@ function VisibilityIcon({ visibility }: { visibility: string }) {
function formatDuration(seconds: number | null): string { function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00'; if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60); const totalSeconds = Math.floor(seconds);
const secs = Math.floor(seconds % 60); const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
} }
+8 -34
View File
@@ -142,10 +142,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
} }
const body = await request.json(); const body = await request.json();
const { content, isResolved } = body; const { content, isResolved, tagId } = body;
// Only author can edit content // Only author can edit content or tag
if (content !== undefined && !isAuthor) { if ((content !== undefined || tagId !== undefined) && !isAuthor) {
return apiErrors.forbidden('Only the author can edit comment content'); return apiErrors.forbidden('Only the author can edit comment content');
} }
@@ -156,6 +156,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (content !== undefined) updateData.content = content.trim(); if (content !== undefined) updateData.content = content.trim();
if (tagId !== undefined) updateData.tagId = tagId;
if (isResolved !== undefined) { if (isResolved !== undefined) {
updateData.isResolved = isResolved; updateData.isResolved = isResolved;
updateData.resolvedAt = isResolved ? new Date() : null; updateData.resolvedAt = isResolved ? new Date() : null;
@@ -166,9 +167,11 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
data: updateData, data: updateData,
include: { include: {
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
replies: { replies: {
include: { include: {
author: { select: { id: true, name: true, image: true } }, author: { select: { id: true, name: true, image: true } },
tag: { select: { id: true, name: true, color: true } },
}, },
}, },
}, },
@@ -199,15 +202,6 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
where: { id: commentId }, where: { id: commentId },
include: { include: {
replies: { select: { voiceUrl: true } }, replies: { select: { voiceUrl: true } },
version: {
include: {
video: {
include: {
project: true,
},
},
},
},
}, },
}); });
@@ -215,30 +209,10 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
return apiErrors.notFound('Comment'); return apiErrors.notFound('Comment');
} }
const project = comment.version.video.project;
const isOwner = project.ownerId === session.user.id;
const isAuthor = comment.authorId === session.user.id; const isAuthor = comment.authorId === session.user.id;
// Check workspace membership for delete permissions if (!isAuthor) {
let isWorkspaceMember = false; return apiErrors.forbidden('You can only delete your own comments');
if (!isOwner && !isAuthor && session.user.id) {
const wsMember = await db.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId: project.workspaceId,
userId: session.user.id,
},
},
});
const wsOwner = await db.workspace.findUnique({
where: { id: project.workspaceId },
select: { ownerId: true },
});
isWorkspaceMember = !!wsMember || wsOwner?.ownerId === session.user.id;
}
if (!isOwner && !isAuthor && !isWorkspaceMember) {
return apiErrors.forbidden('Only the author or project owner can delete this comment');
} }
// Collect all voice URLs to delete from R2 (comment + its replies) // Collect all voice URLs to delete from R2 (comment + its replies)
@@ -57,6 +57,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const response = successResponse({ const response = successResponse({
...video, ...video,
isAuthenticated: !!session?.user?.id, isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
}); });
return withCacheControl(response, 'private, no-cache'); return withCacheControl(response, 'private, no-cache');
@@ -0,0 +1,151 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client';
import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
async function getVersionWithAccess(projectId: string, videoId: string, versionId: string, userId: string) {
const version = await db.videoVersion.findFirst({
where: { id: versionId, videoParentId: videoId },
include: {
video: {
include: {
project: {
include: {
members: { where: { userId } },
workspace: {
include: {
members: { where: { userId } },
},
},
},
},
},
},
},
});
if (!version || version.video.projectId !== projectId) {
return null;
}
const project = version.video.project;
const isOwner = project.ownerId === userId;
const membership = project.members[0];
const workspaceMembership = project.workspace.members[0];
const canEdit = isOwner ||
membership?.role === ProjectMemberRole.ADMIN ||
workspaceMembership?.role === WorkspaceMemberRole.ADMIN;
return { version, canEdit, isOwner };
}
// PATCH /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
export async function PATCH(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId, versionId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
if (!result) {
return apiErrors.notFound('Version');
}
if (!result.canEdit) {
return apiErrors.forbidden('Access denied');
}
const body = await request.json();
const { duration, versionLabel, isActive } = body;
const updateData: Record<string, unknown> = {};
if (duration !== undefined) updateData.duration = duration;
if (versionLabel !== undefined) updateData.versionLabel = versionLabel?.trim() || null;
if (isActive === true) {
// Deactivate all other versions, then activate this one
await db.videoVersion.updateMany({
where: { videoParentId: videoId },
data: { isActive: false },
});
updateData.isActive = true;
}
const updated = await db.videoVersion.update({
where: { id: versionId },
data: updateData,
});
const response = successResponse(updated);
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error updating version:', error);
return apiErrors.internalError('Failed to update version');
}
}
// DELETE /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
export async function DELETE(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'mutate');
if (limited) return limited;
const session = await auth();
const { projectId, videoId, versionId } = await params;
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
if (!result) {
return apiErrors.notFound('Version');
}
if (!result.canEdit) {
return apiErrors.forbidden('Access denied');
}
// Check there's more than one version — can't delete the last one
const versionCount = await db.videoVersion.count({
where: { videoParentId: videoId },
});
if (versionCount <= 1) {
return apiErrors.badRequest('Cannot delete the only version. Delete the video instead.');
}
const wasActive = result.version.isActive;
// Delete the version (cascades to comments)
await db.videoVersion.delete({ where: { id: versionId } });
// If the deleted version was active, activate the latest remaining one
if (wasActive) {
const latestVersion = await db.videoVersion.findFirst({
where: { videoParentId: videoId },
orderBy: { versionNumber: 'desc' },
});
if (latestVersion) {
await db.videoVersion.update({
where: { id: latestVersion.id },
data: { isActive: true },
});
}
}
const response = successResponse({ message: 'Version deleted' });
return withCacheControl(response, 'private, no-store');
} catch (error) {
console.error('Error deleting version:', error);
return apiErrors.internalError('Failed to delete version');
}
}
+1
View File
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
visibility: project.visibility, visibility: project.visibility,
}, },
isAuthenticated: !!session?.user?.id, isAuthenticated: !!session?.user?.id,
currentUserId: session?.user?.id || null,
canComment: access.hasAccess, canComment: access.hasAccess,
}); });
+2
View File
@@ -164,6 +164,8 @@ export function VideoCard({ video, projectId }: VideoCardProps) {
}); });
if (res.ok) { if (res.ok) {
setShowDeleteDialog(false); setShowDeleteDialog(false);
// Give revalidatePath time to invalidate cache before refreshing
await new Promise((r) => setTimeout(r, 300));
router.refresh(); router.refresh();
} }
} catch (err) { } catch (err) {
+345 -132
View File
@@ -49,6 +49,16 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -115,12 +125,18 @@ interface VideoData {
}; };
versions: (Version & { comments: Comment[] })[]; versions: (Version & { comments: Comment[] })[];
isAuthenticated: boolean; isAuthenticated: boolean;
currentUserId: string | null;
canComment?: boolean; canComment?: boolean;
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60); const totalSeconds = Math.floor(seconds);
const secs = Math.floor(seconds % 60); const hrs = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
} }
@@ -138,6 +154,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const playerRef = useRef<YT.Player | null>(null); const playerRef = useRef<YT.Player | null>(null);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const [video, setVideo] = useState<VideoData | null>(null); const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -149,6 +166,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [cursorIdle, setCursorIdle] = useState(false);
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [isSubmittingComment, setIsSubmittingComment] = useState(false); const [isSubmittingComment, setIsSubmittingComment] = useState(false);
@@ -181,6 +200,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const replyRecordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null); const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [editText, setEditText] = useState(''); const [editText, setEditText] = useState('');
const [editTagId, setEditTagId] = useState<string | null>(null);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null); const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
const isMutatingRef = useRef(false); const isMutatingRef = useRef(false);
@@ -210,6 +230,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const projectId = propProjectId || video?.projectId; const projectId = propProjectId || video?.projectId;
// Cursor idle detection: hide overlay when cursor idle for 3s while playing
const handleVideoMouseMove = useCallback(() => {
setCursorIdle(false);
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
cursorIdleTimerRef.current = setTimeout(() => {
setCursorIdle(true);
}, 3000);
}, []);
const handleVideoMouseLeave = useCallback(() => {
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
setCursorIdle(false);
}, []);
useEffect(() => {
return () => {
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
};
}, []);
// Determine current user ID for permission checks
const currentUserId = video?.currentUserId || null;
const apiBasePath = mode === 'dashboard' const apiBasePath = mode === 'dashboard'
? `/api/projects/${propProjectId}/videos/${videoId}` ? `/api/projects/${propProjectId}/videos/${videoId}`
: `/api/watch/${videoId}`; : `/api/watch/${videoId}`;
@@ -324,6 +367,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}; };
}, [activeVersionId]); }, [activeVersionId]);
// Save detected duration to DB if the version doesn't have one stored
useEffect(() => {
if (!videoDuration || !activeVersion || !propProjectId) return;
if (activeVersion.duration && activeVersion.duration > 0) return;
const roundedDuration = Math.round(videoDuration);
// Fire-and-forget PATCH to save duration
fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersion.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration: roundedDuration }),
}).catch(() => { /* ignore save errors */ });
// Also update local state so the version object has the duration
setVideo((prev) => {
if (!prev) return prev;
return {
...prev,
versions: prev.versions.map((v) =>
v.id === activeVersion.id ? { ...v, duration: roundedDuration } : v
),
};
});
}, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]);
useEffect(() => { useEffect(() => {
if (!isReady || !playerRef.current) return; if (!isReady || !playerRef.current) return;
@@ -824,11 +892,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: !c.isResolved } : c c.id === commentId ? { ...c, isResolved: !c.isResolved } : c
), ),
} }
: v : v
), ),
}; };
@@ -849,11 +917,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: currentlyResolved } : c c.id === commentId ? { ...c, isResolved: currentlyResolved } : c
), ),
} }
: v : v
), ),
}; };
@@ -868,11 +936,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === commentId ? { ...c, isResolved: currentlyResolved } : c c.id === commentId ? { ...c, isResolved: currentlyResolved } : c
), ),
} }
: v : v
), ),
}; };
@@ -909,13 +977,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === parentId c.id === parentId
? { ...c, replies: [...c.replies, optimisticReply] } ? { ...c, replies: [...c.replies, optimisticReply] }
: c : c
), ),
} }
: v : v
), ),
}; };
@@ -952,13 +1020,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === parentId c.id === parentId
? { ...c, replies: c.replies.map(r => r.id === tempId ? newReply : r) } ? { ...c, replies: c.replies.map(r => r.id === tempId ? newReply : r) }
: c : c
), ),
} }
: v : v
), ),
}; };
@@ -971,13 +1039,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === parentId c.id === parentId
? { ...c, replies: c.replies.filter(r => r.id !== tempId) } ? { ...c, replies: c.replies.filter(r => r.id !== tempId) }
: c : c
), ),
} }
: v : v
), ),
}; };
@@ -992,13 +1060,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
versions: prev.versions.map((v) => versions: prev.versions.map((v) =>
v.id === activeVersionId v.id === activeVersionId
? { ? {
...v, ...v,
comments: v.comments.map((c) => comments: v.comments.map((c) =>
c.id === parentId c.id === parentId
? { ...c, replies: c.replies.filter(r => r.id !== tempId) } ? { ...c, replies: c.replies.filter(r => r.id !== tempId) }
: c : c
), ),
} }
: v : v
), ),
}; };
@@ -1082,12 +1150,15 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsSubmittingEdit(true); setIsSubmittingEdit(true);
isMutatingRef.current = true; isMutatingRef.current = true;
try { try {
const body: Record<string, unknown> = { content: editText };
if (editTagId !== undefined) body.tagId = editTagId;
const res = await fetch(`/api/comments/${commentId}`, { const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editText }), body: JSON.stringify(body),
}); });
if (res.ok) { if (res.ok) {
const editedTag = editTagId ? availableTags.find(t => t.id === editTagId) || null : null;
setVideo((prev) => { setVideo((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { return {
@@ -1097,7 +1168,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
? { ? {
...v, ...v,
comments: v.comments.map((c) => { comments: v.comments.map((c) => {
if (c.id === commentId) return { ...c, content: editText.trim() }; if (c.id === commentId) return { ...c, content: editText.trim(), tag: editTagId !== undefined ? editedTag : c.tag };
return { return {
...c, ...c,
replies: c.replies.map((r) => replies: c.replies.map((r) =>
@@ -1112,6 +1183,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}); });
setEditingCommentId(null); setEditingCommentId(null);
setEditText(''); setEditText('');
setEditTagId(null);
} }
} catch (err) { } catch (err) {
console.error('Failed to edit comment:', err); console.error('Failed to edit comment:', err);
@@ -1119,7 +1191,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsSubmittingEdit(false); setIsSubmittingEdit(false);
isMutatingRef.current = false; isMutatingRef.current = false;
} }
}, [editText, activeVersionId]); }, [editText, editTagId, activeVersionId, availableTags]);
const handleDeleteComment = useCallback(async (commentId: string) => { const handleDeleteComment = useCallback(async (commentId: string) => {
setDeletingCommentId(commentId); setDeletingCommentId(commentId);
@@ -1217,13 +1289,20 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}); });
if (res.ok) { if (res.ok) {
const videoRes = await fetch(`/api/projects/${propProjectId}/videos/${videoId}`); const versionData = await res.json();
if (videoRes.ok) { const newVersion = versionData.data;
const data = await videoRes.json(); // Optimistically add the new version to local state instead of refetching
setVideo(data.data); setVideo((prev) => {
const active = data.data.versions.find((v: Version) => v.isActive) || data.data.versions[0]; if (!prev) return prev;
if (active) setActiveVersionId(active.id); const updatedVersions = prev.versions.map(v => ({ ...v, isActive: false }));
} const createdVersion = {
...newVersion,
comments: [],
};
updatedVersions.unshift(createdVersion);
return { ...prev, versions: updatedVersions };
});
setActiveVersionId(newVersion.id);
setShowVersionDialog(false); setShowVersionDialog(false);
setNewVersionUrl(''); setNewVersionUrl('');
setNewVersionLabel(''); setNewVersionLabel('');
@@ -1236,6 +1315,43 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
} }
}; };
// Version deletion
const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false);
const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
const [isDeletingVersion, setIsDeletingVersion] = useState(false);
const handleDeleteVersion = async () => {
if (!versionToDelete || !propProjectId) return;
setIsDeletingVersion(true);
try {
const res = await fetch(
`/api/projects/${propProjectId}/videos/${videoId}/versions/${versionToDelete}`,
{ method: 'DELETE' }
);
if (res.ok) {
setVideo((prev) => {
if (!prev) return prev;
const remaining = prev.versions.filter((v) => v.id !== versionToDelete);
return { ...prev, versions: remaining };
});
// If deleted version was active, switch to the first remaining
if (activeVersionId === versionToDelete && video) {
const remaining = video.versions.filter((v) => v.id !== versionToDelete);
if (remaining.length > 0) setActiveVersionId(remaining[0].id);
}
setShowDeleteVersionDialog(false);
setVersionToDelete(null);
} else {
const data = await res.json();
toast.error(data.error || 'Failed to delete version');
}
} catch {
toast.error('Failed to delete version');
} finally {
setIsDeletingVersion(false);
}
};
const getEmbedUrl = (version: Version) => { const getEmbedUrl = (version: Version) => {
if (version.providerId === 'youtube') { if (version.providerId === 'youtube') {
return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
@@ -1449,9 +1565,47 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
{mode === 'dashboard' && video.versions.length > 1 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => {
setVersionToDelete(activeVersionId);
setShowDeleteVersionDialog(true);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Current Version
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Version Delete Confirmation */}
<AlertDialog open={showDeleteVersionDialog} onOpenChange={setShowDeleteVersionDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this version?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this version and all its comments. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingVersion}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteVersion}
disabled={isDeletingVersion}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeletingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Delete Version
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{mode === 'dashboard' && ( {mode === 'dashboard' && (
<> <>
<Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}> <Dialog open={showVersionDialog} onOpenChange={setShowVersionDialog}>
@@ -1531,8 +1685,14 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
<div <div
className="flex-1 bg-black flex items-center justify-center relative cursor-pointer group min-h-0" ref={videoContainerRef}
className={cn(
'flex-1 bg-black flex items-center justify-center relative cursor-pointer group min-h-0',
cursorIdle && isPlaying && 'cursor-none'
)}
onClick={handlePlayPause} onClick={handlePlayPause}
onMouseMove={handleVideoMouseMove}
onMouseLeave={handleVideoMouseLeave}
> >
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<iframe <iframe
@@ -1546,8 +1706,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<div <div
className={cn( className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity', 'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300',
isPlaying ? 'opacity-0 group-hover:opacity-100' : 'opacity-100' isPlaying
? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
: 'opacity-100'
)} )}
> >
<div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center"> <div className="w-16 h-16 rounded-full bg-black/60 flex items-center justify-center">
@@ -1705,14 +1867,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="text-sm font-medium truncate">{authorName}</span> <span className="text-sm font-medium truncate">{authorName}</span>
{comment.tag && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
style={{ backgroundColor: comment.tag.color }}
>
{comment.tag.name}
</span>
)}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
@@ -1739,40 +1893,45 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Circle className="h-4 w-4" /> <Circle className="h-4 w-4" />
)} )}
</Button> </Button>
<DropdownMenu> {(comment.author?.id === currentUserId || video.project.ownerId === currentUserId) && (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-6 w-6 opacity-0 group-hover:opacity-100" size="icon"
> className="h-6 w-6 opacity-0 group-hover:opacity-100"
<MoreVertical className="h-4 w-4" /> >
</Button> <MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => { <DropdownMenuContent align="end">
setReplyingTo(comment.id); <DropdownMenuItem onClick={() => {
setReplyText(''); setReplyingTo(comment.id);
}}> setReplyText('');
<Reply className="h-4 w-4 mr-2" /> }}>
Reply <Reply className="h-4 w-4 mr-2" />
</DropdownMenuItem> Reply
<DropdownMenuItem onClick={() => { </DropdownMenuItem>
setEditingCommentId(comment.id); {comment.author?.id === currentUserId && (
setEditText(comment.content || ''); <DropdownMenuItem onClick={() => {
}}> setEditingCommentId(comment.id);
<Pencil className="h-4 w-4 mr-2" /> setEditText(comment.content || '');
Edit setEditTagId(comment.tag?.id || null);
</DropdownMenuItem> }}>
<DropdownMenuItem <Pencil className="h-4 w-4 mr-2" />
className="text-destructive" Edit
onClick={() => handleDeleteComment(comment.id)} </DropdownMenuItem>
> )}
<Trash2 className="h-4 w-4 mr-2" /> <DropdownMenuItem
Delete className="text-destructive"
</DropdownMenuItem> onClick={() => handleDeleteComment(comment.id)}
</DropdownMenuContent> >
</DropdownMenu> <Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
</div> </div>
@@ -1791,10 +1950,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingCommentId(null); setEditingCommentId(null);
setEditText(''); setEditText('');
setEditTagId(null);
} }
}} }}
/> />
<div className="flex gap-1"> <div className="flex items-center gap-1">
<Button <Button
size="sm" size="sm"
onClick={() => handleEditComment(comment.id)} onClick={() => handleEditComment(comment.id)}
@@ -1806,11 +1966,50 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { setEditingCommentId(null); setEditText(''); }} onClick={() => { setEditingCommentId(null); setEditText(''); setEditTagId(null); }}
className="h-7 text-xs" className="h-7 text-xs"
> >
Cancel Cancel
</Button> </Button>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant={editTagId ? 'default' : 'outline'}
className="h-7 text-xs ml-auto"
style={editTagId ? {
backgroundColor: availableTags.find(t => t.id === editTagId)?.color
} : undefined}
>
<Tag className="h-3 w-3 mr-1" />
{editTagId ? availableTags.find(t => t.id === editTagId)?.name || 'Tag' : 'Tag'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditTagId(null)} className="gap-2">
<X className="h-3 w-3" />
No Tag
{!editTagId && <span className="ml-auto"></span>}
</DropdownMenuItem>
<DropdownMenuSeparator />
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setEditTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{editTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
</div> </div>
) : ( ) : (
@@ -1853,9 +2052,19 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
</div> </div>
)} )}
<p className="text-xs text-muted-foreground"> <div className="flex items-center justify-between">
{new Date(comment.createdAt).toLocaleDateString()} <p className="text-xs text-muted-foreground">
</p> {new Date(comment.createdAt).toLocaleDateString()}
</p>
{comment.tag && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white shrink-0"
style={{ backgroundColor: comment.tag.color }}
>
{comment.tag.name}
</span>
)}
</div>
{comment.replies.length > 0 && ( {comment.replies.length > 0 && (
<div className="mt-3 pl-3 border-l-2 space-y-2"> <div className="mt-3 pl-3 border-l-2 space-y-2">
@@ -1877,33 +2086,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
{new Date(reply.createdAt).toLocaleDateString()} {new Date(reply.createdAt).toLocaleDateString()}
</span> </span>
</div> </div>
<DropdownMenu> {(reply.author?.id === currentUserId || video.project.ownerId === currentUserId) && (
<DropdownMenuTrigger asChild> <DropdownMenu>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-5 w-5 opacity-0 group-hover/reply:opacity-100 shrink-0" size="icon"
> className="h-5 w-5 opacity-0 group-hover/reply:opacity-100 shrink-0"
<MoreVertical className="h-3 w-3" /> >
</Button> <MoreVertical className="h-3 w-3" />
</DropdownMenuTrigger> </Button>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => { <DropdownMenuContent align="end">
setEditingCommentId(reply.id); {reply.author?.id === currentUserId && (
setEditText(reply.content || ''); <DropdownMenuItem onClick={() => {
}}> setEditingCommentId(reply.id);
<Pencil className="h-4 w-4 mr-2" /> setEditText(reply.content || '');
Edit }}>
</DropdownMenuItem> <Pencil className="h-4 w-4 mr-2" />
<DropdownMenuItem Edit
className="text-destructive" </DropdownMenuItem>
onClick={() => handleDeleteComment(reply.id)} )}
> <DropdownMenuItem
<Trash2 className="h-4 w-4 mr-2" /> className="text-destructive"
Delete onClick={() => handleDeleteComment(reply.id)}
</DropdownMenuItem> >
</DropdownMenuContent> <Trash2 className="h-4 w-4 mr-2" />
</DropdownMenu> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
{isEditingReply ? ( {isEditingReply ? (
<div className="mb-1"> <div className="mb-1">