From 70404ff070dcfb23a1ae426b6d43f34b011f48b0 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Sun, 29 Mar 2026 11:38:54 +0000 Subject: [PATCH] Feat v0.4.1 folders navigation tree (#23) Add folders as a first-class concept in the Notes surface with full CRUD, nested tree navigation, and folder-based note filtering. Backend (script.star): - Folder CRUD handlers: list, create, update, delete - Move-note endpoint to reassign folder_id - Delete cascades: orphan notes to unfiled, reparent child folders - Stats now include unfiled and folder counts Frontend (js/main.js): - FolderTree component with flat-to-tree builder via useMemo - FolderNode recursive component with expand/collapse, inline rename - Folder state: activeFolderId, showUnfiled for sidebar filtering - Editor folder select dropdown for moving notes between folders - New notes inherit active folder on creation Schema (manifest.json): - New ext_notes_folders table (name, parent_id, creator_id, sort_order) - 5 new API routes: GET/POST /folders, PUT/DELETE /folders/*, POST /notes/move - Version bumped to 0.2.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/notes/css/main.css | 87 ++++++++++++++ packages/notes/js/main.js | 223 +++++++++++++++++++++++++++++++++-- packages/notes/manifest.json | 21 +++- packages/notes/script.star | 126 +++++++++++++++++++- 4 files changed, 441 insertions(+), 16 deletions(-) diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css index 7d7602a..3c88db6 100644 --- a/packages/notes/css/main.css +++ b/packages/notes/css/main.css @@ -39,6 +39,75 @@ gap: 4px; } +/* ── Folder Tree ────────────────────────── */ +.folder-tree { + padding: 4px 0; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + max-height: 40%; + overflow-y: auto; +} +.folder-tree__item { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + font-size: 13px; + color: var(--text-2); + cursor: pointer; + border-radius: var(--radius); + margin: 1px 6px; + transition: var(--transition); + user-select: none; +} +.folder-tree__item:hover { + background: var(--bg-hover); + color: var(--text); +} +.folder-tree__item--active { + background: var(--bg-active); + color: var(--text); + font-weight: 500; +} +.folder-tree__toggle { + width: 14px; + font-size: 9px; + text-align: center; + flex-shrink: 0; + color: var(--text-3); +} +.folder-tree__icon { + font-size: 13px; + flex-shrink: 0; +} +.folder-tree__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.folder-tree__edit { + flex: 1; + font-size: 13px; + padding: 1px 4px; + border: 1px solid var(--accent); + border-radius: 3px; + background: var(--bg-surface); + color: var(--text); + font-family: var(--font); + outline: none; +} +.folder-tree__add { + padding: 6px 12px; + font-size: 12px; + color: var(--text-3); + cursor: pointer; + transition: var(--transition); +} +.folder-tree__add:hover { + color: var(--accent); +} + /* ── Search ──────────────────────────────── */ .notes-search { padding: 8px 14px; @@ -171,6 +240,23 @@ align-items: center; } +/* ── Folder select in editor ────────────── */ +.notes-editor__folder-select { + font-size: 12px; + padding: 3px 6px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-raised); + color: var(--text-2); + font-family: var(--font); + cursor: pointer; + max-width: 140px; +} +.notes-editor__folder-select:focus { + outline: none; + border-color: var(--accent); +} + /* ── Editor Body ─────────────────────────── */ .notes-editor__body { flex: 1; @@ -291,4 +377,5 @@ @media (max-width: 700px) { .notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; } .notes-app { flex-direction: column; } + .folder-tree { max-height: 30vh; } } diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index b0341c5..5f4933f 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -1,5 +1,5 @@ /** - * Notes — Surface Entry Point (v0.1.0) + * Notes — Surface Entry Point (v0.2.0) * * Markdown notes surface using the SDK: * sw.api.ext('notes') — scoped API client @@ -164,11 +164,132 @@ } + // ═══════════════════════════════════════════ + // FolderNode — recursive tree node + // ═══════════════════════════════════════════ + + function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub }) { + var [expanded, setExpanded] = useState(true); + var [editing, setEditing] = useState(false); + var [editName, setEditName] = useState(node.folder.name); + var hasChildren = node.children.length > 0; + var isActive = activeFolderId === node.folder.id; + var indent = 20 + depth * 16; + + function handleToggle(e) { + e.stopPropagation(); + setExpanded(!expanded); + } + + function handleRename() { + if (editName.trim() && editName !== node.folder.name) { + onRename(node.folder.id, editName.trim()); + } + setEditing(false); + } + + function handleContext(e) { + e.preventDefault(); + var action = prompt('Type "rename" to rename, "delete" to delete, "subfolder" to add subfolder'); + if (action === 'rename') { setEditing(true); setEditName(node.folder.name); } + else if (action === 'delete') { onDelete(node.folder.id); } + else if (action === 'subfolder') { onCreateSub(node.folder.id); } + } + + return html` +
+
+ + ${hasChildren ? (expanded ? '▼' : '▶') : '·'} + + ${isActive ? '📂' : '📁'} + ${editing + ? html`` + : html`${node.folder.name}` + } +
+ ${expanded && node.children.map(function(child) { + return html`<${FolderNode} key=${child.folder.id} node=${child} depth=${depth + 1} + activeFolderId=${activeFolderId} + onSelect=${onSelect} onRename=${onRename} + onDelete=${onDelete} onCreateSub=${onCreateSub} />`; + })} +
+ `; + } + + + // ═══════════════════════════════════════════ + // FolderTree — navigation panel + // ═══════════════════════════════════════════ + + function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder }) { + // build tree from flat list + var tree = useMemo(function() { + var byId = {}; + var roots = []; + for (var i = 0; i < folders.length; i++) { + byId[folders[i].id] = { folder: folders[i], children: [] }; + } + for (var i = 0; i < folders.length; i++) { + var f = folders[i]; + var node = byId[f.id]; + if (f.parent_id && byId[f.parent_id]) { + byId[f.parent_id].children.push(node); + } else { + roots.push(node); + } + } + function sortNodes(arr) { + arr.sort(function(a, b) { + if (a.folder.sort_order !== b.folder.sort_order) return a.folder.sort_order - b.folder.sort_order; + return (a.folder.name || '').localeCompare(b.folder.name || ''); + }); + for (var j = 0; j < arr.length; j++) sortNodes(arr[j].children); + } + sortNodes(roots); + return roots; + }, [folders]); + + return html` +
+
+ 📋 + All Notes +
+
+ 📄 + Unfiled +
+ ${tree.map(function(node) { + return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0} + activeFolderId=${activeFolderId} + onSelect=${onSelectFolder} onRename=${onRenameFolder} + onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} />`; + })} +
+ + New Folder +
+
+ `; + } + + // ═══════════════════════════════════════════ // EditorPane — note editor + preview // ═══════════════════════════════════════════ - function EditorPane({ note, onSave, onDelete, onRefresh }) { + function EditorPane({ note, folders, onSave, onDelete, onRefresh }) { var [title, setTitle] = useState(note ? note.title : ''); var [body, setBody] = useState(note ? note.body : ''); var [preview, setPreview] = useState(false); @@ -258,6 +379,17 @@ value=${title} onInput=${handleTitleChange} placeholder="Note title" />
+ ${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')} @@ -292,12 +424,15 @@ // ═══════════════════════════════════════════ function NotesApp() { - var [notes, setNotes] = useState([]); - var [loading, setLoading] = useState(true); - var [activeId, setActiveId] = useState(null); - var [activeNote, setActiveNote] = useState(null); - var [searchTerm, setSearchTerm] = useState(''); - var [stats, setStats] = useState(null); + var [notes, setNotes] = useState([]); + var [loading, setLoading] = useState(true); + var [activeId, setActiveId] = useState(null); + var [activeNote, setActiveNote] = useState(null); + var [searchTerm, setSearchTerm] = useState(''); + var [stats, setStats] = useState(null); + var [folders, setFolders] = useState([]); + var [activeFolderId, setActiveFolderId] = useState(null); + var [showUnfiled, setShowUnfiled] = useState(false); // ── Load note list ──────────────────────── var loadNotes = useCallback(async function() { @@ -305,11 +440,17 @@ var res; if (searchTerm.trim()) { res = await api.get('/search?q=' + encodeURIComponent(searchTerm.trim())); + } else if (activeFolderId) { + res = await api.get('/notes?folder_id=' + encodeURIComponent(activeFolderId)); } else { res = await api.get('/notes'); } var items = (res && res.data) || res || []; if (!Array.isArray(items)) items = []; + // filter to unfiled when that view is active + if (showUnfiled && !searchTerm.trim() && !activeFolderId) { + items = items.filter(function(n) { return !n.folder_id; }); + } // sort: pinned first, then by updated_at desc items.sort(function(a, b) { if (a.pinned !== b.pinned) return b.pinned - a.pinned; @@ -321,7 +462,7 @@ } finally { setLoading(false); } - }, [searchTerm]); + }, [searchTerm, activeFolderId, showUnfiled]); // ── Load single note (full body) ────────── var loadNote = useCallback(async function(id) { @@ -335,6 +476,15 @@ } }, []); + // ── Load folders ────────────────────────── + var loadFolders = useCallback(async function() { + try { + var res = await api.get('/folders'); + var items = (res && res.data) || res || []; + setFolders(Array.isArray(items) ? items : []); + } catch (e) { console.error('Load folders failed:', e); } + }, []); + // ── Load stats ──────────────────────────── var loadStats = useCallback(async function() { try { @@ -343,7 +493,7 @@ } catch (e) { /* ignore */ } }, []); - useEffect(function() { loadNotes(); loadStats(); }, [loadNotes]); + useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]); // select note function handleSelect(id) { @@ -354,7 +504,9 @@ // ── Create note ─────────────────────────── async function handleNew() { try { - var note = await api.post('/notes', { title: 'Untitled', body: '' }); + var payload = { title: 'Untitled', body: '' }; + if (activeFolderId) payload.folder_id = activeFolderId; + var note = await api.post('/notes', payload); if (note && note.id) { setActiveId(note.id); loadNote(note.id); @@ -381,6 +533,46 @@ loadStats(); } + // ── Folder actions ─────────────────────── + function handleSelectFolder(folderId) { + setActiveFolderId(folderId); + setShowUnfiled(false); + setSearchTerm(''); + } + function handleSelectAll() { + setActiveFolderId(null); + setShowUnfiled(false); + } + function handleSelectUnfiled() { + setActiveFolderId(null); + setShowUnfiled(true); + } + async function handleCreateFolder(parentId) { + var name = prompt('Folder name:'); + if (!name || !name.trim()) return; + try { + await api.post('/folders', { name: name.trim(), parent_id: parentId || '' }); + loadFolders(); + loadStats(); + } catch (e) { console.error('Create folder failed:', e); } + } + async function handleRenameFolder(folderId, newName) { + try { + await api.put('/folders/' + folderId, { name: newName }); + loadFolders(); + } catch (e) { console.error('Rename folder failed:', e); } + } + async function handleDeleteFolder(folderId) { + if (!confirm('Delete this folder? Notes will be moved to Unfiled.')) return; + try { + await api.del('/folders/' + folderId); + if (activeFolderId === folderId) setActiveFolderId(null); + loadFolders(); + loadNotes(); + loadStats(); + } catch (e) { console.error('Delete folder failed:', e); } + } + // ── Search ──────────────────────────────── function handleSearch(e) { var val = e.target.value; @@ -398,6 +590,13 @@ Notes ${stats && html`${stats.total}`}
+ <${FolderTree} folders=${folders} + activeFolderId=${activeFolderId} showUnfiled=${showUnfiled} + onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll} + onSelectUnfiled=${handleSelectUnfiled} + onCreateFolder=${handleCreateFolder} + onRenameFolder=${handleRenameFolder} + onDeleteFolder=${handleDeleteFolder} /> - <${EditorPane} note=${activeNote} + <${EditorPane} note=${activeNote} folders=${folders} onSave=${handleRefresh} onDelete=${handleDelete} onRefresh=${handleRefresh} /> diff --git a/packages/notes/manifest.json b/packages/notes/manifest.json index 96c66a2..8056d9d 100644 --- a/packages/notes/manifest.json +++ b/packages/notes/manifest.json @@ -6,7 +6,7 @@ "route": "/s/notes", "auth": "authenticated", "layout": "single", - "version": "0.1.0", + "version": "0.2.0", "icon": "📝", "description": "Markdown notes surface with folders, tags, and backlinks.", "author": "switchboard", @@ -20,7 +20,12 @@ {"method": "PUT", "path": "/notes/*"}, {"method": "DELETE", "path": "/notes/*"}, {"method": "GET", "path": "/search"}, - {"method": "GET", "path": "/stats"} + {"method": "GET", "path": "/stats"}, + {"method": "GET", "path": "/folders"}, + {"method": "POST", "path": "/folders"}, + {"method": "PUT", "path": "/folders/*"}, + {"method": "DELETE", "path": "/folders/*"}, + {"method": "POST", "path": "/notes/move"} ], "db_tables": { @@ -40,6 +45,18 @@ ["pinned"], ["updated_at"] ] + }, + "folders": { + "columns": { + "name": "text", + "parent_id": "text", + "creator_id": "text", + "sort_order": "int" + }, + "indexes": [ + ["parent_id"], + ["creator_id"] + ] } }, diff --git a/packages/notes/script.star b/packages/notes/script.star index 4bc1aa4..6aaf53e 100644 --- a/packages/notes/script.star +++ b/packages/notes/script.star @@ -1,4 +1,4 @@ -# Notes — Starlark Backend (v0.1.0) +# Notes — Starlark Backend (v0.2.0) # # Markdown notes surface using ext_data. # @@ -76,6 +76,27 @@ def on_request(req): if method == "DELETE" and path.startswith("/notes/"): return _delete_note(path[len("/notes/"):], req) + # POST /notes/move + if method == "POST" and path == "/notes/move": + return _move_note(req) + + # ── Folder routes ────────────────────────── + # GET /folders + if method == "GET" and path == "/folders": + return _list_folders() + + # POST /folders + if method == "POST" and path == "/folders": + return _create_folder(req) + + # PUT /folders/:id + if method == "PUT" and path.startswith("/folders/"): + return _update_folder(path[len("/folders/"):], req) + + # DELETE /folders/:id + if method == "DELETE" and path.startswith("/folders/"): + return _delete_folder(path[len("/folders/"):]) + return _resp(404, {"error": "not found"}) @@ -200,6 +221,102 @@ def _delete_note(note_id, req): return _resp(200, {"archived": True}) +# ═══════════════════════════════════════════════ +# Move note between folders +# ═══════════════════════════════════════════════ + +def _move_note(req): + body = json.decode(req.get("body", "{}")) + note_id = _str(body.get("note_id", "")) + folder_id = _str(body.get("folder_id", "")) + if not note_id: + return _resp(400, {"error": "note_id required"}) + existing = db.query("notes", filters={"id": note_id}, limit=1) + if not existing: + return _resp(404, {"error": "note not found"}) + ok = db.update("notes", note_id, {"folder_id": folder_id, "updated_at": "now"}) + if not ok: + return _resp(500, {"error": "move failed"}) + rows = db.query("notes", filters={"id": note_id}, limit=1) + return _resp(200, rows[0] if rows else {}) + + +# ═══════════════════════════════════════════════ +# Folder CRUD +# ═══════════════════════════════════════════════ + +def _list_folders(): + rows = db.query("folders", order="sort_order", limit=500) + items = [] + for r in (rows or []): + items.append({ + "id": r.get("id", ""), + "name": r.get("name", ""), + "parent_id": r.get("parent_id", ""), + "sort_order": _int(r.get("sort_order", 0)), + "creator_id": r.get("creator_id", ""), + "created_at": r.get("created_at", ""), + }) + return _resp(200, {"data": items}) + + +def _create_folder(req): + body = json.decode(req.get("body", "{}")) + user_id = req.get("user_id", "") + name = _str(body.get("name", "")) + if not name: + return _resp(400, {"error": "name required"}) + row = db.insert("folders", { + "name": name, + "parent_id": _str(body.get("parent_id", "")), + "creator_id": user_id, + "sort_order": _int(body.get("sort_order", 0)), + }) + return _resp(201, row) + + +def _update_folder(folder_id, req): + body = json.decode(req.get("body", "{}")) + existing = db.query("folders", filters={"id": folder_id}, limit=1) + if not existing: + return _resp(404, {"error": "folder not found"}) + updates = {} + for key in ["name", "parent_id"]: + if key in body: + updates[key] = _str(body[key]) + if "sort_order" in body: + updates["sort_order"] = _int(body["sort_order"]) + if not updates: + return _resp(200, existing[0]) + ok = db.update("folders", folder_id, updates) + if not ok: + return _resp(500, {"error": "update failed"}) + rows = db.query("folders", filters={"id": folder_id}, limit=1) + return _resp(200, rows[0] if rows else {}) + + +def _delete_folder(folder_id): + existing = db.query("folders", filters={"id": folder_id}, limit=1) + if not existing: + return _resp(404, {"error": "folder not found"}) + parent_id = _str(existing[0].get("parent_id", "")) + + # reparent child folders to deleted folder's parent + children = db.query("folders", filters={"parent_id": folder_id}, limit=500) + for child in (children or []): + db.update("folders", child["id"], {"parent_id": parent_id}) + + # orphan notes in this folder (move to unfiled) + notes_in_folder = db.query("notes", filters={"folder_id": folder_id}, limit=1000) + for n in (notes_in_folder or []): + db.update("notes", n["id"], {"folder_id": ""}) + + ok = db.delete("folders", folder_id) + if not ok: + return _resp(500, {"error": "delete failed"}) + return _resp(200, {"deleted": True, "orphaned_notes": len(notes_in_folder or [])}) + + # ═══════════════════════════════════════════════ # Search # ═══════════════════════════════════════════════ @@ -241,6 +358,7 @@ def _get_stats(): total = 0 pinned = 0 archived = 0 + unfiled = 0 for n in notes: if _int(n.get("archived", 0)) == 1: archived = archived + 1 @@ -248,4 +366,8 @@ def _get_stats(): total = total + 1 if _int(n.get("pinned", 0)) == 1: pinned = pinned + 1 - return _resp(200, {"total": total, "pinned": pinned, "archived": archived}) + if not _str(n.get("folder_id", "")): + unfiled = unfiled + 1 + all_folders = db.query("folders", limit=10000) + folder_count = len(all_folders or []) + return _resp(200, {"total": total, "pinned": pinned, "archived": archived, "unfiled": unfiled, "folders": folder_count})