Initial commit
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
MouseEvent,
|
||||
} from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { saveAnnotation } from "@/actions/annotations";
|
||||
import { addComment } from "@/actions/comments";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { AnnotationShape, AnnotationDrawingData, AnnotationPoint } from "@/types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface AnnotationCanvasProps {
|
||||
versionId: string;
|
||||
frameNumber: number;
|
||||
fps: number;
|
||||
isAnnotating: boolean;
|
||||
showAnnotations: boolean;
|
||||
existingAnnotations?: unknown[];
|
||||
onAnnotationSaved?: (frameNumber: number) => void;
|
||||
}
|
||||
|
||||
type DrawingState = {
|
||||
isDrawing: boolean;
|
||||
currentShape: AnnotationShape | null;
|
||||
shapes: AnnotationShape[];
|
||||
};
|
||||
|
||||
export function AnnotationCanvas({
|
||||
versionId,
|
||||
frameNumber,
|
||||
fps,
|
||||
isAnnotating,
|
||||
showAnnotations,
|
||||
existingAnnotations = [],
|
||||
onAnnotationSaved,
|
||||
}: AnnotationCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { selectedTool, selectedColor, strokeWidth } = useReviewStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [drawingState, setDrawingState] = useState<DrawingState>({
|
||||
isDrawing: false,
|
||||
currentShape: null,
|
||||
shapes: [],
|
||||
});
|
||||
const drawingStateRef = useRef(drawingState);
|
||||
drawingStateRef.current = drawingState;
|
||||
|
||||
// Per-frame saved shapes — persists across frame navigation so annotations
|
||||
// remain visible without depending on a parent API refetch.
|
||||
const [savedShapesByFrame, setSavedShapesByFrame] = useState<Record<number, AnnotationShape[]>>(() => {
|
||||
const map: Record<number, AnnotationShape[]> = {};
|
||||
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||
const shapes = a.drawingData?.shapes ?? [];
|
||||
map[a.frameNumber] = [...(map[a.frameNumber] ?? []), ...shapes];
|
||||
}
|
||||
return map;
|
||||
});
|
||||
// Ref so redraw closure can read latest without being recreated
|
||||
const savedShapesByFrameRef = useRef(savedShapesByFrame);
|
||||
savedShapesByFrameRef.current = savedShapesByFrame;
|
||||
|
||||
// Track frames that already have an annotation comment this session,
|
||||
// initialized from existing annotations so we don't double-comment on refresh.
|
||||
const [annotationCommentedFrames, setAnnotationCommentedFrames] = useState<Set<number>>(() => {
|
||||
const frames = new Set<number>();
|
||||
for (const a of (existingAnnotations as { frameNumber: number }[])) {
|
||||
frames.add(a.frameNumber);
|
||||
}
|
||||
return frames;
|
||||
});
|
||||
|
||||
// Sync savedShapesByFrame when the existingAnnotations prop changes (e.g. after parent API refresh).
|
||||
// MERGE server shapes with locally-drawn shapes (deduplicate by shape id) so locally-drawn
|
||||
// annotations are never wiped by a new array reference from the parent.
|
||||
const prevExistingLengthRef = useRef(existingAnnotations.length);
|
||||
const prevExistingRef = useRef(existingAnnotations);
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevExistingRef.current === existingAnnotations ||
|
||||
prevExistingLengthRef.current === existingAnnotations.length
|
||||
) {
|
||||
prevExistingRef.current = existingAnnotations;
|
||||
prevExistingLengthRef.current = existingAnnotations.length;
|
||||
return;
|
||||
}
|
||||
prevExistingRef.current = existingAnnotations;
|
||||
prevExistingLengthRef.current = existingAnnotations.length;
|
||||
|
||||
setSavedShapesByFrame((prev) => {
|
||||
// Build map from server data
|
||||
const serverMap: Record<number, AnnotationShape[]> = {};
|
||||
const serverShapeIds = new Set<string>();
|
||||
for (const a of (existingAnnotations as { frameNumber: number; drawingData: AnnotationDrawingData }[])) {
|
||||
const shapes = a.drawingData?.shapes ?? [];
|
||||
serverMap[a.frameNumber] = [...(serverMap[a.frameNumber] ?? []), ...shapes];
|
||||
for (const s of shapes) serverShapeIds.add(s.id);
|
||||
}
|
||||
// Merge: keep local shapes not yet in server data (pending save race)
|
||||
const merged = { ...serverMap };
|
||||
for (const [frame, shapes] of Object.entries(prev)) {
|
||||
for (const s of shapes) {
|
||||
if (!serverShapeIds.has(s.id)) {
|
||||
const f = Number(frame);
|
||||
merged[f] = [...(merged[f] ?? []), s];
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Also update frames that now have server-side annotation comments
|
||||
setAnnotationCommentedFrames((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const a of (existingAnnotations as { frameNumber: number }[])) {
|
||||
next.add(a.frameNumber);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [existingAnnotations]);
|
||||
|
||||
|
||||
// ── Coordinate normalization ─────────────────────────────────────────────
|
||||
const toNormalized = (e: MouseEvent): AnnotationPoint => {
|
||||
const canvas = canvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) / rect.width,
|
||||
y: (e.clientY - rect.top) / rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
const toPixel = (p: AnnotationPoint, w: number, h: number) => ({
|
||||
x: p.x * w,
|
||||
y: p.y * h,
|
||||
});
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
const renderShape = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
shape: AnnotationShape,
|
||||
w: number,
|
||||
h: number
|
||||
) => {
|
||||
if (shape.points.length === 0) return;
|
||||
|
||||
ctx.strokeStyle = shape.color;
|
||||
ctx.lineWidth = shape.strokeWidth;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.globalAlpha = 0.85;
|
||||
|
||||
const pts = shape.points.map((p) => toPixel(p, w, h));
|
||||
|
||||
switch (shape.tool) {
|
||||
case "freehand": {
|
||||
if (pts.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, pts[0].y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i].x, pts[i].y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case "rectangle": {
|
||||
if (pts.length < 2) return;
|
||||
const x = Math.min(pts[0].x, pts[pts.length - 1].x);
|
||||
const y = Math.min(pts[0].y, pts[pts.length - 1].y);
|
||||
const width = Math.abs(pts[pts.length - 1].x - pts[0].x);
|
||||
const height = Math.abs(pts[pts.length - 1].y - pts[0].y);
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case "circle": {
|
||||
if (pts.length < 2) return;
|
||||
const cx = pts[0].x;
|
||||
const cy = pts[0].y;
|
||||
const dx = pts[pts.length - 1].x - cx;
|
||||
const dy = pts[pts.length - 1].y - cy;
|
||||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
if (pts.length < 2) return;
|
||||
const start = pts[0];
|
||||
const end = pts[pts.length - 1];
|
||||
const angle = Math.atan2(end.y - start.y, end.x - start.x);
|
||||
const arrowLen = Math.max(10, shape.strokeWidth * 5);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x, start.y);
|
||||
ctx.lineTo(end.x, end.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Arrowhead
|
||||
ctx.fillStyle = shape.color;
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(end.x, end.y);
|
||||
ctx.lineTo(
|
||||
end.x - arrowLen * Math.cos(angle - Math.PI / 7),
|
||||
end.y - arrowLen * Math.sin(angle - Math.PI / 7)
|
||||
);
|
||||
ctx.lineTo(
|
||||
end.x - arrowLen * Math.cos(angle + Math.PI / 7),
|
||||
end.y - arrowLen * Math.sin(angle + Math.PI / 7)
|
||||
);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (!showAnnotations) return;
|
||||
|
||||
// Draw saved shapes for this frame (seeded from DB + newly saved this session)
|
||||
(savedShapesByFrameRef.current[frameNumber] ?? []).forEach((shape) => {
|
||||
renderShape(ctx, shape, w, h);
|
||||
});
|
||||
|
||||
// Draw current (in-progress) shape
|
||||
const current = drawingStateRef.current.currentShape;
|
||||
if (current && drawingStateRef.current.isDrawing) {
|
||||
renderShape(ctx, current, w, h);
|
||||
}
|
||||
}, [frameNumber, showAnnotations, renderShape]);
|
||||
|
||||
// Resize observer — stable, only recreated when redraw changes (which is rare now)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
redraw();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, [redraw]);
|
||||
|
||||
// Redraw whenever frame, saved shapes map, in-progress shape, or visibility changes
|
||||
useEffect(() => {
|
||||
redraw();
|
||||
}, [redraw, savedShapesByFrame, drawingState.currentShape, showAnnotations]);
|
||||
|
||||
// ── Mouse events ─────────────────────────────────────────────────────────
|
||||
const handleMouseDown = useCallback(
|
||||
(e: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isAnnotating) return;
|
||||
const point = toNormalized(e);
|
||||
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 handleMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingStateRef.current.isDrawing || !drawingStateRef.current.currentShape) return;
|
||||
const point = toNormalized(e);
|
||||
setDrawingState((prev) => ({
|
||||
...prev,
|
||||
currentShape: prev.currentShape
|
||||
? { ...prev.currentShape, points: [...prev.currentShape.points, point] }
|
||||
: null,
|
||||
}));
|
||||
redraw();
|
||||
},
|
||||
[redraw]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(async () => {
|
||||
const state = drawingStateRef.current;
|
||||
if (!state.isDrawing || !state.currentShape) return;
|
||||
|
||||
const finishedShape = state.currentShape;
|
||||
// Clear the in-progress drawing state
|
||||
setDrawingState((prev) => ({
|
||||
...prev,
|
||||
isDrawing: false,
|
||||
currentShape: null,
|
||||
shapes: [],
|
||||
}));
|
||||
|
||||
// Immediately persist shape into the per-frame cache so it survives frame navigation
|
||||
setSavedShapesByFrame((prev) => ({
|
||||
...prev,
|
||||
[frameNumber]: [...(prev[frameNumber] ?? []), finishedShape],
|
||||
}));
|
||||
|
||||
// Save to DB
|
||||
try {
|
||||
const canvas = canvasRef.current;
|
||||
const drawingData: AnnotationDrawingData = {
|
||||
shapes: [finishedShape],
|
||||
canvasWidth: canvas?.offsetWidth ?? 1920,
|
||||
canvasHeight: canvas?.offsetHeight ?? 1080,
|
||||
version: "1.0",
|
||||
};
|
||||
await saveAnnotation({
|
||||
versionId,
|
||||
frameNumber,
|
||||
drawingData,
|
||||
color: selectedColor,
|
||||
});
|
||||
|
||||
// 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({
|
||||
versionId,
|
||||
frameNumber,
|
||||
timestamp: frameNumber / fps,
|
||||
text: `✏️ Annotation at frame ${frameNumber}`,
|
||||
});
|
||||
setAnnotationCommentedFrames((prev) => new Set([...prev, frameNumber]));
|
||||
}
|
||||
|
||||
onAnnotationSaved?.(frameNumber);
|
||||
} catch {
|
||||
toast({
|
||||
title: "Failed to save annotation",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
|
||||
style={{
|
||||
cursor: isAnnotating ? "crosshair" : "default",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnnotationTool } from "@/types";
|
||||
import {
|
||||
Pencil,
|
||||
ArrowUpRight,
|
||||
Square,
|
||||
Circle,
|
||||
Minus,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const TOOLS: { tool: AnnotationTool; icon: React.ElementType; label: string }[] = [
|
||||
{ tool: "freehand", icon: Pencil, label: "Freehand" },
|
||||
{ tool: "arrow", icon: ArrowUpRight, label: "Arrow" },
|
||||
{ tool: "rectangle", icon: Square, label: "Rectangle" },
|
||||
{ tool: "circle", icon: Circle, label: "Circle" },
|
||||
];
|
||||
|
||||
const COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#f59e0b", label: "Amber" },
|
||||
{ value: "#22c55e", label: "Green" },
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#a855f7", label: "Purple" },
|
||||
{ value: "#ffffff", label: "White" },
|
||||
{ value: "#000000", label: "Black" },
|
||||
];
|
||||
|
||||
export function AnnotationTools() {
|
||||
const {
|
||||
isAnnotating,
|
||||
selectedTool,
|
||||
selectedColor,
|
||||
strokeWidth,
|
||||
setSelectedTool,
|
||||
setSelectedColor,
|
||||
setStrokeWidth,
|
||||
setAnnotating,
|
||||
} = useReviewStore();
|
||||
|
||||
if (!isAnnotating) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-zinc-900/95 border-b border-white/5">
|
||||
{/* Tool selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
{TOOLS.map(({ tool, icon: Icon, label }) => (
|
||||
<Tooltip key={tool}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
selectedTool === tool &&
|
||||
"bg-primary/20 text-primary hover:bg-primary/30"
|
||||
)}
|
||||
onClick={() => setSelectedTool(tool)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-white/10" />
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<Tooltip key={c.value}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"h-5 w-5 rounded-full border-2 transition-transform hover:scale-110",
|
||||
selectedColor === c.value
|
||||
? "border-white scale-110"
|
||||
: "border-transparent"
|
||||
)}
|
||||
style={{ backgroundColor: c.value }}
|
||||
onClick={() => setSelectedColor(c.value)}
|
||||
title={c.label}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-white/10" />
|
||||
|
||||
{/* Stroke width */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white h-6 w-6"
|
||||
onClick={() => setStrokeWidth(Math.max(1, strokeWidth - 1))}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Thinner</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className="rounded-full bg-current"
|
||||
style={{
|
||||
width: Math.min(16, strokeWidth * 3),
|
||||
height: Math.min(16, strokeWidth * 3),
|
||||
backgroundColor: selectedColor,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white h-6 w-6"
|
||||
onClick={() => setStrokeWidth(Math.min(8, strokeWidth + 1))}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Thicker</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<span className="text-xs text-zinc-500 font-mono w-4">{strokeWidth}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { TaskList } from "@/components/tasks/TaskList";
|
||||
import { NewTaskDialog } from "@/components/tasks/NewTaskDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Package,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { ShotStatus } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
ShotStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" },
|
||||
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20" },
|
||||
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20" },
|
||||
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20" },
|
||||
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" },
|
||||
};
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500", NORMAL: "bg-blue-500", HIGH: "bg-amber-500", URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AssetCardProps {
|
||||
asset: {
|
||||
id: string;
|
||||
assetCode: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: ShotStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
lead?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||
tasks: any[];
|
||||
_count?: { tasks: number };
|
||||
};
|
||||
projectId: string;
|
||||
artists: Artist[];
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
export function AssetCard({ asset, projectId, artists, canManage = false }: AssetCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const statusCfg = STATUS_CONFIG[asset.status] ?? STATUS_CONFIG.WAITING;
|
||||
const doneTasks = asset.tasks.filter((t: any) => t.status === "DONE").length;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-zinc-800/50 transition-colors text-left"
|
||||
>
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full shrink-0", PRIORITY_DOT[asset.priority] ?? "bg-zinc-500")} />
|
||||
|
||||
<Package className="h-4 w-4 text-zinc-400 shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-zinc-300">{asset.assetCode}</span>
|
||||
<span className="text-sm text-white">{asset.name}</span>
|
||||
</div>
|
||||
{asset.description && (
|
||||
<p className="text-xs text-zinc-500 truncate">{asset.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{asset.tasks.length > 0 && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
<CheckCircle2 className="h-3 w-3 inline mr-1" />
|
||||
{doneTasks}/{asset.tasks.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Badge className={cn("text-xs border px-1.5 py-0 h-5", statusCfg.color)}>
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
|
||||
{asset.dueDate && (
|
||||
<span className="text-xs text-zinc-500 hidden sm:flex items-center gap-1">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{formatDistanceToNow(new Date(asset.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{asset.lead && (
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={asset.lead.image ?? undefined} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(asset.lead.name ?? asset.lead.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-zinc-500" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded task list */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-border bg-zinc-900/50">
|
||||
<TaskList
|
||||
tasks={asset.tasks}
|
||||
projectId={projectId}
|
||||
assetId={asset.id}
|
||||
artists={artists}
|
||||
canManage={canManage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createAsset } from "@/actions/assets";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { ShotPriority } from "@prisma/client";
|
||||
|
||||
const assetSchema = z.object({
|
||||
assetCode: z
|
||||
.string()
|
||||
.min(1, "Asset code is required")
|
||||
.max(30)
|
||||
.regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore only"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
priority: z.nativeEnum(ShotPriority),
|
||||
dueDate: z.string().optional(),
|
||||
});
|
||||
|
||||
type AssetFormValues = z.infer<typeof assetSchema>;
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface NewAssetDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function NewAssetDialog({ projectId, open, onClose, onSuccess }: NewAssetDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<AssetFormValues>({
|
||||
resolver: zodResolver(assetSchema),
|
||||
defaultValues: { priority: "NORMAL" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: AssetFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createAsset({ projectId, status: "WAITING", ...data });
|
||||
toast({ title: "Asset created" });
|
||||
reset();
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to create asset",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Asset</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="assetCode">Asset Code *</Label>
|
||||
<Input
|
||||
id="assetCode"
|
||||
placeholder="CAR_01"
|
||||
className="uppercase"
|
||||
{...register("assetCode")}
|
||||
/>
|
||||
{errors.assetCode && (
|
||||
<p className="text-xs text-destructive">{errors.assetCode.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
defaultValue="NORMAL"
|
||||
onValueChange={(v) => setValue("priority", v as ShotPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="NORMAL">Normal</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="URGENT">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">Asset Name *</Label>
|
||||
<Input id="name" placeholder="Hero Car" {...register("name")} />
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" {...register("dueDate")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="What is this asset?"
|
||||
rows={2}
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating…" : "Create Asset"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const schema = z.object({
|
||||
company: z.string().min(1, "Company name is required"),
|
||||
contactPerson: z.string().min(1, "Contact person is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface NewClientDialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function NewClientDialog({ children }: NewClientDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { company: "", contactPerson: "", email: "", phone: "", notes: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/clients", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.error ?? "Failed to create client");
|
||||
}
|
||||
const data = await res.json();
|
||||
toast({ title: `Client "${data.company}" created` });
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to create client",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Client</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="company">Company *</Label>
|
||||
<Input id="company" placeholder="Acme Productions" {...register("company")} />
|
||||
{errors.company && <p className="text-xs text-red-400">{errors.company.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contactPerson">Contact Person *</Label>
|
||||
<Input id="contactPerson" placeholder="Jane Smith" {...register("contactPerson")} />
|
||||
{errors.contactPerson && <p className="text-xs text-red-400">{errors.contactPerson.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email *</Label>
|
||||
<Input id="email" type="email" placeholder="jane@acme.com" {...register("email")} />
|
||||
{errors.email && <p className="text-xs text-red-400">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
<Input id="phone" type="tel" placeholder="+1 (555) 000-0000" {...register("phone")} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea id="notes" placeholder="Any additional information..." className="resize-none" rows={3} {...register("notes")} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Creating..." : "Create Client"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, Copy, Check, Trash2, Clock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { cn, formatRelativeDate } from "@/lib/utils";
|
||||
|
||||
interface ReviewSession {
|
||||
id: string;
|
||||
token: string;
|
||||
label: string | null;
|
||||
email: string | null;
|
||||
expiresAt: Date | string | null;
|
||||
accessCount: number;
|
||||
isActive: boolean;
|
||||
project: { name: string };
|
||||
}
|
||||
|
||||
interface ReviewSessionListProps {
|
||||
sessions: ReviewSession[];
|
||||
}
|
||||
|
||||
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||
|
||||
export function ReviewSessionList({ sessions }: ReviewSessionListProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const handleCopy = async (token: string, id: string) => {
|
||||
const url = `${APP_URL}/client/${token}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const handleDeactivate = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/review-sessions?id=${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error();
|
||||
toast({ title: "Review link deactivated" });
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({ title: "Failed to deactivate link", variant: "destructive" });
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white mb-3">
|
||||
Active Review Links ({sessions.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => {
|
||||
const portalUrl = `${APP_URL}/client/${session.token}`;
|
||||
const expired =
|
||||
session.expiresAt && new Date(session.expiresAt) < new Date();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn(
|
||||
"flex items-center gap-4 p-4 rounded-xl border transition-all",
|
||||
expired
|
||||
? "border-zinc-800 bg-zinc-900/50 opacity-60"
|
||||
: "border-zinc-800 bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-white text-sm">{session.label || "Untitled Review"}</p>
|
||||
{expired && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
|
||||
Expired
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-zinc-500">{session.project.name}</span>
|
||||
{session.email && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="text-xs text-zinc-500">{session.email}</span>
|
||||
</>
|
||||
)}
|
||||
{session.expiresAt && (
|
||||
<>
|
||||
<span className="text-zinc-700">·</span>
|
||||
<span className="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{expired ? "Expired" : `Expires ${formatRelativeDate(session.expiresAt)}`}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-mono text-xs text-zinc-600 truncate mt-1">{portalUrl}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-zinc-500 mr-2">
|
||||
{session.accessCount} view{session.accessCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 gap-1.5"
|
||||
onClick={() => handleCopy(session.token, session.id)}
|
||||
>
|
||||
{copiedId === session.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedId === session.id ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<a href={portalUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button size="sm" variant="outline" className="h-7 px-2">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-red-400 hover:bg-red-500/10 border-red-500/20"
|
||||
onClick={() => handleDeactivate(session.id)}
|
||||
disabled={deletingId === session.id}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const schema = z.object({
|
||||
projectId: z.string().min(1, "Select a project"),
|
||||
label: z.string().min(1, "Label is required"),
|
||||
email: z.string().email("Invalid email"),
|
||||
expiresInDays: z.number().int().positive().default(30),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface ShareReviewDialogProps {
|
||||
children: React.ReactNode;
|
||||
clientId: string;
|
||||
clientEmail: string;
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
export function ShareReviewDialog({
|
||||
children,
|
||||
clientEmail,
|
||||
projects,
|
||||
}: ShareReviewDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [portalUrl, setPortalUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
projectId: projects[0]?.id ?? "",
|
||||
label: "Review Round 1",
|
||||
email: clientEmail,
|
||||
expiresInDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!portalUrl) return;
|
||||
await navigator.clipboard.writeText(portalUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPortalUrl(null);
|
||||
reset({
|
||||
projectId: projects[0]?.id ?? "",
|
||||
label: "Review Round 1",
|
||||
email: clientEmail,
|
||||
expiresInDays: 30,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/review-sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error ?? "Failed to create review link");
|
||||
}
|
||||
const data = await res.json();
|
||||
setPortalUrl(data.portalUrl);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to create review link",
|
||||
description: e instanceof Error ? e.message : undefined,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) { setPortalUrl(null); }
|
||||
setOpen(o);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share Review Link</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{portalUrl ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Your review link is ready. Copy it and share it with your client.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<ExternalLink className="h-4 w-4 text-zinc-500 shrink-0" />
|
||||
<span className="flex-1 text-sm font-mono text-zinc-300 truncate">{portalUrl}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 gap-1.5 shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Create Another
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="projectId">Project *</Label>
|
||||
<Select
|
||||
defaultValue={watch("projectId")}
|
||||
onValueChange={(v) => setValue("projectId", v)}
|
||||
>
|
||||
<SelectTrigger id="projectId">
|
||||
<SelectValue placeholder="Select project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} <span className="text-zinc-500 text-xs ml-1">({p.code})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.projectId && <p className="text-xs text-red-400">{errors.projectId.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label *</Label>
|
||||
<Input id="label" placeholder="e.g. Review Round 1" {...register("label")} />
|
||||
{errors.label && <p className="text-xs text-red-400">{errors.label.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Client Email *</Label>
|
||||
<Input id="email" type="email" {...register("email")} />
|
||||
{errors.email && <p className="text-xs text-red-400">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="expiresInDays">Expires In (days)</Label>
|
||||
<Select
|
||||
defaultValue={String(watch("expiresInDays"))}
|
||||
onValueChange={(v) => setValue("expiresInDays", Number(v))}
|
||||
>
|
||||
<SelectTrigger id="expiresInDays">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[7, 14, 30, 60, 90].map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{d} days
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || projects.length === 0}>
|
||||
{loading ? "Generating..." : "Create Link"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import { frameToTimecode } from "@/lib/frame-utils";
|
||||
import {
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
MessageSquare,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
} from "lucide-react";
|
||||
import { addComment, addReply, resolveComment } from "@/actions/comments";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { CommentWithReplies } from "@/types";
|
||||
|
||||
interface CommentPanelProps {
|
||||
versionId: string;
|
||||
fps: number;
|
||||
comments: CommentWithReplies[];
|
||||
onCommentsChange?: () => void;
|
||||
pendingFrame?: number | null;
|
||||
onPendingFrameCleared?: () => void;
|
||||
onSeekToFrame?: (frame: number) => void;
|
||||
}
|
||||
|
||||
export function CommentPanel({
|
||||
versionId,
|
||||
fps,
|
||||
comments,
|
||||
onCommentsChange,
|
||||
pendingFrame,
|
||||
onPendingFrameCleared,
|
||||
onSeekToFrame,
|
||||
}: CommentPanelProps) {
|
||||
const { currentFrame, setCurrentFrame } = useReviewStore();
|
||||
const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [activeCommentFrame, setActiveCommentFrame] = useState<number | null>(null);
|
||||
const { toast } = useToast();
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Handle pending frame from "Add Comment" button click
|
||||
useEffect(() => {
|
||||
if (pendingFrame !== null && pendingFrame !== undefined) {
|
||||
setActiveCommentFrame(pendingFrame);
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
onPendingFrameCleared?.();
|
||||
}
|
||||
}, [pendingFrame, onPendingFrameCleared]);
|
||||
|
||||
const filteredComments = comments.filter((c) => {
|
||||
if (filterResolved === "unresolved") return !c.isResolved;
|
||||
if (filterResolved === "resolved") return c.isResolved;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!commentText.trim() || activeCommentFrame === null) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const timestamp = activeCommentFrame / fps;
|
||||
await addComment({
|
||||
versionId,
|
||||
frameNumber: activeCommentFrame,
|
||||
timestamp,
|
||||
text: commentText.trim(),
|
||||
});
|
||||
setCommentText("");
|
||||
setActiveCommentFrame(null);
|
||||
onCommentsChange?.();
|
||||
toast({ title: "Comment added", variant: "default" });
|
||||
} catch (err) {
|
||||
toast({ title: "Failed to add comment", variant: "destructive" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmitComment();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setActiveCommentFrame(null);
|
||||
setCommentText("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card border-l border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
Comments
|
||||
{comments.length > 0 && (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({comments.length})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["all", "unresolved", "resolved"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilterResolved(f)}
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded text-xs transition-colors",
|
||||
filterResolved === f
|
||||
? "bg-primary/20 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{f === "all" ? "All" : f === "unresolved" ? "Open" : "Resolved"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment Input */}
|
||||
{activeCommentFrame !== null ? (
|
||||
<div className="shrink-0 border-b border-border p-3 bg-primary/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="pending" className="text-xs font-mono">
|
||||
Frame {activeCommentFrame}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{frameToTimecode(activeCommentFrame, fps)}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add your feedback... (Ctrl+Enter to submit)"
|
||||
className="min-h-[80px] text-sm bg-background/50"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<button
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setActiveCommentFrame(null); setCommentText(""); }}
|
||||
>
|
||||
Cancel (Esc)
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmitComment}
|
||||
disabled={isSubmitting || !commentText.trim()}
|
||||
>
|
||||
<Send className="h-3 w-3 mr-1" />
|
||||
{isSubmitting ? "Posting..." : "Post"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 px-3 py-2 border-b border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => setActiveCommentFrame(currentFrame)}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3 mr-2" />
|
||||
Comment at frame {currentFrame}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment List */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-3 space-y-3">
|
||||
{filteredComments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<MessageSquare className="h-8 w-8 mb-3 opacity-30" />
|
||||
<p className="text-sm">No comments yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Pause the video and click "Add Comment"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredComments.map((comment) => (
|
||||
<CommentThread
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
fps={fps}
|
||||
isActive={comment.frameNumber === currentFrame}
|
||||
onJumpToFrame={(frame) => {
|
||||
setCurrentFrame(frame);
|
||||
onSeekToFrame?.(frame);
|
||||
}}
|
||||
onResolved={onCommentsChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Individual Comment Thread ─────────────────────────────────────────────────
|
||||
|
||||
interface CommentThreadProps {
|
||||
comment: CommentWithReplies;
|
||||
fps: number;
|
||||
isActive: boolean;
|
||||
onJumpToFrame: (frame: number) => void;
|
||||
onResolved?: () => void;
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
comment,
|
||||
fps,
|
||||
isActive,
|
||||
onJumpToFrame,
|
||||
onResolved,
|
||||
}: CommentThreadProps) {
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleReply = async () => {
|
||||
if (!replyText.trim()) return;
|
||||
setIsSubmittingReply(true);
|
||||
try {
|
||||
await addReply(comment.id, replyText.trim());
|
||||
setReplyText("");
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: "Failed to post reply", variant: "destructive" });
|
||||
} finally {
|
||||
setIsSubmittingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleResolve = async () => {
|
||||
try {
|
||||
await resolveComment(comment.id, !comment.isResolved);
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: "Failed to update comment", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border/60 overflow-hidden transition-all",
|
||||
isActive && "border-primary/40 bg-primary/5",
|
||||
comment.isResolved && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{/* Comment body */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Avatar className="h-6 w-6 shrink-0 mt-0.5">
|
||||
{comment.author?.image && (
|
||||
<AvatarImage src={comment.author.image} alt={comment.author.name ?? ""} />
|
||||
)}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(comment.author?.name ?? null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">
|
||||
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
||||
</span>
|
||||
<button
|
||||
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
|
||||
onClick={() => onJumpToFrame(comment.frameNumber)}
|
||||
>
|
||||
F{String(comment.frameNumber).padStart(4, "0")}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatRelativeDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 mt-2 ml-8">
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={handleToggleResolve}
|
||||
>
|
||||
{comment.isResolved ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
<span className="text-emerald-500">Resolved</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle className="h-3 w-3" />
|
||||
<span>Resolve</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowReplies(!showReplies)}
|
||||
>
|
||||
{comment.replies.length > 0 && (
|
||||
<>
|
||||
{showReplies ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
|
||||
</>
|
||||
)}
|
||||
{comment.replies.length === 0 && "Reply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{showReplies && (
|
||||
<div className="border-t border-border/50 bg-background/30">
|
||||
{comment.replies.map((reply) => (
|
||||
<div key={reply.id} className="flex gap-2 px-3 py-2.5 border-b border-border/30 last:border-0">
|
||||
<Avatar className="h-5 w-5 shrink-0 mt-0.5">
|
||||
{reply.author?.image && (
|
||||
<AvatarImage src={reply.author.image} />
|
||||
)}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{getInitials(reply.author?.name ?? null)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{reply.author?.name ?? reply.author?.email ?? "Deleted User"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeDate(reply.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 text-foreground/80 whitespace-pre-wrap">
|
||||
{reply.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Reply input */}
|
||||
<div className="p-2">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleReply();
|
||||
}
|
||||
}}
|
||||
placeholder="Reply..."
|
||||
className="min-h-[48px] text-xs py-2 bg-background/50"
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
onClick={handleReply}
|
||||
disabled={isSubmittingReply || !replyText.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Film,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Upload,
|
||||
Bell,
|
||||
AtSign,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
createdAt: Date;
|
||||
user?: {
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities: ActivityItem[];
|
||||
}
|
||||
|
||||
const ACTIVITY_ICONS: Record<string, { icon: React.ElementType; color: string }> = {
|
||||
VERSION_UPLOADED: { icon: Upload, color: "text-blue-400 bg-blue-500/10" },
|
||||
FEEDBACK_ADDED: { icon: MessageSquare, color: "text-amber-400 bg-amber-500/10" },
|
||||
SHOT_APPROVED: { icon: CheckCircle2, color: "text-emerald-400 bg-emerald-500/10" },
|
||||
SHOT_REJECTED: { icon: XCircle, color: "text-red-400 bg-red-500/10" },
|
||||
COMMENT_REPLY: { icon: MessageSquare, color: "text-purple-400 bg-purple-500/10" },
|
||||
MENTION: { icon: AtSign, color: "text-primary bg-primary/10" },
|
||||
REVISION_REQUESTED: { icon: Film, color: "text-orange-400 bg-orange-500/10" },
|
||||
};
|
||||
|
||||
export function RecentActivity({ activities }: RecentActivityProps) {
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-zinc-500">
|
||||
<Bell className="h-8 w-8 mb-3 opacity-30" />
|
||||
<p className="text-sm">No recent activity</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-1 p-1">
|
||||
{activities.map((item) => {
|
||||
const cfg = ACTIVITY_ICONS[item.type] ?? { icon: Bell, color: "text-muted-foreground bg-muted/20" };
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex gap-3 px-2 py-2.5 rounded-lg hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className={cn("p-1.5 rounded-md shrink-0 mt-0.5", cfg.color.split(" ")[1])}>
|
||||
<Icon className={cn("h-3 w-3", cfg.color.split(" ")[0])} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{item.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{item.user && (
|
||||
<Avatar className="h-5 w-5">
|
||||
{item.user.image && <AvatarImage src={item.user.image} />}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{getInitials(item.user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatRelativeDate(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { format, addDays, startOfWeek, differenceInDays, parseISO } from "date-fns";
|
||||
import { CalendarDays, AlertTriangle, Clock, Users, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
|
||||
function toDate(val: string | null | undefined): Date | null {
|
||||
if (!val) return null;
|
||||
try {
|
||||
return parseISO(val);
|
||||
} catch {
|
||||
return new Date(val);
|
||||
}
|
||||
}
|
||||
|
||||
interface ScheduleTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate: string | null;
|
||||
estimatedHours: number | null;
|
||||
scheduledStartDate: string | null;
|
||||
scheduledEndDate: string | null;
|
||||
assignedArtistId: string | null;
|
||||
assignedArtist: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
} | null;
|
||||
shot: { shotCode: string } | null;
|
||||
asset: { assetCode: string } | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
}
|
||||
|
||||
interface ScheduleWidgetsProps {
|
||||
scheduledTasks: ScheduleTask[];
|
||||
artists: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
role: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
function calcArtistLoad(
|
||||
tasks: ScheduleTask[],
|
||||
artistId: string,
|
||||
weekStart: Date
|
||||
): number {
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
|
||||
let totalHours = 0;
|
||||
|
||||
for (const task of artistTasks) {
|
||||
const start = toDate(task.scheduledStartDate);
|
||||
const end = toDate(task.scheduledEndDate) ?? start;
|
||||
if (!start || !end) continue;
|
||||
|
||||
// Check overlap with week
|
||||
if (start > weekEnd || end < weekStart) continue;
|
||||
|
||||
const dur = Math.max(1, differenceInDays(end, start) + 1);
|
||||
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
|
||||
|
||||
// Count days in this week
|
||||
let daysInWeek = 0;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = addDays(weekStart, i);
|
||||
if (day >= start && day <= end) daysInWeek++;
|
||||
}
|
||||
|
||||
totalHours += hoursPerDay * daysInWeek;
|
||||
}
|
||||
|
||||
return totalHours;
|
||||
}
|
||||
|
||||
export function ScheduleWidgets({
|
||||
scheduledTasks,
|
||||
artists,
|
||||
}: ScheduleWidgetsProps) {
|
||||
const today = new Date();
|
||||
const weekStart = startOfWeek(today, { weekStartsOn: 1 });
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
|
||||
// Tasks due this week
|
||||
const dueThisWeek = scheduledTasks.filter((t) => {
|
||||
const due = toDate(t.dueDate);
|
||||
return due && due >= today && due <= weekEnd && t.status !== "DONE";
|
||||
});
|
||||
|
||||
// Overloaded artists (> 40h this week = > 8h/day avg)
|
||||
const artistLoads = artists.map((a) => ({
|
||||
artist: a,
|
||||
hours: calcArtistLoad(scheduledTasks, a.id, weekStart),
|
||||
}));
|
||||
|
||||
const overloaded = artistLoads.filter((al) => al.hours > 40);
|
||||
|
||||
// Upcoming reviews
|
||||
const upcomingReviews = scheduledTasks.filter(
|
||||
(t) =>
|
||||
t.status === "INTERNAL_REVIEW" || t.status === "CLIENT_REVIEW"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Due This Week */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-zinc-200">
|
||||
Due This Week
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
|
||||
{dueThisWeek.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
|
||||
{dueThisWeek.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
No tasks due this week
|
||||
</div>
|
||||
) : (
|
||||
dueThisWeek.slice(0, 6).map((task) => {
|
||||
const code = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const cfg = TASK_STATUS_CONFIG[task.status as TaskStatus];
|
||||
const StatusIcon = cfg.icon;
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={`/tasks/${task.id}`}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{code && (
|
||||
<span className="text-[10px] font-mono text-zinc-500">
|
||||
{code}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-300 truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
cfg.color.split(" ").find((c) => c.startsWith("text-"))
|
||||
)}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
{task.dueDate && (
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{format(toDate(task.dueDate)!, "EEE, MMM d")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[8px] bg-zinc-700">
|
||||
{getInitials(
|
||||
task.assignedArtist.name ?? task.assignedArtist.email
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artist Utilization */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-zinc-200">
|
||||
This Week
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/schedule"
|
||||
className="text-[10px] text-zinc-500 hover:text-amber-400 flex items-center gap-0.5 transition-colors"
|
||||
>
|
||||
Schedule <ExternalLink className="h-2.5 w-2.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
|
||||
{artistLoads.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
No scheduled work this week
|
||||
</div>
|
||||
) : (
|
||||
artistLoads
|
||||
.filter((al) => al.hours > 0)
|
||||
.sort((a, b) => b.hours - a.hours)
|
||||
.slice(0, 6)
|
||||
.map(({ artist, hours }) => {
|
||||
const pct = Math.min(100, (hours / 40) * 100);
|
||||
const isOver = hours > 40;
|
||||
return (
|
||||
<div key={artist.id} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={artist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[8px] bg-zinc-700">
|
||||
{getInitials(artist.name ?? artist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-zinc-300 flex-1 truncate">
|
||||
{artist.name ?? artist.email.split("@")[0]}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium",
|
||||
isOver ? "text-orange-400" : "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{Math.round(hours)}h
|
||||
{isOver && " ⚠"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
isOver ? "bg-orange-500" : "bg-blue-500"
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Reviews */}
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-sm font-medium text-zinc-200">
|
||||
In Review
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">
|
||||
{upcomingReviews.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60 max-h-52 overflow-y-auto">
|
||||
{upcomingReviews.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-zinc-600">
|
||||
Nothing in review
|
||||
</div>
|
||||
) : (
|
||||
upcomingReviews.slice(0, 6).map((task) => {
|
||||
const code = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const isClient = task.status === "CLIENT_REVIEW";
|
||||
return (
|
||||
<Link
|
||||
key={task.id}
|
||||
href={`/tasks/${task.id}`}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||
isClient ? "bg-amber-400" : "bg-purple-400"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{code && (
|
||||
<span className="text-[10px] font-mono text-zinc-500">
|
||||
{code}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-300 truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
isClient ? "text-amber-500" : "text-purple-500"
|
||||
)}
|
||||
>
|
||||
{isClient ? "Client Review" : "Internal Review"}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Film,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
|
||||
interface ShotQueueProps {
|
||||
shots: ShotWithDetails[];
|
||||
projectId?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
WAITING: "text-zinc-400",
|
||||
IN_PROGRESS: "text-blue-400",
|
||||
IN_REVIEW: "text-purple-400",
|
||||
REVISIONS: "text-orange-400",
|
||||
COMPLETE: "text-emerald-400",
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, React.ElementType> = {
|
||||
WAITING: Clock,
|
||||
IN_PROGRESS: Film,
|
||||
IN_REVIEW: AlertCircle,
|
||||
REVISIONS: AlertCircle,
|
||||
COMPLETE: CheckCircle2,
|
||||
};
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
export function ShotQueue({ shots, projectId, title = "Shot Queue" }: ShotQueueProps) {
|
||||
if (shots.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Film className="h-8 w-8 mb-3 opacity-30" />
|
||||
<p className="text-sm">No shots yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{shots.map((shot) => {
|
||||
const StatusIcon = STATUS_ICONS[shot.status] ?? Clock;
|
||||
const latestVersion = shot.versions?.[0];
|
||||
const openComments = shot.versions
|
||||
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
|
||||
|
||||
const href = projectId
|
||||
? `/projects/${projectId}/shots/${shot.id}`
|
||||
: `/projects/${(shot as any).projectId ?? "#"}/shots/${shot.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={shot.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors group"
|
||||
>
|
||||
{/* Priority dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
PRIORITY_DOT[shot.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
title={shot.priority}
|
||||
/>
|
||||
|
||||
{/* Shot code */}
|
||||
<span className="font-mono text-xs text-muted-foreground w-32 shrink-0">
|
||||
{shot.shotCode}
|
||||
</span>
|
||||
|
||||
{/* Description / name */}
|
||||
<span className="flex-1 text-sm truncate text-foreground/90">
|
||||
{shot.description ?? shot.shotCode}
|
||||
</span>
|
||||
|
||||
{/* Open comments */}
|
||||
{openComments > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-400 shrink-0">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{openComments}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
STATUS_STYLES[shot.status] ?? "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">
|
||||
{shot.status.replace("_", " ")}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Artist */}
|
||||
{shot.artist && (
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
{shot.artist.image && <img src={shot.artist.image} alt="" />}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(shot.artist.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
{/* Open link */}
|
||||
<Link href={href}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { DashboardStats } from "@/types";
|
||||
|
||||
interface StatsCardsProps {
|
||||
stats: DashboardStats;
|
||||
}
|
||||
|
||||
export function StatsCards({ stats }: StatsCardsProps) {
|
||||
const cards = [
|
||||
{
|
||||
label: "Awaiting Review",
|
||||
value: stats.awaitingReview,
|
||||
icon: Clock,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
ring: "ring-amber-500/20",
|
||||
},
|
||||
{
|
||||
label: "Needs Revisions",
|
||||
value: stats.needsRevisions,
|
||||
icon: AlertCircle,
|
||||
color: "text-orange-400",
|
||||
bg: "bg-orange-500/10",
|
||||
ring: "ring-orange-500/20",
|
||||
},
|
||||
{
|
||||
label: "Approved",
|
||||
value: stats.approved,
|
||||
icon: CheckCircle2,
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
ring: "ring-emerald-500/20",
|
||||
},
|
||||
{
|
||||
label: "Overdue Tasks",
|
||||
value: stats.tasksOverdue ?? stats.overdue,
|
||||
icon: XCircle,
|
||||
color: "text-red-400",
|
||||
bg: "bg-red-500/10",
|
||||
ring: "ring-red-500/20",
|
||||
},
|
||||
{
|
||||
label: "Active Projects",
|
||||
value: stats.activeProjects,
|
||||
icon: TrendingUp,
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-500/10",
|
||||
ring: "ring-blue-500/20",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{cards.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Card key={card.label}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={cn("p-2 rounded-md", card.bg)}>
|
||||
<Icon className={cn("h-4 w-4", card.color)} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white">
|
||||
{card.value}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 mt-0.5">{card.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import {
|
||||
CalendarDays,
|
||||
Eye,
|
||||
ListTodo,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
shot?: { shotCode: string } | null;
|
||||
asset?: { assetCode: string } | null;
|
||||
project: { id: string; name: string; code: string };
|
||||
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: TaskRow }) {
|
||||
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||
const Icon = cfg.icon;
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) < new Date() &&
|
||||
task.status !== "DONE";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800/60 transition-colors group"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full shrink-0",
|
||||
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-200 group-hover:text-amber-400 transition-colors truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
{contextCode && (
|
||||
<span className="text-[10px] font-mono text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded shrink-0">
|
||||
{contextCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[11px] text-zinc-500">{TASK_TYPE_LABELS[task.type]}</span>
|
||||
<span className="text-[11px] text-zinc-700">·</span>
|
||||
<span className="text-[11px] text-zinc-500">{task.project.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-5 w-5">
|
||||
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
|
||||
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[10px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
className={cn("text-[10px] border px-1.5 py-0 h-4 gap-1 shrink-0", cfg.color)}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskWidgetsProps {
|
||||
myTasks: TaskRow[];
|
||||
reviewTasks: TaskRow[];
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function TaskWidgets({ myTasks, reviewTasks, role }: TaskWidgetsProps) {
|
||||
const isArtist = role === "ARTIST";
|
||||
const showReview = !isArtist && reviewTasks.length > 0;
|
||||
|
||||
if (myTasks.length === 0 && reviewTasks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-4", showReview ? "grid-cols-1 xl:grid-cols-2" : "grid-cols-1")}>
|
||||
{/* My Tasks */}
|
||||
{myTasks.length > 0 && (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListTodo className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-semibold text-zinc-200">My Tasks</span>
|
||||
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-2 py-0.5 font-mono">
|
||||
{myTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/tasks"
|
||||
className="text-xs text-zinc-500 hover:text-amber-400 transition-colors"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{myTasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks In Review (supervisors/producers) */}
|
||||
{showReview && (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-sm font-semibold text-zinc-200">Needs Review</span>
|
||||
<span className="text-xs text-zinc-600 bg-zinc-800 rounded-full px-2 py-0.5 font-mono">
|
||||
{reviewTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/tasks?status=INTERNAL_REVIEW"
|
||||
className="text-xs text-zinc-500 hover:text-amber-400 transition-colors"
|
||||
>
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{reviewTasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import { LogOut, User, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string;
|
||||
breadcrumbs?: { label: string; href?: string }[];
|
||||
}
|
||||
|
||||
export function Header({ title, breadcrumbs }: HeaderProps) {
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-zinc-800 bg-zinc-950 px-6">
|
||||
{/* Left: Title / Breadcrumbs */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{breadcrumbs ? (
|
||||
<nav className="flex items-center gap-1.5 text-sm">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span className="text-muted-foreground">/</span>}
|
||||
{crumb.href ? (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-foreground font-medium truncate">
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
title && (
|
||||
<h1 className="text-sm font-semibold truncate">{title}</h1>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Notifications + User menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationBell />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-secondary transition-colors">
|
||||
<Avatar className="h-7 w-7">
|
||||
{user?.image && <AvatarImage src={user.image} alt={user.name ?? ""} />}
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(user?.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="hidden sm:block text-left">
|
||||
<p className="text-xs font-medium leading-none">{user?.name ?? user?.email}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{user?.role?.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>
|
||||
<p className="font-medium truncate">{user?.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-normal truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive cursor-pointer"
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000, // 30s
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
{process.env.NODE_ENV === "development" && (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
)}
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { Josefin_Sans } from 'next/font/google';
|
||||
import { Montserrat } from 'next/font/google';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Users,
|
||||
UserCog,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ListTodo,
|
||||
CalendarRange,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/projects', label: 'Projects', icon: FolderOpen },
|
||||
{ href: '/tasks', label: 'My Tasks', icon: ListTodo, hideForClient: true },
|
||||
{ href: '/schedule', label: 'Schedule', icon: CalendarRange, adminOnly: true },
|
||||
{ href: '/clients', label: 'Clients', icon: Users, adminOnly: true },
|
||||
{ href: '/users', label: 'Users', icon: UserCog, adminOnly: true, adminStrictOnly: true },
|
||||
{ href: '/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
const josefin = Josefin_Sans({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400'],
|
||||
});
|
||||
const montserrat = Montserrat({
|
||||
subsets: ['latin'],
|
||||
weight: ['200', '500', '600'],
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { data: session } = useSession();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const isAdmin = ['ADMIN', 'PRODUCER'].includes(session?.user?.role ?? '');
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex flex-col border-r border-zinc-800 bg-zinc-900 transition-all duration-200',
|
||||
collapsed ? 'w-16' : 'w-60',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-5 border-b border-zinc-800',
|
||||
collapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-black">
|
||||
<Image src="/logo.svg" alt="Logo" width={32} height={32} />
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className={montserrat.className}>
|
||||
<span className="block text-2xl font-light text-white leading-none">
|
||||
TWO TALES
|
||||
</span>
|
||||
|
||||
<span className="block text-[11px] tracking-[0.18em] italic text-zinc-400 leading-none -mt-0.25">
|
||||
vfx review
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
if (item.adminOnly && !isAdmin) return null;
|
||||
if ((item as any).adminStrictOnly && session?.user?.role !== 'ADMIN') return null;
|
||||
if ((item as any).hideForClient && session?.user?.role === 'CLIENT')
|
||||
return null;
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
collapsed && 'justify-center px-0 w-full',
|
||||
isActive
|
||||
? 'bg-amber-500/10 text-amber-400'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-800',
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="border-t border-zinc-800 p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="w-full"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bell, Check, CheckCheck } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { formatRelativeDate } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
data: Record<string, string> | null;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const unreadCount = notifications.filter((n) => !n.isRead).length;
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/notifications");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data.notifications ?? []);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await fetch("/api/notifications", { method: "PATCH" });
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
const getNotificationHref = (n: Notification): string | null => {
|
||||
const data = n.data as Record<string, string> | null;
|
||||
if (data?.versionId) return `/review/${data.versionId}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
VERSION_UPLOADED: "🎬",
|
||||
FEEDBACK_ADDED: "💬",
|
||||
SHOT_APPROVED: "✅",
|
||||
SHOT_REJECTED: "❌",
|
||||
COMMENT_REPLY: "↩️",
|
||||
REVISION_REQUESTED: "⚠️",
|
||||
};
|
||||
return icons[type] ?? "🔔";
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => open && fetchNotifications()}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<DropdownMenuLabel className="p-0 text-sm font-semibold">
|
||||
Notifications
|
||||
</DropdownMenuLabel>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto py-0 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={markAllRead}
|
||||
>
|
||||
<CheckCheck className="h-3 w-3 mr-1" />
|
||||
Mark all read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-80">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Bell className="h-8 w-8 mb-2 opacity-30" />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{notifications.map((n) => {
|
||||
const href = getNotificationHref(n);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="text-base mt-0.5 shrink-0">
|
||||
{getNotificationIcon(n.type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn("text-xs leading-relaxed", !n.isRead && "font-medium")}>
|
||||
{n.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatRelativeDate(new Date(n.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
{!n.isRead && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary shrink-0 mt-1.5" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
"flex gap-3 px-4 py-3 hover:bg-secondary/50 transition-colors cursor-pointer border-b border-border/50 last:border-0",
|
||||
!n.isRead && "bg-primary/5"
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link key={n.id} href={href} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={n.id} className={className}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback, RefObject } from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { frameToPosition } from "@/lib/frame-utils";
|
||||
import type { CommentWithReplies } from "@/types";
|
||||
|
||||
interface FrameTimelineProps {
|
||||
fps: number;
|
||||
comments: CommentWithReplies[];
|
||||
annotations?: { frameNumber: number }[];
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
onSeek: (frame: number) => void;
|
||||
}
|
||||
|
||||
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const { currentFrame, totalFrames } = useReviewStore();
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "hsl(0 0% 6%)";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Frame ruler ticks
|
||||
if (totalFrames > 0) {
|
||||
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40)));
|
||||
|
||||
ctx.strokeStyle = "hsl(0 0% 20%)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = "hsl(0 0% 35%)";
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "center";
|
||||
|
||||
for (let f = 0; f <= totalFrames; f += tickInterval) {
|
||||
const x = Math.round((f / totalFrames) * W);
|
||||
const isMajor = f % (tickInterval * 5) === 0 || tickInterval > 20;
|
||||
|
||||
ctx.strokeStyle = isMajor ? "hsl(0 0% 22%)" : "hsl(0 0% 16%)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, isMajor ? 0 : H / 2);
|
||||
ctx.lineTo(x, H);
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor && f > 0) {
|
||||
ctx.fillStyle = "hsl(0 0% 35%)";
|
||||
ctx.fillText(String(f), x, 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Annotation markers — amber diamonds at top
|
||||
const seenAnnotationFrames = new Set<number>();
|
||||
annotations.forEach((ann) => {
|
||||
if (totalFrames === 0) return;
|
||||
if (seenAnnotationFrames.has(ann.frameNumber)) return;
|
||||
seenAnnotationFrames.add(ann.frameNumber);
|
||||
const x = Math.round(frameToPosition(ann.frameNumber, totalFrames) * W);
|
||||
// Amber diamond
|
||||
ctx.fillStyle = "hsl(38 92% 50%)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 2);
|
||||
ctx.lineTo(x + 4, 7);
|
||||
ctx.lineTo(x, 12);
|
||||
ctx.lineTo(x - 4, 7);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Comment markers — blue triangles pointing down from top
|
||||
comments.forEach((comment) => {
|
||||
if (totalFrames === 0) return;
|
||||
const x = Math.round(frameToPosition(comment.frameNumber, totalFrames) * W);
|
||||
|
||||
if (comment.isResolved) {
|
||||
ctx.fillStyle = "hsl(142 71% 45% / 0.7)";
|
||||
} else {
|
||||
ctx.fillStyle = "hsl(213 94% 68%)";
|
||||
}
|
||||
|
||||
// Triangle marker pointing down from top
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 4, 0);
|
||||
ctx.lineTo(x + 4, 0);
|
||||
ctx.lineTo(x, 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Playhead
|
||||
if (totalFrames > 0) {
|
||||
const playheadX = Math.round(
|
||||
frameToPosition(currentFrame, totalFrames) * W
|
||||
);
|
||||
|
||||
// Red line
|
||||
ctx.strokeStyle = "hsl(0 72% 51%)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadX, 0);
|
||||
ctx.lineTo(playheadX, H);
|
||||
ctx.stroke();
|
||||
|
||||
// Red diamond at top
|
||||
ctx.fillStyle = "hsl(0 72% 51%)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadX, H - 4);
|
||||
ctx.lineTo(playheadX - 5, H - 10);
|
||||
ctx.lineTo(playheadX, H - 16);
|
||||
ctx.lineTo(playheadX + 5, H - 10);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}, [currentFrame, totalFrames, comments, annotations]);
|
||||
|
||||
// Resize observer to match canvas to container
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
draw();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Seek on click/drag
|
||||
const getFrameFromEvent = useCallback(
|
||||
(e: React.MouseEvent | MouseEvent): number => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || totalFrames === 0) return 0;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
return Math.round((x / rect.width) * totalFrames);
|
||||
},
|
||||
[totalFrames]
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
isDragging.current = true;
|
||||
onSeek(getFrameFromEvent(e));
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
onSeek(getFrameFromEvent(e));
|
||||
},
|
||||
[getFrameFromEvent, onSeek]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="frame-timeline w-full cursor-ew-resize"
|
||||
style={{ height: 48 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Pencil,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { frameToTimecode } from "@/lib/frame-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
fps: number;
|
||||
isReversing: boolean;
|
||||
onStepBackward: () => void;
|
||||
onStepForward: () => void;
|
||||
onReverse: () => void;
|
||||
onTogglePlay: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onAddComment: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackControls({
|
||||
videoRef,
|
||||
fps,
|
||||
isReversing,
|
||||
onStepBackward,
|
||||
onStepForward,
|
||||
onReverse,
|
||||
onTogglePlay,
|
||||
onToggleFullscreen,
|
||||
onAddComment,
|
||||
}: PlaybackControlsProps) {
|
||||
const {
|
||||
isPlaying,
|
||||
currentFrame,
|
||||
currentTime,
|
||||
totalFrames,
|
||||
playbackRate,
|
||||
isAnnotating,
|
||||
showAnnotations,
|
||||
setPlaybackRate,
|
||||
setAnnotating,
|
||||
setShowAnnotations,
|
||||
} = useReviewStore();
|
||||
|
||||
const handleRateChange = (val: string) => {
|
||||
const rate = parseFloat(val);
|
||||
if (videoRef.current) videoRef.current.playbackRate = rate;
|
||||
setPlaybackRate(rate);
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 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">
|
||||
{timecode}
|
||||
</span>
|
||||
<span className="text-white font-semibold">
|
||||
F{String(currentFrame).padStart(4, "0")}
|
||||
</span>
|
||||
<span className="text-zinc-600 hidden md:block">
|
||||
/ {totalFrames}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-white/10 mx-1" />
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
if (videoRef.current) videoRef.current.currentTime = 0;
|
||||
}}
|
||||
>
|
||||
<ChevronFirst className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go to start</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
isReversing && "text-amber-400 bg-amber-400/10"
|
||||
)}
|
||||
onClick={onReverse}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reverse (J)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={onStepBackward}
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Step back (←)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Play/Pause — slightly larger */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:text-white hover:bg-white/10 h-9 w-9"
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5 fill-current" />
|
||||
) : (
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Play / Pause (K or Space)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={onStepForward}
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Step forward (→)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
if (videoRef.current)
|
||||
videoRef.current.currentTime = videoRef.current.duration;
|
||||
}}
|
||||
>
|
||||
<ChevronLast className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go to end</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Playback speed */}
|
||||
<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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYBACK_RATES.map((r) => (
|
||||
<SelectItem key={r} value={String(r)} className="text-xs">
|
||||
{r}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Annotation toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
isAnnotating && "text-amber-400 bg-amber-400/10"
|
||||
)}
|
||||
onClick={() => setAnnotating(!isAnnotating)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isAnnotating ? "Stop drawing" : "Draw annotation"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Show/hide annotations */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => setShowAnnotations(!showAnnotations)}
|
||||
>
|
||||
{showAnnotations ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showAnnotations ? "Hide annotations" : "Show annotations"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add Comment */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-primary"
|
||||
onClick={onAddComment}
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add comment at frame {currentFrame}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Fullscreen (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas";
|
||||
import { AnnotationTools } from "@/components/annotations/AnnotationTools";
|
||||
import { FrameTimeline } from "./FrameTimeline";
|
||||
import { PlaybackControls } from "./PlaybackControls";
|
||||
import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentWithReplies } from "@/types";
|
||||
|
||||
export interface ReviewPlayerRef {
|
||||
seekToFrame: (frame: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
}
|
||||
|
||||
interface ReviewPlayerProps {
|
||||
videoUrl: string;
|
||||
versionId: string;
|
||||
fps?: number;
|
||||
comments?: CommentWithReplies[];
|
||||
annotations?: unknown[];
|
||||
className?: string;
|
||||
onAddComment?: (frameNumber: number, timestamp: number) => void;
|
||||
onAnnotationSaved?: (frameNumber: number) => void;
|
||||
}
|
||||
|
||||
export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
function ReviewPlayer(
|
||||
{
|
||||
videoUrl,
|
||||
versionId,
|
||||
fps = 24,
|
||||
comments = [],
|
||||
annotations,
|
||||
className,
|
||||
onAddComment,
|
||||
onAnnotationSaved,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
// Stabilise the annotations reference so AnnotationCanvas's sync effect
|
||||
// only fires when the actual content changes, not on every parent re-render.
|
||||
const stableAnnotations = useMemo(() => annotations ?? [], [annotations]);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [isReversing, setIsReversing] = useState(false);
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
currentFrame,
|
||||
isAnnotating,
|
||||
showAnnotations,
|
||||
setPlaying,
|
||||
setCurrentFrame,
|
||||
setCurrentTime,
|
||||
setDuration,
|
||||
setFps,
|
||||
setTotalFrames,
|
||||
} = useReviewStore();
|
||||
|
||||
// Initialize player config from props
|
||||
useEffect(() => {
|
||||
setFps(fps);
|
||||
}, [fps, setFps]);
|
||||
|
||||
// ── Playback state sync ──────────────────────────────────────────────────
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const frame = timeToFrame(video.currentTime, fps);
|
||||
setCurrentFrame(frame);
|
||||
setCurrentTime(video.currentTime);
|
||||
}, [fps, setCurrentFrame, setCurrentTime]);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const frames = durationToFrameCount(video.duration, fps);
|
||||
setDuration(video.duration);
|
||||
setTotalFrames(frames);
|
||||
}, [fps, setDuration, setTotalFrames]);
|
||||
|
||||
const handlePlay = useCallback(() => setPlaying(true), [setPlaying]);
|
||||
const handlePause = useCallback(() => setPlaying(false), [setPlaying]);
|
||||
|
||||
// ── Exposed ref API ──────────────────────────────────────────────────────
|
||||
useImperativeHandle(ref, () => ({
|
||||
seekToFrame: (frame: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.currentTime = frameToTime(frame, fps);
|
||||
if (!video.paused) video.pause();
|
||||
},
|
||||
play: () => videoRef.current?.play(),
|
||||
pause: () => videoRef.current?.pause(),
|
||||
}));
|
||||
|
||||
// ── Frame step functions ─────────────────────────────────────────────────
|
||||
const stepFrame = useCallback(
|
||||
(delta: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
stopReverse();
|
||||
const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps));
|
||||
video.currentTime = newTime;
|
||||
},
|
||||
[fps]
|
||||
);
|
||||
|
||||
const stopReverse = useCallback(() => {
|
||||
if (reverseIntervalRef.current) {
|
||||
clearInterval(reverseIntervalRef.current);
|
||||
reverseIntervalRef.current = null;
|
||||
}
|
||||
setIsReversing(false);
|
||||
}, []);
|
||||
|
||||
const startReverse = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
setIsReversing(true);
|
||||
reverseIntervalRef.current = setInterval(() => {
|
||||
if (!videoRef.current || videoRef.current.currentTime <= 0) {
|
||||
stopReverse();
|
||||
return;
|
||||
}
|
||||
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / fps);
|
||||
}, 1000 / fps);
|
||||
}, [fps, stopReverse]);
|
||||
|
||||
const togglePlayback = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
stopReverse();
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [stopReverse]);
|
||||
|
||||
// ── Keyboard shortcuts (JKL + arrows + space + F) ───────────────────────
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
)
|
||||
return;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "j":
|
||||
e.preventDefault();
|
||||
if (isReversing) {
|
||||
stopReverse();
|
||||
} else {
|
||||
startReverse();
|
||||
}
|
||||
break;
|
||||
case "k":
|
||||
e.preventDefault();
|
||||
if (isReversing) {
|
||||
stopReverse();
|
||||
} else {
|
||||
togglePlayback();
|
||||
}
|
||||
break;
|
||||
case "l":
|
||||
e.preventDefault();
|
||||
stopReverse();
|
||||
if (videoRef.current?.paused) videoRef.current.play();
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
stepFrame(-1);
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
stepFrame(1);
|
||||
break;
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
stopReverse();
|
||||
togglePlayback();
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
stopReverse();
|
||||
};
|
||||
}, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]);
|
||||
|
||||
// ── Fullscreen ───────────────────────────────────────────────────────────
|
||||
const toggleFullscreen = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(frame: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
stopReverse();
|
||||
video.currentTime = frameToTime(frame, fps);
|
||||
video.pause();
|
||||
},
|
||||
[fps, stopReverse]
|
||||
);
|
||||
|
||||
const handleAddComment = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.pause();
|
||||
onAddComment?.(currentFrame, video.currentTime);
|
||||
}, [currentFrame, onAddComment]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"review-player-container flex flex-col bg-black select-none h-full",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Video */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="block w-full h-full object-contain"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* Annotation canvas overlay */}
|
||||
<AnnotationCanvas
|
||||
versionId={versionId}
|
||||
frameNumber={currentFrame}
|
||||
fps={fps}
|
||||
isAnnotating={isAnnotating && !isPlaying}
|
||||
showAnnotations={showAnnotations}
|
||||
existingAnnotations={stableAnnotations}
|
||||
onAnnotationSaved={onAnnotationSaved}
|
||||
/>
|
||||
|
||||
{/* JKL hint overlay — shown briefly when reversing */}
|
||||
{isReversing && (
|
||||
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
||||
◄◄ REVERSE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Annotation Tools (visible when annotation mode is active) */}
|
||||
<AnnotationTools />
|
||||
|
||||
{/* Frame Timeline */}
|
||||
<FrameTimeline
|
||||
fps={fps}
|
||||
comments={comments}
|
||||
annotations={annotations as { frameNumber: number }[]}
|
||||
videoRef={videoRef}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<PlaybackControls
|
||||
videoRef={videoRef}
|
||||
fps={fps}
|
||||
isReversing={isReversing}
|
||||
onStepBackward={() => stepFrame(-1)}
|
||||
onStepForward={() => stepFrame(1)}
|
||||
onReverse={isReversing ? stopReverse : startReverse}
|
||||
onTogglePlay={togglePlayback}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onAddComment={handleAddComment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createProject } from "@/actions/projects";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const projectSchema = z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
code: z.string().min(2, "Project code required").regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
|
||||
showId: z.string().min(1, "Show ID required").max(10, "Max 10 chars").regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
|
||||
projectType: z.enum(["STANDARD", "EPISODIC"]).default("STANDARD"),
|
||||
description: z.string().optional(),
|
||||
clientId: z.string().optional(),
|
||||
deadline: z.string().optional(),
|
||||
});
|
||||
|
||||
type ProjectFormValues = z.infer<typeof projectSchema>;
|
||||
|
||||
interface NewProjectDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
clients?: { id: string; company: string }[];
|
||||
onSuccess?: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export function NewProjectDialog({
|
||||
open,
|
||||
onClose,
|
||||
clients = [],
|
||||
onSuccess,
|
||||
}: NewProjectDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ProjectFormValues>({
|
||||
resolver: zodResolver(projectSchema),
|
||||
defaultValues: { projectType: "STANDARD" },
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ProjectFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createProject({
|
||||
name: data.name,
|
||||
code: data.code.toUpperCase(),
|
||||
showId: data.showId.toUpperCase(),
|
||||
projectType: data.projectType,
|
||||
description: data.description,
|
||||
clientId: data.clientId || undefined,
|
||||
deadline: data.deadline ? new Date(data.deadline) : undefined,
|
||||
});
|
||||
toast({ title: "Project created" });
|
||||
reset();
|
||||
router.push(`/projects/${result.project.id}`);
|
||||
router.refresh();
|
||||
onSuccess?.(result.project.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to create project",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="name">Project Name *</Label>
|
||||
<Input id="name" placeholder="Stellar Montage" {...register("name")} />
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code">Code *</Label>
|
||||
<Input
|
||||
id="code"
|
||||
placeholder="NOVA-25"
|
||||
className="uppercase"
|
||||
{...register("code")}
|
||||
/>
|
||||
{errors.code && (
|
||||
<p className="text-xs text-destructive">{errors.code.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="showId">Show ID *</Label>
|
||||
<Input
|
||||
id="showId"
|
||||
placeholder="PRJX"
|
||||
className="uppercase"
|
||||
maxLength={10}
|
||||
{...register("showId")}
|
||||
/>
|
||||
{errors.showId && (
|
||||
<p className="text-xs text-destructive">{errors.showId.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">Used in shot codes (e.g. PRJX_SC010_0010)</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Project Type *</Label>
|
||||
<Select
|
||||
defaultValue="STANDARD"
|
||||
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="STANDARD">Standard</SelectItem>
|
||||
<SelectItem value="EPISODIC">Episodic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Brief description of this project..."
|
||||
{...register("description")}
|
||||
className="min-h-[70px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{clients.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client</Label>
|
||||
<Select onValueChange={(v) => setValue("clientId", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select client (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.company}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="deadline">Deadline</Label>
|
||||
<Input id="deadline" type="date" {...register("deadline")} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create Project"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn, getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import {
|
||||
Film,
|
||||
Layers,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Users,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
description?: string | null;
|
||||
deadline?: Date | null;
|
||||
createdAt: Date;
|
||||
client?: { company: string } | null;
|
||||
producer?: { name: string | null; image: string | null } | null;
|
||||
_count?: { shots: number };
|
||||
shotStats?: {
|
||||
total: number;
|
||||
approved: number;
|
||||
inProgress: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
ACTIVE: "bg-green-900/60 text-green-300",
|
||||
ON_HOLD: "bg-yellow-900/60 text-yellow-300",
|
||||
COMPLETED: "bg-blue-900/60 text-blue-300",
|
||||
ARCHIVED: "bg-zinc-800 text-zinc-500",
|
||||
};
|
||||
|
||||
export function ProjectCard({ project }: ProjectCardProps) {
|
||||
const total = project.shotStats?.total ?? project._count?.shots ?? 0;
|
||||
const approved = project.shotStats?.approved ?? 0;
|
||||
const progress = total > 0 ? Math.round((approved / total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Card className="group hover:border-zinc-700 transition-all flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-mono text-xs text-zinc-500">{project.code}</span>
|
||||
{project.client && (
|
||||
<span className="text-xs text-zinc-600">• {project.client.company}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-base leading-tight text-white">{project.name}</h3>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full border-0 font-medium shrink-0",
|
||||
STATUS_STYLES[project.status] ?? STATUS_STYLES.ACTIVE
|
||||
)}
|
||||
>
|
||||
{project.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-zinc-400 line-clamp-2 mt-1">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-3 space-y-3">
|
||||
{/* Progress */}
|
||||
{total > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Shot progress</span>
|
||||
<span className="text-foreground font-medium">{approved}/{total} approved</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
|
||||
<Layers className="h-3.5 w-3.5 text-zinc-400 mb-1" />
|
||||
<span className="font-semibold text-white">{total}</span>
|
||||
<span className="text-zinc-400">shots</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-400 mb-1" />
|
||||
<span className="font-semibold text-white">{approved}</span>
|
||||
<span className="text-zinc-400">done</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center rounded-lg bg-zinc-800 py-2">
|
||||
<Film className="h-3.5 w-3.5 text-blue-400 mb-1" />
|
||||
<span className="font-semibold text-white">
|
||||
{project.shotStats?.inProgress ?? 0}
|
||||
</span>
|
||||
<span className="text-zinc-400">active</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-2 border-t border-zinc-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-400">
|
||||
{project.producer ? (
|
||||
<>
|
||||
<Avatar className="h-5 w-5">
|
||||
{project.producer.image && <AvatarImage src={project.producer.image} />}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(project.producer.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{project.producer.name ?? "Producer"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatRelativeDate(project.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{project.deadline && (
|
||||
<span className="text-amber-400 ml-2">
|
||||
Due {formatRelativeDate(project.deadline)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" asChild className="h-7 text-xs gap-1">
|
||||
<Link href={`/projects/${project.id}`}>
|
||||
Open
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { updateProject } from "@/actions/projects";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Settings, Building2, Users, Webhook } from "lucide-react";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
code: z.string().min(1, "Code is required").max(20).regex(/^[A-Z0-9_\-]+$/i, "Alphanumeric, dash, underscore"),
|
||||
showId: z.string().min(1, "Show ID is required").max(10).regex(/^[A-Z0-9_]+$/i, "Letters, numbers, underscore only"),
|
||||
projectType: z.enum(["STANDARD", "EPISODIC"]),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["ACTIVE", "ON_HOLD", "COMPLETED", "ARCHIVED"]),
|
||||
clientId: z.string().optional(),
|
||||
producerId: z.string().optional(),
|
||||
supervisorId: z.string().optional(),
|
||||
dueDate: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
slackWebhook: z.string().optional(),
|
||||
slackChannel: z.string().optional(),
|
||||
});
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||
|
||||
interface Client {
|
||||
id: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface ProjectSettingsTabProps {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
showId: string;
|
||||
projectType: "STANDARD" | "EPISODIC";
|
||||
description: string | null;
|
||||
status: string;
|
||||
clientId: string | null;
|
||||
producerId: string | null;
|
||||
supervisorId: string | null;
|
||||
dueDate: Date | null;
|
||||
startDate: Date | null;
|
||||
slackWebhook: string | null;
|
||||
slackChannel: string | null;
|
||||
};
|
||||
clients: Client[];
|
||||
teamMembers: TeamMember[];
|
||||
}
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
function toDateInput(d: Date | null | undefined) {
|
||||
if (!d) return "";
|
||||
return new Date(d).toISOString().substring(0, 10);
|
||||
}
|
||||
|
||||
export function ProjectSettingsTab({ project, clients, teamMembers }: ProjectSettingsTabProps) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const producers = teamMembers.filter((m) => ["ADMIN", "PRODUCER"].includes(m.role));
|
||||
const supervisors = teamMembers.filter((m) => ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(m.role));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {
|
||||
name: project.name,
|
||||
code: project.code,
|
||||
showId: project.showId,
|
||||
projectType: project.projectType,
|
||||
description: project.description ?? "",
|
||||
status: project.status as SettingsFormValues["status"],
|
||||
clientId: project.clientId ?? NONE,
|
||||
producerId: project.producerId ?? NONE,
|
||||
supervisorId: project.supervisorId ?? NONE,
|
||||
dueDate: toDateInput(project.dueDate),
|
||||
startDate: toDateInput(project.startDate),
|
||||
slackWebhook: project.slackWebhook ?? "",
|
||||
slackChannel: project.slackChannel ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: SettingsFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateProject({
|
||||
id: project.id,
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
showId: data.showId,
|
||||
projectType: data.projectType,
|
||||
description: data.description || null,
|
||||
status: data.status as any,
|
||||
clientId: data.clientId === NONE ? null : data.clientId || null,
|
||||
producerId: data.producerId === NONE ? null : data.producerId || null,
|
||||
supervisorId: data.supervisorId === NONE ? null : data.supervisorId || null,
|
||||
dueDate: data.dueDate || null,
|
||||
startDate: data.startDate || null,
|
||||
slackWebhook: data.slackWebhook || null,
|
||||
slackChannel: data.slackChannel || null,
|
||||
});
|
||||
toast({ title: "Project settings saved" });
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to save settings",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 max-w-2xl">
|
||||
|
||||
{/* ── General ── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-zinc-400" />
|
||||
<h2 className="text-sm font-semibold text-zinc-200">General</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="name">Project Name</Label>
|
||||
<Input id="name" {...register("name")} />
|
||||
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="code">Project Code</Label>
|
||||
<Input id="code" className="uppercase font-mono" {...register("code")} />
|
||||
{errors.code && <p className="text-xs text-destructive">{errors.code.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
defaultValue={project.status}
|
||||
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"], { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ACTIVE">Active</SelectItem>
|
||||
<SelectItem value="ON_HOLD">On Hold</SelectItem>
|
||||
<SelectItem value="COMPLETED">Completed</SelectItem>
|
||||
<SelectItem value="ARCHIVED">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" className="min-h-[80px]" {...register("description")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="startDate">Start Date</Label>
|
||||
<Input id="startDate" type="date" {...register("startDate")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" {...register("dueDate")} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Shot Naming ── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-zinc-400" />
|
||||
<h2 className="text-sm font-semibold text-zinc-200">Shot Naming</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="showId">Show ID</Label>
|
||||
<Input id="showId" className="uppercase font-mono" maxLength={10} {...register("showId")} />
|
||||
{errors.showId && <p className="text-xs text-destructive">{errors.showId.message}</p>}
|
||||
<p className="text-xs text-muted-foreground">Used in shot codes, e.g. SHOWID_SC010_0010</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Project Type</Label>
|
||||
<Select
|
||||
defaultValue={project.projectType}
|
||||
onValueChange={(v) => setValue("projectType", v as "STANDARD" | "EPISODIC", { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="STANDARD">Standard</SelectItem>
|
||||
<SelectItem value="EPISODIC">Episodic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">Episodic adds an episode segment to shot codes</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Client & Team ── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-zinc-400" />
|
||||
<h2 className="text-sm font-semibold text-zinc-200">Client & Team</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label>Client</Label>
|
||||
<Select
|
||||
defaultValue={project.clientId ?? NONE}
|
||||
onValueChange={(v) => setValue("clientId", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No client assigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>No client</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.company}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Producer</Label>
|
||||
<Select
|
||||
defaultValue={project.producerId ?? NONE}
|
||||
onValueChange={(v) => setValue("producerId", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not assigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Not assigned</SelectItem>
|
||||
{producers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name ?? m.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Supervisor</Label>
|
||||
<Select
|
||||
defaultValue={project.supervisorId ?? NONE}
|
||||
onValueChange={(v) => setValue("supervisorId", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Not assigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>Not assigned</SelectItem>
|
||||
{supervisors.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name ?? m.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Integrations ── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4 text-zinc-400" />
|
||||
<h2 className="text-sm font-semibold text-zinc-200">Slack Integration</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="slackWebhook">Webhook URL</Label>
|
||||
<Input
|
||||
id="slackWebhook"
|
||||
type="url"
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
{...register("slackWebhook")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slackChannel">Channel</Label>
|
||||
<Input
|
||||
id="slackChannel"
|
||||
placeholder="#vfx-pipeline"
|
||||
{...register("slackChannel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="submit" disabled={isSubmitting || !isDirty} className="min-w-[120px]">
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useDroppable, useDraggable } from "@dnd-kit/core";
|
||||
import { format, parseISO, formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CalendarDays,
|
||||
Clock,
|
||||
GripVertical,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import {
|
||||
ScheduleTask,
|
||||
ActiveDragData,
|
||||
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
function toDate(val: string | null | undefined): Date | null {
|
||||
if (!val) return null;
|
||||
try {
|
||||
return parseISO(val);
|
||||
} catch {
|
||||
return new Date(val);
|
||||
}
|
||||
}
|
||||
|
||||
function BacklogItem({
|
||||
task,
|
||||
canEdit,
|
||||
}: {
|
||||
task: ScheduleTask;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: task.id,
|
||||
disabled: !canEdit,
|
||||
data: {
|
||||
type: "backlog",
|
||||
taskId: task.id,
|
||||
estimatedHours: task.estimatedHours,
|
||||
} as ActiveDragData,
|
||||
});
|
||||
|
||||
const cfg = TASK_STATUS_CONFIG[task.status];
|
||||
const StatusIcon = cfg.icon;
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const dueDate = toDate(task.dueDate);
|
||||
const isOverdue =
|
||||
dueDate && dueDate < new Date() && task.status !== "DONE";
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: 50,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group flex items-start gap-2 px-3 py-2.5 rounded-lg border transition-colors",
|
||||
"border-zinc-800 bg-zinc-900 hover:border-zinc-700 hover:bg-zinc-800/80",
|
||||
isDragging && "opacity-30 border-amber-500/40",
|
||||
canEdit && "cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Grip */}
|
||||
{canEdit && (
|
||||
<GripVertical className="h-3.5 w-3.5 text-zinc-600 shrink-0 mt-0.5 group-hover:text-zinc-400" />
|
||||
)}
|
||||
|
||||
{/* Priority dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full shrink-0 mt-1.5",
|
||||
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{contextCode && (
|
||||
<span className="text-[10px] font-mono bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded shrink-0">
|
||||
{contextCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-medium text-zinc-200 truncate">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
{TASK_TYPE_LABELS[task.type as TaskType]} · {task.project.code}
|
||||
</span>
|
||||
{task.estimatedHours && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-zinc-600">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
{task.estimatedHours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
"text-[9px] border px-1 py-0 h-3.5 gap-0.5 shrink-0",
|
||||
cfg.color
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-2 w-2" />
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
{dueDate && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 text-[10px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-600"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-2.5 w-2.5" />
|
||||
{formatDistanceToNow(dueDate, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BacklogPanelProps {
|
||||
tasks: ScheduleTask[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function BacklogPanel({ tasks, canEdit }: BacklogPanelProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "backlog-drop-zone",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border-l border-zinc-800 bg-zinc-900 transition-colors",
|
||||
"w-72 shrink-0"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-zinc-800 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-200">Backlog</span>
|
||||
<span className="text-xs text-zinc-500 bg-zinc-800 px-1.5 py-0.5 rounded-full">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
Drag onto timeline
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drop zone + list */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex-1 transition-colors",
|
||||
isOver && "bg-amber-500/5 ring-1 ring-inset ring-amber-500/20"
|
||||
)}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-zinc-600">
|
||||
<Layers className="h-8 w-8 mb-2 opacity-30" />
|
||||
<p className="text-xs">All tasks scheduled</p>
|
||||
{canEdit && (
|
||||
<p className="text-[10px] mt-1 text-zinc-700">
|
||||
Drop here to unschedule
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{canEdit && isOver && (
|
||||
<div className="flex items-center justify-center py-3 rounded-lg border border-dashed border-amber-500/40 text-amber-400 text-xs">
|
||||
Drop to unschedule
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((task) => (
|
||||
<BacklogItem key={task.id} task={task} canEdit={canEdit} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { addWeeks, subWeeks, addDays, format } from "date-fns";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, CalendarDays } from "lucide-react";
|
||||
import { ScheduleArtist } from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "TODO", label: "To Do" },
|
||||
{ value: "IN_PROGRESS", label: "In Progress" },
|
||||
{ value: "INTERNAL_REVIEW", label: "Internal Review" },
|
||||
{ value: "CLIENT_REVIEW", label: "Client Review" },
|
||||
{ value: "CHANGES", label: "Changes" },
|
||||
];
|
||||
|
||||
interface ScheduleFiltersProps {
|
||||
projects: { id: string; name: string; code: string }[];
|
||||
artists: ScheduleArtist[];
|
||||
filterProject: string;
|
||||
filterArtist: string;
|
||||
filterStatus: string;
|
||||
viewStart: Date;
|
||||
onProjectChange: (v: string) => void;
|
||||
onArtistChange: (v: string) => void;
|
||||
onStatusChange: (v: string) => void;
|
||||
onViewStartChange: (d: Date) => void;
|
||||
}
|
||||
|
||||
export function ScheduleFilters({
|
||||
projects,
|
||||
artists,
|
||||
filterProject,
|
||||
filterArtist,
|
||||
filterStatus,
|
||||
viewStart,
|
||||
onProjectChange,
|
||||
onArtistChange,
|
||||
onStatusChange,
|
||||
onViewStartChange,
|
||||
}: ScheduleFiltersProps) {
|
||||
const goPrev = () => onViewStartChange(subWeeks(viewStart, 1));
|
||||
const goNext = () => onViewStartChange(addWeeks(viewStart, 1));
|
||||
const goToday = () => {
|
||||
const today = new Date();
|
||||
const monday = addDays(today, -((today.getDay() + 6) % 7));
|
||||
onViewStartChange(monday);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-950 shrink-0 flex-wrap">
|
||||
{/* Week navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-zinc-400 hover:text-white"
|
||||
onClick={goPrev}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-zinc-400 hover:text-white gap-1.5"
|
||||
onClick={goToday}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-zinc-400 hover:text-white"
|
||||
onClick={goNext}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-zinc-500 ml-1">
|
||||
{format(viewStart, "MMM d")} – {format(addDays(viewStart, 34), "MMM d, yyyy")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-zinc-800" />
|
||||
|
||||
{/* Project filter */}
|
||||
<Select
|
||||
value={filterProject || "__all__"}
|
||||
onValueChange={(v) => onProjectChange(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||
<SelectValue placeholder="All projects" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
All projects
|
||||
</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id} className="text-xs">
|
||||
[{p.code}] {p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Artist filter */}
|
||||
<Select
|
||||
value={filterArtist || "__all__"}
|
||||
onValueChange={(v) => onArtistChange(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||
<SelectValue placeholder="All artists" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
All artists
|
||||
</SelectItem>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id} className="text-xs">
|
||||
{a.name ?? a.email.split("@")[0]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Status filter */}
|
||||
<Select
|
||||
value={filterStatus || "__all__"}
|
||||
onValueChange={(v) => onStatusChange(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-36 bg-zinc-900 border-zinc-700">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-900 border-zinc-700">
|
||||
<SelectItem value="__all__" className="text-xs">
|
||||
All statuses
|
||||
</SelectItem>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value} className="text-xs">
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(filterProject || filterArtist || filterStatus) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-zinc-500 hover:text-white"
|
||||
onClick={() => {
|
||||
onProjectChange("");
|
||||
onArtistChange("");
|
||||
onStatusChange("");
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import {
|
||||
format,
|
||||
isAfter,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarDays, Clock, ExternalLink, CalendarOff } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import {
|
||||
ScheduleTask,
|
||||
ActiveDragData,
|
||||
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
import { TASK_TYPE_LABELS } from "@/components/tasks/TaskCard";
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
TaskStatus,
|
||||
{ bg: string; border: string; text: string; dot: string }
|
||||
> = {
|
||||
TODO: {
|
||||
bg: "bg-zinc-800/80",
|
||||
border: "border-zinc-600/60",
|
||||
text: "text-zinc-300",
|
||||
dot: "bg-zinc-500",
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
bg: "bg-blue-900/50",
|
||||
border: "border-blue-600/60",
|
||||
text: "text-blue-200",
|
||||
dot: "bg-blue-400",
|
||||
},
|
||||
INTERNAL_REVIEW: {
|
||||
bg: "bg-purple-900/50",
|
||||
border: "border-purple-600/60",
|
||||
text: "text-purple-200",
|
||||
dot: "bg-purple-400",
|
||||
},
|
||||
CLIENT_REVIEW: {
|
||||
bg: "bg-amber-900/50",
|
||||
border: "border-amber-600/60",
|
||||
text: "text-amber-200",
|
||||
dot: "bg-amber-400",
|
||||
},
|
||||
CHANGES: {
|
||||
bg: "bg-orange-900/50",
|
||||
border: "border-orange-600/60",
|
||||
text: "text-orange-200",
|
||||
dot: "bg-orange-400",
|
||||
},
|
||||
DONE: {
|
||||
bg: "bg-emerald-900/50",
|
||||
border: "border-emerald-600/60",
|
||||
text: "text-emerald-200",
|
||||
dot: "bg-emerald-400",
|
||||
},
|
||||
};
|
||||
|
||||
function toDate(val: string | null | undefined): Date | null {
|
||||
if (!val) return null;
|
||||
try {
|
||||
return parseISO(val);
|
||||
} catch {
|
||||
return new Date(val);
|
||||
}
|
||||
}
|
||||
|
||||
interface ScheduleTaskBlockProps {
|
||||
task: ScheduleTask;
|
||||
dayIndex: number;
|
||||
duration: number;
|
||||
artistIndex: number;
|
||||
viewStart: Date;
|
||||
days: Date[];
|
||||
canEdit: boolean;
|
||||
isDragging: boolean;
|
||||
laneTop: number;
|
||||
taskHeight: number;
|
||||
onResizeMouseDown: (
|
||||
taskId: string,
|
||||
currentEndDate: string
|
||||
) => (e: React.MouseEvent) => void;
|
||||
onUnschedule: (taskId: string) => void;
|
||||
dayWidth: number;
|
||||
}
|
||||
|
||||
export function ScheduleTaskBlock({
|
||||
task,
|
||||
dayIndex,
|
||||
duration,
|
||||
canEdit,
|
||||
isDragging,
|
||||
laneTop,
|
||||
taskHeight,
|
||||
onResizeMouseDown,
|
||||
onUnschedule,
|
||||
dayWidth,
|
||||
}: ScheduleTaskBlockProps) {
|
||||
const router = useRouter();
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: task.id,
|
||||
disabled: !canEdit,
|
||||
data: {
|
||||
type: "scheduled",
|
||||
taskId: task.id,
|
||||
duration,
|
||||
} as ActiveDragData,
|
||||
});
|
||||
|
||||
const taskWidth = Math.max(20, (task.estimatedHours ?? 8) / 8 * dayWidth - 4);
|
||||
|
||||
const style = {
|
||||
position: "absolute" as const,
|
||||
left: Math.max(0, dayIndex) * dayWidth + 2,
|
||||
width: taskWidth,
|
||||
top: laneTop,
|
||||
height: taskHeight,
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
const statusStyle = STATUS_STYLES[task.status];
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
const contextName = task.shot?.shotCode ?? task.asset?.name ?? task.title;
|
||||
const thumbnail = task.shot?.thumbnailUrl ?? null;
|
||||
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
task.scheduledEndDate &&
|
||||
isAfter(
|
||||
toDate(task.scheduledEndDate)!,
|
||||
toDate(task.dueDate)!
|
||||
);
|
||||
|
||||
const dueDate = toDate(task.dueDate);
|
||||
const tooNarrow = (task.estimatedHours ?? 8) < 3; // < 3h = too narrow for labels
|
||||
const tooShort = taskHeight < 16;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"absolute rounded-md border flex items-center overflow-hidden select-none group",
|
||||
"transition-opacity duration-100",
|
||||
statusStyle.bg,
|
||||
statusStyle.border,
|
||||
isDragging && "opacity-30",
|
||||
canEdit && "cursor-grab active:cursor-grabbing",
|
||||
isOverdue && "ring-1 ring-red-500/50"
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 w-1 self-stretch rounded-l-md",
|
||||
statusStyle.dot
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Thumbnail */}
|
||||
{thumbnail && !tooNarrow && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
className="shrink-0 h-full object-cover"
|
||||
style={{ width: taskHeight * 2.39, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden px-1">
|
||||
{!tooShort && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-mono font-semibold truncate shrink-0 max-w-[50%]",
|
||||
statusStyle.text
|
||||
)}
|
||||
>
|
||||
{contextName}
|
||||
</span>
|
||||
)}
|
||||
{!tooNarrow && !tooShort && (
|
||||
<span className="text-[9px] text-zinc-400 truncate">
|
||||
{TASK_TYPE_LABELS[task.type as TaskType]}
|
||||
</span>
|
||||
)}
|
||||
{!tooNarrow && !tooShort && task.estimatedHours && (
|
||||
<span className="text-[9px] text-zinc-500 shrink-0 ml-auto">
|
||||
{task.estimatedHours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overdue indicator */}
|
||||
{isOverdue && (
|
||||
<div className="shrink-0 w-1.5 h-1.5 rounded-full bg-red-400 mr-1" />
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
{canEdit && task.scheduledEndDate && (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={onResizeMouseDown(task.id, task.scheduledEndDate)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-3/4 rounded-full bg-current opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-zinc-900 border-zinc-700 text-zinc-100 max-w-[240px]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium text-xs">
|
||||
{contextCode && (
|
||||
<span className="text-amber-400 mr-1">{contextCode}</span>
|
||||
)}
|
||||
{task.title}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-400">
|
||||
{TASK_TYPE_LABELS[task.type as TaskType]} ·{" "}
|
||||
{task.project.code}
|
||||
</div>
|
||||
{task.scheduledStartDate && task.scheduledEndDate && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{format(toDate(task.scheduledStartDate)!, "MMM d")} →{" "}
|
||||
{format(toDate(task.scheduledEndDate)!, "MMM d")}
|
||||
</div>
|
||||
)}
|
||||
{task.estimatedHours && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
{task.estimatedHours}h estimated
|
||||
</div>
|
||||
)}
|
||||
{dueDate && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[11px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-400"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
Due {format(dueDate, "MMM d")}
|
||||
{isOverdue && " — Late!"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent className="bg-zinc-900 border-zinc-700 text-zinc-100 w-48">
|
||||
<ContextMenuItem
|
||||
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-white"
|
||||
onSelect={() => router.push(`/tasks/${task.id}`)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-zinc-400" />
|
||||
View Task
|
||||
</ContextMenuItem>
|
||||
{canEdit && (
|
||||
<>
|
||||
<ContextMenuSeparator className="bg-zinc-800" />
|
||||
<ContextMenuItem
|
||||
className="gap-2 cursor-pointer focus:bg-zinc-800 focus:text-red-400 text-red-400"
|
||||
onSelect={() => onUnschedule(task.id)}
|
||||
>
|
||||
<CalendarOff className="h-3.5 w-3.5" />
|
||||
Unschedule
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, RefObject } from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
format,
|
||||
differenceInDays,
|
||||
startOfDay,
|
||||
isToday,
|
||||
isWeekend,
|
||||
isBefore,
|
||||
parseISO,
|
||||
addDays,
|
||||
} from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import { ScheduleTaskBlock } from "./ScheduleTaskBlock";
|
||||
import {
|
||||
ScheduleTask,
|
||||
ScheduleArtist,
|
||||
DAY_WIDTH,
|
||||
ROW_HEIGHT,
|
||||
HEADER_HEIGHT,
|
||||
} from "@/app/(dashboard)/schedule/SchedulePageClient";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
interface ResizePreview {
|
||||
taskId: string;
|
||||
endDate: Date;
|
||||
estimatedHours: number;
|
||||
}
|
||||
|
||||
interface ScheduleTimelineProps {
|
||||
artists: ScheduleArtist[];
|
||||
tasks: ScheduleTask[];
|
||||
days: Date[];
|
||||
viewStart: Date;
|
||||
canEdit: boolean;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
resizePreview: ResizePreview | null;
|
||||
onResizeMouseDown: (
|
||||
taskId: string,
|
||||
currentEndDate: string
|
||||
) => (e: React.MouseEvent) => void;
|
||||
activeDragId: string | null;
|
||||
onUnschedule: (taskId: string) => void;
|
||||
dayWidth: number;
|
||||
rowHeight: number;
|
||||
}
|
||||
|
||||
function toDate(val: string | null | undefined): Date | null {
|
||||
if (!val) return null;
|
||||
try {
|
||||
return parseISO(val);
|
||||
} catch {
|
||||
return new Date(val);
|
||||
}
|
||||
}
|
||||
|
||||
function getDayIndex(date: Date | null, viewStart: Date): number {
|
||||
if (!date) return -1;
|
||||
return differenceInDays(date, viewStart);
|
||||
}
|
||||
|
||||
/** Greedy lane assignment based on hour-ranges so task width = hours.
|
||||
* Two tasks for the same artist conflict when their [startHour, endHour]
|
||||
* ranges overlap. Each task occupies exactly one lane (fixed height).
|
||||
*/
|
||||
function computeLanes(
|
||||
tasks: ScheduleTask[],
|
||||
viewStart: Date
|
||||
): Map<string, number> {
|
||||
// Build hour ranges: startHour = dayIndex * 8, endHour = startHour + estimatedHours
|
||||
const ranges = tasks
|
||||
.filter((t) => t.scheduledStartDate)
|
||||
.map((t) => {
|
||||
const dayIdx = Math.max(
|
||||
0,
|
||||
differenceInDays(toDate(t.scheduledStartDate)!, viewStart)
|
||||
);
|
||||
const startHour = dayIdx * 8;
|
||||
const endHour = startHour + (t.estimatedHours ?? 8);
|
||||
return { id: t.id, startHour, endHour };
|
||||
})
|
||||
.sort((a, b) => a.startHour - b.startHour);
|
||||
|
||||
const laneEndHours: number[] = [];
|
||||
const result = new Map<string, number>();
|
||||
|
||||
for (const task of ranges) {
|
||||
let lane = laneEndHours.findIndex((end) => task.startHour >= end);
|
||||
if (lane === -1) lane = laneEndHours.length;
|
||||
laneEndHours[lane] = task.endHour;
|
||||
result.set(task.id, lane);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTaskDuration(task: ScheduleTask): number {
|
||||
return Math.max(1, Math.ceil((task.estimatedHours ?? 8) / 8));
|
||||
}
|
||||
|
||||
// Calculate daily load (hours) for each artist
|
||||
function calcDailyLoad(
|
||||
tasks: ScheduleTask[],
|
||||
artistId: string,
|
||||
days: Date[]
|
||||
): Record<string, number> {
|
||||
const result: Record<string, number> = {};
|
||||
const artistTasks = tasks.filter((t) => t.assignedArtistId === artistId);
|
||||
|
||||
for (const day of days) {
|
||||
const dayStr = format(day, "yyyy-MM-dd");
|
||||
let totalHours = 0;
|
||||
for (const task of artistTasks) {
|
||||
const start = toDate(task.scheduledStartDate);
|
||||
const end = toDate(task.scheduledEndDate) ?? start;
|
||||
if (!start || !end) continue;
|
||||
const dayStart = new Date(day);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(day);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
if (start <= dayEnd && end >= dayStart) {
|
||||
const dur = Math.max(1, differenceInDays(end, start) + 1);
|
||||
const hoursPerDay = (task.estimatedHours ?? 8) / dur;
|
||||
totalHours += hoursPerDay;
|
||||
}
|
||||
}
|
||||
result[dayStr] = totalHours;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ArtistLabel({
|
||||
artist,
|
||||
isOverloaded,
|
||||
rowHeight,
|
||||
}: {
|
||||
artist: ScheduleArtist;
|
||||
isOverloaded: boolean;
|
||||
rowHeight: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 border-b border-zinc-800 bg-zinc-900"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
<Avatar className="h-7 w-7 shrink-0">
|
||||
<AvatarImage src={artist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[10px] bg-zinc-700 text-zinc-300">
|
||||
{getInitials(artist.name ?? artist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-zinc-200 truncate">
|
||||
{artist.name ?? artist.email.split("@")[0]}
|
||||
</span>
|
||||
{isOverloaded && (
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 capitalize">
|
||||
{artist.role.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineDropZone({ id }: { id: string }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"absolute inset-0 transition-colors pointer-events-none",
|
||||
isOver && "bg-amber-500/5"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleTimeline({
|
||||
artists,
|
||||
tasks,
|
||||
days,
|
||||
viewStart,
|
||||
canEdit,
|
||||
timelineRef,
|
||||
resizePreview,
|
||||
onResizeMouseDown,
|
||||
activeDragId,
|
||||
onUnschedule,
|
||||
dayWidth,
|
||||
rowHeight,
|
||||
}: ScheduleTimelineProps) {
|
||||
const totalWidth = days.length * dayWidth;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sticky left: artist labels */}
|
||||
<div
|
||||
className="flex-shrink-0 bg-zinc-900 border-r border-zinc-800 z-10"
|
||||
style={{ width: 208 }}
|
||||
>
|
||||
{/* Header spacer */}
|
||||
<div
|
||||
className="border-b border-zinc-800 bg-zinc-900"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
/>
|
||||
{artists.map((artist) => {
|
||||
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
|
||||
const maxLoad = Math.max(...Object.values(dailyLoad), 0);
|
||||
const isOverloaded = maxLoad > 8;
|
||||
return (
|
||||
<ArtistLabel
|
||||
key={artist.id}
|
||||
artist={artist}
|
||||
isOverloaded={isOverloaded}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline */}
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="flex-1 overflow-x-auto overflow-y-hidden relative"
|
||||
style={{ scrollbarColor: "#3f3f46 transparent" }}
|
||||
>
|
||||
{/* Date header - sticky top */}
|
||||
<div
|
||||
className="flex sticky top-0 z-20 bg-zinc-900 border-b border-zinc-800"
|
||||
style={{ width: totalWidth, height: HEADER_HEIGHT }}
|
||||
>
|
||||
{days.map((day, i) => {
|
||||
const isT = isToday(day);
|
||||
const isWE = isWeekend(day);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center border-r text-center shrink-0 select-none",
|
||||
isWE ? "border-zinc-800" : "border-zinc-800/60",
|
||||
isT ? "bg-amber-500/10" : isWE ? "bg-zinc-900/80" : ""
|
||||
)}
|
||||
style={{ width: dayWidth }}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[9px] font-medium uppercase tracking-wider",
|
||||
isT ? "text-amber-400" : "text-zinc-600"
|
||||
)}
|
||||
>
|
||||
{format(day, "EEE")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isT ? "text-amber-300" : isWE ? "text-zinc-600" : "text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</span>
|
||||
{format(day, "d") === "1" || i === 0 ? (
|
||||
<span className="text-[9px] text-zinc-600 absolute bottom-0.5">
|
||||
{format(day, "MMM")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Artist rows */}
|
||||
<div style={{ width: totalWidth }}>
|
||||
{artists.map((artist, artistIndex) => {
|
||||
const artistTasks = tasks.filter(
|
||||
(t) => t.assignedArtistId === artist.id
|
||||
);
|
||||
const dailyLoad = calcDailyLoad(tasks, artist.id, days);
|
||||
|
||||
// Apply resize preview so width animates live
|
||||
const tasksWithPreview = artistTasks.map((t) =>
|
||||
resizePreview?.taskId === t.id
|
||||
? {
|
||||
...t,
|
||||
scheduledEndDate: resizePreview.endDate.toISOString(),
|
||||
estimatedHours: resizePreview.estimatedHours,
|
||||
}
|
||||
: t
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={artist.id}
|
||||
className="relative border-b border-zinc-800/60"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
{/* Drop zone overlay */}
|
||||
<TimelineDropZone id={`timeline-row-${artist.id}`} />
|
||||
|
||||
{/* Day grid lines + overload indicators */}
|
||||
{days.map((day, dayIndex) => {
|
||||
const dayStr = format(day, "yyyy-MM-dd");
|
||||
const load = dailyLoad[dayStr] ?? 0;
|
||||
const isT = isToday(day);
|
||||
const isWE = isWeekend(day);
|
||||
const isOverloaded = load > 8;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 border-r",
|
||||
isWE ? "border-zinc-800 bg-zinc-900/30" : "border-zinc-800/40",
|
||||
isT && "bg-amber-500/5",
|
||||
isOverloaded && "bg-orange-500/10"
|
||||
)}
|
||||
style={{
|
||||
left: dayIndex * dayWidth,
|
||||
width: dayWidth,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Task blocks */}
|
||||
{tasksWithPreview.map((task) => {
|
||||
const startDate = toDate(task.scheduledStartDate);
|
||||
if (!startDate) return null;
|
||||
|
||||
// dayIndex may be fractional: integer part = day column,
|
||||
// fractional part = intra-day hour offset stored in the time component.
|
||||
const dayIdx = getDayIndex(startOfDay(startDate), viewStart);
|
||||
const hourOffset = startDate.getHours(); // hours into the 8h workday
|
||||
const dayIndex = dayIdx + hourOffset / 8;
|
||||
const duration = getTaskDuration(task);
|
||||
|
||||
// Skip tasks outside view
|
||||
if (dayIndex + duration < 0 || dayIndex >= days.length)
|
||||
return null;
|
||||
|
||||
const taskHeight = rowHeight - 8;
|
||||
const laneTop = 4;
|
||||
|
||||
return (
|
||||
<ScheduleTaskBlock
|
||||
key={task.id}
|
||||
task={task}
|
||||
dayIndex={dayIndex}
|
||||
duration={duration}
|
||||
artistIndex={artistIndex}
|
||||
viewStart={viewStart}
|
||||
days={days}
|
||||
canEdit={canEdit}
|
||||
isDragging={activeDragId === task.id}
|
||||
onResizeMouseDown={onResizeMouseDown}
|
||||
laneTop={laneTop}
|
||||
taskHeight={taskHeight}
|
||||
onUnschedule={onUnschedule}
|
||||
dayWidth={dayWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{artists.length === 0 && (
|
||||
<div className="flex items-center justify-center py-16 text-zinc-600 text-sm">
|
||||
No artists to display
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { AlertTriangle, Eye, EyeOff, KeyRound } from 'lucide-react';
|
||||
import { changeOwnPassword } from '@/actions/users';
|
||||
|
||||
interface Props {
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export function ChangePasswordForm({ mustChangePassword }: Props) {
|
||||
const router = useRouter();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('New password must be at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await changeOwnPassword({ currentPassword, newPassword });
|
||||
setSuccess(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
// Reload so the session banner clears
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{mustChangePassword && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0 text-amber-400" />
|
||||
<span>
|
||||
You must set a new password before continuing. Your account was created with a
|
||||
temporary password.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{success ? (
|
||||
<p className="text-sm text-emerald-400">
|
||||
Password updated successfully.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currentPassword">Current password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrent ? 'text' : 'password'}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showCurrent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="newPassword">New password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNew ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">At least 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmPassword">Confirm new password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving…' : 'Update password'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { importShotsFromCsv } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Upload, AlertCircle, CheckCircle2, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ParsedRow {
|
||||
scene: string;
|
||||
episode?: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
fps?: number;
|
||||
frameStart?: number;
|
||||
frameEnd?: number;
|
||||
}
|
||||
|
||||
interface ImportShotsDialogProps {
|
||||
projectId: string;
|
||||
projectType?: "STANDARD" | "EPISODIC";
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const TEMPLATE_STANDARD = `scene,description,priority,fps,frameStart,frameEnd
|
||||
010,Opening wide shot,NORMAL,24,1001,1100
|
||||
020,Close up reaction,NORMAL,24,,
|
||||
030,Action sequence,HIGH,24,1001,1250`;
|
||||
|
||||
const TEMPLATE_EPISODIC = `scene,episode,description,priority,fps,frameStart,frameEnd
|
||||
010,EP01,Opening wide shot,NORMAL,24,1001,1100
|
||||
020,EP01,Close up reaction,NORMAL,24,,
|
||||
010,EP02,Act two opener,HIGH,24,1001,1200`;
|
||||
|
||||
function parseCsv(raw: string): { rows: ParsedRow[]; parseErrors: string[] } {
|
||||
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length < 2) return { rows: [], parseErrors: ["Need at least a header row and one data row"] };
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const sceneIdx = headers.indexOf("scene");
|
||||
if (sceneIdx === -1) return { rows: [], parseErrors: ['Missing required "scene" column'] };
|
||||
|
||||
const idx = (name: string) => {
|
||||
const i = headers.indexOf(name);
|
||||
return i === -1 ? null : i;
|
||||
};
|
||||
|
||||
const rows: ParsedRow[] = [];
|
||||
const parseErrors: string[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cells = lines[i].split(",").map((c) => c.trim());
|
||||
const get = (name: string) => {
|
||||
const j = idx(name);
|
||||
return j !== null ? cells[j] ?? "" : "";
|
||||
};
|
||||
|
||||
const scene = get("scene");
|
||||
if (!scene) { parseErrors.push(`Row ${i + 1}: empty scene — skipped`); continue; }
|
||||
|
||||
const fpsRaw = get("fps");
|
||||
const fsRaw = get("framestart") || get("frameStart");
|
||||
const feRaw = get("frameend") || get("frameEnd");
|
||||
|
||||
rows.push({
|
||||
scene,
|
||||
episode: get("episode") || undefined,
|
||||
description: get("description") || undefined,
|
||||
priority: get("priority") || undefined,
|
||||
fps: fpsRaw ? Number(fpsRaw) : undefined,
|
||||
frameStart: fsRaw ? Number(fsRaw) : undefined,
|
||||
frameEnd: feRaw ? Number(feRaw) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { rows, parseErrors };
|
||||
}
|
||||
|
||||
export function ImportShotsDialog({
|
||||
projectId,
|
||||
projectType = "STANDARD",
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ImportShotsDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [step, setStep] = useState<"input" | "preview">("input");
|
||||
const [csvText, setCsvText] = useState("");
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{ created: string[]; errors: string[] } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setCsvText(ev.target?.result as string ?? "");
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleParse = () => {
|
||||
const { rows, parseErrors: errs } = parseCsv(csvText);
|
||||
setParsedRows(rows);
|
||||
setParseErrors(errs);
|
||||
if (rows.length > 0) setStep("preview");
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await importShotsFromCsv(projectId, parsedRows);
|
||||
setImportResult(result);
|
||||
if (result.created.length > 0) {
|
||||
toast({ title: `${result.created.length} shot${result.created.length === 1 ? "" : "s"} created` });
|
||||
onSuccess?.();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: "Import failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStep("input");
|
||||
setCsvText("");
|
||||
setParsedRows([]);
|
||||
setParseErrors([]);
|
||||
setImportResult(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const template = projectType === "EPISODIC" ? TEMPLATE_EPISODIC : TEMPLATE_STANDARD;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Shots from CSV</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{importResult ? (
|
||||
/* Result screen */
|
||||
<div className="space-y-4 py-2">
|
||||
{importResult.created.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-emerald-400">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{importResult.created.length} shot{importResult.created.length === 1 ? "" : "s"} created
|
||||
</p>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs font-mono text-zinc-300 max-h-40 overflow-y-auto">
|
||||
{importResult.created.map((code) => <div key={code}>{code}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="flex items-center gap-2 text-sm font-medium text-amber-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{importResult.errors.length} warning{importResult.errors.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-3 text-xs text-zinc-400 max-h-32 overflow-y-auto space-y-0.5">
|
||||
{importResult.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : step === "input" ? (
|
||||
/* CSV input step */
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste CSV or upload a file. Required column: <code className="text-xs bg-zinc-800 px-1 rounded">scene</code>.
|
||||
{projectType === "EPISODIC" && (
|
||||
<> Also required: <code className="text-xs bg-zinc-800 px-1 rounded">episode</code>.</>
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Upload file
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileUpload} />
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
className="font-mono text-xs min-h-[180px]"
|
||||
placeholder={template}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<div className="text-xs text-amber-400 space-y-0.5">
|
||||
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className="text-xs text-zinc-500 cursor-pointer">
|
||||
<summary className="hover:text-zinc-300 transition-colors">CSV format & example</summary>
|
||||
<pre className="mt-2 bg-zinc-900 border border-zinc-800 rounded p-3 text-zinc-400 whitespace-pre-wrap">{template}</pre>
|
||||
</details>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleParse} disabled={!csvText.trim()} className="gap-1.5">
|
||||
Parse
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
/* Preview step */
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{parsedRows.length} shot{parsedRows.length === 1 ? "" : "s"} ready to import. Review and confirm.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-zinc-900 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Scene</th>
|
||||
{projectType === "EPISODIC" && <th className="px-3 py-2 text-left font-medium">Episode</th>}
|
||||
<th className="px-3 py-2 text-left font-medium">Description</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Priority</th>
|
||||
<th className="px-3 py-2 text-left font-medium">FPS</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Frames</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{parsedRows.map((row, i) => (
|
||||
<tr key={i} className={cn("bg-zinc-950", i % 2 === 0 && "bg-zinc-900/30")}>
|
||||
<td className="px-3 py-2 font-mono text-zinc-200">{row.scene}</td>
|
||||
{projectType === "EPISODIC" && <td className="px-3 py-2 font-mono text-zinc-400">{row.episode ?? "—"}</td>}
|
||||
<td className="px-3 py-2 text-zinc-400 truncate max-w-[160px]">{row.description ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{row.priority ?? "NORMAL"}</td>
|
||||
<td className="px-3 py-2 text-zinc-400">{row.fps ?? 24}</td>
|
||||
<td className="px-3 py-2 text-zinc-400 font-mono">
|
||||
{row.frameStart || row.frameEnd ? `${row.frameStart ?? "?"}–${row.frameEnd ?? "?"}` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<div className="text-xs text-amber-400 space-y-0.5">
|
||||
{parseErrors.map((e, i) => <div key={i} className="flex items-start gap-1.5"><AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />{e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setStep("input")}>Back</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || parsedRows.length === 0}>
|
||||
{isImporting ? "Importing…" : `Import ${parsedRows.length} Shot${parsedRows.length === 1 ? "" : "s"}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
const shotSchema = z.object({
|
||||
scene: z.string().min(1, "Scene is required").max(50).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscore only"),
|
||||
episode: z.string().max(50).optional(),
|
||||
description: z.string().optional(),
|
||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
|
||||
fps: z.coerce.number().min(1).max(120),
|
||||
});
|
||||
|
||||
type ShotFormValues = z.infer<typeof shotSchema>;
|
||||
|
||||
interface NewShotDialogProps {
|
||||
projectId: string;
|
||||
projectType?: "STANDARD" | "EPISODIC";
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function NewShotDialog({ projectId, projectType = "STANDARD", open, onClose, onSuccess }: NewShotDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const isEpisodic = projectType === "EPISODIC";
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ShotFormValues>({
|
||||
resolver: zodResolver(shotSchema),
|
||||
defaultValues: { priority: "NORMAL", fps: 24 },
|
||||
});
|
||||
|
||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setThumbnailFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setThumbnailPreview(event.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ShotFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let thumbnailUrl: string | undefined;
|
||||
|
||||
// Upload thumbnail if provided
|
||||
if (thumbnailFile) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", thumbnailFile);
|
||||
formData.append("type", "image");
|
||||
|
||||
const uploadRes = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Failed to upload thumbnail");
|
||||
}
|
||||
|
||||
const uploadData = await uploadRes.json();
|
||||
thumbnailUrl = uploadData.url;
|
||||
}
|
||||
|
||||
await createShot({ projectId, ...data, thumbnailUrl });
|
||||
toast({ title: "Shot created" });
|
||||
reset();
|
||||
setThumbnailFile(null);
|
||||
setThumbnailPreview(null);
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to create shot",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Shot</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className={`grid gap-3 ${isEpisodic ? "grid-cols-2" : "grid-cols-1"}`}>
|
||||
{isEpisodic && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="episode">Episode *</Label>
|
||||
<Input
|
||||
id="episode"
|
||||
placeholder="EP01"
|
||||
{...register("episode")}
|
||||
className="uppercase"
|
||||
/>
|
||||
{errors.episode && (
|
||||
<p className="text-xs text-destructive">{errors.episode.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="scene">Scene *</Label>
|
||||
<Input
|
||||
id="scene"
|
||||
placeholder="SC010"
|
||||
{...register("scene")}
|
||||
className="uppercase"
|
||||
/>
|
||||
{errors.scene && (
|
||||
<p className="text-xs text-destructive">{errors.scene.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shot code will be auto-generated (e.g.{" "}
|
||||
{isEpisodic ? "SHOW_EP01_SC010_0010" : "SHOW_SC010_0010"})
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="What happens in this shot?"
|
||||
{...register("description")}
|
||||
className="min-h-[70px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="thumbnail">Thumbnail (optional)</Label>
|
||||
<Input
|
||||
id="thumbnail"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleThumbnailChange}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{thumbnailPreview && (
|
||||
<div className="relative w-full aspect-[2.39] rounded-lg overflow-hidden border border-border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={thumbnailPreview}
|
||||
alt="Thumbnail preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
defaultValue="NORMAL"
|
||||
onValueChange={(v) => setValue("priority", v as any)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="NORMAL">Normal</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="URGENT">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fps">FPS</Label>
|
||||
<Input
|
||||
id="fps"
|
||||
type="number"
|
||||
placeholder="24"
|
||||
{...register("fps")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create Shot"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials, formatRelativeDate } from "@/lib/utils";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Film,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
} from "lucide-react";
|
||||
import type { ShotWithDetails } from "@/types";
|
||||
|
||||
interface ShotCardProps {
|
||||
shot: ShotWithDetails;
|
||||
projectId: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; color: string; icon: React.ElementType }
|
||||
> = {
|
||||
WAITING: { label: "Waiting", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
|
||||
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Film },
|
||||
IN_REVIEW: { label: "In Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: AlertCircle },
|
||||
REVISIONS: { label: "Revisions", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: AlertCircle },
|
||||
COMPLETE: { label: "Complete", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
export function ShotCard({ shot, projectId, compact = false }: ShotCardProps) {
|
||||
const router = useRouter();
|
||||
const statusCfg = STATUS_CONFIG[shot.status] ?? STATUS_CONFIG.WAITING;
|
||||
const StatusIcon = statusCfg.icon;
|
||||
const latestVersion = shot.versions?.[0];
|
||||
const openComments = shot.versions
|
||||
?.reduce((sum, v) => sum + (v._count?.comments ?? 0), 0) ?? 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border hover:border-border/80 transition-colors group">
|
||||
<div
|
||||
className={cn("w-2 h-2 rounded-full shrink-0", PRIORITY_DOT[shot.priority] ?? "bg-zinc-500")}
|
||||
title={shot.priority}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">{shot.shotCode}</span>
|
||||
{shot.sequence && (
|
||||
<span className="text-xs text-muted-foreground/60">{shot.sequence}</span>
|
||||
)}
|
||||
</div>
|
||||
{shot.description && (
|
||||
<p className="text-sm truncate text-foreground/80 mt-0.5">{shot.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded border", statusCfg.color)}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
<Link href={`/projects/${projectId}/shots/${shot.id}`}>
|
||||
<Button variant="ghost" size="icon-sm" className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="group hover:border-border/80 transition-colors">
|
||||
<CardHeader className="pb-2">
|
||||
{/* Thumbnail with cinema scope aspect ratio (2.39:1) */}
|
||||
<div className="mb-3 -mx-6 -mt-6 overflow-hidden rounded-t-lg">
|
||||
<div className="relative w-full aspect-[2.39]">
|
||||
{shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl ? (
|
||||
<Image
|
||||
src={shot.thumbnailUrl || latestVersion?.thumbnailUrl || latestVersion?.posterUrl || ""}
|
||||
alt={shot.shotCode}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-800 to-zinc-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Film className="h-12 w-12 text-zinc-600 mx-auto mb-2" />
|
||||
<p className="font-mono text-lg font-semibold text-zinc-400">{shot.shotCode}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header info */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn("w-2 h-2 rounded-full mt-0.5", PRIORITY_DOT[shot.priority] ?? "bg-zinc-500")}
|
||||
title={`Priority: ${shot.priority}`}
|
||||
/>
|
||||
<div>
|
||||
<span className="font-mono text-xs text-muted-foreground">{shot.shotCode}</span>
|
||||
{shot.sequence && (
|
||||
<span className="text-xs text-muted-foreground/50 ml-2">{shot.sequence}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-xs px-2 py-0.5 rounded-full border", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3 inline mr-1" />
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="opacity-100 group-hover:opacity-100 transition-opacity text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/shots/${shot.id}`}>
|
||||
View shot
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shot.description && (
|
||||
<p className="text-sm text-foreground/80 line-clamp-2 mt-1">{shot.description}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-2">
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Film className="h-3.5 w-3.5" />
|
||||
{shot.versions?.length ?? 0} version{(shot.versions?.length ?? 0) !== 1 ? "s" : ""}
|
||||
</span>
|
||||
{openComments > 0 && (
|
||||
<span className="flex items-center gap-1 text-amber-400">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{openComments} open
|
||||
</span>
|
||||
)}
|
||||
{shot.dueDate && (
|
||||
<span className="flex items-center gap-1 ml-auto">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatRelativeDate(shot.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="pt-2 flex items-center justify-between">
|
||||
{/* Artist avatar */}
|
||||
{shot.artist ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Avatar className="h-5 w-5">
|
||||
{shot.artist.image && <AvatarImage src={shot.artist.image} />}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(shot.artist.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{shot.artist.name ?? shot.artist.email}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" asChild className="text-xs h-7">
|
||||
<Link href={`/projects/${projectId}/shots/${shot.id}`}>
|
||||
VIEW
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { updateShot } from "@/actions/shots";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Upload, X, Film, ImageIcon } from "lucide-react";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["WAITING", "IN_PROGRESS", "IN_REVIEW", "REVISIONS", "COMPLETE"]),
|
||||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
|
||||
fps: z.coerce.number().min(1).max(240),
|
||||
frameStart: z.coerce.number().int().optional().or(z.literal("")),
|
||||
frameEnd: z.coerce.number().int().optional().or(z.literal("")),
|
||||
dueDate: z.string().optional(),
|
||||
artistId: z.string().optional(),
|
||||
});
|
||||
|
||||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ShotSettingsTabProps {
|
||||
shot: {
|
||||
id: string;
|
||||
shotCode: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
fps: number;
|
||||
frameStart?: number | null;
|
||||
frameEnd?: number | null;
|
||||
dueDate: Date | string | null;
|
||||
artistId: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
};
|
||||
artists: Artist[];
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(shot.thumbnailUrl ?? null);
|
||||
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const formatDate = (d: Date | string | null) => {
|
||||
if (!d) return "";
|
||||
return new Date(d).toISOString().split("T")[0];
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<SettingsFormValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {
|
||||
description: shot.description ?? "",
|
||||
status: shot.status as SettingsFormValues["status"],
|
||||
priority: shot.priority as SettingsFormValues["priority"],
|
||||
fps: shot.fps,
|
||||
frameStart: shot.frameStart ?? "",
|
||||
frameEnd: shot.frameEnd ?? "",
|
||||
dueDate: formatDate(shot.dueDate),
|
||||
artistId: shot.artistId ?? "__none__",
|
||||
},
|
||||
});
|
||||
|
||||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setThumbnailFile(file);
|
||||
setClearThumbnail(false);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setThumbnailPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: SettingsFormValues) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let thumbnailUrl: string | null | undefined = undefined;
|
||||
|
||||
if (clearThumbnail) {
|
||||
thumbnailUrl = null;
|
||||
} else if (thumbnailFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", thumbnailFile);
|
||||
fd.append("type", "image");
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
if (!res.ok) throw new Error("Thumbnail upload failed");
|
||||
const data = await res.json();
|
||||
thumbnailUrl = data.url;
|
||||
}
|
||||
|
||||
await updateShot({
|
||||
shotId: shot.id,
|
||||
description: values.description || undefined,
|
||||
status: values.status,
|
||||
priority: values.priority,
|
||||
fps: values.fps,
|
||||
frameStart: values.frameStart !== "" && values.frameStart != null ? Number(values.frameStart) : null,
|
||||
frameEnd: values.frameEnd !== "" && values.frameEnd != null ? Number(values.frameEnd) : null,
|
||||
dueDate: values.dueDate || null,
|
||||
artistId: values.artistId === "__none__" ? null : values.artistId,
|
||||
thumbnailUrl,
|
||||
});
|
||||
|
||||
toast({ title: "Shot updated" });
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8 max-w-2xl">
|
||||
{/* Details */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<Film className="h-4 w-4 text-amber-500" />
|
||||
Details
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Description</Label>
|
||||
<Textarea {...register("description")} rows={3} placeholder="Shot description…" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
defaultValue={shot.status}
|
||||
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"])}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WAITING">Waiting</SelectItem>
|
||||
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
|
||||
<SelectItem value="IN_REVIEW">In Review</SelectItem>
|
||||
<SelectItem value="REVISIONS">Revisions</SelectItem>
|
||||
<SelectItem value="COMPLETE">Complete</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
defaultValue={shot.priority}
|
||||
onValueChange={(v) => setValue("priority", v as SettingsFormValues["priority"])}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="NORMAL">Normal</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="CRITICAL">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<span className="text-amber-500 font-mono text-xs">FPS</span>
|
||||
Timing
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>FPS</Label>
|
||||
<Input type="number" step="any" {...register("fps")} />
|
||||
{errors.fps && <p className="text-xs text-destructive">{errors.fps.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Frame Start</Label>
|
||||
<Input type="number" {...register("frameStart")} placeholder="1001" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Frame End</Label>
|
||||
<Input type="number" {...register("frameEnd")} placeholder="1100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-w-xs">
|
||||
<Label>Due Date</Label>
|
||||
<Input type="date" {...register("dueDate")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignment */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<span className="text-amber-500">👤</span>
|
||||
Assignment
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-1.5 max-w-xs">
|
||||
<Label>Artist</Label>
|
||||
<Select
|
||||
defaultValue={shot.artistId ?? "__none__"}
|
||||
onValueChange={(v) => setValue("artistId", v)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Unassigned" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name ?? a.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<ImageIcon className="h-4 w-4 text-amber-500" />
|
||||
Thumbnail
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{thumbnailPreview && !clearThumbnail ? (
|
||||
<div className="relative w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border group">
|
||||
<Image src={thumbnailPreview} alt={shot.shotCode} fill className="object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setClearThumbnail(true); setThumbnailPreview(null); setThumbnailFile(null); }}
|
||||
className="absolute top-1.5 right-1.5 bg-black/70 hover:bg-black text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-72 aspect-[2.39] rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex items-center justify-center gap-2 text-sm text-muted-foreground cursor-pointer transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload thumbnail
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleThumbnailChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { KanbanCard } from "./KanbanCard";
|
||||
import { TASK_STATUS_CONFIG } from "./TaskCard";
|
||||
import { updateTaskStatus } from "@/actions/tasks";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const COLUMN_ORDER: TaskStatus[] = [
|
||||
"TODO",
|
||||
"IN_PROGRESS",
|
||||
"INTERNAL_REVIEW",
|
||||
"CLIENT_REVIEW",
|
||||
"CHANGES",
|
||||
"DONE",
|
||||
];
|
||||
|
||||
interface KanbanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
shot?: { shotCode: string } | null;
|
||||
asset?: { assetCode: string; name: string } | null;
|
||||
assignedArtist?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
} | null;
|
||||
_count?: { versions: number };
|
||||
versions?: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
status,
|
||||
tasks,
|
||||
}: {
|
||||
status: TaskStatus;
|
||||
tasks: KanbanTask[];
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const cfg = TASK_STATUS_CONFIG[status];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-[240px] w-[240px] shrink-0">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<Icon className="h-3.5 w-3.5 text-zinc-500" />
|
||||
<span className="text-xs font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-zinc-600 bg-zinc-800 rounded-full px-1.5 py-0.5 font-mono">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col gap-2 rounded-xl p-2 min-h-[200px] transition-colors",
|
||||
isOver ? "bg-zinc-800/60 ring-1 ring-amber-500/30" : "bg-zinc-900/40"
|
||||
)}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard key={task.id} task={task} />
|
||||
))}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-xs text-zinc-700">Drop here</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: KanbanTask[];
|
||||
projectId: string;
|
||||
artists?: { id: string; name: string | null; email: string }[];
|
||||
}
|
||||
|
||||
export function KanbanBoard({ tasks: initialTasks, projectId, artists = [] }: KanbanBoardProps) {
|
||||
const [tasks, setTasks] = useState<KanbanTask[]>(initialTasks);
|
||||
const [draggingTask, setDraggingTask] = useState<KanbanTask | null>(null);
|
||||
const [filterArtist, setFilterArtist] = useState<string>("__all__");
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
const filteredTasks =
|
||||
filterArtist === "__all__"
|
||||
? tasks
|
||||
: tasks.filter((t) => t.assignedArtist?.id === filterArtist);
|
||||
|
||||
const columns = COLUMN_ORDER.map((status) => ({
|
||||
status,
|
||||
tasks: filteredTasks.filter((t) => t.status === status),
|
||||
}));
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const task = tasks.find((t) => t.id === event.active.id);
|
||||
if (task) setDraggingTask(task);
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
setDraggingTask(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskStatus;
|
||||
|
||||
// Validate target is a column
|
||||
if (!COLUMN_ORDER.includes(newStatus)) return;
|
||||
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task || task.status === newStatus) return;
|
||||
|
||||
// Optimistic update
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t))
|
||||
);
|
||||
|
||||
try {
|
||||
await updateTaskStatus(taskId, newStatus);
|
||||
router.refresh();
|
||||
} catch {
|
||||
// Revert on failure
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: task.status } : t))
|
||||
);
|
||||
toast({ title: "Failed to update task status", variant: "destructive" });
|
||||
}
|
||||
},
|
||||
[tasks, router, toast]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* Artist filter */}
|
||||
{artists.length > 0 && (
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-xs text-zinc-500 font-medium">Filter by artist:</span>
|
||||
<Select value={filterArtist} onValueChange={setFilterArtist}>
|
||||
<SelectTrigger className="w-44 h-8 text-sm">
|
||||
<SelectValue placeholder="All artists" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All artists</SelectItem>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name ?? a.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{filterArtist !== "__all__" && (
|
||||
<button
|
||||
onClick={() => setFilterArtist("__all__")}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[400px]">
|
||||
{columns.map(({ status, tasks: colTasks }) => (
|
||||
<KanbanColumn key={status} status={status} tasks={colTasks} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggingTask && <KanbanCard task={draggingTask} isDragOverlay />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Link from "next/link";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
import {
|
||||
CalendarDays,
|
||||
Layers,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import { TASK_STATUS_CONFIG, TASK_TYPE_LABELS } from "./TaskCard";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500",
|
||||
NORMAL: "bg-blue-500",
|
||||
HIGH: "bg-amber-500",
|
||||
URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface KanbanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
shot?: { shotCode: string } | null;
|
||||
asset?: { assetCode: string; name: string } | null;
|
||||
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||
_count?: { versions: number };
|
||||
versions?: {
|
||||
id: string;
|
||||
versionNumber: number;
|
||||
approvalStatus: string;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
task: KanbanTask;
|
||||
isDragOverlay?: boolean;
|
||||
}
|
||||
|
||||
export function KanbanCard({ task, isDragOverlay = false }: KanbanCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? { transform: CSS.Translate.toString(transform) }
|
||||
: undefined;
|
||||
|
||||
const latestVersion = task.versions?.[0];
|
||||
const isOverdue =
|
||||
task.dueDate &&
|
||||
new Date(task.dueDate) < new Date() &&
|
||||
task.status !== "DONE";
|
||||
|
||||
const contextCode = task.shot?.shotCode ?? task.asset?.assetCode;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group bg-zinc-900 border rounded-lg p-3 space-y-2.5 cursor-grab active:cursor-grabbing transition-all",
|
||||
isDragging && !isDragOverlay
|
||||
? "opacity-30 border-dashed border-zinc-600"
|
||||
: "border-zinc-800 hover:border-zinc-600 shadow-sm",
|
||||
isDragOverlay && "shadow-xl border-amber-500/30 rotate-1"
|
||||
)}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{/* Header: type badge + context code */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-medium text-zinc-500 uppercase tracking-wider">
|
||||
{TASK_TYPE_LABELS[task.type]}
|
||||
</span>
|
||||
{contextCode && (
|
||||
<span className="text-[10px] font-mono text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded">
|
||||
{contextCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="block text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors leading-tight"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
|
||||
{/* Latest version + approval */}
|
||||
{latestVersion && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500 font-mono">
|
||||
v{latestVersion.versionNumber}
|
||||
</span>
|
||||
{latestVersion.approvalStatus === "APPROVED" ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||
) : latestVersion.approvalStatus === "CHANGES_REQUESTED" ? (
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
||||
) : (
|
||||
<Clock className="h-3.5 w-3.5 text-zinc-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: due date + priority dot + artist */}
|
||||
<div className="flex items-center justify-between gap-2 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Priority dot */}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-1.5 rounded-full shrink-0",
|
||||
PRIORITY_DOT[task.priority] ?? "bg-zinc-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Due date */}
|
||||
{task.dueDate && (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-[10px]",
|
||||
isOverdue ? "text-red-400" : "text-zinc-500"
|
||||
)}
|
||||
>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Version count */}
|
||||
{(task._count?.versions ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-zinc-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
{task._count!.versions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignee avatar */}
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-5 w-5 shrink-0">
|
||||
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[8px] bg-zinc-700 text-zinc-300">
|
||||
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createTask } from "@/actions/tasks";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { TaskType, ShotPriority } from "@prisma/client";
|
||||
|
||||
const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||
TRACK: "Track",
|
||||
ROTO: "Roto",
|
||||
KEY: "Key",
|
||||
COMP: "Comp",
|
||||
FX: "FX",
|
||||
LIGHTING: "Lighting",
|
||||
RENDER: "Render",
|
||||
ANIMATION: "Animation",
|
||||
MODEL: "Model",
|
||||
TEXTURE: "Texture",
|
||||
RIG: "Rig",
|
||||
LOOKDEV: "Lookdev",
|
||||
GENERAL: "General",
|
||||
};
|
||||
|
||||
// Common shot task templates
|
||||
const SHOT_TEMPLATES: TaskType[] = ["TRACK", "ROTO", "KEY", "COMP", "FX", "LIGHTING"];
|
||||
// Common asset task templates
|
||||
const ASSET_TEMPLATES: TaskType[] = ["MODEL", "TEXTURE", "RIG", "LOOKDEV"];
|
||||
|
||||
const taskSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional(),
|
||||
type: z.nativeEnum(TaskType),
|
||||
priority: z.nativeEnum(ShotPriority),
|
||||
dueDate: z.string().optional(),
|
||||
estimatedHours: z.string().optional(),
|
||||
assignedArtistId: z.string().optional(),
|
||||
});
|
||||
|
||||
type TaskFormValues = z.infer<typeof taskSchema>;
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface NewTaskDialogProps {
|
||||
projectId: string;
|
||||
shotId?: string;
|
||||
assetId?: string;
|
||||
artists: Artist[];
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
/** If provided, pre-fills the task type and title */
|
||||
prefillType?: TaskType;
|
||||
}
|
||||
|
||||
export function NewTaskDialog({
|
||||
projectId,
|
||||
shotId,
|
||||
assetId,
|
||||
artists,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
prefillType,
|
||||
}: NewTaskDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const templates = shotId ? SHOT_TEMPLATES : assetId ? ASSET_TEMPLATES : [];
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<TaskFormValues>({
|
||||
resolver: zodResolver(taskSchema),
|
||||
defaultValues: {
|
||||
type: prefillType ?? "GENERAL",
|
||||
priority: "NORMAL",
|
||||
title: prefillType ? TASK_TYPE_LABELS[prefillType] : "",
|
||||
},
|
||||
});
|
||||
|
||||
const selectedType = watch("type");
|
||||
|
||||
const applyTemplate = (type: TaskType) => {
|
||||
setValue("type", type);
|
||||
setValue("title", TASK_TYPE_LABELS[type]);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TaskFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createTask({
|
||||
...data,
|
||||
projectId,
|
||||
shotId: shotId ?? "",
|
||||
assetId: assetId ?? "",
|
||||
assignedArtistId: (data.assignedArtistId === "__none__" ? undefined : data.assignedArtistId) ?? undefined,
|
||||
estimatedHours: data.estimatedHours ? Number(data.estimatedHours) : undefined,
|
||||
});
|
||||
toast({ title: "Task created" });
|
||||
reset();
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to create task",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Quick add</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{templates.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => applyTemplate(t)}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium border transition-colors ${
|
||||
selectedType === t
|
||||
? "bg-amber-500/20 text-amber-400 border-amber-500/40"
|
||||
: "bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-zinc-500"
|
||||
}`}
|
||||
>
|
||||
+ {TASK_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input id="title" placeholder="Task title" {...register("title")} />
|
||||
{errors.title && (
|
||||
<p className="text-xs text-destructive">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(v) => setValue("type", v as TaskType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(TASK_TYPE_LABELS) as TaskType[]).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{TASK_TYPE_LABELS[t]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
defaultValue="NORMAL"
|
||||
onValueChange={(v) => setValue("priority", v as ShotPriority)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOW">Low</SelectItem>
|
||||
<SelectItem value="NORMAL">Normal</SelectItem>
|
||||
<SelectItem value="HIGH">High</SelectItem>
|
||||
<SelectItem value="URGENT">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dueDate">Due Date</Label>
|
||||
<Input id="dueDate" type="date" {...register("dueDate")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="estimatedHours">Est. Hours</Label>
|
||||
<Input
|
||||
id="estimatedHours"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.5"
|
||||
placeholder="0"
|
||||
{...register("estimatedHours")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{artists.length > 0 && (
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label>Assign Artist</Label>
|
||||
<Select
|
||||
defaultValue=""
|
||||
onValueChange={(v) => setValue("assignedArtistId", v === "__none__" ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Unassigned</SelectItem>
|
||||
{artists.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name ?? a.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="What needs to be done?"
|
||||
rows={2}
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating…" : "Create Task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
CalendarDays,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { updateTaskStatus, deleteTask } from "@/actions/tasks";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { TaskStatus, TaskType } from "@prisma/client";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export const TASK_STATUS_CONFIG: Record<
|
||||
TaskStatus,
|
||||
{ label: string; color: string; icon: React.ElementType }
|
||||
> = {
|
||||
TODO: { label: "To Do", color: "bg-zinc-500/10 text-zinc-400 border-zinc-500/20", icon: Clock },
|
||||
IN_PROGRESS: { label: "In Progress", color: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Loader2 },
|
||||
INTERNAL_REVIEW: { label: "Internal Review", color: "bg-purple-500/10 text-purple-400 border-purple-500/20", icon: Eye },
|
||||
CLIENT_REVIEW: { label: "Client Review", color: "bg-amber-500/10 text-amber-400 border-amber-500/20", icon: AlertCircle },
|
||||
CHANGES: { label: "Changes", color: "bg-orange-500/10 text-orange-400 border-orange-500/20", icon: RefreshCw },
|
||||
DONE: { label: "Done", color: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", icon: CheckCircle2 },
|
||||
};
|
||||
|
||||
export const TASK_TYPE_LABELS: Record<TaskType, string> = {
|
||||
TRACK: "Track", ROTO: "Roto", KEY: "Key", COMP: "Comp", FX: "FX",
|
||||
LIGHTING: "Lighting", RENDER: "Render", ANIMATION: "Animation",
|
||||
MODEL: "Model", TEXTURE: "Texture", RIG: "Rig", LOOKDEV: "Lookdev", GENERAL: "General",
|
||||
};
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
LOW: "bg-zinc-500", NORMAL: "bg-blue-500", HIGH: "bg-amber-500", URGENT: "bg-red-500",
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||
_count?: { versions: number };
|
||||
versions?: { id: string; versionNumber: number; approvalStatus: string; createdAt: Date }[];
|
||||
};
|
||||
projectId: string;
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard({ task, projectId, canManage = false }: TaskCardProps) {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const statusCfg = TASK_STATUS_CONFIG[task.status];
|
||||
const StatusIcon = statusCfg.icon;
|
||||
const latestVersion = task.versions?.[0];
|
||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== "DONE";
|
||||
|
||||
const handleStatusChange = async (newStatus: TaskStatus) => {
|
||||
try {
|
||||
await updateTaskStatus(task.id, newStatus);
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({ title: "Failed to update status", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Delete this task? All versions and comments will be lost.")) return;
|
||||
try {
|
||||
await deleteTask(task.id);
|
||||
router.refresh();
|
||||
toast({ title: "Task deleted" });
|
||||
} catch {
|
||||
toast({ title: "Failed to delete task", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex items-start gap-3 px-3 py-3 rounded-lg border border-border bg-card hover:border-border/80 transition-colors">
|
||||
{/* Priority dot */}
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full mt-2 shrink-0", PRIORITY_DOT[task.priority] ?? "bg-zinc-500")} />
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="font-medium text-sm text-white hover:text-amber-400 transition-colors truncate"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
<span className="text-xs text-zinc-500 shrink-0">{TASK_TYPE_LABELS[task.type]}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Badge className={cn("text-xs border px-1.5 py-0 h-5 gap-1", statusCfg.color)}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
|
||||
{task._count && task._count.versions > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-zinc-500">
|
||||
<Layers className="h-3 w-3" />
|
||||
v{latestVersion?.versionNumber ?? task._count.versions}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{task.dueDate && (
|
||||
<span className={cn("flex items-center gap-1 text-xs", isOverdue ? "text-red-400" : "text-zinc-500")}>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{isOverdue ? "Overdue · " : ""}
|
||||
{formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{task.assignedArtist && (
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={task.assignedArtist.image ?? undefined} />
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{getInitials(task.assignedArtist.name ?? task.assignedArtist.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-zinc-800"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5 text-zinc-400" />
|
||||
</Link>
|
||||
|
||||
{canManage && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-zinc-800">
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-zinc-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/tasks/${task.id}`}>Open task</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(TASK_STATUS_CONFIG) as TaskStatus[])
|
||||
.filter((s) => s !== task.status)
|
||||
.map((s) => (
|
||||
<DropdownMenuItem key={s} onClick={() => handleStatusChange(s)}>
|
||||
Move to {TASK_STATUS_CONFIG[s].label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TaskCard } from "./TaskCard";
|
||||
import { NewTaskDialog } from "./NewTaskDialog";
|
||||
import { Plus, ListTodo } from "lucide-react";
|
||||
import { TaskType } from "@prisma/client";
|
||||
|
||||
const SHOT_QUICK_TYPES: TaskType[] = ["TRACK", "ROTO", "KEY", "COMP", "FX"];
|
||||
const ASSET_QUICK_TYPES: TaskType[] = ["MODEL", "TEXTURE", "RIG", "LOOKDEV"];
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TaskType;
|
||||
status: any;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
assignedArtist?: { id: string; name: string | null; email: string; image: string | null } | null;
|
||||
_count?: { versions: number };
|
||||
versions?: { id: string; versionNumber: number; approvalStatus: string; createdAt: Date }[];
|
||||
}
|
||||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
projectId: string;
|
||||
shotId?: string;
|
||||
assetId?: string;
|
||||
artists: Artist[];
|
||||
canManage?: boolean;
|
||||
onTaskCreated?: () => void;
|
||||
}
|
||||
|
||||
export function TaskList({ tasks, projectId, shotId, assetId, artists, canManage = false, onTaskCreated }: TaskListProps) {
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [prefillType, setPrefillType] = useState<TaskType | undefined>();
|
||||
|
||||
const quickTypes = shotId ? SHOT_QUICK_TYPES : assetId ? ASSET_QUICK_TYPES : [];
|
||||
|
||||
const openQuick = (type: TaskType) => {
|
||||
setPrefillType(type);
|
||||
setShowNew(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-zinc-300">Tasks</h3>
|
||||
{canManage && (
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1.5 text-xs" onClick={() => { setPrefillType(undefined); setShowNew(true); }}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Task
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick-add templates */}
|
||||
{canManage && quickTypes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quickTypes.map((t) => {
|
||||
const label = {
|
||||
TRACK: "Track",
|
||||
ROTO: "Roto",
|
||||
KEY: "Key",
|
||||
COMP: "Comp",
|
||||
FX: "FX",
|
||||
ANIMATION: "Animation",
|
||||
GENERAL: "General",
|
||||
LIGHTING: "Lighting",
|
||||
RENDER: "Render",
|
||||
MODEL: "Model",
|
||||
TEXTURE: "Texture",
|
||||
RIG: "Rig",
|
||||
LOOKDEV: "Lookdev"
|
||||
}[t] ?? t;
|
||||
const alreadyExists = tasks.some((task) => task.type === t);
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
disabled={alreadyExists}
|
||||
onClick={() => openQuick(t)}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium border transition-colors ${
|
||||
alreadyExists
|
||||
? "opacity-30 cursor-not-allowed bg-zinc-900 text-zinc-500 border-zinc-800"
|
||||
: "bg-zinc-800 text-zinc-400 border-zinc-700 hover:border-amber-500/50 hover:text-amber-400"
|
||||
}`}
|
||||
>
|
||||
+ {label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-6 border border-dashed border-border rounded-lg">
|
||||
<ListTodo className="h-6 w-6 mx-auto mb-2 text-zinc-600" />
|
||||
<p className="text-xs text-muted-foreground">No tasks yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} projectId={projectId} canManage={canManage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NewTaskDialog
|
||||
projectId={projectId}
|
||||
shotId={shotId}
|
||||
assetId={assetId}
|
||||
artists={artists}
|
||||
open={showNew}
|
||||
prefillType={prefillType}
|
||||
onClose={() => setShowNew(false)}
|
||||
onSuccess={onTaskCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-amber-500/20 text-amber-400 hover:bg-amber-500/30",
|
||||
secondary: "border-transparent bg-zinc-700 text-zinc-300 hover:bg-zinc-700/80",
|
||||
destructive: "border-transparent bg-red-900/60 text-red-300 hover:bg-red-900/80",
|
||||
outline: "border-zinc-700 text-zinc-300",
|
||||
// Approval status variants
|
||||
approved: "border-transparent bg-green-900/60 text-green-300",
|
||||
rejected: "border-transparent bg-red-900/60 text-red-300",
|
||||
pending: "border-transparent bg-amber-900/60 text-amber-300",
|
||||
changes: "border-transparent bg-orange-900/60 text-orange-300",
|
||||
// Shot status variants
|
||||
waiting: "border-transparent bg-zinc-700 text-zinc-300",
|
||||
"in-progress": "border-transparent bg-blue-900/60 text-blue-300",
|
||||
"internal-review": "border-transparent bg-cyan-900/60 text-cyan-300",
|
||||
"client-review": "border-transparent bg-purple-900/60 text-purple-300",
|
||||
revisions: "border-transparent bg-orange-900/60 text-orange-300",
|
||||
final: "border-transparent bg-green-900/60 text-green-300",
|
||||
// Priority
|
||||
urgent: "border-transparent bg-red-900/60 text-red-300",
|
||||
high: "border-transparent bg-orange-900/60 text-orange-300",
|
||||
normal: "border-transparent bg-zinc-700 text-zinc-300",
|
||||
low: "border-transparent bg-zinc-800 text-zinc-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-amber-500 text-black hover:bg-amber-400",
|
||||
destructive: "bg-red-900/60 text-red-300 hover:bg-red-900/80",
|
||||
outline: "border border-zinc-700 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-white",
|
||||
secondary: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700 hover:text-white",
|
||||
ghost: "text-zinc-400 hover:bg-zinc-800 hover:text-white",
|
||||
link: "text-amber-400 underline-offset-4 hover:underline",
|
||||
approve: "bg-green-900/60 text-green-300 hover:bg-green-900/80",
|
||||
reject: "bg-red-900/60 text-red-300 hover:bg-red-900/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-lg px-3 text-xs",
|
||||
lg: "h-11 rounded-lg px-8",
|
||||
icon: "h-9 w-9",
|
||||
"icon-sm": "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border border-zinc-800 bg-zinc-900 text-card-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", inset && "pl-8", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent/20 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-xs font-semibold text-muted-foreground", inset && "pl-8", className)} {...props} />
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
)
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/20 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }
|
||||
>(({ className, orientation = "horizontal", ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="separator"
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = "Separator"
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-card text-card-foreground",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
||||
))
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn("inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", className)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} />
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type ToastActionElement, type ToastProps } from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 5
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
type Action =
|
||||
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
|
||||
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
|
||||
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
|
||||
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] }
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) return
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({ type: "REMOVE_TOAST", toastId })
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) }
|
||||
case "UPDATE_TOAST":
|
||||
return { ...state, toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)) }
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => addToRemoveQueue(toast.id))
|
||||
}
|
||||
return { ...state, toasts: state.toasts.map((t) => (t.id === toastId || toastId === undefined ? { ...t, open: false } : t)) }
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) return { ...state, toasts: [] }
|
||||
return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId) }
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => listener(memoryState))
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } })
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
dispatch({ type: "ADD_TOAST", toast: { ...props, id, open: true, onOpenChange: (open) => { if (!open) dismiss() } } })
|
||||
return { id, dismiss, update }
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) listeners.splice(index, 1)
|
||||
}
|
||||
}, [state])
|
||||
return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }) }
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { updateUser } from "@/actions/users";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Eye, EyeOff, ShieldAlert } from "lucide-react";
|
||||
|
||||
export interface EditableUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface EditUserDialogProps {
|
||||
user: EditableUser;
|
||||
isSelf: boolean;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function EditUserDialog({ user, isSelf, open, onClose, onSuccess }: EditUserDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: user.name ?? "",
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
newPassword: "",
|
||||
});
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
|
||||
const set = <K extends keyof typeof form>(key: K, value: typeof form[K]) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
if (key === "newPassword") setPasswordError("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (form.newPassword && form.newPassword.length < 8) {
|
||||
setPasswordError("At least 8 characters");
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateUser({
|
||||
userId: user.id,
|
||||
name: form.name || undefined,
|
||||
role: form.role as "ADMIN" | "PRODUCER" | "SUPERVISOR" | "ARTIST" | "CLIENT",
|
||||
isActive: form.isActive,
|
||||
newPassword: form.newPassword || undefined,
|
||||
});
|
||||
toast({ title: "User updated" });
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({ title: "Failed to update user", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
{/* Read-only email */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-400">Email</Label>
|
||||
<p className="text-sm text-zinc-300 bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Name <span className="text-zinc-500 font-normal">(optional)</span></Label>
|
||||
<Input value={form.name} onChange={(e) => set("name", e.target.value)} placeholder="Jane Smith" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Role</Label>
|
||||
<Select value={form.role} onValueChange={(v) => set("role", v)} disabled={isSelf}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
<SelectItem value="PRODUCER">Producer</SelectItem>
|
||||
<SelectItem value="SUPERVISOR">Supervisor</SelectItem>
|
||||
<SelectItem value="ARTIST">Artist</SelectItem>
|
||||
<SelectItem value="CLIENT">Client</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={form.isActive ? "active" : "inactive"}
|
||||
onValueChange={(v) => set("isActive", v === "active")}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelf && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
|
||||
<ShieldAlert className="h-3.5 w-3.5 shrink-0" />
|
||||
You cannot change your own role or deactivate yourself.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Password reset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>New Password <span className="text-zinc-500 font-normal">(leave blank to keep current)</span></Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.newPassword}
|
||||
onChange={(e) => set("newPassword", e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{passwordError && <p className="text-xs text-destructive">{passwordError}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving…" : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { createUser } from "@/actions/users";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface NewUserDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function NewUserDialog({ open, onClose, onSuccess }: NewUserDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
role: "ARTIST",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = () => {
|
||||
const e: Record<string, string> = {};
|
||||
if (!form.email.trim()) e.email = "Email is required";
|
||||
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) e.email = "Invalid email";
|
||||
if (!form.password) e.password = "Password is required";
|
||||
else if (form.password.length < 8) e.password = "At least 8 characters";
|
||||
return e;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const errs = validate();
|
||||
if (Object.keys(errs).length) { setErrors(errs); return; }
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createUser({
|
||||
name: form.name || undefined,
|
||||
email: form.email.trim().toLowerCase(),
|
||||
password: form.password,
|
||||
role: form.role as "ADMIN" | "PRODUCER" | "SUPERVISOR" | "ARTIST" | "CLIENT",
|
||||
});
|
||||
toast({ title: "User created" });
|
||||
setForm({ name: "", email: "", password: "", role: "ARTIST" });
|
||||
setErrors({});
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({ title: "Failed to create user", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (key: string, value: string) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
setErrors((e) => { const n = { ...e }; delete n[key]; return n; });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Name <span className="text-zinc-500 font-normal">(optional)</span></Label>
|
||||
<Input value={form.name} onChange={(e) => set("name", e.target.value)} placeholder="Jane Smith" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Email <span className="text-red-400">*</span></Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => set("email", e.target.value)}
|
||||
placeholder="jane@studio.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.email && <p className="text-xs text-destructive">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Password <span className="text-red-400">*</span></Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={form.password}
|
||||
onChange={(e) => set("password", e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-200"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-xs text-destructive">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Role</Label>
|
||||
<Select value={form.role} onValueChange={(v) => set("role", v)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ADMIN">Admin</SelectItem>
|
||||
<SelectItem value="PRODUCER">Producer</SelectItem>
|
||||
<SelectItem value="SUPERVISOR">Supervisor</SelectItem>
|
||||
<SelectItem value="ARTIST">Artist</SelectItem>
|
||||
<SelectItem value="CLIENT">Client</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating…" : "Create User"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NewUserDialog } from "./NewUserDialog";
|
||||
import { EditUserDialog, type EditableUser } from "./EditUserDialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PlusCircle, Pencil, ShieldCheck, Shield, Eye, Palette, User2, Trash2 } from "lucide-react";
|
||||
import { deleteUser } from "@/actions/users";
|
||||
|
||||
const ROLE_CONFIG: Record<string, { label: string; className: string; Icon: React.ElementType }> = {
|
||||
ADMIN: { label: "Admin", className: "bg-red-500/10 text-red-400 border border-red-500/20", Icon: ShieldCheck },
|
||||
PRODUCER: { label: "Producer", className: "bg-blue-500/10 text-blue-400 border border-blue-500/20", Icon: Shield },
|
||||
SUPERVISOR: { label: "Supervisor", className: "bg-violet-500/10 text-violet-400 border border-violet-500/20", Icon: Eye },
|
||||
ARTIST: { label: "Artist", className: "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20", Icon: Palette },
|
||||
CLIENT: { label: "Client", className: "bg-zinc-500/10 text-zinc-400 border border-zinc-500/20", Icon: User2 },
|
||||
};
|
||||
|
||||
function getInitials(name: string | null, email: string) {
|
||||
const src = name ?? email;
|
||||
return src.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UsersClientProps {
|
||||
users: User[];
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
export function UsersClient({ users, currentUserId }: UsersClientProps) {
|
||||
const router = useRouter();
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<EditableUser | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleSuccess = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
async function handleDelete(userId: string) {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteUser(userId);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setConfirmDeleteId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{users.length} user{users.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<Button className="gap-2" onClick={() => setShowNew(true)}>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
New User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-zinc-900 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-zinc-400">User</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-zinc-400">Role</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-zinc-400">Status</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-zinc-400"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{users.map((user) => {
|
||||
const role = ROLE_CONFIG[user.role] ?? ROLE_CONFIG.ARTIST;
|
||||
const RoleIcon = role.Icon;
|
||||
const isSelf = user.id === currentUserId;
|
||||
return (
|
||||
<tr key={user.id} className={cn("bg-zinc-950 hover:bg-zinc-900/60 transition-colors", !user.isActive && "opacity-50")}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center shrink-0 text-xs font-semibold text-zinc-300">
|
||||
{getInitials(user.name, user.email)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{user.name ?? <span className="text-zinc-500 italic">No name</span>}
|
||||
{isSelf && <span className="ml-2 text-xs text-amber-400 font-normal">(you)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn("inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium", role.className)}>
|
||||
<RoleIcon className="h-3 w-3" />
|
||||
{role.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border",
|
||||
user.isActive
|
||||
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
: "bg-zinc-700/30 text-zinc-500 border-zinc-700/50"
|
||||
)}>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", user.isActive ? "bg-emerald-400" : "bg-zinc-500")} />
|
||||
{user.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 h-7 text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setEditTarget({ id: user.id, name: user.name, email: user.email, role: user.role, isActive: user.isActive })}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
{!isSelf && (
|
||||
confirmDeleteId === user.id ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Confirm delete"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-zinc-600 hover:text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => setConfirmDeleteId(user.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<NewUserDialog open={showNew} onClose={() => setShowNew(false)} onSuccess={handleSuccess} />
|
||||
|
||||
{editTarget && (
|
||||
<EditUserDialog
|
||||
user={editTarget}
|
||||
isSelf={editTarget.id === currentUserId}
|
||||
open={!!editTarget}
|
||||
onClose={() => setEditTarget(null)}
|
||||
onSuccess={() => { setEditTarget(null); handleSuccess(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { shareVersionWithClient } from "@/actions/versions";
|
||||
import { Send, CheckCircle2 } from "lucide-react";
|
||||
|
||||
interface ShareWithClientButtonProps {
|
||||
versionId: string;
|
||||
isAlreadyShared?: boolean;
|
||||
onShared?: () => void;
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
export function ShareWithClientButton({
|
||||
versionId,
|
||||
isAlreadyShared = false,
|
||||
onShared,
|
||||
size = "sm",
|
||||
}: ShareWithClientButtonProps) {
|
||||
const [shared, setShared] = useState(isAlreadyShared);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleShare = async () => {
|
||||
if (shared) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await shareVersionWithClient(versionId);
|
||||
setShared(true);
|
||||
toast({ title: "Version shared with client" });
|
||||
onShared?.();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Failed to share version",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (shared) {
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 text-xs gap-1 text-amber-400 border-amber-500/30"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">Shared with Client</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={handleShare}
|
||||
className="h-7 text-xs gap-1 text-amber-400 border-amber-500/30 hover:bg-amber-500/10"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{loading ? "Sharing…" : "Share with Client"}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatRelativeDate, formatFileSize } from "@/lib/utils";
|
||||
import {
|
||||
Film,
|
||||
Clock,
|
||||
MoreHorizontal,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Star,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import type { VersionWithDetails } from "@/types";
|
||||
import { submitApproval } from "@/actions/approvals";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
interface VersionListProps {
|
||||
shotId: string;
|
||||
versions: VersionWithDetails[];
|
||||
currentVersionId?: string;
|
||||
onVersionSelect?: (versionId: string) => void;
|
||||
canApprove?: boolean;
|
||||
}
|
||||
|
||||
const APPROVAL_STATUS_STYLES: Record<string, string> = {
|
||||
PENDING_REVIEW: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
APPROVED: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
REJECTED: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
NEEDS_CHANGES: "bg-orange-500/10 text-orange-400 border-orange-500/20",
|
||||
};
|
||||
|
||||
const APPROVAL_ICONS: Record<string, React.ElementType> = {
|
||||
PENDING_REVIEW: Clock,
|
||||
APPROVED: CheckCircle2,
|
||||
REJECTED: XCircle,
|
||||
NEEDS_CHANGES: AlertCircle,
|
||||
};
|
||||
|
||||
function getApprovalLabel(status: string) {
|
||||
switch (status) {
|
||||
case "PENDING_REVIEW": return "Pending";
|
||||
case "APPROVED": return "Approved";
|
||||
case "REJECTED": return "Rejected";
|
||||
case "NEEDS_CHANGES": return "Needs Changes";
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
export function VersionList({
|
||||
shotId,
|
||||
versions,
|
||||
currentVersionId,
|
||||
onVersionSelect,
|
||||
canApprove = false,
|
||||
}: VersionListProps) {
|
||||
const [expanded, setExpanded] = useState<string | null>(currentVersionId ?? null);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Film className="h-8 w-8 mb-3 opacity-30" />
|
||||
<p className="text-sm">No versions uploaded yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleApprove = async (versionId: string) => {
|
||||
try {
|
||||
await submitApproval({ versionId, status: "APPROVED", notes: "" });
|
||||
toast({ title: "Version approved" });
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({ title: "Failed to approve", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (versionId: string) => {
|
||||
try {
|
||||
await submitApproval({ versionId, status: "REJECTED", notes: "Rejected from version list" });
|
||||
toast({ title: "Version rejected", variant: "destructive" });
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast({ title: "Failed to reject", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{versions.map((version) => {
|
||||
const isActive = version.id === currentVersionId;
|
||||
const isExpanded = expanded === version.id;
|
||||
const StatusIcon = APPROVAL_ICONS[version.approvalStatus] ?? Clock;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={version.id}
|
||||
className={cn(
|
||||
"rounded-lg border transition-all",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border hover:border-border/80"
|
||||
)}
|
||||
>
|
||||
{/* Row */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
{/* Version label + latest badge */}
|
||||
<div className="flex items-center gap-2 min-w-[80px]">
|
||||
<span className="font-mono text-sm font-semibold">
|
||||
v{String(version.versionNumber).padStart(3, "0")}
|
||||
</span>
|
||||
{version.isLatest && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Star className="h-3 w-3 text-amber-400 fill-amber-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Latest version</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approval status */}
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-xs",
|
||||
APPROVAL_STATUS_STYLES[version.approvalStatus] ??
|
||||
"bg-muted/30 text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{getApprovalLabel(version.approvalStatus)}
|
||||
</span>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex-1 min-w-0 text-xs text-muted-foreground hidden sm:flex items-center gap-3">
|
||||
<span>{version.fps} fps</span>
|
||||
{version.duration && (
|
||||
<span className="font-mono">{Math.round(version.duration)}s</span>
|
||||
)}
|
||||
<span>{formatRelativeDate(version.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<Button
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => onVersionSelect?.(version.id)}
|
||||
>
|
||||
{isActive ? "Reviewing" : "Review"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setExpanded(isExpanded ? null : version.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/review/${version.id}`} target="_blank">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" />
|
||||
Open review page
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{canApprove && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-emerald-500"
|
||||
onClick={() => handleApprove(version.id)}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-2" />
|
||||
Approve
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-red-500"
|
||||
onClick={() => handleReject(version.id)}
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5 mr-2" />
|
||||
Reject
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/50 px-3 py-2.5 text-xs text-muted-foreground space-y-2">
|
||||
{version.notes && (
|
||||
<p className="text-foreground/80 italic">“{version.notes}”</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>
|
||||
<span className="text-foreground">Uploaded by</span>{" "}
|
||||
{version.artist?.name ?? "Unknown"}
|
||||
</span>
|
||||
{version.frameCount && (
|
||||
<span>
|
||||
<span className="text-foreground">{version.frameCount}</span> frames
|
||||
</span>
|
||||
)}
|
||||
{version.fileSize && (
|
||||
<span>{formatFileSize(version.fileSize)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approval history */}
|
||||
{version.approvals && version.approvals.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5 pt-2 border-t border-border/40">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Review history
|
||||
</p>
|
||||
{version.approvals.map((approval) => {
|
||||
const statusStyles: Record<string, string> = {
|
||||
APPROVED: "text-emerald-400",
|
||||
REJECTED: "text-red-400",
|
||||
NEEDS_CHANGES: "text-orange-400",
|
||||
PENDING_REVIEW: "text-amber-400",
|
||||
};
|
||||
const StatusIcon = APPROVAL_ICONS[approval.status] ?? Clock;
|
||||
return (
|
||||
<div key={approval.id} className="flex gap-2">
|
||||
<StatusIcon
|
||||
className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", statusStyles[approval.status])}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={cn("font-medium", statusStyles[approval.status])}>
|
||||
{getApprovalLabel(approval.status)}
|
||||
</span>
|
||||
<span className="text-muted-foreground/70"> by </span>
|
||||
<span className="text-foreground/80">
|
||||
{approval.user.name ?? "Reviewer"}
|
||||
</span>
|
||||
{approval.notes && (
|
||||
<p className="mt-0.5 text-foreground/70 italic leading-snug">
|
||||
“{approval.notes}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { createVersion } from "@/actions/versions";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Upload, Film, X, CheckCircle2 } from "lucide-react";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VersionUploadProps {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
currentVersionNumber: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (versionId: string) => void;
|
||||
}
|
||||
|
||||
export function VersionUpload({
|
||||
taskId,
|
||||
projectId,
|
||||
currentVersionNumber,
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: VersionUploadProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [fps, setFps] = useState("24");
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadState, setUploadState] = useState<
|
||||
"idle" | "uploading" | "processing" | "done" | "error"
|
||||
>("idle");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const nextVersion = currentVersionNumber + 1;
|
||||
const versionLabel = `v${String(nextVersion).padStart(3, "0")}`;
|
||||
const acceptedTypes = "video/mp4,video/quicktime,video/x-msvideo,.mp4,.mov,.avi,.mxf";
|
||||
|
||||
const handleFileSelect = (selectedFile: File) => {
|
||||
if (!selectedFile.type.match(/video\//)) {
|
||||
toast({ title: "Please select a video file (.mp4, .mov)", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (selectedFile.size > 2 * 1024 * 1024 * 1024) {
|
||||
toast({ title: "File too large (max 2GB)", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const dropped = e.dataTransfer.files[0];
|
||||
if (dropped) handleFileSelect(dropped);
|
||||
}, []);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
setUploadState("uploading");
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Upload via local API (XHR for progress) or UploadThing
|
||||
const fileUrl = await uploadViaLocal(file, (p) =>
|
||||
setUploadProgress(Math.round(p * 0.85))
|
||||
);
|
||||
|
||||
setUploadProgress(90);
|
||||
setUploadState("processing");
|
||||
|
||||
const result = await createVersion({
|
||||
taskId,
|
||||
fileUrl,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
fps: parseFloat(fps) || 24,
|
||||
notes: notes.trim() || undefined,
|
||||
});
|
||||
|
||||
setUploadProgress(100);
|
||||
setUploadState("done");
|
||||
toast({ title: `${versionLabel} uploaded successfully` });
|
||||
setTimeout(() => {
|
||||
onSuccess?.(result.version.id);
|
||||
router.refresh();
|
||||
onClose();
|
||||
resetState();
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setUploadState("error");
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: (err as Error).message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null);
|
||||
setNotes("");
|
||||
setFps("24");
|
||||
setUploadProgress(0);
|
||||
setUploadState("idle");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (uploadState === "uploading") return;
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Film className="h-5 w-5 text-amber-500" />
|
||||
Upload {versionLabel}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Drop zone */}
|
||||
{!file ? (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer",
|
||||
isDragOver
|
||||
? "border-amber-500 bg-amber-500/10"
|
||||
: "border-zinc-700 hover:border-amber-500/50 hover:bg-zinc-800/50"
|
||||
)}
|
||||
onClick={() => document.getElementById("file-input")?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept={acceptedTypes}
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
||||
/>
|
||||
<Upload className="h-8 w-8 text-zinc-500 mb-3" />
|
||||
<p className="text-sm font-medium text-white">Drop video here or click to browse</p>
|
||||
<p className="text-xs text-zinc-400 mt-1">MP4, MOV, AVI up to 2 GB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 p-3 bg-zinc-800/50">
|
||||
<Film className="h-8 w-8 text-amber-500 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate text-white">{file.name}</p>
|
||||
<p className="text-xs text-zinc-400">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
{uploadState === "idle" && (
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => setFile(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{uploadState === "done" && (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{file && uploadState === "idle" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fps" className="text-xs">Frame Rate (FPS)</Label>
|
||||
<Input
|
||||
id="fps"
|
||||
value={fps}
|
||||
onChange={(e) => setFps(e.target.value)}
|
||||
placeholder="24"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notes" className="text-xs">Version Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="What changed in this version?"
|
||||
className="text-sm min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{(uploadState === "uploading" || uploadState === "processing" || uploadState === "done") && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-zinc-400">
|
||||
{uploadState === "uploading" && "Uploading..."}
|
||||
{uploadState === "processing" && "Processing..."}
|
||||
{uploadState === "done" && "Complete!"}
|
||||
</span>
|
||||
<span className="text-amber-400 font-mono">{uploadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={uploadState === "uploading"}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploadState !== "idle"}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload {versionLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Upload a file to /api/upload/local using XHR so we get progress events. */
|
||||
function uploadViaLocal(
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/upload/local");
|
||||
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) onProgress(e.loaded / e.total);
|
||||
});
|
||||
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (json.url) resolve(json.url);
|
||||
else reject(new Error(json.error ?? "Upload failed"));
|
||||
} catch {
|
||||
reject(new Error("Invalid server response"));
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener("error", () => reject(new Error("Network error")));
|
||||
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user