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) <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||
<div>
|
||||
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''}"
|
||||
style="padding-left: ${indent}px"
|
||||
onClick=${function() { onSelect(node.folder.id); }}
|
||||
onContextMenu=${handleContext}>
|
||||
<span class="folder-tree__toggle" onClick=${handleToggle}>
|
||||
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
||||
</span>
|
||||
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span>
|
||||
${editing
|
||||
? html`<input class="folder-tree__edit" value=${editName}
|
||||
onInput=${function(e) { setEditName(e.target.value); }}
|
||||
onBlur=${handleRename}
|
||||
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
|
||||
onClick=${function(e) { e.stopPropagation(); }}
|
||||
ref=${function(el) { if (el) el.focus(); }} />`
|
||||
: html`<span class="folder-tree__name">${node.folder.name}</span>`
|
||||
}
|
||||
</div>
|
||||
${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} />`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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`
|
||||
<div class="folder-tree">
|
||||
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''}"
|
||||
onClick=${onSelectAll}>
|
||||
<span class="folder-tree__icon">📋</span>
|
||||
<span class="folder-tree__name">All Notes</span>
|
||||
</div>
|
||||
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''}"
|
||||
onClick=${onSelectUnfiled}>
|
||||
<span class="folder-tree__icon">📄</span>
|
||||
<span class="folder-tree__name">Unfiled</span>
|
||||
</div>
|
||||
${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} />`;
|
||||
})}
|
||||
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
||||
+ New Folder
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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" />
|
||||
<div class="notes-editor__actions">
|
||||
<select class="notes-editor__folder-select"
|
||||
value=${note.folder_id || ''}
|
||||
onChange=${async function(e) {
|
||||
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
|
||||
onRefresh();
|
||||
}}>
|
||||
<option value="">Unfiled</option>
|
||||
${(folders || []).map(function(f) {
|
||||
return html`<option key=${f.id} value=${f.id}>${f.name}</option>`;
|
||||
})}
|
||||
</select>
|
||||
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
|
||||
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
||||
</span>
|
||||
@@ -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 @@
|
||||
<span class="notes-sidebar__title">Notes</span>
|
||||
${stats && html`<span class="notes-sidebar__count">${stats.total}</span>`}
|
||||
</div>
|
||||
<${FolderTree} folders=${folders}
|
||||
activeFolderId=${activeFolderId} showUnfiled=${showUnfiled}
|
||||
onSelectFolder=${handleSelectFolder} onSelectAll=${handleSelectAll}
|
||||
onSelectUnfiled=${handleSelectUnfiled}
|
||||
onCreateFolder=${handleCreateFolder}
|
||||
onRenameFolder=${handleRenameFolder}
|
||||
onDeleteFolder=${handleDeleteFolder} />
|
||||
<div class="notes-search">
|
||||
<input type="text" placeholder="Search notes…"
|
||||
value=${searchTerm} onInput=${handleSearch} />
|
||||
@@ -418,7 +617,7 @@
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<${EditorPane} note=${activeNote}
|
||||
<${EditorPane} note=${activeNote} folders=${folders}
|
||||
onSave=${handleRefresh}
|
||||
onDelete=${handleDelete}
|
||||
onRefresh=${handleRefresh} />
|
||||
|
||||
Reference in New Issue
Block a user