mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: Implement video version management API, enable comment tag editing, and enhance video duration display to include hours.
This commit is contained in:
@@ -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')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+352
-139
@@ -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,13 +200,14 @@ 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);
|
||||||
|
|
||||||
const [guestName, setGuestName] = useState('');
|
const [guestName, setGuestName] = useState('');
|
||||||
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
|
const [guestNameConfirmed, setGuestNameConfirmed] = useState(mode === 'dashboard');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem('openframe_guest_name');
|
const saved = localStorage.getItem('openframe_guest_name');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -210,7 +230,30 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
|
|
||||||
const projectId = propProjectId || video?.projectId;
|
const projectId = propProjectId || video?.projectId;
|
||||||
|
|
||||||
const apiBasePath = mode === 'dashboard'
|
// 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'
|
||||||
? `/api/projects/${propProjectId}/videos/${videoId}`
|
? `/api/projects/${propProjectId}/videos/${videoId}`
|
||||||
: `/api/watch/${videoId}`;
|
: `/api/watch/${videoId}`;
|
||||||
|
|
||||||
@@ -220,7 +263,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const res = await fetch(apiBasePath, { cache: 'no-store' });
|
const res = await fetch(apiBasePath, { cache: 'no-store' });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = mode === 'dashboard' ? await res.text() : '';
|
const errorText = mode === 'dashboard' ? await res.text() : '';
|
||||||
setError(mode === 'dashboard'
|
setError(mode === 'dashboard'
|
||||||
? `Failed to load video: ${res.status} ${errorText}`
|
? `Failed to load video: ${res.status} ${errorText}`
|
||||||
: 'Video not found or access denied'
|
: 'Video not found or access denied'
|
||||||
);
|
);
|
||||||
@@ -242,8 +285,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
fetchVideo();
|
fetchVideo();
|
||||||
}, [apiBasePath, mode]);
|
}, [apiBasePath, mode]);
|
||||||
|
|
||||||
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?.find((v) => v.isActive) ||
|
||||||
video?.versions?.[0];
|
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);
|
||||||
@@ -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`;
|
||||||
@@ -1255,8 +1371,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const containerHeight = mode === 'dashboard' ? 'h-[calc(100vh-3.5rem)]' : 'h-screen';
|
const containerHeight = mode === 'dashboard' ? 'h-[calc(100vh-3.5rem)]' : 'h-screen';
|
||||||
const backHref = mode === 'dashboard'
|
const backHref = mode === 'dashboard'
|
||||||
? `/projects/${propProjectId}`
|
? `/projects/${propProjectId}`
|
||||||
: (video?.projectId ? `/projects/${video.projectId}` : '/');
|
: (video?.projectId ? `/projects/${video.projectId}` : '/');
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user