From d7e212150e6c26b54778d6dbe2b18a0b24313ae7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 28 Aug 2026 15:22:16 +0200 Subject: [PATCH] Add admin projects manager and per-project import target Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- web/admin.jsx | 161 ++++++++++++++++++++++++++++++++++++++++++++++--- web/app.jsx | 116 ++++++++++++++++++++++++++++++----- web/styles.css | 38 ++++++++++++ 3 files changed, 292 insertions(+), 23 deletions(-) diff --git a/web/admin.jsx b/web/admin.jsx index 67f1a55..20378dc 100644 --- a/web/admin.jsx +++ b/web/admin.jsx @@ -461,7 +461,106 @@ function AppearanceCard({ tweaks, setTweak }) { ); } -function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup, archiveData, onArchiveItems, onRestoreItems, onViewItem }) { +// ---- Projects card (api mode only, todo 8) ---- +// Registry manager: rows come straight from the server (authoritative size / +// missing flags). Rename/remove reuse the SAME app-level handlers the backlog +// sections use, so the ConfirmDialog-with-checkbox and PromptDialog flows have +// exactly one implementation. +function ProjectsCard({ projects, onRegisterProject, onRenameProject, onRemoveProject }) { + const propRows = () => (projects || []).map(p => ({ name: p.name, path: p.path, size: undefined, missing: !!p.missing })); + const [rows, setRows] = useState(propRows); + const [regPath, setRegPath] = useState(''); + const [regName, setRegName] = useState(''); + const [registering, setRegistering] = useState(false); + + // Re-fetch the registry whenever the app-level project set changes + // (register/rename/remove/poller reloads); on failure fall back to the + // app's list so the card never blanks out. + useEffect(() => { + let cancelled = false; + ApiBackend.listProjects() + .then(list => { if (!cancelled && Array.isArray(list)) setRows(list); }) + .catch(() => { if (!cancelled) setRows(propRows()); }); + return () => { cancelled = true; }; + }, [projects]); + + const submitRegister = (e) => { + e.preventDefault(); + const path = regPath.trim(); + if (!path || registering) return; + setRegistering(true); + // onRegisterProject returns a promise; the app toasts success/error. + Promise.resolve(onRegisterProject(path, regName.trim() || null)) + .finally(() => { + setRegistering(false); + setRegPath(''); + setRegName(''); + }); + }; + + return ( +
+
+

Projects

+ {rows.length} registered · rename and remove act on the registry, not the file +
+ + + + + + + + + + + + {rows.map(r => ( + + + + + + + + ))} + +
NamePathSizeStatus
{r.name}{r.path}{typeof r.size === 'number' ? fmtBytes(r.size) : —} + {r.missing + ? missing + : ✓ ok} + + + +
+
+ setRegPath(e.target.value)} + /> + setRegName(e.target.value)} + /> + +
+
+ ); +} + +function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup, archiveData, onArchiveItems, onRestoreItems, onViewItem, storageMode, projects, onRegisterProject, onRenameProject, onRemoveProject }) { const [previewBackup, setPreviewBackup] = useState(null); const counts = countByStatus(data.entries); @@ -521,6 +620,16 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo + {/* Projects (api mode only) */} + {storageMode === 'api' && ( + + )} + {/* Manual actions */}

Manual actions

@@ -657,6 +766,7 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo onArchiveItems={onArchiveItems} onRestoreItems={onRestoreItems} onViewItem={onViewItem} + projects={storageMode === 'api' ? projects : []} /> {/* Recent history */} @@ -698,16 +808,36 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo } // ---- Archive Manager Card ---- -function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onViewItem }) { +// In multi-project api mode the card scopes to ONE project (select at the +// top): both lists and every count reflect the selection. Writes already +// route per-project in app.jsx (archive/restore group by _projectId), so the +// select is a read-side filter — restore of an item from project X rewrites +// only X's backlog.md + archive.md. Single-project mode renders exactly as +// before (no select). +function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onViewItem, projects = [] }) { const [selectedIds, setSelectedIds] = useState([]); const [filter, setFilter] = useState('done'); // 'done', 'cancelled', 'all' const [searchTerm, setSearchTerm] = useState(''); const [archiveSearchTerm, setArchiveSearchTerm] = useState(''); const [expandedArchive, setExpandedArchive] = useState(false); + const isMulti = projects.length > 1; + const [archivePid, setArchivePid] = useState(projects[0]?.id ?? null); + + // Keep the selection valid across register/rename/remove reloads. + useEffect(() => { + if (isMulti && !projects.some(p => p.id === archivePid)) { + setArchivePid(projects[0]?.id ?? null); + } + }, [projects]); + + const pidOf = x => x._projectId ?? projects[0]?.id; + const scopedBacklog = isMulti ? entries.filter(e => pidOf(e) === archivePid) : entries; + const scopedArchive = isMulti ? (archiveData?.entries || []).filter(e => pidOf(e) === archivePid) + : (archiveData?.entries || []); // Find archivable items (done or cancelled) const archivableItems = []; - walkTree(entries, (item) => { + walkTree(scopedBacklog, (item) => { if (filter === 'all' || item.status === filter) { if (item.status === 'done' || item.status === 'cancelled') { archivableItems.push(item); @@ -721,7 +851,7 @@ function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onV ); // Archive entries - const archiveEntries = archiveData?.entries || []; + const archiveEntries = scopedArchive; const filteredArchiveItems = []; walkTree(archiveEntries, (item) => { if (!archiveSearchTerm || item.title.toLowerCase().includes(archiveSearchTerm.toLowerCase())) { @@ -748,9 +878,25 @@ function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onV

