"use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { DndContext, PointerSensor, TouchSensor, KeyboardSensor, closestCenter, useSensor, useSensors, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, rectSortingStrategy, useSortable, sortableKeyboardCoordinates, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; export type MediaItem = { id: string; type: "PHOTO" | "VIDEO"; s3Url: string; s3Key: string; sortOrder: number; }; type Props = { carbetId: string; initialMedia: MediaItem[]; }; type UploadEntry = { tempId: string; name: string; sizeBytes: number; mime: string; progress: number; error?: string; done: boolean; }; const MAX_PARALLEL = 3; export function MediaUploader({ carbetId, initialMedia }: Props) { const [items, setItems] = useState( [...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder), ); const [uploads, setUploads] = useState([]); const [dragging, setDragging] = useState(false); const inputId = useId(); const fileInput = useRef(null); const queueRef = useRef([]); const activeRef = useRef(0); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const allIds = useMemo(() => items.map((i) => i.id), [items]); const reorderOnServer = useCallback( async (orderedIds: string[]) => { await fetch("/api/media/reorder", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ carbetId, orderedIds }), }).catch(() => {}); }, [carbetId], ); function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; setItems((prev) => { const oldIdx = prev.findIndex((p) => p.id === active.id); const newIdx = prev.findIndex((p) => p.id === over.id); if (oldIdx < 0 || newIdx < 0) return prev; const next = arrayMove(prev, oldIdx, newIdx); reorderOnServer(next.map((m) => m.id)); return next; }); } const setCover = useCallback( (id: string) => { setItems((prev) => { const idx = prev.findIndex((p) => p.id === id); if (idx <= 0) return prev; const next = arrayMove(prev, idx, 0); reorderOnServer(next.map((m) => m.id)); return next; }); }, [reorderOnServer], ); const removeItem = useCallback(async (id: string) => { if (!confirm("Supprimer ce média ?")) return; const res = await fetch(`/api/media/${id}`, { method: "DELETE" }); if (res.ok) setItems((prev) => prev.filter((p) => p.id !== id)); }, []); const processFile = useCallback(async function processFile(file: File): Promise { const tempId = crypto.randomUUID(); setUploads((u) => [ ...u, { tempId, name: file.name, sizeBytes: file.size, mime: file.type, progress: 0, done: false }, ]); try { const presignRes = await fetch("/api/uploads/presign", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ carbetId, mime: file.type, sizeBytes: file.size }), }); const presign = await presignRes.json(); if (!presignRes.ok) throw new Error(presign?.error || "presign refusé"); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener("progress", (ev) => { if (!ev.lengthComputable) return; const pct = Math.round((ev.loaded / ev.total) * 100); setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: pct } : x))); }); xhr.addEventListener("load", () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)), ); xhr.addEventListener("error", () => reject(new Error("Réseau coupé"))); xhr.open("PUT", presign.uploadUrl); xhr.setRequestHeader("Content-Type", file.type); xhr.send(file); }); const finalizeRes = await fetch("/api/uploads/finalize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ carbetId, s3Key: presign.s3Key, s3Url: presign.publicUrl, mime: file.type, }), }); const finalize = await finalizeRes.json(); if (!finalizeRes.ok) throw new Error(finalize?.error || "finalize refusé"); setItems((prev) => [...prev, finalize.media]); setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, progress: 100, done: true } : x))); // Cleanup après 2s setTimeout(() => { setUploads((u) => u.filter((x) => x.tempId !== tempId)); }, 2000); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setUploads((u) => u.map((x) => (x.tempId === tempId ? { ...x, error: msg } : x))); } }, [carbetId]); const popQueueRef = useRef<() => void>(() => {}); const popQueue = useCallback(() => { while (activeRef.current < MAX_PARALLEL && queueRef.current.length > 0) { const file = queueRef.current.shift()!; activeRef.current++; processFile(file).finally(() => { activeRef.current--; popQueueRef.current(); }); } }, [processFile]); useEffect(() => { popQueueRef.current = popQueue; }, [popQueue]); function addFiles(files: FileList | File[]) { const arr = Array.from(files); queueRef.current.push(...arr); popQueue(); } function onChange(e: React.ChangeEvent) { if (e.target.files) addFiles(e.target.files); if (fileInput.current) fileInput.current.value = ""; } function onDrop(e: React.DragEvent) { e.preventDefault(); setDragging(false); if (e.dataTransfer.files) addFiles(e.dataTransfer.files); } // Permet le coller depuis presse-papier useEffect(() => { function onPaste(e: ClipboardEvent) { if (!e.clipboardData?.files?.length) return; addFiles(e.clipboardData.files); } window.addEventListener("paste", onPaste); return () => window.removeEventListener("paste", onPaste); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{ e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} className={ "rounded-lg border-2 border-dashed p-4 text-center transition " + (dragging ? "border-emerald-500 bg-emerald-50" : "border-zinc-300 bg-zinc-50 hover:border-zinc-400") } >
{uploads.length > 0 ? (
    {uploads.map((u) => (
  • {u.name} {u.error ? "❌" : u.done ? "✓" : `${Math.round(u.sizeBytes / 1000)} ko · ${u.progress}%`}
    {u.error ?
    {u.error}
    : null}
  • ))}
) : null} {items.length > 0 ? (
{items.map((item, idx) => ( setCover(item.id)} onDelete={() => removeItem(item.id)} /> ))}
) : (

Pas encore de média. Ajoutez votre premier ci-dessus.

)}

Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue)

); } function SortableTile({ item, isCover, onSetCover, onDelete, }: { item: MediaItem; isCover: boolean; onSetCover: () => void; onDelete: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{item.type === "VIDEO" ? (
{isCover ? ( Cover ) : null} {item.type}
{!isCover ? ( ) : null}
); }