delete comments enabled
Deploy / deploy (push) Successful in 2m27s

This commit is contained in:
twotalesanimation
2026-06-12 10:29:16 +02:00
parent 01474084fa
commit a7195b5f78
5 changed files with 142 additions and 7 deletions
+27
View File
@@ -136,6 +136,25 @@ export async function resolveComment(commentId: string, resolved: boolean) {
return { success: true }; return { success: true };
} }
export async function editComment(commentId: string, newText: string) {
const session = await auth();
if (!session?.user) throw new Error("Unauthorized");
const trimmed = newText.trim();
if (!trimmed) throw new Error("Comment text cannot be empty");
const comment = await db.comment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Comment not found");
if (comment.authorId !== session.user.id && session.user.role !== "ADMIN") {
throw new Error("Unauthorized");
}
await db.comment.update({ where: { id: commentId }, data: { text: trimmed } });
revalidatePath(`/review/${comment.versionId}`);
return { success: true };
}
export async function deleteComment(commentId: string) { export async function deleteComment(commentId: string) {
const session = await auth(); const session = await auth();
if (!session?.user) throw new Error("Unauthorized"); if (!session?.user) throw new Error("Unauthorized");
@@ -149,6 +168,14 @@ export async function deleteComment(commentId: string) {
} }
await db.comment.delete({ where: { id: commentId } }); await db.comment.delete({ where: { id: commentId } });
// If this was an annotation companion comment, also delete the canvas drawings at that frame
if (comment.text.startsWith("✏️ Annotation at frame")) {
await db.annotation.deleteMany({
where: { versionId: comment.versionId, frameNumber: comment.frameNumber },
});
}
revalidatePath(`/review/${comment.versionId}`); revalidatePath(`/review/${comment.versionId}`);
return { success: true }; return { success: true };
} }
+3 -3
View File
@@ -8,10 +8,10 @@ const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
template: "%s | FeedBack", template: "%s | VFX Review",
default: "FeedBack — VFX Review Platform", default: "VFX Review — Two Tales VFX Review Platform",
}, },
description: "Frame-accurate review and approval for VFX and animation studios.", description: "VFX review and approval for Two Tales VFX.",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -90,6 +90,7 @@ interface ReviewPageClientProps {
canApprove: boolean; canApprove: boolean;
canShare: boolean; canShare: boolean;
currentUserId: string; currentUserId: string;
userRole?: string;
} }
const APPROVAL_STATUS_STYLES: Record<string, string> = { const APPROVAL_STATUS_STYLES: Record<string, string> = {
@@ -106,6 +107,7 @@ export function ReviewPageClient({
canApprove, canApprove,
canShare, canShare,
currentUserId, currentUserId,
userRole,
}: ReviewPageClientProps) { }: ReviewPageClientProps) {
const [comments, setComments] = useState(initialComments); const [comments, setComments] = useState(initialComments);
const [annotations, setAnnotations] = useState(initialAnnotations); const [annotations, setAnnotations] = useState(initialAnnotations);
@@ -348,9 +350,12 @@ export function ReviewPageClient({
fps={version.fps} fps={version.fps}
comments={comments} comments={comments}
onCommentsChange={handleCommentsChange} onCommentsChange={handleCommentsChange}
onAnnotationDeleted={handleAnnotationSaved}
pendingFrame={pendingFrame} pendingFrame={pendingFrame}
onPendingFrameCleared={() => setPendingFrame(null)} onPendingFrameCleared={() => setPendingFrame(null)}
onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)} onSeekToFrame={(frame) => playerRef.current?.seekToFrame(frame)}
currentUserId={currentUserId}
isAdmin={userRole === "ADMIN"}
/> />
</div> </div>
</div> </div>
+1
View File
@@ -121,6 +121,7 @@ export default async function ReviewPage({
canApprove={canApprove} canApprove={canApprove}
canShare={canShare} canShare={canShare}
currentUserId={session.user.id} currentUserId={session.user.id}
userRole={session.user.role as string}
/> />
); );
} }
+106 -4
View File
@@ -18,8 +18,12 @@ import {
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Filter, Filter,
Pencil,
Trash2,
Check,
X,
} from "lucide-react"; } from "lucide-react";
import { addComment, addReply, resolveComment } from "@/actions/comments"; import { addComment, addReply, resolveComment, deleteComment, editComment } from "@/actions/comments";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import type { CommentWithReplies } from "@/types"; import type { CommentWithReplies } from "@/types";
@@ -28,9 +32,12 @@ interface CommentPanelProps {
fps: number; fps: number;
comments: CommentWithReplies[]; comments: CommentWithReplies[];
onCommentsChange?: () => void; onCommentsChange?: () => void;
onAnnotationDeleted?: () => void;
pendingFrame?: number | null; pendingFrame?: number | null;
onPendingFrameCleared?: () => void; onPendingFrameCleared?: () => void;
onSeekToFrame?: (frame: number) => void; onSeekToFrame?: (frame: number) => void;
currentUserId?: string;
isAdmin?: boolean;
} }
export function CommentPanel({ export function CommentPanel({
@@ -38,9 +45,12 @@ export function CommentPanel({
fps, fps,
comments, comments,
onCommentsChange, onCommentsChange,
onAnnotationDeleted,
pendingFrame, pendingFrame,
onPendingFrameCleared, onPendingFrameCleared,
onSeekToFrame, onSeekToFrame,
currentUserId,
isAdmin,
}: CommentPanelProps) { }: CommentPanelProps) {
const { currentFrame, setCurrentFrame } = useReviewStore(); const { currentFrame, setCurrentFrame } = useReviewStore();
const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all"); const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all");
@@ -205,6 +215,9 @@ export function CommentPanel({
onSeekToFrame?.(frame); onSeekToFrame?.(frame);
}} }}
onResolved={onCommentsChange} onResolved={onCommentsChange}
onAnnotationDeleted={onAnnotationDeleted}
currentUserId={currentUserId}
isAdmin={isAdmin}
/> />
)) ))
)} )}
@@ -222,6 +235,9 @@ interface CommentThreadProps {
isActive: boolean; isActive: boolean;
onJumpToFrame: (frame: number) => void; onJumpToFrame: (frame: number) => void;
onResolved?: () => void; onResolved?: () => void;
onAnnotationDeleted?: () => void;
currentUserId?: string;
isAdmin?: boolean;
} }
function CommentThread({ function CommentThread({
@@ -230,12 +246,21 @@ function CommentThread({
isActive, isActive,
onJumpToFrame, onJumpToFrame,
onResolved, onResolved,
onAnnotationDeleted,
currentUserId,
isAdmin,
}: CommentThreadProps) { }: CommentThreadProps) {
const [showReplies, setShowReplies] = useState(false); const [showReplies, setShowReplies] = useState(false);
const [replyText, setReplyText] = useState(""); const [replyText, setReplyText] = useState("");
const [isSubmittingReply, setIsSubmittingReply] = useState(false); const [isSubmittingReply, setIsSubmittingReply] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState("");
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const canModify = !!currentUserId && (comment.authorId === currentUserId || !!isAdmin);
const handleReply = async () => { const handleReply = async () => {
if (!replyText.trim()) return; if (!replyText.trim()) return;
setIsSubmittingReply(true); setIsSubmittingReply(true);
@@ -259,6 +284,40 @@ function CommentThread({
} }
}; };
const handleStartEdit = () => {
setEditText(comment.text);
setIsEditing(true);
};
const handleSaveEdit = async () => {
if (!editText.trim()) return;
setIsSavingEdit(true);
try {
await editComment(comment.id, editText.trim());
setIsEditing(false);
onResolved?.();
} catch {
toast({ title: "Failed to update comment", variant: "destructive" });
} finally {
setIsSavingEdit(false);
}
};
const handleDelete = async () => {
if (!confirm("Delete this comment? This cannot be undone.")) return;
setIsDeleting(true);
try {
const isAnnotationComment = comment.text.startsWith("✏️ Annotation at frame");
await deleteComment(comment.id);
if (isAnnotationComment) onAnnotationDeleted?.();
onResolved?.();
} catch {
toast({ title: "Failed to delete comment", variant: "destructive" });
} finally {
setIsDeleting(false);
}
};
return ( return (
<div <div
className={cn( className={cn(
@@ -292,10 +351,53 @@ function CommentThread({
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">
{formatRelativeDate(comment.createdAt)} {formatRelativeDate(comment.createdAt)}
</span> </span>
{canModify && !isEditing && (
<div className="flex items-center gap-1 ml-1">
<button
className="text-muted-foreground/50 hover:text-foreground transition-colors p-0.5 rounded"
onClick={handleStartEdit}
title="Edit comment"
>
<Pencil className="h-3 w-3" />
</button>
<button
className="text-muted-foreground/50 hover:text-red-400 transition-colors p-0.5 rounded"
onClick={handleDelete}
disabled={isDeleting}
title="Delete comment"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div> </div>
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap"> {isEditing ? (
{comment.text} <div className="mt-1.5 space-y-1.5">
</p> <Textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSaveEdit(); }
if (e.key === "Escape") { e.preventDefault(); setIsEditing(false); }
}}
className="min-h-[60px] text-sm bg-background/50"
autoFocus
/>
<div className="flex items-center gap-2">
<Button size="sm" className="h-6 text-xs gap-1" onClick={handleSaveEdit} disabled={isSavingEdit || !editText.trim()}>
<Check className="h-3 w-3" />
{isSavingEdit ? "Saving…" : "Save"}
</Button>
<button className="text-xs text-muted-foreground hover:text-foreground" onClick={() => setIsEditing(false)}>
<X className="h-3 w-3 inline mr-0.5" />Cancel
</button>
</div>
</div>
) : (
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
)}
</div> </div>
</div> </div>