@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user