This commit is contained in:
@@ -278,7 +278,7 @@ export default function ClientReviewPage({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
||||
className="h-10 sm:h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
|
||||
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
@@ -286,7 +286,7 @@ export default function ClientReviewPage({
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
className="h-10 sm:h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
@@ -295,8 +295,8 @@ export default function ClientReviewPage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main: player + comments */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main: player + comments — stacked on mobile, side-by-side on md+ */}
|
||||
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
|
||||
{/* Player */}
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
|
||||
<ReviewPlayer
|
||||
@@ -310,7 +310,7 @@ export default function ClientReviewPage({
|
||||
</div>
|
||||
|
||||
{/* Comment panel */}
|
||||
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900">
|
||||
<div className="h-64 md:h-auto md:w-72 xl:w-80 shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-zinc-800 bg-zinc-900">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-zinc-400" />
|
||||
|
||||
@@ -362,17 +362,73 @@ export function AnnotationCanvas({
|
||||
}
|
||||
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
|
||||
|
||||
// ── Touch events (mirrors mouse events for mobile drawing) ──────────────
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (!isAnnotating) return;
|
||||
e.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const point: AnnotationPoint = {
|
||||
x: (touch.clientX - rect.left) / rect.width,
|
||||
y: (touch.clientY - rect.top) / rect.height,
|
||||
};
|
||||
const newShape: AnnotationShape = {
|
||||
id: uuidv4(),
|
||||
tool: selectedTool,
|
||||
points: [point],
|
||||
color: selectedColor,
|
||||
strokeWidth,
|
||||
frameNumber,
|
||||
};
|
||||
setDrawingState((prev) => ({ ...prev, isDrawing: true, currentShape: newShape }));
|
||||
},
|
||||
[isAnnotating, selectedTool, selectedColor, strokeWidth, frameNumber]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingStateRef.current.isDrawing || !drawingStateRef.current.currentShape) return;
|
||||
e.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
const point: AnnotationPoint = {
|
||||
x: (touch.clientX - rect.left) / rect.width,
|
||||
y: (touch.clientY - rect.top) / rect.height,
|
||||
};
|
||||
setDrawingState((prev) => ({
|
||||
...prev,
|
||||
currentShape: prev.currentShape
|
||||
? { ...prev.currentShape, points: [...prev.currentShape.points, point] }
|
||||
: null,
|
||||
}));
|
||||
redraw();
|
||||
},
|
||||
[redraw]
|
||||
);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
|
||||
style={{
|
||||
cursor: isAnnotating ? "crosshair" : "default",
|
||||
touchAction: isAnnotating ? "none" : "auto",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={() => void handleMouseUp()}
|
||||
onTouchCancel={() => void handleMouseUp()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,21 +170,60 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Touch scrubbing ──────────────────────────────────────────────────────
|
||||
const getFrameFromTouch = useCallback(
|
||||
(touch: Touch): number => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || totalFrames === 0) return 0;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
|
||||
return Math.round((x / rect.width) * totalFrames);
|
||||
},
|
||||
[totalFrames]
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
isDragging.current = true;
|
||||
onSeek(getFrameFromTouch(touch));
|
||||
},
|
||||
[getFrameFromTouch, onSeek]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
onSeek(getFrameFromTouch(touch));
|
||||
},
|
||||
[getFrameFromTouch, onSeek]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
}, [handleMouseMove, handleMouseUp, handleTouchMove]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="frame-timeline w-full cursor-ew-resize"
|
||||
style={{ height: 48 }}
|
||||
style={{ height: 48, touchAction: "none" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function PlaybackControls({
|
||||
const timecode = frameToTimecode(currentFrame, fps);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5">
|
||||
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5" style={{ touchAction: "manipulation" }}>
|
||||
{/* Frame info */}
|
||||
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0">
|
||||
<span className="hidden sm:block text-zinc-500">
|
||||
@@ -91,6 +91,8 @@ export function PlaybackControls({
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Go to start + Reverse — hidden on mobile */}
|
||||
<div className="hidden sm:contents">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -123,6 +125,7 @@ export function PlaybackControls({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reverse (J)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -171,6 +174,8 @@ export function PlaybackControls({
|
||||
<TooltipContent>Step forward (→)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Go to end — hidden on mobile */}
|
||||
<div className="hidden sm:contents">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -188,13 +193,15 @@ export function PlaybackControls({
|
||||
<TooltipContent>Go to end</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Playback speed */}
|
||||
{/* Playback speed — hidden on mobile */}
|
||||
<div className="hidden sm:contents">
|
||||
<Select value={String(playbackRate)} onValueChange={handleRateChange}>
|
||||
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
|
||||
<SelectValue />
|
||||
@@ -207,6 +214,7 @@ export function PlaybackControls({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Annotation toggle */}
|
||||
<Tooltip>
|
||||
|
||||
@@ -250,8 +250,11 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Video */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating) */}
|
||||
<div
|
||||
className="relative flex-1 min-h-0 overflow-hidden"
|
||||
onClick={!isAnnotating ? togglePlayback : undefined}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
|
||||
Reference in New Issue
Block a user