Add cold archive feature for done/cancelled items
- Separate archive.md file for archived items (append-only) - Archive manager in Admin page with filters and search - Global search shows archive results with count in header - Restore items to original location via restore-path metadata - Read-only view dialog for archived items (double-click to open) - Python API endpoints: GET/POST /api/archive, POST /api/archive/restore - Support in all storage backends (API, Direct, Browser)
This commit is contained in:
@@ -94,6 +94,22 @@ All your tasks live in `backlog.md`, structured as:
|
||||
|
||||
You can edit this file in any text editor. The app detects external changes and reloads automatically.
|
||||
|
||||
### Archive File
|
||||
|
||||
Completed tasks can be moved to `archive.md` to keep the main backlog clean. The archive uses the same format with an additional `restore-path` field:
|
||||
|
||||
```markdown
|
||||
# Archive
|
||||
|
||||
<!-- SECTION: ENTRIES -->
|
||||
|
||||
- [x] [P1] Old completed task *(archived: 2025-05-01, restore-path: i-abc/i-def)*
|
||||
> Original body preserved
|
||||
- [x] Child task also archived
|
||||
```
|
||||
|
||||
The `restore-path` stores the original parent chain, allowing items to be restored to their original location. If the parent no longer exists, items restore to root level.
|
||||
|
||||
### Features
|
||||
|
||||
- **4-level nesting** — Area → Project → Task → Sub-task
|
||||
@@ -108,7 +124,8 @@ You can edit this file in any text editor. The app detects external changes and
|
||||
- **Automatic backups** — timestamped on every save, rotating retention
|
||||
- **Stats & metrics** — items created/completed, avg time in-progress, most active project — all from real history data
|
||||
- **Import/Export** — Markdown or JSON, with checksum validation
|
||||
- **Admin page** — health monitoring, backup browser, stats overview, manual actions
|
||||
- **Archive** — move done/cancelled items to separate `archive.md` to reduce clutter; searchable archive with restore capability; items return to original location
|
||||
- **Admin page** — health monitoring, backup browser, archive manager, stats overview, manual actions
|
||||
- **Dark mode** — system / light / dark, configurable in Admin → Appearance; persisted across reloads
|
||||
- **Icon sets** — 4 styles (Color, Flat, Emoji, ASCII), configurable in Admin → Appearance; persisted across reloads
|
||||
- **Chrome / Edge direct file access** — reads and writes `backlog.md` on disk directly via the File System Access API; no server needed
|
||||
@@ -179,8 +196,9 @@ personal-backlog/
|
||||
│ ├── architecture.md # High-level architecture
|
||||
│ └── tdd.md # Technical Design Document
|
||||
├── server/
|
||||
│ ├── server.py # Python REST API server (~420 LoC, stdlib only)
|
||||
│ ├── server.py # Python REST API server (~450 LoC, stdlib only)
|
||||
│ ├── backlog.md # Master data file (created on first run)
|
||||
│ ├── archive.md # Archived items (created on first archive)
|
||||
│ ├── backups/ # Automatic timestamped backups
|
||||
│ └── stats.jsonl # Append-only analytics log
|
||||
├── web/ # V2 source code (React 18 + JSX) + build tooling
|
||||
|
||||
@@ -23,6 +23,7 @@ class Config:
|
||||
self.dir = directory.resolve()
|
||||
self.port = port
|
||||
self.master = self.dir / "backlog.md"
|
||||
self.archive = self.dir / "archive.md"
|
||||
self.backups_dir = self.dir / "backups"
|
||||
self.stats_file = self.dir / "stats.jsonl"
|
||||
self.web_dir = web_dir.resolve() if web_dir else Path(__file__).parent.parent / "webapp"
|
||||
@@ -129,6 +130,26 @@ def write_master(content: str) -> dict:
|
||||
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
|
||||
|
||||
|
||||
def read_archive() -> dict:
|
||||
"""Read archive.md, return empty structure if not exists."""
|
||||
if not CONFIG.archive.exists():
|
||||
return {"content": "", "checksum": "", "size": 0, "exists": False}
|
||||
text = CONFIG.archive.read_text(encoding="utf-8")
|
||||
entries, history, meta = parse_markdown_sections(text)
|
||||
checksum = meta["checksum"] if meta else ""
|
||||
return {"content": text, "checksum": checksum, "size": len(text.encode("utf-8")), "exists": True}
|
||||
|
||||
|
||||
def write_archive(content: str) -> dict:
|
||||
"""Write archive.md (append-only semantics enforced at API level)."""
|
||||
tmp = CONFIG.archive.with_suffix(".md.tmp")
|
||||
tmp.write_text(content, encoding="utf-8")
|
||||
parse_markdown_sections(content) # Verify it parses
|
||||
tmp.replace(CONFIG.archive)
|
||||
_, _, meta = parse_markdown_sections(content)
|
||||
return {"ok": True, "checksum": meta["checksum"] if meta else "", "saved": meta["saved"] if meta else ""}
|
||||
|
||||
|
||||
def rotate_backups():
|
||||
files = sorted(CONFIG.backups_dir.glob("backlog_*.md"), key=lambda p: p.stat().st_mtime)
|
||||
if not files:
|
||||
@@ -285,6 +306,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
if path == "/api/health":
|
||||
info = read_master()
|
||||
archive_info = read_archive()
|
||||
backups = list_backups()
|
||||
self._json_response({
|
||||
"status": "ok",
|
||||
@@ -294,6 +316,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"backupCount": len(backups),
|
||||
"masterPath": str(CONFIG.master),
|
||||
"backupsPath": str(CONFIG.backups_dir),
|
||||
"archiveExists": archive_info["exists"],
|
||||
"archiveSize": archive_info["size"],
|
||||
"archivePath": str(CONFIG.archive),
|
||||
})
|
||||
return
|
||||
|
||||
@@ -308,6 +333,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json_response({"content": info["content"], "checksum": info["checksum"]})
|
||||
return
|
||||
|
||||
if path == "/api/archive":
|
||||
info = read_archive()
|
||||
if not info["exists"]:
|
||||
self._json_response({"content": "", "checksum": "", "exists": False})
|
||||
return
|
||||
client_checksum = qs.get("checksum", [None])[0]
|
||||
if client_checksum and client_checksum == info["checksum"]:
|
||||
self.send_response(304)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
return
|
||||
self._json_response({"content": info["content"], "checksum": info["checksum"], "exists": True})
|
||||
return
|
||||
|
||||
if path == "/api/backups":
|
||||
self._json_response({"backups": list_backups()})
|
||||
return
|
||||
@@ -342,6 +381,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json_response(result)
|
||||
return
|
||||
|
||||
if path == "/api/archive":
|
||||
# Archive items: save both backlog and archive atomically
|
||||
body = self._read_json_body()
|
||||
backlog_content = body.get("backlog", "")
|
||||
archive_content = body.get("archive", "")
|
||||
# Validate both parse correctly
|
||||
parse_markdown_sections(backlog_content)
|
||||
parse_markdown_sections(archive_content)
|
||||
# Write archive first (append-only), then backlog
|
||||
archive_result = write_archive(archive_content)
|
||||
backlog_result = write_master(backlog_content)
|
||||
self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result})
|
||||
return
|
||||
|
||||
if path == "/api/archive/restore":
|
||||
# Restore items from archive: save both backlog and archive atomically
|
||||
body = self._read_json_body()
|
||||
backlog_content = body.get("backlog", "")
|
||||
archive_content = body.get("archive", "")
|
||||
parse_markdown_sections(backlog_content)
|
||||
parse_markdown_sections(archive_content)
|
||||
backlog_result = write_master(backlog_content)
|
||||
archive_result = write_archive(archive_content)
|
||||
self._json_response({"ok": True, "backlog": backlog_result, "archive": archive_result})
|
||||
return
|
||||
|
||||
if path == "/api/export":
|
||||
body = self._read_json_body()
|
||||
fmt = body.get("format", "json")
|
||||
|
||||
+162
-1
@@ -461,7 +461,7 @@ function AppearanceCard({ tweaks, setTweak }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup }) {
|
||||
function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onForceBackup, onCompact, onRestore, onDownloadBackup, archiveData, onArchiveItems, onRestoreItems, onViewItem }) {
|
||||
const [previewBackup, setPreviewBackup] = useState(null);
|
||||
|
||||
const counts = countByStatus(data.entries);
|
||||
@@ -650,6 +650,15 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Archive manager */}
|
||||
<ArchiveCard
|
||||
entries={data.entries}
|
||||
archiveData={archiveData}
|
||||
onArchiveItems={onArchiveItems}
|
||||
onRestoreItems={onRestoreItems}
|
||||
onViewItem={onViewItem}
|
||||
/>
|
||||
|
||||
{/* Recent history */}
|
||||
<section className="card span-3">
|
||||
<div className="card-head">
|
||||
@@ -688,4 +697,156 @@ function AdminPage({ data, onClose, history, tweaks, setTweak, onForceSave, onFo
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Archive Manager Card ----
|
||||
function ArchiveCard({ entries, archiveData, onArchiveItems, onRestoreItems, onViewItem }) {
|
||||
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);
|
||||
|
||||
// Find archivable items (done or cancelled)
|
||||
const archivableItems = [];
|
||||
walkTree(entries, (item) => {
|
||||
if (filter === 'all' || item.status === filter) {
|
||||
if (item.status === 'done' || item.status === 'cancelled') {
|
||||
archivableItems.push(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter by search
|
||||
const filteredItems = archivableItems.filter(it =>
|
||||
!searchTerm || it.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Archive entries
|
||||
const archiveEntries = archiveData?.entries || [];
|
||||
const filteredArchiveItems = [];
|
||||
walkTree(archiveEntries, (item) => {
|
||||
if (!archiveSearchTerm || item.title.toLowerCase().includes(archiveSearchTerm.toLowerCase())) {
|
||||
filteredArchiveItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(filteredItems.map(it => it.id));
|
||||
};
|
||||
|
||||
const handleArchive = () => {
|
||||
if (selectedIds.length > 0) {
|
||||
onArchiveItems(selectedIds);
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* Archivable items */}
|
||||
<div className="archive-section">
|
||||
<div className="archive-toolbar">
|
||||
<span className="archive-label">Archivable items</span>
|
||||
<select value={filter} onChange={e => setFilter(e.target.value)} className="archive-filter">
|
||||
<option value="done">Done only</option>
|
||||
<option value="cancelled">Cancelled only</option>
|
||||
<option value="all">Done + Cancelled</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="archive-search"
|
||||
/>
|
||||
<button className="btn-secondary btn-sm" onClick={selectAll} disabled={filteredItems.length === 0}>
|
||||
Select all
|
||||
</button>
|
||||
<button className="btn-primary btn-sm" onClick={handleArchive} disabled={selectedIds.length === 0}>
|
||||
Archive {selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="archive-empty">No archivable items found</div>
|
||||
) : (
|
||||
<div className="archive-list">
|
||||
{filteredItems.slice(0, 20).map(it => (
|
||||
<label key={it.id} className="archive-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(it.id)}
|
||||
onChange={() => toggleSelect(it.id)}
|
||||
/>
|
||||
<StatusIcon status={it.status} size={14}/>
|
||||
<span className="archive-item-title">{it.title}</span>
|
||||
{it.children?.length > 0 && (
|
||||
<span className="archive-item-children">+{it.children.length} children</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{filteredItems.length > 20 && (
|
||||
<div className="archive-more">...and {filteredItems.length - 20} more</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Archive browser */}
|
||||
<div className="archive-section">
|
||||
<div className="archive-toolbar">
|
||||
<span className="archive-label">Archived items ({archiveEntries.length})</span>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setExpandedArchive(!expandedArchive)}>
|
||||
{expandedArchive ? 'Collapse' : 'Expand'}
|
||||
</button>
|
||||
{expandedArchive && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search archive..."
|
||||
value={archiveSearchTerm}
|
||||
onChange={e => setArchiveSearchTerm(e.target.value)}
|
||||
className="archive-search"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expandedArchive && (
|
||||
filteredArchiveItems.length === 0 ? (
|
||||
<div className="archive-empty">
|
||||
{archiveEntries.length === 0 ? 'Archive is empty' : 'No matches found'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="archive-list">
|
||||
{filteredArchiveItems.slice(0, 30).map(it => (
|
||||
<div key={it.id} className="archive-item archived">
|
||||
<StatusIcon status={it.status} size={14}/>
|
||||
<span className="archive-item-title" onDoubleClick={() => onViewItem?.(it)}>{it.title}</span>
|
||||
{it.archived && (
|
||||
<span className="archive-item-date">archived {it.archived}</span>
|
||||
)}
|
||||
<button className="btn-secondary btn-xs" onClick={() => onRestoreItems([it.id])}>Restore</button>
|
||||
</div>
|
||||
))}
|
||||
{filteredArchiveItems.length > 30 && (
|
||||
<div className="archive-more">...and {filteredArchiveItems.length - 30} more</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
window.AdminPage = AdminPage;
|
||||
window.ArchiveCard = ArchiveCard;
|
||||
|
||||
+230
-1
@@ -56,6 +56,7 @@ function App() {
|
||||
const [itemDialog, setItemDialog] = useStateMain(null);
|
||||
const [confirm, setConfirm] = useStateMain(null);
|
||||
const [needsConnect, setNeedsConnect] = useStateMain(false);
|
||||
const [archiveData, setArchiveData] = useStateMain({ entries: [], history: [] });
|
||||
|
||||
const TWEAK_DEFAULTS = { accent_hue: 35, density: 'comfortable', show_ids: false, paper_texture: true, status_style: 'color', sort_mode: 'priority', theme: 'system' };
|
||||
const [tweaks, setTweak] = useTweaks({ ...TWEAK_DEFAULTS, ...(loadLocalState()?.tweaks ?? {}) });
|
||||
@@ -441,6 +442,19 @@ function App() {
|
||||
|
||||
const filteredCount = useMemoMain(() => countAll(filtered), [filtered]);
|
||||
const totalCount = useMemoMain(() => countAll(data.entries), [data]);
|
||||
const archiveMatchCount = useMemoMain(() => {
|
||||
if (!filters.text?.trim()) return 0;
|
||||
const q = filters.text.trim().toLowerCase();
|
||||
let count = 0;
|
||||
walkTree(archiveData.entries, (item) => {
|
||||
if (item.title.toLowerCase().includes(q) ||
|
||||
item.body?.toLowerCase().includes(q) ||
|
||||
item.tags?.some(t => t.toLowerCase().includes(q))) {
|
||||
count++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}, [filters.text, archiveData.entries]);
|
||||
|
||||
// ---- Keyboard shortcuts ----
|
||||
useEffectMain(() => {
|
||||
@@ -508,6 +522,142 @@ function App() {
|
||||
triggerSave('Imported');
|
||||
};
|
||||
|
||||
// ---- Archive functions ----
|
||||
const loadArchive = async () => {
|
||||
try {
|
||||
const raw = await Storage.loadArchive();
|
||||
if (raw.exists && raw.content) {
|
||||
const parsed = await Parser.parse(raw.content);
|
||||
setArchiveData({ entries: parsed.entries || [], history: parsed.history || [] });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load archive:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Load archive when switching to admin view
|
||||
useEffectMain(() => {
|
||||
if (view === 'admin') loadArchive();
|
||||
}, [view]);
|
||||
|
||||
const handleArchiveItems = async (itemIds) => {
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
const toArchive = [];
|
||||
|
||||
// Build items to archive with restore-path metadata
|
||||
for (const id of itemIds) {
|
||||
const item = findItem(data.entries, id);
|
||||
if (!item) continue;
|
||||
const restorePath = getRestorePath(data.entries, id);
|
||||
// Deep clone and add archive metadata
|
||||
const cloned = JSON.parse(JSON.stringify(item));
|
||||
cloned.archived = now;
|
||||
cloned.restorePath = restorePath || null;
|
||||
toArchive.push(cloned);
|
||||
}
|
||||
|
||||
if (toArchive.length === 0) return;
|
||||
|
||||
// Remove from backlog
|
||||
const { entries: newEntries } = removeItems([...data.entries], itemIds);
|
||||
|
||||
// Add to archive
|
||||
const newArchiveEntries = [...archiveData.entries, ...toArchive];
|
||||
|
||||
// Add history entries
|
||||
const newHistory = [...data.history];
|
||||
for (const item of toArchive) {
|
||||
newHistory.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
itemId: item.id,
|
||||
action: 'item_archived',
|
||||
details: `moved to archive.md`
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Serialize both files
|
||||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||||
const archiveContent = await Parser.serialize({ entries: newArchiveEntries, history: archiveData.history });
|
||||
|
||||
// Save both atomically
|
||||
const result = await Storage.saveArchive(backlogContent, archiveContent);
|
||||
if (!result.ok) throw new Error('Archive failed');
|
||||
|
||||
// Update local state
|
||||
setData(d => ({ ...d, entries: newEntries, history: newHistory }));
|
||||
setArchiveData({ entries: newArchiveEntries, history: archiveData.history });
|
||||
isDirtyRef.current = false;
|
||||
|
||||
showToast(`Archived ${toArchive.length} item${toArchive.length > 1 ? 's' : ''}`);
|
||||
} catch (e) {
|
||||
showToast('Archive failed: ' + e.message, 'err');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreItems = async (itemIds) => {
|
||||
const toRestore = [];
|
||||
const remainingArchive = [];
|
||||
|
||||
// Find items to restore
|
||||
for (const item of archiveData.entries) {
|
||||
if (itemIds.includes(item.id)) {
|
||||
toRestore.push(item);
|
||||
} else {
|
||||
remainingArchive.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (toRestore.length === 0) return;
|
||||
|
||||
// Restore to backlog
|
||||
const newEntries = [...data.entries];
|
||||
const warnings = [];
|
||||
for (const item of toRestore) {
|
||||
const restorePath = item.restorePath;
|
||||
// Clear archive metadata
|
||||
delete item.archived;
|
||||
delete item.restorePath;
|
||||
// Try to insert at original location
|
||||
const inserted = insertAtPath(newEntries, item, restorePath);
|
||||
if (!inserted) {
|
||||
warnings.push(item.title);
|
||||
}
|
||||
}
|
||||
|
||||
// Add history entries
|
||||
const newHistory = [...data.history];
|
||||
for (const item of toRestore) {
|
||||
newHistory.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
itemId: item.id,
|
||||
action: 'item_restored',
|
||||
details: `restored from archive`
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Serialize both files
|
||||
const backlogContent = await Parser.serialize({ entries: newEntries, history: newHistory });
|
||||
const archiveContent = await Parser.serialize({ entries: remainingArchive, history: archiveData.history });
|
||||
|
||||
// Save both atomically
|
||||
const result = await Storage.restoreFromArchive(backlogContent, archiveContent);
|
||||
if (!result.ok) throw new Error('Restore failed');
|
||||
|
||||
// Update local state
|
||||
setData(d => ({ ...d, entries: newEntries, history: newHistory }));
|
||||
setArchiveData({ entries: remainingArchive, history: archiveData.history });
|
||||
isDirtyRef.current = false;
|
||||
|
||||
let msg = `Restored ${toRestore.length} item${toRestore.length > 1 ? 's' : ''}`;
|
||||
if (warnings.length) msg += ` (${warnings.length} to root - parent not found)`;
|
||||
showToast(msg);
|
||||
} catch (e) {
|
||||
showToast('Restore failed: ' + e.message, 'err');
|
||||
}
|
||||
};
|
||||
|
||||
const saveLabel = saveState.status === 'saving' ? 'Saving…'
|
||||
: saveState.status === 'error' ? 'Save failed'
|
||||
: `Saved · ${fmtTimestamp(saveState.lastSaved)}`;
|
||||
@@ -584,7 +734,14 @@ function App() {
|
||||
<div>
|
||||
<div className="eyebrow">Backlog</div>
|
||||
<h1 className="content-title">
|
||||
{hasFilters ? <>{filteredCount} of {totalCount} items</> : <>{totalCount} items</>}
|
||||
{hasFilters ? (
|
||||
<>
|
||||
{filteredCount} of {totalCount} items
|
||||
{archiveMatchCount > 0 && <> · {archiveMatchCount} in archive</>}
|
||||
</>
|
||||
) : (
|
||||
<>{totalCount} items</>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="content-head-actions">
|
||||
@@ -635,6 +792,15 @@ function App() {
|
||||
manualOrder={tweaks.sort_mode === 'manual'}
|
||||
/>
|
||||
)}
|
||||
{filters.text?.trim() && (
|
||||
<ArchiveSearchResults
|
||||
query={filters.text.trim()}
|
||||
archiveEntries={archiveData.entries}
|
||||
onRestore={handleRestoreItems}
|
||||
onLoadArchive={loadArchive}
|
||||
onView={(item) => setItemDialog({ mode: 'view', initial: item })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -664,6 +830,10 @@ function App() {
|
||||
showToast(`${name.slice(0, 28)}… — download not available in direct mode`);
|
||||
}
|
||||
}}
|
||||
archiveData={archiveData}
|
||||
onArchiveItems={handleArchiveItems}
|
||||
onRestoreItems={handleRestoreItems}
|
||||
onViewItem={(item) => setItemDialog({ mode: 'view', initial: item })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -941,4 +1111,63 @@ function ImportExportDialog({ open, data, storageMode, onClose, onImport }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- Archive Search Results -----
|
||||
function ArchiveSearchResults({ query, archiveEntries, onRestore, onLoadArchive, onView }) {
|
||||
const [loading, setLoading] = useStateMain(false);
|
||||
const loadedRef = useRefMain(false);
|
||||
|
||||
// Auto-load archive on first render
|
||||
useEffectMain(() => {
|
||||
if (!loadedRef.current && archiveEntries.length === 0) {
|
||||
loadedRef.current = true;
|
||||
setLoading(true);
|
||||
onLoadArchive().finally(() => setLoading(false));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Filter archive entries by query
|
||||
const matches = [];
|
||||
if (query) {
|
||||
const q = query.toLowerCase();
|
||||
walkTree(archiveEntries, (item) => {
|
||||
if (item.title.toLowerCase().includes(q) ||
|
||||
item.body?.toLowerCase().includes(q) ||
|
||||
item.tags?.some(t => t.toLowerCase().includes(q))) {
|
||||
matches.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="archive-search-section">
|
||||
<div className="archive-search-header">Archive — loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="archive-search-section">
|
||||
<div className="archive-search-header">Archive ({matches.length})</div>
|
||||
<div className="archive-search-results">
|
||||
{matches.slice(0, 10).map(item => (
|
||||
<div key={item.id} className="archive-search-item">
|
||||
<StatusIcon status={item.status} size={14}/>
|
||||
<span className="archive-search-title" onDoubleClick={() => onView(item)}>{item.title}</span>
|
||||
{item.archived && <span className="archive-search-date">{item.archived}</span>}
|
||||
<button className="btn-secondary btn-xs" onClick={() => onRestore([item.id])}>Restore</button>
|
||||
</div>
|
||||
))}
|
||||
{matches.length > 10 && (
|
||||
<div className="archive-search-more">
|
||||
...and {matches.length - 10} more (see Admin → Archive)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
window.App = App;
|
||||
|
||||
+60
-33
@@ -305,8 +305,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader
|
||||
id="dlg-item-title"
|
||||
eyebrow={mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
|
||||
title={mode === "edit" ? "Edit details" : "Create item"}
|
||||
eyebrow={mode === "view" ? "Archived item" : mode === "edit" ? "Edit item" : (mode === "add-child" ? "Add sub-item" : "New item")}
|
||||
title={mode === "view" ? "View details (read-only)" : mode === "edit" ? "Edit details" : "Create item"}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -320,17 +320,22 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="What needs to happen?"
|
||||
required
|
||||
readOnly={mode === "view"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Details <span className="field-hint">rich text, supports markdown</span></label>
|
||||
<RichTextEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Add notes, context, or details..."
|
||||
collapsed={!body}
|
||||
/>
|
||||
<label className="field-label">Details <span className="field-hint">{mode === "view" ? "read-only" : "rich text, supports markdown"}</span></label>
|
||||
{mode === "view" ? (
|
||||
<div className="rte-readonly" dangerouslySetInnerHTML={{ __html: MarkdownUtils.toHtml(body || '') }} />
|
||||
) : (
|
||||
<RichTextEditor
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder="Add notes, context, or details..."
|
||||
collapsed={!body}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
@@ -340,7 +345,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
{PRIORITIES.map(p => (
|
||||
<button type="button" key={p}
|
||||
className={`seg-btn pri-${p} ${priority === p ? "active" : ""}`}
|
||||
onClick={() => setPriority(p)}>
|
||||
onClick={() => mode !== "view" && setPriority(p)}
|
||||
disabled={mode === "view"}>
|
||||
{p === "P0" && <span className="p0-dot"/>}
|
||||
{p}
|
||||
</button>
|
||||
@@ -349,7 +355,7 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Due</label>
|
||||
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)}/>
|
||||
<input className="text-input" type="date" value={due} onChange={(e) => setDue(e.target.value)} readOnly={mode === "view"}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,7 +365,8 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
{STATUSES.map(s => (
|
||||
<button type="button" key={s.key}
|
||||
className={`status-card ${status === s.key ? "active" : ""}`}
|
||||
onClick={() => setStatus(s.key)}>
|
||||
onClick={() => mode !== "view" && setStatus(s.key)}
|
||||
disabled={mode === "view"}>
|
||||
<span className={`status-card-icon status-${s.key}`}>
|
||||
<StatusIcon status={s.key} size={16}/>
|
||||
</span>
|
||||
@@ -374,18 +381,18 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
<label className="field-label">
|
||||
Completion
|
||||
<span className="field-hint">
|
||||
{status === "done"
|
||||
{mode === "view" ? "read-only" : status === "done"
|
||||
? "locked at 100% while marked done"
|
||||
: "rough estimate — set 100% to mark done"}
|
||||
</span>
|
||||
</label>
|
||||
<div className="progress-row">
|
||||
<div className={`seg progress-seg ${status === "done" ? "locked" : ""}`}>
|
||||
<div className={`seg progress-seg ${status === "done" || mode === "view" ? "locked" : ""}`}>
|
||||
{PROGRESS_STEPS.map(p => (
|
||||
<button type="button" key={p}
|
||||
className={`seg-btn ${progress === p ? "active" : ""}`}
|
||||
onClick={() => setProgress(p)}
|
||||
disabled={status === "done"}>
|
||||
disabled={status === "done" || mode === "view"}>
|
||||
{p}%
|
||||
</button>
|
||||
))}
|
||||
@@ -400,32 +407,52 @@ function ItemDialog({ open, mode, initial, onClose, onSubmit, recentTags = [] })
|
||||
<label className="field-label">Block reason</label>
|
||||
<input className="text-input" value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Waiting for…"/>
|
||||
placeholder="Waiting for…"
|
||||
readOnly={mode === "view"}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">Tags <span className="field-hint">type a name, press Enter or comma to add</span></label>
|
||||
<TagAutocompleteInput
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
tagInput={tagInput}
|
||||
setTagInput={setTagInput}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
allTags={recentTags}
|
||||
/>
|
||||
<label className="field-label">Tags {mode !== "view" && <span className="field-hint">type a name, press Enter or comma to add</span>}</label>
|
||||
{mode === "view" ? (
|
||||
<div className="tag-list-readonly">
|
||||
{tags.length === 0 ? <span className="muted">No tags</span> : tags.map(t => (
|
||||
<span key={t} className="tag">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<TagAutocompleteInput
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
tagInput={tagInput}
|
||||
setTagInput={setTagInput}
|
||||
addTag={addTag}
|
||||
removeTag={removeTag}
|
||||
allTags={recentTags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dlg-foot">
|
||||
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
|
||||
<div className="dlg-foot-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={!title.trim()}>
|
||||
{mode === "edit" ? "Save changes" : "Create item"}
|
||||
</button>
|
||||
</div>
|
||||
{mode === "view" ? (
|
||||
<>
|
||||
<span className="dlg-hint">Read-only · archived {initial?.archived || ''}</span>
|
||||
<div className="dlg-foot-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="dlg-hint">⌘↩ to save · Esc to cancel</span>
|
||||
<div className="dlg-foot-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={!title.trim()}>
|
||||
{mode === "edit" ? "Save changes" : "Create item"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
+61
-1
@@ -520,11 +520,71 @@ const ClipboardUtils = {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Archive Helpers ----
|
||||
function getRestorePath(entries, itemId) {
|
||||
// Build path of parent IDs from root to item's parent
|
||||
const path = [];
|
||||
function find(items, targetId, currentPath) {
|
||||
for (const it of items) {
|
||||
if (it.id === targetId) return currentPath;
|
||||
if (it.children?.length) {
|
||||
const result = find(it.children, targetId, [...currentPath, it.id]);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return find(entries, itemId, [])?.join('/') || '';
|
||||
}
|
||||
|
||||
function removeItems(entries, itemIds) {
|
||||
// Remove items by ID, returns removed items with their children
|
||||
const removed = [];
|
||||
const idsSet = new Set(itemIds);
|
||||
function remove(items) {
|
||||
const kept = [];
|
||||
for (const it of items) {
|
||||
if (idsSet.has(it.id)) {
|
||||
removed.push(it);
|
||||
} else {
|
||||
if (it.children?.length) it.children = remove(it.children);
|
||||
kept.push(it);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
const newEntries = remove(entries);
|
||||
return { entries: newEntries, removed };
|
||||
}
|
||||
|
||||
function insertAtPath(entries, item, restorePath) {
|
||||
// Insert item at the path specified by parent IDs
|
||||
if (!restorePath) {
|
||||
entries.push(item);
|
||||
return true;
|
||||
}
|
||||
const pathIds = restorePath.split('/').filter(Boolean);
|
||||
let current = entries;
|
||||
for (const pid of pathIds) {
|
||||
const parent = current.find(it => it.id === pid);
|
||||
if (!parent) {
|
||||
// Parent not found - insert at root with warning
|
||||
entries.push(item);
|
||||
return false;
|
||||
}
|
||||
if (!parent.children) parent.children = [];
|
||||
current = parent.children;
|
||||
}
|
||||
current.push(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
STATUSES, STATUS_BY_KEY, STATUS_ORDER, PRIORITIES,
|
||||
parseDate, daysBetween, isOverdue, dueRelative, fmtTimestamp, fmtBytes, fmtRelative, fmtShortDate,
|
||||
walkTree, findItem, allTags, countAll, countByStatus, filterTree,
|
||||
Icon, StatusIcon, ColorStatusIcon, STATUS_COLORS, STATUS_EMOJI, StatusStyleContext,
|
||||
PROGRESS_STEPS, snapProgress, progressFor, shouldShowProgress, cycleProgress, ProgressGauge,
|
||||
MarkdownUtils, ClipboardUtils
|
||||
MarkdownUtils, ClipboardUtils,
|
||||
getRestorePath, removeItems, insertAtPath
|
||||
});
|
||||
|
||||
+102
-8
@@ -103,16 +103,19 @@ const Parser = {
|
||||
const raw = m[3].trim();
|
||||
|
||||
let title = raw, due = null, reason = null, tags = [], priority = 'P1', progress = 0;
|
||||
let archived = null, restorePath = null;
|
||||
|
||||
const metaM = raw.match(/^(.*?)\s*\*\((.*)\)\*\s*$/);
|
||||
if (metaM) {
|
||||
title = metaM[1].trim();
|
||||
for (const p of metaM[2].split(',').map(s => s.trim())) {
|
||||
if (p.startsWith('due:')) due = p.slice(4).trim();
|
||||
else if (p.startsWith('reason:')) reason = p.slice(7).trim();
|
||||
else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
|
||||
else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
|
||||
else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
|
||||
if (p.startsWith('due:')) due = p.slice(4).trim();
|
||||
else if (p.startsWith('reason:')) reason = p.slice(7).trim();
|
||||
else if (p.startsWith('tags:')) tags = p.slice(5).trim().split(/\s+/).filter(Boolean);
|
||||
else if (p.startsWith('priority:')) priority = p.slice(9).trim().toUpperCase();
|
||||
else if (p.startsWith('progress:')) progress = parseInt(p.slice(9).trim(), 10) || 0;
|
||||
else if (p.startsWith('archived:')) archived = p.slice(9).trim();
|
||||
else if (p.startsWith('restore-path:')) restorePath = p.slice(13).trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +134,8 @@ const Parser = {
|
||||
collapsed: false,
|
||||
children: [],
|
||||
body: null,
|
||||
archived: archived || null,
|
||||
restorePath: restorePath || null,
|
||||
};
|
||||
stack[stack.length - 1].children.push(item);
|
||||
stack.push({ children: item.children, depth, level: item.level, item });
|
||||
@@ -163,6 +168,8 @@ const Parser = {
|
||||
if (it.tags?.length) meta.push(`tags: ${it.tags.join(' ')}`);
|
||||
if (it.priority && it.priority !== 'P1') meta.push(`priority: ${it.priority}`);
|
||||
if ((it.progress ?? 0) > 0) meta.push(`progress: ${it.progress}`);
|
||||
if (it.archived) meta.push(`archived: ${it.archived}`);
|
||||
if (it.restorePath) meta.push(`restore-path: ${it.restorePath}`);
|
||||
const metaStr = meta.length ? ` *(${meta.join(', ')})*` : '';
|
||||
const prefix = it.priority ? `[${it.priority}] ` : '';
|
||||
lines.push(`${indent}- [${GLYPH[it.status] || ' '}] ${prefix}${it.title}${metaStr}`);
|
||||
@@ -249,9 +256,38 @@ const ApiBackend = {
|
||||
statsSize: 0,
|
||||
masterPath: j.masterPath || '',
|
||||
backupsPath: j.backupsPath || '',
|
||||
archiveExists: j.archiveExists || false,
|
||||
archiveSize: j.archiveSize || 0,
|
||||
archivePath: j.archivePath || '',
|
||||
};
|
||||
} catch { return {}; }
|
||||
},
|
||||
|
||||
async loadArchive() {
|
||||
const r = await fetch('/api/archive');
|
||||
if (!r.ok) throw new Error('API archive load failed: ' + r.status);
|
||||
return r.json(); // { content, checksum, exists }
|
||||
},
|
||||
|
||||
async saveArchive(backlogContent, archiveContent) {
|
||||
const r = await fetch('/api/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backlog: backlogContent, archive: archiveContent }),
|
||||
});
|
||||
if (!r.ok) throw new Error('API archive save failed: ' + r.status);
|
||||
return r.json();
|
||||
},
|
||||
|
||||
async restoreFromArchive(backlogContent, archiveContent) {
|
||||
const r = await fetch('/api/archive/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backlog: backlogContent, archive: archiveContent }),
|
||||
});
|
||||
if (!r.ok) throw new Error('API archive restore failed: ' + r.status);
|
||||
return r.json();
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Direct File Backend (File System Access API) ----
|
||||
@@ -376,8 +412,31 @@ const DirectBackend = {
|
||||
async getHealthInfo() {
|
||||
try {
|
||||
const f = await (await this.dirHandle.getFileHandle('backlog.md')).getFile();
|
||||
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null };
|
||||
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null }; }
|
||||
let archiveExists = false, archiveSize = 0;
|
||||
try {
|
||||
const af = await (await this.dirHandle.getFileHandle('archive.md')).getFile();
|
||||
archiveExists = true;
|
||||
archiveSize = af.size;
|
||||
} catch {}
|
||||
return { masterSize: f.size, statsSize: 0, dirName: this.dirHandle?.name ?? null, archiveExists, archiveSize };
|
||||
} catch { return { masterSize: 0, statsSize: 0, dirName: this.dirHandle?.name ?? null, archiveExists: false, archiveSize: 0 }; }
|
||||
},
|
||||
|
||||
async loadArchive() {
|
||||
const content = await this._readFile('archive.md');
|
||||
return { content: content || '', checksum: '', exists: !!content };
|
||||
},
|
||||
|
||||
async saveArchive(backlogContent, archiveContent) {
|
||||
await this._writeFile('backlog.md', backlogContent);
|
||||
await this._writeFile('archive.md', archiveContent);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async restoreFromArchive(backlogContent, archiveContent) {
|
||||
await this._writeFile('backlog.md', backlogContent);
|
||||
await this._writeFile('archive.md', archiveContent);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -518,6 +577,7 @@ const BrowserStorageBackend = {
|
||||
async getHealthInfo() {
|
||||
try {
|
||||
const content = await this._get(this._CONTENT_KEY) || '';
|
||||
const archiveContent = await this._get('archive') || '';
|
||||
const estimate = await navigator.storage?.estimate?.() || {};
|
||||
const persisted = await navigator.storage?.persisted?.() || false;
|
||||
return {
|
||||
@@ -527,9 +587,40 @@ const BrowserStorageBackend = {
|
||||
persisted,
|
||||
quota: estimate.quota,
|
||||
usage: estimate.usage,
|
||||
archiveExists: !!archiveContent,
|
||||
archiveSize: new TextEncoder().encode(archiveContent).length,
|
||||
};
|
||||
} catch {
|
||||
return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false };
|
||||
return { masterSize: 0, statsSize: 0, browserStorage: true, persisted: false, archiveExists: false, archiveSize: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
async loadArchive() {
|
||||
try {
|
||||
const content = await this._get('archive') || '';
|
||||
return { content, checksum: '', exists: !!content };
|
||||
} catch {
|
||||
return { content: '', checksum: '', exists: false };
|
||||
}
|
||||
},
|
||||
|
||||
async saveArchive(backlogContent, archiveContent) {
|
||||
try {
|
||||
await this._set(this._CONTENT_KEY, backlogContent);
|
||||
await this._set('archive', archiveContent);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async restoreFromArchive(backlogContent, archiveContent) {
|
||||
try {
|
||||
await this._set(this._CONTENT_KEY, backlogContent);
|
||||
await this._set('archive', archiveContent);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -583,6 +674,9 @@ const Storage = {
|
||||
async listBackups() { return this.backend?.listBackups() ?? []; },
|
||||
async restoreBackup(name) { return this.backend?.restoreBackup(name) ?? { ok: false }; },
|
||||
async getHealthInfo() { return this.backend?.getHealthInfo?.() ?? {}; },
|
||||
async loadArchive() { return this.backend?.loadArchive?.() ?? { content: '', exists: false }; },
|
||||
async saveArchive(backlog, archive) { return this.backend?.saveArchive?.(backlog, archive) ?? { ok: false }; },
|
||||
async restoreFromArchive(backlog, archive) { return this.backend?.restoreFromArchive?.(backlog, archive) ?? { ok: false }; },
|
||||
};
|
||||
|
||||
// ---- SyncPoller — detect external file changes (5s interval) ----
|
||||
|
||||
@@ -1304,3 +1304,99 @@ input { font: inherit; color: inherit; }
|
||||
.field-row { grid-template-columns: 1fr; }
|
||||
.status-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* ---------- Archive Manager ---------- */
|
||||
.archive-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--rule-2); }
|
||||
.archive-section:first-of-type { margin-top: 8px; padding-top: 0; border-top: 0; }
|
||||
.archive-toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.archive-label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
|
||||
.archive-filter {
|
||||
padding: 4px 8px; font-size: 12px;
|
||||
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink);
|
||||
}
|
||||
.archive-search {
|
||||
padding: 4px 10px; font-size: 12px;
|
||||
border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
background: var(--paper); color: var(--ink);
|
||||
width: 160px;
|
||||
}
|
||||
.archive-search::placeholder { color: var(--ink-3); }
|
||||
.archive-empty {
|
||||
padding: 20px; text-align: center;
|
||||
color: var(--ink-3); font-size: 13px;
|
||||
}
|
||||
.archive-list {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
max-height: 280px; overflow-y: auto;
|
||||
}
|
||||
.archive-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; background: var(--bg); border-radius: var(--radius);
|
||||
font-size: 13px; cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.archive-item:hover { background: var(--bg-2); }
|
||||
.archive-item.archived { cursor: default; }
|
||||
.archive-item input[type="checkbox"] { flex-shrink: 0; }
|
||||
.archive-item .glyph { flex-shrink: 0; width: 18px; text-align: center; }
|
||||
.archive-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.archive-item-children {
|
||||
font-size: 11px; color: var(--ink-3);
|
||||
padding: 2px 6px; background: var(--rule-2); border-radius: 3px;
|
||||
}
|
||||
.archive-item-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
|
||||
.archive-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
.btn-xs { padding: 2px 8px; font-size: 11px; }
|
||||
|
||||
/* ---------- Archive Search Results ---------- */
|
||||
.archive-search-section {
|
||||
margin-top: 16px; padding-top: 16px;
|
||||
border-top: 1px dashed var(--rule);
|
||||
}
|
||||
.archive-search-toggle {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px; font-weight: 500; color: var(--ink-2);
|
||||
background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.archive-search-toggle:hover { background: var(--bg-2); color: var(--ink); }
|
||||
.archive-loading { color: var(--ink-3); font-style: italic; }
|
||||
.archive-count { color: var(--ink-3); }
|
||||
.archive-search-results {
|
||||
margin-top: 8px; padding: 8px;
|
||||
background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius);
|
||||
}
|
||||
.archive-search-empty {
|
||||
padding: 12px; text-align: center;
|
||||
color: var(--ink-3); font-size: 13px;
|
||||
}
|
||||
.archive-search-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.archive-search-item:hover { background: var(--bg-2); }
|
||||
.archive-search-item .glyph { flex-shrink: 0; width: 18px; text-align: center; }
|
||||
.archive-search-title { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.archive-search-date { font-size: 11px; color: var(--ink-3); font-family: var(--font-mono); }
|
||||
.archive-search-more { padding: 8px; text-align: center; font-size: 12px; color: var(--ink-3); }
|
||||
.archive-search-header {
|
||||
font-size: 12px; font-weight: 600; color: var(--ink-2);
|
||||
padding: 4px 0; margin-bottom: 8px;
|
||||
}
|
||||
.archive-match-hint { font-size: 14px; color: var(--ink-3); font-weight: 400; }
|
||||
.rte-readonly {
|
||||
padding: 10px 12px; background: var(--bg); border: 1px solid var(--rule);
|
||||
border-radius: var(--radius); font-size: 13px; line-height: 1.5; color: var(--ink-2);
|
||||
min-height: 60px; max-height: 200px; overflow-y: auto;
|
||||
}
|
||||
.rte-readonly:empty::before { content: 'No details'; color: var(--ink-3); }
|
||||
.tag-list-readonly { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
|
||||
+756
-27
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user