Feat v0.4.2 tags search (#24)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m39s
CI/CD / test-sqlite (push) Successful in 2m48s
CI/CD / build-and-deploy (push) Successful in 1m6s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #24.
This commit is contained in:
2026-03-29 13:55:18 +00:00
committed by xcaliber
parent 03c182b9d1
commit 32beb3cee4
7 changed files with 661 additions and 48 deletions

View File

@@ -1,5 +1,5 @@
/**
* Notes — Surface Entry Point (v0.2.0)
* Notes — Surface Entry Point (v0.3.0)
*
* Markdown notes surface using the SDK:
* sw.api.ext('notes') — scoped API client
@@ -143,21 +143,158 @@
// ═══════════════════════════════════════════
// NoteCard — sidebar list item
// TagInput — editor tag management
// ═══════════════════════════════════════════
function TagInput({ tags, allTags, onChange }) {
var [input, setInput] = useState('');
var [showAC, setShowAC] = useState(false);
var inputRef = useRef(null);
var suggestions = useMemo(function() {
if (!input.trim()) return [];
var q = input.trim().toLowerCase();
return (allTags || []).filter(function(t) {
return t.toLowerCase().indexOf(q) !== -1 && (tags || []).indexOf(t) === -1;
}).slice(0, 8);
}, [input, allTags, tags]);
function addTag(tag) {
var t = tag.trim().toLowerCase();
if (!t) return;
var current = tags || [];
if (current.indexOf(t) === -1) {
onChange(current.concat([t]));
}
setInput('');
setShowAC(false);
if (inputRef.current) inputRef.current.focus();
}
function removeTag(tag) {
onChange((tags || []).filter(function(t) { return t !== tag; }));
}
function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (input.trim()) addTag(input);
}
if (e.key === 'Backspace' && !input && tags && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
if (e.key === 'Escape') {
setShowAC(false);
}
}
function handleInput(e) {
var val = e.target.value.replace(',', '');
setInput(val);
setShowAC(val.trim().length > 0);
}
return html`
<div class="tag-input">
${(tags || []).map(function(tag) {
return html`
<span key=${tag} class="tag-pill">
${tag}
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
</span>
`;
})}
<input ref=${inputRef} class="tag-input__field" type="text"
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
value=${input}
onInput=${handleInput}
onKeyDown=${handleKeyDown}
onFocus=${function() { if (input.trim()) setShowAC(true); }}
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
${showAC && suggestions.length > 0 && html`
<div class="tag-autocomplete">
${suggestions.map(function(s) {
return html`<div key=${s} class="tag-autocomplete__item"
onClick=${function() { addTag(s); }}>${s}</div>`;
})}
</div>
`}
</div>
`;
}
// ═══════════════════════════════════════════
// TagFilter — sidebar tag filter
// ═══════════════════════════════════════════
function TagFilter({ tags, activeTag, onSelect }) {
if (!tags || tags.length === 0) return null;
return html`
<div class="tag-filter">
<div class="tag-filter__label">
<span>Tags</span>
${activeTag && html`
<button class="tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
`}
</div>
<div class="tag-filter__pills">
${tags.map(function(tag) {
return html`
<span key=${tag} class="tag-pill ${activeTag === tag ? 'tag-pill--active' : ''}"
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
${tag}
</span>
`;
})}
</div>
</div>
`;
}
// ═══════════════════════════════════════════
// NoteCard — sidebar list item (draggable)
// ═══════════════════════════════════════════
function NoteCard({ note, active, onClick }) {
var [dragging, setDragging] = useState(false);
var dateStr = note.updated_at || note.created_at || '';
if (dateStr) {
try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ }
}
var tags = note.tags || [];
var showTags = tags.slice(0, 3);
var overflow = tags.length - 3;
function handleDragStart(e) {
e.dataTransfer.setData('text/plain', note.id);
e.dataTransfer.effectAllowed = 'move';
setDragging(true);
}
function handleDragEnd() {
setDragging(false);
}
return html`
<div class="note-card ${active ? 'note-card--active' : ''}" onClick=${onClick}>
<div class="note-card ${active ? 'note-card--active' : ''} ${dragging ? 'note-card--dragging' : ''}"
onClick=${onClick}
draggable="true"
onDragStart=${handleDragStart}
onDragEnd=${handleDragEnd}>
<div class="note-card__title">
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
${note.title || 'Untitled'}
</div>
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`}
${showTags.length > 0 && html`
<div class="note-card__tags">
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })}
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`}
</div>
`}
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
</div>
`;
@@ -165,13 +302,40 @@
// ═══════════════════════════════════════════
// FolderNode — recursive tree node
// FolderContextMenu — right-click menu
// ═══════════════════════════════════════════
function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub }) {
function FolderContextMenu({ x, y, onRename, onDelete, onAddSub, onClose }) {
var menuRef = useRef(null);
useEffect(function() {
function handleClick(e) {
if (menuRef.current && !menuRef.current.contains(e.target)) onClose();
}
document.addEventListener('mousedown', handleClick);
return function() { document.removeEventListener('mousedown', handleClick); };
}, [onClose]);
return html`
<div ref=${menuRef} class="folder-context-menu" style="top: ${y}px; left: ${x}px">
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div>
<div class="folder-context-menu__item folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
</div>
`;
}
// ═══════════════════════════════════════════
// FolderNode — recursive tree node (drop target)
// ═══════════════════════════════════════════
function FolderNode({ node, depth, activeFolderId, onSelect, onRename, onDelete, onCreateSub, onDropNote }) {
var [expanded, setExpanded] = useState(true);
var [editing, setEditing] = useState(false);
var [editName, setEditName] = useState(node.folder.name);
var [dropOver, setDropOver] = useState(false);
var [menu, setMenu] = useState(null);
var hasChildren = node.children.length > 0;
var isActive = activeFolderId === node.folder.id;
var indent = 20 + depth * 16;
@@ -190,18 +354,32 @@
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); }
e.stopPropagation();
setMenu({ x: e.clientX, y: e.clientY });
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropOver(true);
}
function handleDragLeave() { setDropOver(false); }
function handleDrop(e) {
e.preventDefault();
setDropOver(false);
var noteId = e.dataTransfer.getData('text/plain');
if (noteId && onDropNote) onDropNote(noteId, node.folder.id);
}
return html`
<div>
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''}"
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''} ${dropOver ? 'folder-tree__item--drop-target' : ''}"
style="padding-left: ${indent}px"
onClick=${function() { onSelect(node.folder.id); }}
onContextMenu=${handleContext}>
onContextMenu=${handleContext}
onDragOver=${handleDragOver}
onDragLeave=${handleDragLeave}
onDrop=${handleDrop}>
<span class="folder-tree__toggle" onClick=${handleToggle}>
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
</span>
@@ -216,11 +394,17 @@
: html`<span class="folder-tree__name">${node.folder.name}</span>`
}
</div>
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
onRename=${function() { setMenu(null); setEditing(true); setEditName(node.folder.name); }}
onDelete=${function() { setMenu(null); onDelete(node.folder.id); }}
onAddSub=${function() { setMenu(null); onCreateSub(node.folder.id); }}
onClose=${function() { setMenu(null); }} />`}
${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} />`;
onDelete=${onDelete} onCreateSub=${onCreateSub}
onDropNote=${onDropNote} />`;
})}
</div>
`;
@@ -228,10 +412,13 @@
// ═══════════════════════════════════════════
// FolderTree — navigation panel
// FolderTree — navigation panel (drop targets)
// ═══════════════════════════════════════════
function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder }) {
function FolderTree({ folders, activeFolderId, showUnfiled, onSelectFolder, onSelectAll, onSelectUnfiled, onCreateFolder, onRenameFolder, onDeleteFolder, onDropNote }) {
var [dropAll, setDropAll] = useState(false);
var [dropUnfiled, setDropUnfiled] = useState(false);
// build tree from flat list
var tree = useMemo(function() {
var byId = {};
@@ -259,15 +446,32 @@
return roots;
}, [folders]);
// drop handlers for "All Notes" / "Unfiled" (move to unfiled = folder_id "")
function makeDropHandlers(setOver) {
return {
onDragOver: function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOver(true); },
onDragLeave: function() { setOver(false); },
onDrop: function(e) {
e.preventDefault(); setOver(false);
var noteId = e.dataTransfer.getData('text/plain');
if (noteId && onDropNote) onDropNote(noteId, '');
}
};
}
var allDrop = makeDropHandlers(setDropAll);
var unfiledDrop = makeDropHandlers(setDropUnfiled);
return html`
<div class="folder-tree">
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''}"
onClick=${onSelectAll}>
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}"
onClick=${onSelectAll}
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
<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}>
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}"
onClick=${onSelectUnfiled}
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
<span class="folder-tree__icon">📄</span>
<span class="folder-tree__name">Unfiled</span>
</div>
@@ -275,7 +479,8 @@
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
activeFolderId=${activeFolderId}
onSelect=${onSelectFolder} onRename=${onRenameFolder}
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} />`;
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
onDropNote=${onDropNote} />`;
})}
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
+ New Folder
@@ -286,12 +491,13 @@
// ═══════════════════════════════════════════
// EditorPane — note editor + preview
// EditorPane — note editor + preview + tags
// ═══════════════════════════════════════════
function EditorPane({ note, folders, onSave, onDelete, onRefresh }) {
function EditorPane({ note, folders, allTags, onSave, onDelete, onRefresh, onTagsChanged }) {
var [title, setTitle] = useState(note ? note.title : '');
var [body, setBody] = useState(note ? note.body : '');
var [noteTags, setNoteTags] = useState([]);
var [preview, setPreview] = useState(false);
var [dirty, setDirty] = useState(false);
var [saving, setSaving] = useState(false);
@@ -302,6 +508,7 @@
if (note) {
setTitle(note.title || '');
setBody(note.body || '');
setNoteTags(note.tags || []);
setDirty(false);
setPreview(false);
}
@@ -333,6 +540,17 @@
}
}
async function handleTagsChange(newTags) {
if (!note) return;
setNoteTags(newTags);
try {
await api.put('/tags/' + note.id, { tags: newTags });
if (onTagsChanged) onTagsChanged();
} catch (e) {
console.error('Save tags failed:', e);
}
}
async function handlePin() {
if (!note) return;
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
@@ -439,6 +657,7 @@
title="Archive">🗑</button>
</div>
</div>
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
<div class="notes-editor__body">
${preview
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
@@ -467,6 +686,8 @@
var [folders, setFolders] = useState([]);
var [activeFolderId, setActiveFolderId] = useState(null);
var [showUnfiled, setShowUnfiled] = useState(false);
var [allTags, setAllTags] = useState([]);
var [activeTag, setActiveTag] = useState(null);
// ── Load note list ────────────────────────
var loadNotes = useCallback(async function() {
@@ -485,6 +706,12 @@
if (showUnfiled && !searchTerm.trim() && !activeFolderId) {
items = items.filter(function(n) { return !n.folder_id; });
}
// filter by active tag (client-side)
if (activeTag) {
items = items.filter(function(n) {
return n.tags && n.tags.indexOf(activeTag) !== -1;
});
}
// sort: pinned first, then by updated_at desc
items.sort(function(a, b) {
if (a.pinned !== b.pinned) return b.pinned - a.pinned;
@@ -496,7 +723,7 @@
} finally {
setLoading(false);
}
}, [searchTerm, activeFolderId, showUnfiled]);
}, [searchTerm, activeFolderId, showUnfiled, activeTag]);
// ── Load single note (full body) ──────────
var loadNote = useCallback(async function(id) {
@@ -527,7 +754,16 @@
} catch (e) { /* ignore */ }
}, []);
useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]);
// ── Load all tags ─────────────────────────
var loadTags = useCallback(async function() {
try {
var res = await api.get('/tags');
var items = (res && res.data) || res || [];
setAllTags(Array.isArray(items) ? items : []);
} catch (e) { console.error('Load tags failed:', e); }
}, []);
useEffect(function() { loadNotes(); loadFolders(); loadStats(); loadTags(); }, [loadNotes]);
// select note
function handleSelect(id) {
@@ -565,6 +801,29 @@
setActiveNote(null);
loadNotes();
loadStats();
loadTags();
}
// ── Tags changed in editor ────────────────
function handleTagsChanged() {
loadTags();
loadNotes();
loadStats();
}
// ── Tag filter ────────────────────────────
function handleTagFilter(tag) {
setActiveTag(tag);
}
// ── Drag-and-drop move note to folder ─────
async function handleDropNote(noteId, folderId) {
try {
await api.post('/notes/move', { note_id: noteId, folder_id: folderId });
loadNotes();
loadStats();
if (activeId === noteId) loadNote(activeId);
} catch (e) { console.error('Move note failed:', e); }
}
// ── Folder actions ───────────────────────
@@ -630,7 +889,9 @@
onSelectUnfiled=${handleSelectUnfiled}
onCreateFolder=${handleCreateFolder}
onRenameFolder=${handleRenameFolder}
onDeleteFolder=${handleDeleteFolder} />
onDeleteFolder=${handleDeleteFolder}
onDropNote=${handleDropNote} />
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
<div class="notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
@@ -639,7 +900,7 @@
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html`
<div class="notes-list__empty">
${searchTerm ? 'No matching notes' : 'No notes yet. Click + New Note to start.'}
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
</div>
`}
${!loading && notes.map(function(n) {
@@ -651,10 +912,11 @@
})}
</div>
</div>
<${EditorPane} note=${activeNote} folders=${folders}
<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags}
onSave=${handleRefresh}
onDelete=${handleDelete}
onRefresh=${handleRefresh} />
onRefresh=${handleRefresh}
onTagsChanged=${handleTagsChanged} />
</div>
`;
}