- Scene2D pixel tavern (replaces three.js 3D scene) - amerc-api: node:http+node:sqlite+node:crypto, auth/admin/docs/files/boards - docs.amerc.ai + pm.amerc.ai (whiteboard mindmap + netdisk) apps - agent API keys for fleet read/write Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
5.5 KiB
JavaScript
105 lines
5.5 KiB
JavaScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { api, fileUrl, fileToBase64 } from './api.js';
|
|
|
|
const isImage = (m) => /^image\//.test(m || '');
|
|
const isText = (f) => f.kind === 'text' || /^text\/|json|markdown|javascript|xml|csv/.test(f.mime || '');
|
|
const human = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
|
|
|
// Netdisk file browser. `onPick` (optional) turns rows into a picker for the whiteboard.
|
|
export default function Netdisk({ onPick, compact }) {
|
|
const [files, setFiles] = useState([]);
|
|
const [folder, setFolder] = useState('');
|
|
const [sel, setSel] = useState(null);
|
|
const [err, setErr] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const fileInput = useRef(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setErr('');
|
|
try { const d = await api('/files'); setFiles(d.files || []); } catch (e) { setErr(e.message); }
|
|
}, []);
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const folders = Array.from(new Set(['', ...files.map((f) => f.folder || '')])).sort();
|
|
const shown = files.filter((f) => (folder ? (f.folder || '') === folder : true));
|
|
|
|
const upload = async (fileList) => {
|
|
setBusy(true); setErr('');
|
|
try {
|
|
for (const file of fileList) {
|
|
const data = await fileToBase64(file);
|
|
await api('/files', { method: 'POST', body: { name: file.name, folder, mime: file.type || 'application/octet-stream', data } });
|
|
}
|
|
await load();
|
|
} catch (e) { setErr(e.message); } finally { setBusy(false); }
|
|
};
|
|
const newTextFile = async () => {
|
|
const name = window.prompt('New text file name', 'note.md'); if (!name) return;
|
|
try { await api('/files', { method: 'POST', body: { name, folder, kind: 'text', text: '', mime: name.endsWith('.md') ? 'text/markdown' : 'text/plain' } }); load(); }
|
|
catch (e) { setErr(e.message); }
|
|
};
|
|
const del = async (id) => { if (!window.confirm('Delete file?')) return; try { await api(`/files/${id}`, { method: 'DELETE' }); setSel(null); load(); } catch (e) { setErr(e.message); } };
|
|
|
|
return (
|
|
<div className={`nd ${compact ? 'nd-compact' : ''}`}>
|
|
<div className="nd-bar">
|
|
<select value={folder} onChange={(e) => setFolder(e.target.value)}>
|
|
{folders.map((f) => <option key={f} value={f}>{f || '— all / root —'}</option>)}
|
|
</select>
|
|
<input className="nd-folder-input" placeholder="folder for new uploads" value={folder} onChange={(e) => setFolder(e.target.value)} />
|
|
<button className="cs-btn" onClick={() => fileInput.current?.click()} disabled={busy}>{busy ? '…' : 'Upload'}</button>
|
|
<button className="cs-btn" onClick={newTextFile}>New text</button>
|
|
<input ref={fileInput} type="file" multiple hidden onChange={(e) => { upload([...e.target.files]); e.target.value = ''; }} />
|
|
</div>
|
|
{err && <p className="cs-err">{err}</p>}
|
|
<div className="nd-grid">
|
|
<div className="nd-list">
|
|
{shown.map((f) => (
|
|
<div key={f.id} className={`nd-row${sel?.id === f.id ? ' on' : ''}`} onClick={() => setSel(f)}>
|
|
<span className="nd-icon">{isImage(f.mime) ? '🖼' : isText(f) ? '📄' : '📦'}</span>
|
|
<span className="nd-name" title={f.name}>{f.name}</span>
|
|
<span className="nd-meta">{f.folder || '/'} · {human(f.size)}</span>
|
|
{onPick && <button className="cs-btn nd-pick" onClick={(e) => { e.stopPropagation(); onPick(f); }}>link</button>}
|
|
<button className="nd-del" onClick={(e) => { e.stopPropagation(); del(f.id); }}>✕</button>
|
|
</div>
|
|
))}
|
|
{!shown.length && <div className="nd-empty">no files — upload or create one</div>}
|
|
</div>
|
|
{!compact && <FilePreview file={sel} onChange={load} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FilePreview({ file, onChange }) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [text, setText] = useState('');
|
|
const [err, setErr] = useState('');
|
|
useEffect(() => {
|
|
setEditing(false); setErr('');
|
|
if (file && (file.kind === 'text')) {
|
|
api(`/files/${file.id}`).then((d) => setText(d.file.text_body || '')).catch((e) => setErr(e.message));
|
|
}
|
|
}, [file]);
|
|
if (!file) return <div className="nd-preview nd-preview-empty">select a file</div>;
|
|
const save = async () => { try { await api(`/files/${file.id}`, { method: 'PATCH', body: { text } }); setEditing(false); onChange && onChange(); } catch (e) { setErr(e.message); } };
|
|
return (
|
|
<div className="nd-preview">
|
|
<div className="nd-preview-head">
|
|
<strong>{file.name}</strong>
|
|
<span>{file.mime} · {human(file.size)}</span>
|
|
<a className="cs-btn" href={fileUrl(file.id)} target="_blank" rel="noreferrer">Open ↗</a>
|
|
{file.kind === 'text' && !editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
|
|
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
|
|
</div>
|
|
{err && <p className="cs-err">{err}</p>}
|
|
<div className="nd-preview-body">
|
|
{isImage(file.mime) && <img src={fileUrl(file.id)} alt={file.name} className="nd-img" />}
|
|
{file.kind === 'text' && editing && <textarea className="nd-text" value={text} onChange={(e) => setText(e.target.value)} />}
|
|
{file.kind === 'text' && !editing && <pre className="nd-textview">{text}</pre>}
|
|
{!isImage(file.mime) && file.kind !== 'text' && <div className="nd-binary">Binary file — <a href={fileUrl(file.id)} target="_blank" rel="noreferrer">download / open</a></div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|