Add admin projects manager and per-project import target
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+154
-7
@@ -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 (
|
||||
<section className="card span-2">
|
||||
<div className="card-head">
|
||||
<h3>Projects</h3>
|
||||
<span className="card-sub">{rows.length} registered · rename and remove act on the registry, not the file</span>
|
||||
</div>
|
||||
<table className="backup-table projects-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(r => (
|
||||
<tr key={r.name}>
|
||||
<td className="mono">{r.name}</td>
|
||||
<td className="mono projects-path" title={r.path}>{r.path}</td>
|
||||
<td>{typeof r.size === 'number' ? fmtBytes(r.size) : <span className="muted">—</span>}</td>
|
||||
<td>
|
||||
{r.missing
|
||||
? <span className="project-missing-badge">missing</span>
|
||||
: <span className="pill mini ok">✓ ok</span>}
|
||||
</td>
|
||||
<td className="row-actions-cell">
|
||||
<button className="row-btn" disabled={!!r.missing} onClick={() => onRenameProject(r.name)}>
|
||||
<Icon name="edit" size={12}/> Rename
|
||||
</button>
|
||||
<button className="row-btn" onClick={() => onRemoveProject(r.name)}>
|
||||
<Icon name="trash" size={12}/> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<form className="projects-register" onSubmit={submitRegister}>
|
||||
<input
|
||||
type="text"
|
||||
className="projects-input"
|
||||
placeholder="/path/to/project — directory or .md file"
|
||||
value={regPath}
|
||||
onChange={e => setRegPath(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="projects-input projects-input-name"
|
||||
placeholder="Display name (optional)"
|
||||
value={regName}
|
||||
onChange={e => setRegName(e.target.value)}
|
||||
/>
|
||||
<button className="btn-primary" type="submit" disabled={!regPath.trim() || registering}>
|
||||
<Icon name="plus" size={12}/> Register
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Projects (api mode only) */}
|
||||
{storageMode === 'api' && (
|
||||
<ProjectsCard
|
||||
projects={projects}
|
||||
onRegisterProject={onRegisterProject}
|
||||
onRenameProject={onRenameProject}
|
||||
onRemoveProject={onRemoveProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Manual actions */}
|
||||
<section className="card">
|
||||
<div className="card-head"><h3>Manual actions</h3></div>
|
||||
@@ -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
|
||||
<section className="card span-3">
|
||||
<div className="card-head">
|
||||
<h3>Archive manager</h3>
|
||||
<span className="card-sub">
|
||||
{archivableItems.length} archivable · {archiveEntries.length} archived
|
||||
</span>
|
||||
<div className="card-head-right" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{isMulti && (
|
||||
<select
|
||||
value={archivePid ?? ''}
|
||||
onChange={e => setArchivePid(e.target.value)}
|
||||
className="archive-filter"
|
||||
title="Archive source project"
|
||||
aria-label="Archive source project"
|
||||
>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span className="card-sub">
|
||||
{archivableItems.length} archivable · {archiveEntries.length} archived
|
||||
{isMulti ? ` · ${archivePid}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Archivable items */}
|
||||
@@ -850,3 +996,4 @@ function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onV
|
||||
|
||||
window.AdminPage = AdminPage;
|
||||
window.ArchiveCard = ArchiveCard;
|
||||
window.ProjectsCard = ProjectsCard;
|
||||
|
||||
+100
-16
@@ -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 }) {
|
||||
<Dialog open={open} onClose={onClose} width={760} labelledBy="dlg-ie-title">
|
||||
<DialogHeader id="dlg-ie-title" eyebrow="Backup & sync" title="Import / Export" onClose={onClose}/>
|
||||
<div className="dlg-body">
|
||||
{isMulti && (
|
||||
<div className="ie-target-row">
|
||||
<label className="ie-target-label" htmlFor="ie-target-select">Target project</label>
|
||||
<select
|
||||
id="ie-target-select"
|
||||
className="ie-target"
|
||||
value={targetId ?? ''}
|
||||
onChange={e => setTargetId(e.target.value)}
|
||||
>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="card-sub">export and import apply to this project only</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ie-tabs">
|
||||
<button className={`ie-tab ${tab === 'md' ? 'active' : ''}`} onClick={() => setTab('md')}>
|
||||
Markdown <span className="ie-tab-sub">.md</span>
|
||||
|
||||
@@ -1450,3 +1450,41 @@ input { font: inherit; color: inherit; }
|
||||
font-size: 13px; color: var(--ink-2); cursor: pointer;
|
||||
}
|
||||
.dlg-checkbox input { width: 16px; height: 16px; accent-color: var(--danger); cursor: pointer; }
|
||||
|
||||
/* ---- Admin Projects card + import/export target select (multi-project) ---- */
|
||||
.projects-table .projects-path {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.projects-register {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-top: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.projects-input {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
font-family: var(--font-mono); font-size: 12.5px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink);
|
||||
}
|
||||
.projects-input::placeholder { color: var(--ink-3); }
|
||||
.projects-input:focus { outline: none; border-color: var(--accent-edge); }
|
||||
.projects-input-name { flex: 0 1 200px; font-family: inherit; }
|
||||
.ie-target-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ie-target-label {
|
||||
font-size: 11px; font-weight: 600;
|
||||
color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ie-target {
|
||||
font: inherit; font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user