Files
nexu-io--open-design/apps/web/src/components/LibraryUploadModal.tsx
T
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

274 lines
9.3 KiB
TypeScript

// OD Library — manual upload modal.
//
// Opened from the Library toolbar's Upload button (and seeded by a section-wide
// file drop). Supports every common way to get bytes in:
// • choose files — native multi-select picker
// • drag & drop — onto the dropzone
// • paste — image/file from the clipboard, or a text/JSON snippet
//
// Every file runs the shared upload policy (images, fonts, text, HTML, JSON /
// design data; no audio/video; size-capped). The daemon enforces the same
// policy as the source of truth, so a rejected file shows a per-row reason here
// without trusting the client. Uploads run concurrently and the grid refreshes
// once the batch settles (live clipper-style SSE also refreshes it).
//
// Copy is intentionally inline (not yet i18n-keyed), matching LibrarySection.
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { motion } from 'motion/react';
import { LIBRARY_UPLOAD_MAX_BYTES, libraryUploadAcceptAttr } from '@open-design/contracts';
import { Button } from '@open-design/components';
import { Icon } from './Icon';
import { modalOverlay, modalContent } from '../motion';
import { uploadLibraryFile, uploadLibraryText, type LibraryUploadOutcome } from '../providers/registry';
import styles from './LibraryUploadModal.module.css';
type ItemStatus = 'uploading' | 'done' | 'deduped' | 'error';
interface UploadItem {
id: string;
name: string;
status: ItemStatus;
message?: string;
}
interface Props {
/** Files to enqueue immediately (e.g. from a section-wide drop). */
seedFiles: File[] | null;
onClose: () => void;
/** Fired after the batch settles so the grid can refresh. */
onUploaded: () => void;
}
let uploadSeq = 0;
const nextItemId = () => `upload-${(uploadSeq += 1)}`;
const maxMb = Math.round(LIBRARY_UPLOAD_MAX_BYTES / 1_000_000);
export function LibraryUploadModal({ seedFiles, onClose, onUploaded }: Props) {
const [items, setItems] = useState<UploadItem[]>([]);
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const inFlight = useRef(0);
const seededRef = useRef<File[] | null>(null);
const finishItem = useCallback((id: string, outcome: LibraryUploadOutcome) => {
inFlight.current -= 1;
setItems((prev) =>
prev.map((it) =>
it.id === id
? outcome.ok
? { ...it, status: outcome.deduped ? 'deduped' : 'done' }
: { ...it, status: 'error', message: outcome.error ?? 'Upload failed' }
: it,
),
);
// Refresh the grid once every dispatched upload has settled.
if (inFlight.current === 0) onUploaded();
}, [onUploaded]);
const addFiles = useCallback(
(files: File[]) => {
for (const file of files) {
const id = nextItemId();
setItems((prev) => [{ id, name: file.name, status: 'uploading' }, ...prev]);
inFlight.current += 1;
void uploadLibraryFile(file).then((outcome) => finishItem(id, outcome));
}
},
[finishItem],
);
const addText = useCallback(
(text: string) => {
const id = nextItemId();
setItems((prev) => [{ id, name: `Pasted text · ${text.length} chars`, status: 'uploading' }, ...prev]);
inFlight.current += 1;
void uploadLibraryText(text).then((outcome) => finishItem(id, outcome));
},
[finishItem],
);
// Enqueue files handed in from a section-wide drop. The ref guard makes this
// idempotent (StrictMode double-invoke / parent re-renders re-pass the same
// array reference, which we only process once).
useEffect(() => {
if (seedFiles && seedFiles.length && seedFiles !== seededRef.current) {
seededRef.current = seedFiles;
addFiles(seedFiles);
}
}, [seedFiles, addFiles]);
// Clipboard paste: prefer image/file payloads, fall back to a text snippet.
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const dt = e.clipboardData;
if (!dt) return;
const files = Array.from(dt.files ?? []);
if (!files.length) {
for (const item of Array.from(dt.items ?? [])) {
if (item.kind === 'file') {
const f = item.getAsFile();
if (f) files.push(f);
}
}
}
if (files.length) {
e.preventDefault();
addFiles(files);
return;
}
const text = dt.getData('text/plain');
if (text && text.trim()) {
e.preventDefault();
addText(text);
}
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [addFiles, addText]);
// Owns Escape while open (LibrarySection's own handler stands down).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
const onPick = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length) addFiles(files);
e.target.value = ''; // let the same file be re-picked
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files ?? []);
if (files.length) addFiles(files);
};
const pending = items.some((it) => it.status === 'uploading');
const okCount = items.filter((it) => it.status === 'done' || it.status === 'deduped').length;
const errCount = items.filter((it) => it.status === 'error').length;
const modal = (
<motion.div
className={styles.backdrop}
onClick={onClose}
variants={modalOverlay}
initial="hidden"
animate="visible"
exit="exit"
role="presentation"
>
<motion.div
className={styles.modal}
onClick={(e) => e.stopPropagation()}
variants={modalContent}
initial="hidden"
animate="visible"
exit="exit"
role="dialog"
aria-modal="true"
aria-label="Upload to Library"
>
<header className={styles.head}>
<span className={styles.headTitle}>Upload to Library</span>
<button type="button" className={styles.closeBtn} onClick={onClose} aria-label="Close upload">
<Icon name="close" size={18} />
</button>
</header>
<div
className={styles.dropzone}
data-drag={dragOver ? 'true' : 'false'}
onDragEnter={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={(e) => {
e.preventDefault();
setDragOver(false);
}}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
inputRef.current?.click();
}
}}
>
<Icon name="upload" size={26} className={styles.dropIcon} />
<p className={styles.dropTitle}>
Drag &amp; drop, paste, or <span className={styles.dropLink}>choose files</span>
</p>
<p className={styles.dropHint}>
Images, fonts, text, HTML, and JSON / design data · up to {maxMb} MB each
</p>
<input
ref={inputRef}
type="file"
multiple
accept={libraryUploadAcceptAttr()}
className={styles.fileInput}
onChange={onPick}
/>
</div>
{items.length > 0 ? (
<ul className={styles.list}>
{items.map((it) => (
<li key={it.id} className={styles.item} data-status={it.status}>
<span className={styles.itemIcon} aria-hidden>
{it.status === 'uploading' ? (
<Icon name="spinner" size={15} className={styles.spin} />
) : it.status === 'error' ? (
<Icon name="alert-triangle" size={15} />
) : (
<Icon name="check" size={15} />
)}
</span>
<span className={styles.itemName} title={it.name}>
{it.name}
</span>
<span className={styles.itemStatus}>
{it.status === 'uploading'
? 'Uploading…'
: it.status === 'deduped'
? 'Already in Library'
: it.status === 'done'
? 'Added'
: (it.message ?? 'Failed')}
</span>
</li>
))}
</ul>
) : null}
<footer className={styles.foot}>
<span className={styles.summary}>
{items.length === 0
? 'Nothing uploaded yet'
: pending
? 'Uploading…'
: `${okCount} added${errCount ? ` · ${errCount} failed` : ''}`}
</span>
<Button variant="ghost" onClick={onClose}>
Done
</Button>
</footer>
</motion.div>
</motion.div>
);
if (typeof document === 'undefined') return modal;
return createPortal(modal, document.body);
}