Initial commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { saveAnnotation, getAnnotationsForVersion } from "@/actions/annotations";
|
||||
import type { AnnotationShape, AnnotationDrawingData } from "@/types";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface UseAnnotationsOptions {
|
||||
versionId: string;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
export function useAnnotations({ versionId, fps }: UseAnnotationsOptions) {
|
||||
const [savedAnnotations, setSavedAnnotations] = useState<
|
||||
{ id: string; frameNumber: number; drawingData: AnnotationDrawingData }[]
|
||||
>([]);
|
||||
const [sessionShapes, setSessionShapes] = useState<AnnotationShape[]>([]);
|
||||
const [currentShape, setCurrentShape] = useState<AnnotationShape | null>(null);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const startShape = useCallback(
|
||||
(frameNumber: number, point: { x: number; y: number }) => {
|
||||
const shape: AnnotationShape = {
|
||||
id: uuidv4(),
|
||||
tool: selectedTool,
|
||||
points: [point],
|
||||
color: selectedColor,
|
||||
strokeWidth,
|
||||
frameNumber,
|
||||
};
|
||||
setCurrentShape(shape);
|
||||
setIsDrawing(true);
|
||||
},
|
||||
[selectedTool, selectedColor, strokeWidth]
|
||||
);
|
||||
|
||||
const addPoint = useCallback((point: { x: number; y: number }) => {
|
||||
setCurrentShape((prev) =>
|
||||
prev ? { ...prev, points: [...prev.points, point] } : null
|
||||
);
|
||||
}, []);
|
||||
|
||||
const finishShape = useCallback(
|
||||
async (canvasWidth: number, canvasHeight: number) => {
|
||||
if (!currentShape) return;
|
||||
const finished = currentShape;
|
||||
setSessionShapes((prev) => [...prev, finished]);
|
||||
setCurrentShape(null);
|
||||
setIsDrawing(false);
|
||||
|
||||
const drawingData: AnnotationDrawingData = {
|
||||
shapes: [finished],
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
version: "1.0",
|
||||
};
|
||||
|
||||
try {
|
||||
await saveAnnotation({
|
||||
versionId,
|
||||
frameNumber: finished.frameNumber,
|
||||
drawingData,
|
||||
color: selectedColor,
|
||||
});
|
||||
} catch {
|
||||
toast({ title: "Failed to save annotation", variant: "destructive" });
|
||||
}
|
||||
},
|
||||
[currentShape, versionId, selectedColor, toast]
|
||||
);
|
||||
|
||||
const clearSessionShapes = useCallback((frameNumber?: number) => {
|
||||
if (frameNumber !== undefined) {
|
||||
setSessionShapes((prev) => prev.filter((s) => s.frameNumber !== frameNumber));
|
||||
} else {
|
||||
setSessionShapes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAnnotations = useCallback(async () => {
|
||||
try {
|
||||
const data = await getAnnotationsForVersion(versionId);
|
||||
setSavedAnnotations(data as any);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}, [versionId]);
|
||||
|
||||
return {
|
||||
savedAnnotations,
|
||||
sessionShapes,
|
||||
currentShape,
|
||||
isDrawing,
|
||||
startShape,
|
||||
addPoint,
|
||||
finishShape,
|
||||
clearSessionShapes,
|
||||
loadAnnotations,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { CommentWithReplies } from "@/types";
|
||||
|
||||
export function useFrameComments(comments: CommentWithReplies[], frameNumber: number) {
|
||||
const frameComments = useMemo(
|
||||
() => comments.filter((c) => c.frameNumber === frameNumber),
|
||||
[comments, frameNumber]
|
||||
);
|
||||
|
||||
const nearbyComments = useMemo(
|
||||
() => comments.filter((c) => Math.abs(c.frameNumber - frameNumber) <= 3 && c.frameNumber !== frameNumber),
|
||||
[comments, frameNumber]
|
||||
);
|
||||
|
||||
const unresolvedCount = useMemo(
|
||||
() => comments.filter((c) => !c.isResolved).length,
|
||||
[comments]
|
||||
);
|
||||
|
||||
const commentsByFrame = useMemo(() => {
|
||||
const map = new Map<number, CommentWithReplies[]>();
|
||||
comments.forEach((c) => {
|
||||
const existing = map.get(c.frameNumber) ?? [];
|
||||
map.set(c.frameNumber, [...existing, c]);
|
||||
});
|
||||
return map;
|
||||
}, [comments]);
|
||||
|
||||
const hasCommentAtFrame = (frame: number) => commentsByFrame.has(frame);
|
||||
|
||||
const getCommentsAtFrame = (frame: number) =>
|
||||
commentsByFrame.get(frame) ?? [];
|
||||
|
||||
return {
|
||||
frameComments,
|
||||
nearbyComments,
|
||||
unresolvedCount,
|
||||
commentsByFrame,
|
||||
hasCommentAtFrame,
|
||||
getCommentsAtFrame,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { create } from "zustand";
|
||||
import type { AnnotationTool } from "@/types";
|
||||
|
||||
interface ReviewPlayerStore {
|
||||
// Playback
|
||||
isPlaying: boolean;
|
||||
currentFrame: number;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
|
||||
// Annotation
|
||||
isAnnotating: boolean;
|
||||
selectedTool: AnnotationTool;
|
||||
selectedColor: string;
|
||||
strokeWidth: number;
|
||||
showAnnotations: boolean;
|
||||
|
||||
// Config (set from version data)
|
||||
fps: number;
|
||||
totalFrames: number;
|
||||
|
||||
// Actions
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setCurrentFrame: (frame: number) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setMuted: (muted: boolean) => void;
|
||||
setFullscreen: (fullscreen: boolean) => void;
|
||||
setAnnotating: (annotating: boolean) => void;
|
||||
setSelectedTool: (tool: AnnotationTool) => void;
|
||||
setSelectedColor: (color: string) => void;
|
||||
setStrokeWidth: (width: number) => void;
|
||||
setShowAnnotations: (show: boolean) => void;
|
||||
setFps: (fps: number) => void;
|
||||
setTotalFrames: (frames: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
isPlaying: false,
|
||||
currentFrame: 0,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: 1,
|
||||
volume: 1,
|
||||
isMuted: false,
|
||||
isFullscreen: false,
|
||||
isAnnotating: false,
|
||||
selectedTool: "freehand" as AnnotationTool,
|
||||
selectedColor: "#ef4444",
|
||||
strokeWidth: 2,
|
||||
showAnnotations: true,
|
||||
fps: 24,
|
||||
totalFrames: 0,
|
||||
};
|
||||
|
||||
export const useReviewStore = create<ReviewPlayerStore>((set) => ({
|
||||
...initialState,
|
||||
setPlaying: (isPlaying) => set({ isPlaying }),
|
||||
setCurrentFrame: (currentFrame) => set({ currentFrame }),
|
||||
setCurrentTime: (currentTime) => set({ currentTime }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setPlaybackRate: (playbackRate) => set({ playbackRate }),
|
||||
setVolume: (volume) => set({ volume }),
|
||||
setMuted: (isMuted) => set({ isMuted }),
|
||||
setFullscreen: (isFullscreen) => set({ isFullscreen }),
|
||||
setAnnotating: (isAnnotating) => set({ isAnnotating }),
|
||||
setSelectedTool: (selectedTool) => set({ selectedTool }),
|
||||
setSelectedColor: (selectedColor) => set({ selectedColor }),
|
||||
setStrokeWidth: (strokeWidth) => set({ strokeWidth }),
|
||||
setShowAnnotations: (showAnnotations) => set({ showAnnotations }),
|
||||
setFps: (fps) => set({ fps }),
|
||||
setTotalFrames: (totalFrames) => set({ totalFrames }),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
Reference in New Issue
Block a user