The editor folder select was a flat list making it impossible to distinguish subfolder nesting. Now builds a tree and renders with └ prefix indentation (e.g. " └ Projects" under "Work"). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
666 lines
25 KiB
JavaScript
666 lines
25 KiB
JavaScript
/**
|
|
* Notes — Surface Entry Point (v0.2.0)
|
|
*
|
|
* Markdown notes surface using the SDK:
|
|
* sw.api.ext('notes') — scoped API client
|
|
* sw.ui.* — primitive components
|
|
* sw.shell.Topbar — navigation bar
|
|
*/
|
|
(async function () {
|
|
'use strict';
|
|
|
|
var mount = document.getElementById('extension-mount');
|
|
if (!mount) return;
|
|
|
|
var base = window.__BASE__ || '';
|
|
var ver = window.__VERSION__ || '0';
|
|
|
|
// ── Boot SDK ───────────────────────────────
|
|
try {
|
|
if (!window.preact) {
|
|
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
|
|
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
|
|
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
|
|
window.preact = { h, render };
|
|
window.hooks = hooksModule;
|
|
window.html = htmModule.default.bind(h);
|
|
}
|
|
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
|
|
await sdk.boot();
|
|
} catch (e) {
|
|
mount.innerHTML = '<p style="color:var(--danger);padding:24px;">SDK boot failed: ' + e.message + '</p>';
|
|
return;
|
|
}
|
|
|
|
var { html } = window;
|
|
var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
|
|
var { render } = preact;
|
|
|
|
// ── SDK modules ────────────────────────────
|
|
var api = sw.api.ext('notes');
|
|
var { Button, Spinner } = sw.ui;
|
|
var Topbar = sw.shell.Topbar;
|
|
|
|
// ── Debounce helper ────────────────────────
|
|
var _timers = {};
|
|
function debounce(key, fn, ms) {
|
|
clearTimeout(_timers[key]);
|
|
_timers[key] = setTimeout(fn, ms);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════
|
|
// Markdown renderer (inline, no deps)
|
|
// ═══════════════════════════════════════════
|
|
|
|
function renderMarkdown(src) {
|
|
if (!src) return '';
|
|
var lines = src.split('\n');
|
|
var out = [];
|
|
var inCode = false;
|
|
var inList = false;
|
|
var listTag = '';
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i];
|
|
|
|
// fenced code blocks
|
|
if (line.trim().startsWith('```')) {
|
|
if (inCode) {
|
|
out.push('</code></pre>');
|
|
inCode = false;
|
|
} else {
|
|
if (inList) { out.push('</' + listTag + '>'); inList = false; }
|
|
out.push('<pre><code>');
|
|
inCode = true;
|
|
}
|
|
continue;
|
|
}
|
|
if (inCode) {
|
|
out.push(escHtml(line) + '\n');
|
|
continue;
|
|
}
|
|
|
|
// blank line
|
|
if (!line.trim()) {
|
|
if (inList) { out.push('</' + listTag + '>'); inList = false; }
|
|
continue;
|
|
}
|
|
|
|
// headings
|
|
if (line.startsWith('### ')) { closeList(); out.push('<h3>' + inline(line.slice(4)) + '</h3>'); continue; }
|
|
if (line.startsWith('## ')) { closeList(); out.push('<h2>' + inline(line.slice(3)) + '</h2>'); continue; }
|
|
if (line.startsWith('# ')) { closeList(); out.push('<h1>' + inline(line.slice(2)) + '</h1>'); continue; }
|
|
|
|
// hr
|
|
if (/^[-*_]{3,}\s*$/.test(line)) { closeList(); out.push('<hr>'); continue; }
|
|
|
|
// blockquote
|
|
if (line.startsWith('> ')) { closeList(); out.push('<blockquote><p>' + inline(line.slice(2)) + '</p></blockquote>'); continue; }
|
|
|
|
// unordered list
|
|
if (/^[\-*+]\s/.test(line)) {
|
|
if (!inList || listTag !== 'ul') { closeList(); out.push('<ul>'); inList = true; listTag = 'ul'; }
|
|
out.push('<li>' + inline(line.replace(/^[\-*+]\s/, '')) + '</li>');
|
|
continue;
|
|
}
|
|
|
|
// ordered list
|
|
if (/^\d+\.\s/.test(line)) {
|
|
if (!inList || listTag !== 'ol') { closeList(); out.push('<ol>'); inList = true; listTag = 'ol'; }
|
|
out.push('<li>' + inline(line.replace(/^\d+\.\s/, '')) + '</li>');
|
|
continue;
|
|
}
|
|
|
|
// paragraph
|
|
closeList();
|
|
out.push('<p>' + inline(line) + '</p>');
|
|
}
|
|
if (inCode) out.push('</code></pre>');
|
|
if (inList) out.push('</' + listTag + '>');
|
|
return out.join('\n');
|
|
|
|
function closeList() { if (inList) { out.push('</' + listTag + '>'); inList = false; } }
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function inline(s) {
|
|
s = escHtml(s);
|
|
// inline code
|
|
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
// bold
|
|
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
s = s.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
// italic
|
|
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
s = s.replace(/_(.+?)_/g, '<em>$1</em>');
|
|
// links
|
|
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
return s;
|
|
}
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
|
// NoteCard — sidebar list item
|
|
// ═══════════════════════════════════════════
|
|
|
|
function NoteCard({ note, active, onClick }) {
|
|
var dateStr = note.updated_at || note.created_at || '';
|
|
if (dateStr) {
|
|
try { dateStr = new Date(dateStr).toLocaleDateString(); } catch(e) { /* keep raw */ }
|
|
}
|
|
return html`
|
|
<div class="note-card ${active ? 'note-card--active' : ''}" onClick=${onClick}>
|
|
<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>`}
|
|
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
|
// 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, folders, onSave, onDelete, onRefresh }) {
|
|
var [title, setTitle] = useState(note ? note.title : '');
|
|
var [body, setBody] = useState(note ? note.body : '');
|
|
var [preview, setPreview] = useState(false);
|
|
var [dirty, setDirty] = useState(false);
|
|
var [saving, setSaving] = useState(false);
|
|
var textareaRef = useRef(null);
|
|
|
|
// sync when note changes
|
|
useEffect(function() {
|
|
if (note) {
|
|
setTitle(note.title || '');
|
|
setBody(note.body || '');
|
|
setDirty(false);
|
|
setPreview(false);
|
|
}
|
|
}, [note ? note.id : null]);
|
|
|
|
function handleTitleChange(e) {
|
|
setTitle(e.target.value);
|
|
setDirty(true);
|
|
debounce('save', function() { doSave(e.target.value, body); }, 1000);
|
|
}
|
|
|
|
function handleBodyChange(e) {
|
|
setBody(e.target.value);
|
|
setDirty(true);
|
|
debounce('save', function() { doSave(title, e.target.value); }, 1000);
|
|
}
|
|
|
|
async function doSave(t, b) {
|
|
if (!note) return;
|
|
setSaving(true);
|
|
try {
|
|
await api.put('/notes/' + note.id, { title: t || 'Untitled', body: b });
|
|
setDirty(false);
|
|
onRefresh();
|
|
} catch (e) {
|
|
console.error('Save failed:', e);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handlePin() {
|
|
if (!note) return;
|
|
await api.put('/notes/' + note.id, { pinned: note.pinned ? 0 : 1 });
|
|
onRefresh();
|
|
}
|
|
|
|
async function handleArchive() {
|
|
if (!note || !confirm('Archive this note?')) return;
|
|
await api.del('/notes/' + note.id);
|
|
onDelete();
|
|
}
|
|
|
|
function handleKeyDown(e) {
|
|
// Tab inserts two spaces
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
var ta = e.target;
|
|
var start = ta.selectionStart;
|
|
var end = ta.selectionEnd;
|
|
var val = ta.value;
|
|
ta.value = val.substring(0, start) + ' ' + val.substring(end);
|
|
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
setBody(ta.value);
|
|
setDirty(true);
|
|
debounce('save', function() { doSave(title, ta.value); }, 1000);
|
|
}
|
|
// Ctrl/Cmd+S force save
|
|
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
doSave(title, body);
|
|
}
|
|
}
|
|
|
|
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>`;
|
|
}
|
|
|
|
return html`
|
|
<div class="notes-editor">
|
|
<div class="notes-editor__header">
|
|
<input class="notes-editor__title-input" type="text"
|
|
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>
|
|
<button class="notes-toggle ${preview ? 'notes-toggle--active' : ''}"
|
|
onClick=${() => setPreview(!preview)}>
|
|
${preview ? 'Edit' : 'Preview'}
|
|
</button>
|
|
<button class="notes-btn" onClick=${handlePin}
|
|
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
|
${note.pinned ? '📌' : '📍'}
|
|
</button>
|
|
<button class="notes-btn notes-btn--danger" onClick=${handleArchive}
|
|
title="Archive">🗑</button>
|
|
</div>
|
|
</div>
|
|
<div class="notes-editor__body">
|
|
${preview
|
|
? html`<div class="notes-preview" dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
|
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
|
value=${body} onInput=${handleBodyChange}
|
|
onKeyDown=${handleKeyDown}
|
|
placeholder="Start writing in Markdown…" />`
|
|
}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
|
|
// ═══════════════════════════════════════════
|
|
// NotesApp — root component
|
|
// ═══════════════════════════════════════════
|
|
|
|
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 [folders, setFolders] = useState([]);
|
|
var [activeFolderId, setActiveFolderId] = useState(null);
|
|
var [showUnfiled, setShowUnfiled] = useState(false);
|
|
|
|
// ── Load note list ────────────────────────
|
|
var loadNotes = useCallback(async function() {
|
|
try {
|
|
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;
|
|
return (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '');
|
|
});
|
|
setNotes(items);
|
|
} catch (e) {
|
|
console.error('Load notes failed:', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [searchTerm, activeFolderId, showUnfiled]);
|
|
|
|
// ── Load single note (full body) ──────────
|
|
var loadNote = useCallback(async function(id) {
|
|
if (!id) { setActiveNote(null); return; }
|
|
try {
|
|
var note = await api.get('/notes/' + id);
|
|
setActiveNote(note);
|
|
} catch (e) {
|
|
console.error('Load note failed:', e);
|
|
setActiveNote(null);
|
|
}
|
|
}, []);
|
|
|
|
// ── 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 {
|
|
var s = await api.get('/stats');
|
|
setStats(s);
|
|
} catch (e) { /* ignore */ }
|
|
}, []);
|
|
|
|
useEffect(function() { loadNotes(); loadFolders(); loadStats(); }, [loadNotes]);
|
|
|
|
// select note
|
|
function handleSelect(id) {
|
|
setActiveId(id);
|
|
loadNote(id);
|
|
}
|
|
|
|
// ── Create note ───────────────────────────
|
|
async function handleNew() {
|
|
try {
|
|
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);
|
|
loadNotes();
|
|
loadStats();
|
|
}
|
|
} catch (e) {
|
|
console.error('Create failed:', e);
|
|
}
|
|
}
|
|
|
|
// ── Refresh after save ────────────────────
|
|
function handleRefresh() {
|
|
loadNotes();
|
|
if (activeId) loadNote(activeId);
|
|
loadStats();
|
|
}
|
|
|
|
// ── After delete ──────────────────────────
|
|
function handleDelete() {
|
|
setActiveId(null);
|
|
setActiveNote(null);
|
|
loadNotes();
|
|
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;
|
|
setSearchTerm(val);
|
|
debounce('search', function() { loadNotes(); }, 300);
|
|
}
|
|
|
|
return html`
|
|
<${Topbar} title="Notes">
|
|
<${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note<//>
|
|
<//>
|
|
<div class="notes-app">
|
|
<div class="notes-sidebar">
|
|
<div class="notes-sidebar__header">
|
|
<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} />
|
|
</div>
|
|
<div class="notes-list">
|
|
${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.'}
|
|
</div>
|
|
`}
|
|
${!loading && notes.map(function(n) {
|
|
return html`
|
|
<${NoteCard} key=${n.id} note=${n}
|
|
active=${n.id === activeId}
|
|
onClick=${() => handleSelect(n.id)} />
|
|
`;
|
|
})}
|
|
</div>
|
|
</div>
|
|
<${EditorPane} note=${activeNote} folders=${folders}
|
|
onSave=${handleRefresh}
|
|
onDelete=${handleDelete}
|
|
onRefresh=${handleRefresh} />
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Mount ──────────────────────────────────
|
|
render(html`<${NotesApp} />`, mount);
|
|
|
|
})();
|