Feat v0.4.1 folders (#23)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m38s
CI/CD / test-sqlite (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 28s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #23.
This commit is contained in:
2026-03-29 12:23:47 +00:00
committed by xcaliber
parent 2c8dc59284
commit 03c182b9d1
7 changed files with 521 additions and 23 deletions

View File

@@ -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);
@@ -247,6 +368,40 @@
var previewHtml = useMemo(function() { return renderMarkdown(body); }, [body]);
// build indented folder list for the select dropdown
var folderOptions = useMemo(function() {
var list = folders || [];
var byId = {};
var roots = [];
for (var i = 0; i < list.length; i++) {
byId[list[i].id] = { folder: list[i], children: [] };
}
for (var i = 0; i < list.length; i++) {
var f = list[i];
if (f.parent_id && byId[f.parent_id]) {
byId[f.parent_id].children.push(byId[f.id]);
} else {
roots.push(byId[f.id]);
}
}
var flat = [];
function walk(nodes, depth) {
nodes.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 < nodes.length; j++) {
var prefix = '';
for (var k = 0; k < depth; k++) prefix += '\u00A0\u00A0';
if (depth > 0) prefix += '\u2514 ';
flat.push({ id: nodes[j].folder.id, label: prefix + nodes[j].folder.name });
walk(nodes[j].children, depth + 1);
}
}
walk(roots, 0);
return flat;
}, [folders]);
if (!note) {
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`;
}
@@ -258,6 +413,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>
${folderOptions.map(function(f) {
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
})}
</select>
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
</span>
@@ -292,12 +458,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 +474,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 +496,7 @@
} finally {
setLoading(false);
}
}, [searchTerm]);
}, [searchTerm, activeFolderId, showUnfiled]);
// ── Load single note (full body) ──────────
var loadNote = useCallback(async function(id) {
@@ -335,6 +510,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 +527,7 @@
} catch (e) { /* ignore */ }
}, []);
useEffect(function() { loadNotes(); loadStats(); }, [loadNotes]);
useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]);
// select note
function handleSelect(id) {
@@ -354,7 +538,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 +567,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 +624,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 +651,7 @@
})}
</div>
</div>
<${EditorPane} note=${activeNote}
<${EditorPane} note=${activeNote} folders=${folders}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh} />