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}
|
comments={comments as any}
|
||||||
versionId={version.id}
|
versionId={version.id}
|
||||||
onAddComment={handlePlayerAddComment}
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ import { useToast } from "@/components/ui/use-toast";
|
|||||||
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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 {
|
interface AnnotationCanvasProps {
|
||||||
versionId: string;
|
versionId: string;
|
||||||
frameNumber: number;
|
frameNumber: number;
|
||||||
@@ -22,6 +36,10 @@ interface AnnotationCanvasProps {
|
|||||||
showAnnotations: boolean;
|
showAnnotations: boolean;
|
||||||
existingAnnotations?: unknown[];
|
existingAnnotations?: unknown[];
|
||||||
onAnnotationSaved?: (frameNumber: number) => void;
|
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 = {
|
type DrawingState = {
|
||||||
@@ -38,6 +56,8 @@ export function AnnotationCanvas({
|
|||||||
showAnnotations,
|
showAnnotations,
|
||||||
existingAnnotations = [],
|
existingAnnotations = [],
|
||||||
onAnnotationSaved,
|
onAnnotationSaved,
|
||||||
|
saveAnnotationFn,
|
||||||
|
addCommentFn,
|
||||||
}: AnnotationCanvasProps) {
|
}: AnnotationCanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
||||||
@@ -334,7 +354,16 @@ export function AnnotationCanvas({
|
|||||||
canvasHeight: canvas?.offsetHeight ?? 1080,
|
canvasHeight: canvas?.offsetHeight ?? 1080,
|
||||||
version: "1.0",
|
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,
|
versionId,
|
||||||
frameNumber,
|
frameNumber,
|
||||||
drawingData,
|
drawingData,
|
||||||
@@ -344,7 +373,7 @@ export function AnnotationCanvas({
|
|||||||
// Create a companion comment ONCE per frame so the annotation appears in the panel.
|
// 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.
|
// Additional strokes on the same frame update the existing comment count silently.
|
||||||
if (!annotationCommentedFrames.has(frameNumber)) {
|
if (!annotationCommentedFrames.has(frameNumber)) {
|
||||||
await addComment({
|
await doAddComment({
|
||||||
versionId,
|
versionId,
|
||||||
frameNumber,
|
frameNumber,
|
||||||
timestamp: frameNumber / fps,
|
timestamp: frameNumber / fps,
|
||||||
@@ -360,7 +389,7 @@ export function AnnotationCanvas({
|
|||||||
variant: "destructive",
|
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) ──────────────
|
// ── Touch events (mirrors mouse events for mobile drawing) ──────────────
|
||||||
const handleTouchStart = useCallback(
|
const handleTouchStart = useCallback(
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ interface ReviewPlayerProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
||||||
onAnnotationSaved?: (frameNumber: 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>(
|
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||||
@@ -46,6 +50,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
|||||||
className,
|
className,
|
||||||
onAddComment,
|
onAddComment,
|
||||||
onAnnotationSaved,
|
onAnnotationSaved,
|
||||||
|
saveAnnotationFn,
|
||||||
|
addCommentFn,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -276,6 +282,8 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
|||||||
showAnnotations={showAnnotations}
|
showAnnotations={showAnnotations}
|
||||||
existingAnnotations={stableAnnotations}
|
existingAnnotations={stableAnnotations}
|
||||||
onAnnotationSaved={onAnnotationSaved}
|
onAnnotationSaved={onAnnotationSaved}
|
||||||
|
saveAnnotationFn={saveAnnotationFn as any}
|
||||||
|
addCommentFn={addCommentFn}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* JKL hint overlay — shown briefly when reversing */}
|
{/* JKL hint overlay — shown briefly when reversing */}
|
||||||
|
|||||||
Reference in New Issue
Block a user