karbe/src/components/MediaUploader.tsx
Claude Integration 2545a5e1a8
All checks were successful
CI / test (pull_request) Successful in 2m18s
feat: « Au fil de l'eau » — Reels mobile + uploader pro + favoris
2026-06-02 00:27:16 +00:00

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>
);
}