import { useEffect, useId, useRef, useState } from "react";
import { mediaUrl } from "../api";
import Icon from "./Icon";
import AttachmentList from "./AttachmentList";
const imageTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const maxUploadMB = Number(import.meta.env.VITE_MAX_UPLOAD_MB ?? 20);
const uploadLimitMB = (purpose) => Math.min(purpose === "banner" ? 8 : 20, maxUploadMB);
const isImage = (file) => imageTypes.includes(file.type) || (!file.type && /\.(jpe?g|png|webp|gif)$/i.test(file.name));
const fileSize = (size) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`;
function Dropzone({ purpose, disabled, onFiles, hasBanner }) {
const input = useRef(null);
const depth = useRef(0);
const [dragging, setDragging] = useState(false);
const hintId = useId();
const banner = purpose === "banner";
return
{ event.preventDefault(); depth.current += 1; setDragging(true); }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = disabled ? "none" : "copy"; }}
onDragLeave={(event) => { event.preventDefault(); depth.current -= 1; if (depth.current <= 0) setDragging(false); }}
onDrop={(event) => {
event.preventDefault(); depth.current = 0; setDragging(false);
if (!disabled) onFiles(Array.from(event.dataTransfer.files), purpose);
}}>
{banner ? hasBanner ? "Drop a new banner to replace it" : "Give your article a cover" : "Add something worth sharing"}
{banner ? "Drag an image here, or choose one below." : "Drop photos, documents, or other files here."}
{banner ? `JPG, PNG, WebP or GIF · Up to ${uploadLimitMB(purpose)} MB` : `Up to 10 files · ${uploadLimitMB(purpose)} MB each`}
{
onFiles(Array.from(event.target.files), purpose);
event.target.value = "";
}} />
;
}
export default function ArticleUploads({ banner, attachments, busy, title, subtitle, onUpload, onPendingChange, onRemoveBanner, onRemoveAttachment, onMoveAttachment }) {
const [tasks, setTasks] = useState([]);
const [messages, setMessages] = useState([]);
const queue = useRef([]);
const running = useRef(false);
const mounted = useRef(true);
const previews = useRef(new Set());
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
queue.current.forEach((task) => task.controller?.abort());
previews.current.forEach((url) => URL.revokeObjectURL(url));
previews.current.clear();
onPendingChange(false);
};
}, [onPendingChange]);
function updateQueue(next) {
queue.current = next;
if (mounted.current) { setTasks(next); onPendingChange(next.length > 0); }
}
function forget(id) {
const task = queue.current.find((item) => item.id === id);
if (task?.preview) { URL.revokeObjectURL(task.preview); previews.current.delete(task.preview); }
updateQueue(queue.current.filter((item) => item.id !== id));
}
function change(id, values) {
updateQueue(queue.current.map((task) => task.id === id ? { ...task, ...values } : task));
}
async function processQueue() {
if (running.current) return;
running.current = true;
try {
let task;
while (mounted.current && (task = queue.current.find((item) => item.status === "queued"))) {
const controller = new AbortController();
const id = task.id;
change(id, { status: "uploading", controller, progress: 0 });
try {
await onUpload(task.file, task.purpose, {
signal: controller.signal,
onProgress: (progress) => { if (mounted.current) change(id, { progress }); },
});
if (mounted.current) forget(id);
} catch (error) {
if (mounted.current && error.name !== "AbortError") change(id, { status: "error", error: error.message });
}
}
} finally {
running.current = false;
}
}
function addFiles(files, purpose) {
if (busy || !files.length) return;
const errors = [];
if (purpose === "banner" && (files.length > 1 || queue.current.some((task) => task.purpose === "banner"))) {
setMessages(["Choose one banner at a time. Finish or remove the current banner upload first."]);
return;
}
const additions = [];
const existing = [...attachments, ...queue.current.filter((task) => task.purpose === "attachment").map((task) => task.file)];
for (const file of files) {
const limit = uploadLimitMB(purpose) * 1024 * 1024;
if (!file.size) { errors.push(`${file.name}: this file is empty.`); continue; }
if (file.size > limit) { errors.push(`${file.name}: exceeds the ${uploadLimitMB(purpose)} MB limit.`); continue; }
if (purpose === "banner" && !isImage(file)) { errors.push(`${file.name}: choose a JPG, PNG, WebP, or GIF image.`); continue; }
if (purpose === "attachment" && existing.some((item) => item.name === file.name && item.size === file.size)) {
errors.push(`${file.name}: already added.`); continue;
}
if (purpose === "attachment" && existing.length >= 10) { errors.push(`${file.name}: all 10 attachment slots are filled.`); continue; }
const preview = isImage(file) ? URL.createObjectURL(file) : null;
if (preview) previews.current.add(preview);
additions.push({ id: crypto.randomUUID(), file, purpose, preview, status: "queued", progress: 0 });
if (purpose === "attachment") existing.push(file);
}
setMessages(errors);
if (additions.length) {
updateQueue([...queue.current, ...additions]);
processQueue();
}
}
function cancel(task) { task.controller?.abort(); forget(task.id); }
const bannerTask = tasks.find((task) => task.purpose === "banner");
const heroImage = bannerTask?.preview ?? (banner ? mediaUrl(banner) : null);
const attachmentCount = attachments.length + tasks.filter((task) => task.purpose === "attachment").length;
return ;
}
export function ArticleAttachments({ attachments = [] }) {
if (!attachments.length) return null;
return ;
}