Archive manager

- - {archivableItems.length} archivable · {archiveEntries.length} archived - +
+ {isMulti && ( + + )} + + {archivableItems.length} archivable · {archiveEntries.length} archived + {isMulti ? ` · ${archivePid}` : ''} + +
{/* Archivable items */} @@ -850,3 +996,4 @@ function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onV window.AdminPage = AdminPage; window.ArchiveCard = ArchiveCard; +window.ProjectsCard = ProjectsCard; diff --git a/web/app.jsx b/web/app.jsx index d7116e9..ecc01c3 100644 --- a/web/app.jsx +++ b/web/app.jsx @@ -247,6 +247,17 @@ function App() { } }; + // Register a project and refresh every derived piece of state. Shared by + // the ItemDialog "Project" type (todo 7) and the admin Projects card + // register form (todo 8) — one implementation, two callers. + const registerProjectAndRefresh = async (path, name = null) => { + const result = await ApiBackend.registerProject(path, name); + if (!result.ok) throw new Error(result.error || 'Register failed'); + await reloadProjects({ seedExpanded: false }); + await SyncPoller.syncChecksums(); + return result; + }; + // ---- Storage initialisation (runs once on mount) ---- useEffectMain(() => { let cancelled = false; @@ -630,13 +641,8 @@ function App() { if (vals.createAsProject && vals.projectPath) { const path = vals.projectPath; setItemDialog(null); - ApiBackend.registerProject(path, vals.title || null) - .then(async (result) => { - if (!result.ok) throw new Error(result.error || 'Register failed'); - await reloadProjects({ seedExpanded: false }); - await SyncPoller.syncChecksums(); - showToast(`Project registered: ${result.name}`); - }) + registerProjectAndRefresh(path, vals.title || null) + .then(result => showToast(`Project registered: ${result.name}`)) .catch(e => showToast('Register failed: ' + e.message, 'err')); return; } @@ -897,9 +903,34 @@ function App() { }; // ---- Import entries from parsed content ---- - const handleImport = ({ entries, history }) => { - // In api mode imported rows land in the first project (todo 8 adds a - // target-project selector); other projects are left untouched. + const handleImport = ({ entries, history, targetProjectId }) => { + // Multi-project target (todo 8): the dialog's "Target project" select + // routes the file into ONE project. Its backlog.md is written directly + // (merge-only id suffixes stripped via toDiskIds), other projects' + // files are untouched, then everything reloads from the server. + if (Storage.mode === 'api' && targetProjectId) { + if (!entries.length && !(history || []).length) { + showToast('Import failed: no entries found in file', 'err'); + return; + } + (async () => { + try { + const content = await Parser.serialize(toDiskIds(entries, history || [], targetProjectId)); + const result = await ApiBackend.saveProject(targetProjectId, content); + if (!result.ok) throw new Error(result.error || 'Import failed'); + if (result.checksum) SyncPoller.lastChecksums[targetProjectId] = result.checksum; + setImportExportOpen(false); + await reloadProjects({ seedExpanded: false }); + await SyncPoller.syncChecksums(); + showToast(`Imported into ${targetProjectId}`); + } catch (e) { + showToast('Import failed: ' + e.message, 'err'); + } + })(); + return; + } + // Single-project path: imported rows land in the first project (api + // mode with one registered project) or in the single backlog file. const pid = Storage.mode === 'api' ? resolveTargetProjectId() : null; mutate(d => { if (pid) { @@ -1390,6 +1421,15 @@ function App() { onArchiveItems={handleArchiveItems} onRestoreItems={handleRestoreItems} onViewItem={(item) => setItemDialog({ mode: 'view', initial: item })} + storageMode={storageMode} + projects={projects} + onRegisterProject={(path, name) => + registerProjectAndRefresh(path, name) + .then(result => showToast(`Project registered: ${result.name}`)) + .catch(e => showToast('Register failed: ' + e.message, 'err')) + } + onRenameProject={handleRenameProject} + onRemoveProject={handleRemoveProject} /> )} @@ -1433,6 +1473,7 @@ function App() { open={importExportOpen} data={data} storageMode={storageMode} + projects={projects} onClose={() => setImportExportOpen(false)} onImport={handleImport} /> @@ -1571,27 +1612,54 @@ function Header({ view, setView, saveState, saveLabel, storageMode, searchValue, } // ----- Import / Export dialog ----- -function ImportExportDialog({ open, data, storageMode, onClose, onImport }) { +// Multi-project api mode (projects.length > 1) adds a "Target project" +// select: export serializes only the selected project's entries/history (as a +// plain upstream file — merge-only id suffixes stripped), import writes only +// that project's backlog.md. Single-project mode is unchanged. +function ImportExportDialog({ open, data, storageMode, projects = [], onClose, onImport }) { const { useState: useStateD, useEffect: useEffectD, useRef: useRefD } = React; const [tab, setTab] = useStateD('md'); const [copied, setCopied] = useStateD(false); const [mdContent, setMdContent] = useStateD(''); + const [targetId, setTargetId] = useStateD(null); const fileInputRef = useRefD(null); + const isMulti = storageMode === 'api' && projects.length > 1; + + // Default the target to the first registered project on every open. + useEffectD(() => { + if (open) setTargetId(projects[0]?.id ?? null); + }, [open]); + + // Export scope: full data in single mode; the selected project's rows in + // multi mode (toDiskIds keeps exported ids free of `~project` suffixes). + const exportScope = React.useMemo(() => { + if (!isMulti || !targetId) { + return { entries: data?.entries || [], history: data?.history || [], meta: data?.meta }; + } + const pidOf = x => x._projectId ?? projects[0]?.id; + const scoped = toDiskIds( + (data?.entries || []).filter(e => pidOf(e) === targetId), + (data?.history || []).filter(h => pidOf(h) === targetId), + targetId, + ); + return { ...scoped, meta: data?.meta }; + }, [isMulti, targetId, data, projects]); + // Async-generate markdown when dialog opens or data changes. useEffectD(() => { if (!open || !data) return; let cancelled = false; - Parser.serialize({ entries: data.entries, history: data.history }) + Parser.serialize({ entries: exportScope.entries, history: exportScope.history }) .then(md => { if (!cancelled) setMdContent(md); }) .catch(() => {}); return () => { cancelled = true; }; - }, [open, data]); + }, [open, data, exportScope]); const jsonContent = React.useMemo(() => { if (!data) return ''; - return JSON.stringify({ entries: data.entries, history: data.history, meta: data.meta }, null, 2); - }, [data]); + return JSON.stringify({ entries: exportScope.entries, history: exportScope.history, meta: exportScope.meta }, null, 2); + }, [data, exportScope]); const currentContent = tab === 'md' ? mdContent : jsonContent; const filename = tab === 'md' ? 'backlog.md' : 'backlog_export.json'; @@ -1635,7 +1703,7 @@ function ImportExportDialog({ open, data, storageMode, onClose, onImport }) { entries = parsed.entries; history = parsed.history; } - onImport({ entries, history }); + onImport({ entries, history, targetProjectId: isMulti ? targetId : undefined }); } catch (err) { alert('Import failed: ' + err.message); } @@ -1645,6 +1713,22 @@ function ImportExportDialog({ open, data, storageMode, onClose, onImport }) {
+ {isMulti && ( +
+ + + export and import apply to this project only +
+ )}