Files
Alex_Portfolio/frontend/src/components/ArticleUploads.jsx
T

193 lines
11 KiB
React

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 <div className={`upload-dropzone ${dragging && !disabled ? "is-dragging" : ""} ${disabled ? "is-disabled" : ""}`}
onDragEnter={(event) => { 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);
}}>
<span className="upload-dropzone__icon"><Icon name={banner ? "photo" : "upload"} size={25} /></span>
<strong>{banner ? hasBanner ? "Drop a new banner to replace it" : "Give your article a cover" : "Add something worth sharing"}</strong>
<p>{banner ? "Drag an image here, or choose one below." : "Drop photos, documents, or other files here."}</p>
<button type="button" className="upload-browse" disabled={disabled} aria-describedby={hintId} onClick={() => input.current.click()}>
<Icon name="plus" size={16} /> {banner ? hasBanner ? "Replace banner" : "Choose banner" : "Browse files"}
</button>
<small id={hintId}>{banner ? `JPG, PNG, WebP or GIF · Up to ${uploadLimitMB(purpose)} MB` : `Up to 10 files · ${uploadLimitMB(purpose)} MB each`}</small>
<input ref={input} className="upload-input" type="file" tabIndex={-1}
aria-label={banner ? "Banner image" : "Additional photos & files"}
accept={banner ? "image/jpeg,image/png,image/webp,image/gif" : undefined}
multiple={!banner} disabled={disabled} onChange={(event) => {
onFiles(Array.from(event.target.files), purpose);
event.target.value = "";
}} />
</div>;
}
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 <fieldset className="article-uploads" disabled={busy}>
<legend>Photos & files</legend>
<div className="upload-section">
<div className="upload-section__heading"><div><h3>Banner image</h3><p>Your journal thumbnail and article cover.</p></div><span className="upload-label">Optional</span></div>
{heroImage && <div className="upload-hero-preview">
<img src={heroImage} alt="Banner preview" />
<div><span>{bannerTask ? "Preview · Upload pending" : "Hero preview"}</span><strong>{title || "Your article title"}</strong><p>{subtitle || "Your subtitle appears here, over the cover image."}</p></div>
</div>}
{banner && <div className="upload-saved-banner"><span><Icon name="check" size={15} /> {banner.name} <small>{fileSize(banner.size)}</small></span>
<button type="button" className="text-button" disabled={Boolean(bannerTask)} onClick={onRemoveBanner}>Remove banner</button></div>}
<Dropzone purpose="banner" hasBanner={Boolean(banner)} disabled={busy || Boolean(bannerTask)} onFiles={addFiles} />
<small className="upload-tip">Wide images work best. The cover is cropped to fill the hero and thumbnail.</small>
</div>
<div className="upload-section">
<div className="upload-section__heading"><div><h3>Additional photos & files</h3><p>Shown below your article, in this order.</p></div><span className="upload-label">{attachmentCount}/10</span></div>
<Dropzone purpose="attachment" disabled={busy || attachmentCount >= 10} onFiles={addFiles} />
{attachments.length > 0 && <AttachmentList attachments={attachments} busy={busy} onMove={onMoveAttachment} onRemove={onRemoveAttachment} formatSize={fileSize} />}
</div>
{messages.length > 0 && <div className="upload-feedback" role="alert"><strong>Some files could not be added</strong><ul>{messages.map((message, index) => <li key={index}>{message}</li>)}</ul><button type="button" className="text-button" onClick={() => setMessages([])}>Dismiss</button></div>}
{tasks.length > 0 && <div className="upload-queue">
<h3>Upload queue</h3>
<ul className="upload-list">{tasks.map((task) => <li key={task.id} className={task.status === "error" ? "has-error" : ""}>
<span className="upload-file-icon">{task.preview ? <img src={task.preview} alt="" /> : <Icon name="file" size={24} />}</span>
<div className="upload-file-info"><strong>{task.file.name}</strong><small>{fileSize(task.file.size)} · {task.purpose === "banner" ? "Banner" : "Attachment"}</small>
{task.status === "error" ? <p role="alert">{task.error}</p> : <>
<small>{task.status === "queued" ? "Waiting to upload" : task.progress === 100 ? "Processing file…" : `Uploading ${task.progress}%`}</small>
<progress max="100" value={task.progress} aria-label={`Uploading ${task.file.name}`} />
</>}
</div>
<div className="upload-file-actions">
{task.status === "error" && <button type="button" className="text-button" aria-label={`Retry ${task.file.name}`} onClick={() => { change(task.id, { status: "queued", error: "" }); processQueue(); }}>Retry</button>}
<button type="button" className="upload-icon-button" aria-label={`${task.status === "error" ? "Remove" : "Cancel"} upload ${task.file.name}`} onClick={() => cancel(task)}><Icon name="close" size={18} /></button>
</div>
</li>)}</ul>
<p className="upload-tip" role="status">{tasks.some((task) => task.status === "error") ? "Retry or remove failed uploads before publishing." : "You can keep writing while your files upload."}</p>
</div>}
</fieldset>;
}
export function ArticleAttachments({ attachments = [] }) {
if (!attachments.length) return null;
return <aside className="article-attachments" aria-label="Additional photos and files">
<h2>Photos & files</h2>
{attachments.map((file) => file.media_type.startsWith("image/") ? (
<figure key={file.url}>
<a href={mediaUrl(file)} target="_blank" rel="noreferrer"><img src={mediaUrl(file)} alt={file.name} loading="lazy" /></a>
<figcaption>{file.name}</figcaption>
</figure>
) : (
<a className="article-file" key={file.url} href={mediaUrl(file)} download={file.name}>
<span>{file.name}</span><small>Download · {Math.ceil(file.size / 1024)} KB</small>
</a>
))}
</aside>;
}