380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
"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<MediaItem[]>(
|
|
[...initialMedia].sort((a, b) => a.sortOrder - b.sortOrder),
|
|
);
|
|
const [uploads, setUploads] = useState<UploadEntry[]>([]);
|
|
const [dragging, setDragging] = useState(false);
|
|
const inputId = useId();
|
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
const queueRef = useRef<File[]>([]);
|
|
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<void> {
|
|
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<void>((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<HTMLInputElement>) {
|
|
if (e.target.files) addFiles(e.target.files);
|
|
if (fileInput.current) fileInput.current.value = "";
|
|
}
|
|
|
|
function onDrop(e: React.DragEvent<HTMLDivElement>) {
|
|
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 (
|
|
<div className="space-y-3">
|
|
<div
|
|
onDragOver={(e) => {
|
|
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")
|
|
}
|
|
>
|
|
<label htmlFor={inputId} className="block cursor-pointer">
|
|
<div className="text-sm text-zinc-700">
|
|
<strong>Déposez vos photos ou vidéos</strong> ici, ou cliquez pour parcourir
|
|
</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
JPG / PNG / WebP / AVIF (max 10 Mo) · MP4 / MOV / WebM (max 200 Mo) · plusieurs fichiers OK
|
|
</div>
|
|
</label>
|
|
<input
|
|
id={inputId}
|
|
ref={fileInput}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp,image/avif,video/mp4,video/quicktime,video/webm"
|
|
multiple
|
|
capture="environment"
|
|
onChange={onChange}
|
|
className="sr-only"
|
|
/>
|
|
</div>
|
|
|
|
{uploads.length > 0 ? (
|
|
<ul className="space-y-1.5">
|
|
{uploads.map((u) => (
|
|
<li key={u.tempId} className="rounded border border-zinc-200 bg-white px-3 py-2 text-xs">
|
|
<div className="flex items-center justify-between">
|
|
<span className="truncate font-medium text-zinc-700">{u.name}</span>
|
|
<span className="ml-2 text-zinc-500">
|
|
{u.error
|
|
? "❌"
|
|
: u.done
|
|
? "✓"
|
|
: `${Math.round(u.sizeBytes / 1000)} ko · ${u.progress}%`}
|
|
</span>
|
|
</div>
|
|
<div className="mt-1 h-1 overflow-hidden rounded-full bg-zinc-200">
|
|
<div
|
|
className={
|
|
"h-full transition-all " +
|
|
(u.error ? "bg-rose-500" : u.done ? "bg-emerald-500" : "bg-emerald-600")
|
|
}
|
|
style={{ width: `${u.progress}%` }}
|
|
/>
|
|
</div>
|
|
{u.error ? <div className="mt-1 text-rose-700">{u.error}</div> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
|
|
{items.length > 0 ? (
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
<SortableContext items={allIds} strategy={rectSortingStrategy}>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
|
|
{items.map((item, idx) => (
|
|
<SortableTile
|
|
key={item.id}
|
|
item={item}
|
|
isCover={idx === 0}
|
|
onSetCover={() => setCover(item.id)}
|
|
onDelete={() => removeItem(item.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
) : (
|
|
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
|
|
Pas encore de média. Ajoutez votre premier ci-dessus.
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-[11px] text-zinc-500">
|
|
Glissez-déposez pour réordonner · Étoile = cover (image principale sur le catalogue)
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={
|
|
"group relative overflow-hidden rounded-md border bg-zinc-100 " +
|
|
(isCover ? "border-emerald-500 ring-2 ring-emerald-300" : "border-zinc-200")
|
|
}
|
|
>
|
|
<div {...attributes} {...listeners} className="aspect-square w-full cursor-grab touch-none active:cursor-grabbing">
|
|
{item.type === "VIDEO" ? (
|
|
<video
|
|
src={item.s3Url}
|
|
preload="metadata"
|
|
muted
|
|
playsInline
|
|
className="h-full w-full bg-black object-cover"
|
|
/>
|
|
) : (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={item.s3Url}
|
|
alt=""
|
|
loading="lazy"
|
|
draggable={false}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
{isCover ? (
|
|
<span className="absolute left-1 top-1 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-white">
|
|
Cover
|
|
</span>
|
|
) : null}
|
|
<span className="pointer-events-none absolute right-1 top-1 rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
|
|
{item.type}
|
|
</span>
|
|
<div className="absolute inset-x-0 bottom-0 flex justify-end gap-1 bg-gradient-to-t from-black/70 to-transparent p-1.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
|
{!isCover ? (
|
|
<button
|
|
type="button"
|
|
onClick={onSetCover}
|
|
className="rounded bg-white/90 px-2 py-0.5 text-[10px] font-semibold text-zinc-900 hover:bg-white"
|
|
title="Définir comme cover"
|
|
>
|
|
★ Cover
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
className="rounded bg-rose-600 px-2 py-0.5 text-[10px] font-semibold text-white hover:bg-rose-700"
|
|
title="Supprimer"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|