Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+378
View File
@@ -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}
/>
);
}
+146
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+124
View File
@@ -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>
);
}
+145
View File
@@ -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>
);
}
+228
View File
@@ -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>
);
}
+394
View File
@@ -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 &quot;Add Comment&quot;
</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>
);
}
+94
View File
@@ -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>
);
}
+319
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+190
View File
@@ -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}
/>
);
}
+284
View File
@@ -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>
);
}
+313
View File
@@ -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>
);
}
);
+205
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+349
View File
@@ -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>
);
}
+214
View File
@@ -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>
);
}
+168
View File
@@ -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>
);
}
+300
View File
@@ -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>
);
}
+385
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+287
View File
@@ -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>
);
}
+236
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}
+292
View File
@@ -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>
);
}
+222
View File
@@ -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>
);
}
+167
View File
@@ -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>
);
}
+285
View File
@@ -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>
);
}
+189
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+44
View File
@@ -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 }
+47
View File
@@ -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 }
+55
View File
@@ -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 }
+50
View File
@@ -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 }
+200
View File
@@ -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,
}
+95
View File
@@ -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,
}
+102
View File
@@ -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,
}
+23
View File
@@ -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 }
+19
View File
@@ -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 }
+22
View File
@@ -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 }
+39
View File
@@ -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 }
+139
View File
@@ -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,
}
+21
View File
@@ -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 }
+52
View File
@@ -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 }
+22
View File
@@ -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 }
+104
View File
@@ -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,
}
+32
View File
@@ -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>
)
}
+25
View File
@@ -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 }
+102
View File
@@ -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 }
+177
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
}
+188
View File
@@ -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>
);
}
+295
View File
@@ -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">&ldquo;{version.notes}&rdquo;</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">
&ldquo;{approval.notes}&rdquo;
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
+288
View File
@@ -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);
});
}