Files
twotalesanimation 4ae14cf8b2
Deploy / deploy (push) Successful in 2m43s
Shoot sketches
2026-07-12 12:14:39 +02:00

280 lines
9.3 KiB
TypeScript

"use client";
import { useRef, useState, useCallback } from "react";
import { Upload, X, Loader2, ImageIcon, ZoomIn, Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TakeAttachmentData } from "./TakeEditorPanel";
import { SketchPad } from "./SketchPad";
// ─── Lightbox ─────────────────────────────────────────────────────────────────
function Lightbox({
attachments,
index,
onClose,
}: {
attachments: TakeAttachmentData[];
index: number;
onClose: () => void;
}) {
const [current, setCurrent] = useState(index);
const att = attachments[current];
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex flex-col items-center justify-center"
onClick={onClose}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white p-2"
onClick={onClose}
>
<X className="h-6 w-6" />
</button>
{/* Image */}
<div
className="max-w-[90vw] max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.fileUrl}
alt={att.fileName}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
/>
</div>
{/* Caption */}
{att.caption && (
<p className="mt-3 text-sm text-zinc-300 max-w-lg text-center">{att.caption}</p>
)}
<p className="mt-1 text-xs text-zinc-500">{att.fileName}</p>
{/* Prev / Next */}
{attachments.length > 1 && (
<div className="flex gap-4 mt-4" onClick={(e) => e.stopPropagation()}>
<button
disabled={current === 0}
onClick={() => setCurrent((c) => c - 1)}
className="px-4 py-2 rounded bg-zinc-800 text-zinc-300 disabled:opacity-30 hover:bg-zinc-700 text-sm"
>
Prev
</button>
<span className="text-zinc-500 text-sm self-center">
{current + 1} / {attachments.length}
</span>
<button
disabled={current === attachments.length - 1}
onClick={() => setCurrent((c) => c + 1)}
className="px-4 py-2 rounded bg-zinc-800 text-zinc-300 disabled:opacity-30 hover:bg-zinc-700 text-sm"
>
Next
</button>
</div>
)}
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
interface Props {
takeId: string;
attachments: TakeAttachmentData[];
onChange: (updated: TakeAttachmentData[]) => void;
}
export function TakeImageGallery({ takeId, attachments, onChange }: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [dragOver, setDragOver] = useState(false);
const [showSketch, setShowSketch] = useState(false);
const imageAttachments = attachments.filter((a) => a.fileType === "IMAGE");
const uploadFile = useCallback(
async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("type", "image");
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (!res.ok) throw new Error("Upload failed");
const { url, key } = await res.json();
// Register attachment in DB
const attachRes = await fetch(`/api/shoot-log/takes/${takeId}/attachments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileUrl: url,
fileKey: key ?? "",
fileName: file.name,
fileSize: file.size,
fileType: "IMAGE",
category: "MISCELLANEOUS",
}),
});
if (!attachRes.ok) throw new Error("Failed to save attachment");
const { attachment } = await attachRes.json();
return attachment as TakeAttachmentData;
},
[takeId]
);
const handleFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter((f) => f.type.startsWith("image/"));
if (!imageFiles.length) return;
setUploading(true);
try {
const results = await Promise.all(imageFiles.map(uploadFile));
onChange([...attachments, ...results]);
} finally {
setUploading(false);
}
},
[attachments, onChange, uploadFile]
);
const handleDelete = useCallback(
async (id: string) => {
const res = await fetch(
`/api/shoot-log/takes/${takeId}/attachments/${id}`,
{ method: "DELETE" }
);
if (res.ok) {
onChange(attachments.filter((a) => a.id !== id));
}
},
[attachments, onChange, takeId]
);
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleFiles(e.dataTransfer.files);
},
[handleFiles]
);
return (
<div className="space-y-3">
{/* Upload zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer",
dragOver
? "border-amber-500 bg-amber-500/5"
: "border-zinc-700 hover:border-zinc-600"
)}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
capture="environment"
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
{uploading ? (
<div className="flex items-center justify-center gap-2 text-zinc-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Uploading...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2 text-zinc-500">
<Upload className="h-4 w-4" />
<span className="text-sm">
Drop images here, tap to upload, or use camera
</span>
</div>
)}
</div>
{/* Thumbnail grid */}
{imageAttachments.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
{imageAttachments.map((att, i) => (
<div key={att.id} className="relative group aspect-square">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={att.fileUrl}
alt={att.caption ?? att.fileName}
className="w-full h-full object-cover rounded-lg bg-zinc-800 cursor-pointer"
onClick={() => setLightboxIndex(i)}
/>
{/* Overlay */}
<div className="absolute inset-0 rounded-lg bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<div className="opacity-0 group-hover:opacity-100 flex gap-1 transition-opacity">
<button
onClick={() => setLightboxIndex(i)}
className="p-1.5 rounded bg-black/60 text-white hover:bg-black/80"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleDelete(att.id)}
className="p-1.5 rounded bg-black/60 text-red-400 hover:bg-black/80"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Caption badge */}
{att.caption && (
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/60 rounded-b-lg text-[9px] text-zinc-300 truncate">
{att.caption}
</div>
)}
</div>
))}
</div>
)}
{/* Sketch button */}
<button
type="button"
onClick={() => setShowSketch(true)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-zinc-700 hover:border-zinc-500 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
Add Sketch
</button>
{imageAttachments.length === 0 && !uploading && (
<div className="flex items-center gap-2 text-zinc-600 text-xs py-2">
<ImageIcon className="h-4 w-4" />
No images yet
</div>
)}
{/* Sketch pad */}
{showSketch && (
<SketchPad
takeId={takeId}
onSaved={(attachment) => onChange([...attachments, attachment])}
onClose={() => setShowSketch(false)}
/>
)}
{/* Lightbox */}
{lightboxIndex !== null && (
<Lightbox
attachments={imageAttachments}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}