amerc/src/Netdisk.jsx
artheru b055663372 amerc suite: 2D pixel tavern + zero-dep auth/admin/docs/pm backend
- 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>
2026-06-09 06:03:40 +08:00

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>
);
}