This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { validateReviewToken } from "@/lib/review-auth";
|
||||
|
||||
/** Find or create a guest user for the client reviewer based on the session email */
|
||||
async function getOrCreateClientUser(email: string, label?: string | null) {
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return existing;
|
||||
return db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: label ?? email.split("@")[0],
|
||||
role: "CLIENT",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params;
|
||||
const result = await validateReviewToken(token, req);
|
||||
if (result.type === "requiresPassword") {
|
||||
return NextResponse.json({ requiresPassword: true }, { status: 401 });
|
||||
}
|
||||
if (result.type === "invalid") {
|
||||
return NextResponse.json({ error: "Invalid or expired review link" }, { status: 403 });
|
||||
}
|
||||
const session = result.session;
|
||||
|
||||
const body = await req.json();
|
||||
const { versionId, frameNumber, drawingData, color } = body;
|
||||
|
||||
if (!versionId || frameNumber == null || !drawingData) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure the version belongs to this project
|
||||
const version = await db.version.findUnique({
|
||||
where: { id: versionId },
|
||||
include: {
|
||||
shot: { select: { projectId: true } },
|
||||
task: { select: { projectId: true } },
|
||||
},
|
||||
});
|
||||
const projectId = version?.shot?.projectId ?? version?.task?.projectId;
|
||||
if (!version || projectId !== session.projectId) {
|
||||
return NextResponse.json({ error: "Version not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Resolve commenter identity
|
||||
const email = session.email ?? `client+${token.slice(0, 8)}@review.external`;
|
||||
const user = await getOrCreateClientUser(email, session.label);
|
||||
|
||||
const annotation = await db.annotation.create({
|
||||
data: {
|
||||
versionId,
|
||||
authorId: user.id,
|
||||
frameNumber,
|
||||
drawingData,
|
||||
color: color ?? "#ef4444",
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, annotation });
|
||||
}
|
||||
@@ -445,6 +445,23 @@ export default function ClientReviewPage({
|
||||
comments={comments as any}
|
||||
versionId={version.id}
|
||||
onAddComment={handlePlayerAddComment}
|
||||
saveAnnotationFn={async (data) => {
|
||||
const res = await fetch(`/api/client/${token}/annotation`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save annotation");
|
||||
}}
|
||||
addCommentFn={async (data) => {
|
||||
const res = await fetch(`/api/client/${token}/comment`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to post comment");
|
||||
await refreshComments(token, version.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,6 +14,20 @@ import { useToast } from "@/components/ui/use-toast";
|
||||
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface SaveAnnotationData {
|
||||
versionId: string;
|
||||
frameNumber: number;
|
||||
drawingData: AnnotationDrawingData;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface AddCommentData {
|
||||
versionId: string;
|
||||
frameNumber: number;
|
||||
timestamp: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
versionId: string;
|
||||
frameNumber: number;
|
||||
@@ -22,6 +36,10 @@ interface AnnotationCanvasProps {
|
||||
showAnnotations: boolean;
|
||||
existingAnnotations?: unknown[];
|
||||
onAnnotationSaved?: (frameNumber: number) => void;
|
||||
/** Override the default server action used to persist annotations (e.g. for client-token routes) */
|
||||
saveAnnotationFn?: (data: SaveAnnotationData) => Promise<void>;
|
||||
/** Override the default server action used to create the companion comment */
|
||||
addCommentFn?: (data: AddCommentData) => Promise<void>;
|
||||
}
|
||||
|
||||
type DrawingState = {
|
||||
@@ -38,6 +56,8 @@ export function AnnotationCanvas({
|
||||
showAnnotations,
|
||||
existingAnnotations = [],
|
||||
onAnnotationSaved,
|
||||
saveAnnotationFn,
|
||||
addCommentFn,
|
||||
}: AnnotationCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
||||
@@ -334,7 +354,16 @@ export function AnnotationCanvas({
|
||||
canvasHeight: canvas?.offsetHeight ?? 1080,
|
||||
version: "1.0",
|
||||
};
|
||||
await saveAnnotation({
|
||||
|
||||
const doSaveAnnotation = saveAnnotationFn
|
||||
? saveAnnotationFn
|
||||
: (data: SaveAnnotationData) => saveAnnotation(data).then(() => undefined);
|
||||
|
||||
const doAddComment = addCommentFn
|
||||
? addCommentFn
|
||||
: (data: AddCommentData) => addComment(data).then(() => undefined);
|
||||
|
||||
await doSaveAnnotation({
|
||||
versionId,
|
||||
frameNumber,
|
||||
drawingData,
|
||||
@@ -344,7 +373,7 @@ export function AnnotationCanvas({
|
||||
// Create a companion comment ONCE per frame so the annotation appears in the panel.
|
||||
// Additional strokes on the same frame update the existing comment count silently.
|
||||
if (!annotationCommentedFrames.has(frameNumber)) {
|
||||
await addComment({
|
||||
await doAddComment({
|
||||
versionId,
|
||||
frameNumber,
|
||||
timestamp: frameNumber / fps,
|
||||
@@ -360,7 +389,7 @@ export function AnnotationCanvas({
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
|
||||
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast, saveAnnotationFn, addCommentFn]);
|
||||
|
||||
// ── Touch events (mirrors mouse events for mobile drawing) ──────────────
|
||||
const handleTouchStart = useCallback(
|
||||
|
||||
@@ -33,6 +33,10 @@ interface ReviewPlayerProps {
|
||||
className?: string;
|
||||
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
||||
onAnnotationSaved?: (frameNumber: number) => void;
|
||||
/** Override the default server action used to persist annotations (e.g. for client-token routes) */
|
||||
saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise<void>;
|
||||
/** Override the default server action used to create the companion comment */
|
||||
addCommentFn?: (data: { versionId: string; frameNumber: number; timestamp: number; text: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
@@ -46,6 +50,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
className,
|
||||
onAddComment,
|
||||
onAnnotationSaved,
|
||||
saveAnnotationFn,
|
||||
addCommentFn,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
@@ -276,6 +282,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
showAnnotations={showAnnotations}
|
||||
existingAnnotations={stableAnnotations}
|
||||
onAnnotationSaved={onAnnotationSaved}
|
||||
saveAnnotationFn={saveAnnotationFn as any}
|
||||
addCommentFn={addCommentFn}
|
||||
/>
|
||||
|
||||
{/* JKL hint overlay — shown briefly when reversing */}
|
||||
|
||||
Reference in New Issue
Block a user