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
Photos & files

Banner image

Your journal thumbnail and article cover.

Optional
{heroImage &&
Banner preview
{bannerTask ? "Preview · Upload pending" : "Hero preview"}{title || "Your article title"}

{subtitle || "Your subtitle appears here, over the cover image."}

} {banner &&
{banner.name} {fileSize(banner.size)}
} Wide images work best. The cover is cropped to fill the hero and thumbnail.

Additional photos & files

Shown below your article, in this order.

{attachmentCount}/10
= 10} onFiles={addFiles} /> {attachments.length > 0 && }
{messages.length > 0 &&
Some files could not be added
} {tasks.length > 0 &&

Upload queue

{tasks.some((task) => task.status === "error") ? "Retry or remove failed uploads before publishing." : "You can keep writing while your files upload."}

}
; } export function ArticleAttachments({ attachments = [] }) { if (!attachments.length) return null; return ; }