feat(ui): components, routes and styles

Sidebar (lazy tree, keyboard navigation, saved searches), page view with
typed properties, tasks, tags, calendar and clip dialogs, settings with
shortcut rebinding, canvas, database and board views.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent c0587cb267
commit 27fafa0bcd
39 files changed
+8744 -94

No files matched your search

+302
View File
@@ -0,0 +1,302 @@
<script>
import Icon from './Icon.svelte';
import TreeNode from './TreeNode.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, isDesktop } from '$lib/api.js';
import { tagHex } from '$lib/tagColors.js';
import { comboOf, format } from '$lib/shortcuts.js';
let roots = $derived(store.children(null));
let tagged = $derived(
store.filterTag
? store.pages.filter((p) => p.tags.includes(store.filterTag)).sort((a, b) => a.title.localeCompare(b.title))
: []
);
// Many tags: a search box over them, and the list scrolls inside a short box instead of pushing the page tree away.
const TAG_FIND_AT = 8;
let tagQ = $state('');
let shownTags = $derived.by(() => {
const q = tagQ.trim().toLowerCase().replace(/^#/, '');
return q ? store.tagCounts.filter(([t]) => t.toLowerCase().includes(q)) : store.tagCounts;
});
let mod = $derived(isDesktop && window.vault.platform === 'darwin' ? '⌘' : 'Ctrl');
const mac = isDesktop && window.vault.platform === 'darwin';
const key = (id) => format(comboOf(id, store.prefs.shortcuts), mac);
// Tab lands on the list as one stop; the arrow keys then move between rows (TreeNode), starting from the open page.
function focusTree(e) {
if (e.target !== e.currentTarget) return;
const rows = [...e.currentTarget.querySelectorAll('.row[data-id]')].filter((r) => r.offsetHeight > 0);
(rows.find((r) => r.classList.contains('active')) || rows[0])?.focus();
}
/* drag a page onto another to nest it, or onto empty space / the Pages header for the top level */
let treeEl = $state();
// A page opened from search, a link or Back: bring its row into view (after its folders have opened).
$effect(() => {
const id = store.currentId;
if (!id || !treeEl) return;
const t = setTimeout(() => {
const row = [...treeEl.querySelectorAll('.row[data-id]')].find((r) => r.dataset.id === id);
row?.scrollIntoView({ block: 'nearest' });
}, 120);
return () => clearTimeout(t);
});
let drag = null; // { id, x, y, ghost, started, target, hoverTimer, hoverId }
let dropOn = $state(undefined); // page id, null = top level, undefined = nowhere
let dragging = $state(null);
const canDrop = (id, parent) => {
const page = store.pages.find((p) => p.id === id);
return !!page && page.parent !== parent && parent !== id && !(parent && parent.startsWith(id + '/'));
};
function onTreeDown(e) {
if (e.button !== 0 || e.target.closest('button, input')) return;
const row = e.target.closest('.row[data-id]');
if (!row || store.renaming) return;
drag = { id: row.dataset.id, x: e.clientX, y: e.clientY, ghost: null, started: false };
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', stop);
document.addEventListener('keydown', onKey, true);
}
function begin() {
drag.started = true;
dragging = drag.id;
const page = store.pages.find((p) => p.id === drag.id);
const g = document.createElement('div');
g.className = 'page-ghost';
g.textContent = page ? page.title : drag.id;
document.body.append(g);
drag.ghost = g;
document.body.classList.add('page-dragging');
getSelection()?.removeAllRanges();
}
function onMove(e) {
if (!drag.started) {
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return;
begin();
}
drag.ghost.style.transform = `translate(${e.clientX + 12}px, ${e.clientY + 10}px)`;
const el = document.elementFromPoint(e.clientX, e.clientY);
const row = el && el.closest('.sidebar .row[data-id]');
const toRoot = !row && el && (el.closest('.tree') || el.closest('.sec-pages'));
const want = row ? row.dataset.id : toRoot ? null : undefined;
dropOn = want !== undefined && canDrop(drag.id, want) ? want : undefined;
// Over an open canvas, letting go puts the page on it as a note card.
store.canvasView?.dropHint(!!(el && el.closest('.cv')));
// Over the page itself, its left or right half takes the page into split view.
const main = el && !el.closest('.cv') && el.closest('.main');
if (main && store.view === 'page' && drag.id !== store.currentId) {
const r = main.getBoundingClientRect();
store.splitHint = e.clientX < r.left + r.width / 2 ? 'left' : 'right';
} else store.splitHint = null;
// Hovering a closed page with sub-pages opens it after a moment.
if (row && row.dataset.id !== drag.hoverId) {
clearTimeout(drag.hoverTimer);
drag.hoverId = row.dataset.id;
const id = row.dataset.id;
drag.hoverTimer = setTimeout(() => { if (!store.open[id] && store.children(id).length) store.toggleFold(id); }, 650);
} else if (!row) {
clearTimeout(drag.hoverTimer);
drag.hoverId = null;
}
// Scroll the list near its edges.
if (treeEl) {
const r = treeEl.getBoundingClientRect();
if (e.clientY < r.top + 24) treeEl.scrollTop -= 8;
else if (e.clientY > r.bottom - 24) treeEl.scrollTop += 8;
}
}
function onUp(e) {
const d = drag, to = dropOn, side = store.splitHint;
stop();
if (!d.started) return;
// The click that follows a drag should not open a page.
window.addEventListener('click', swallow, true);
setTimeout(() => window.removeEventListener('click', swallow, true), 0);
const over = document.elementFromPoint(e.clientX, e.clientY);
if (over && over.closest('.cv') && store.canvasView) {
store.canvasView.addPageAt(d.id, e.clientX, e.clientY);
return;
}
if (side) { store.dropToSide(d.id, side); return; }
if (to !== undefined) store.movePage(d.id, to);
}
const swallow = (e) => e.stopPropagation();
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stop(); } };
function stop() {
if (!drag) return;
clearTimeout(drag.hoverTimer);
drag.ghost?.remove();
store.canvasView?.dropHint(false);
store.splitHint = null;
document.body.classList.remove('page-dragging');
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', stop);
document.removeEventListener('keydown', onKey, true);
drag = null;
dropOn = undefined;
dragging = null;
}
</script>
<aside class="sidebar island" aria-label="Notes">
<div class="sb-inner">
<div class="ws">
<div class="ws-mark">{(store.vault?.name?.[0] || 'N').toUpperCase()}</div>
<div class="ws-txt">
<strong>{store.vault?.name}</strong>
</div>
<button class="btn-icon" aria-label="Hide sidebar" title="Hide sidebar ({key('sidebar')})" onclick={() => store.toggleSidebar()}>
<Icon name="panel" />
</button>
</div>
<div class="qas" class:row={store.quickLayout === 'horizontal'}>
<button class="qa" aria-label="Search" title="Search ({key('palette')})" onclick={() => (store.paletteOpen = true)}><Icon name="search" /><span class="qa-t">Search</span><kbd>{key('palette')}</kbd></button>
<button class="qa" aria-label="New page" title="New page ({key('newPage')})" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" /><span class="qa-t">New page</span><kbd>{key('newPage')}</kbd></button>
<button class="qa" aria-label="Today" title="Today ({key('daily')})" onclick={() => store.openDaily()}><Icon name="sun" /><span class="qa-t">Today</span><kbd>{key('daily')}</kbd></button>
<button class="qa" class:on={store.view === 'graph'} aria-label="Graph view" title="Graph view ({key('graph')})" onclick={() => (store.view === 'graph' ? store.closeGraph() : store.openGraph())}><Icon name="graph" /><span class="qa-t">Graph view</span><kbd>{key('graph')}</kbd></button>
</div>
{#if store.pinned.length}
<div class="sec"><span>Favorites</span></div>
<div class="favs" role="tree" aria-label="Favorites" tabindex="-1" onpointerdown={onTreeDown}>
{#each store.pinned as p (p.id)}
<div
class="row fav"
class:active={store.currentId === p.id}
class:drop-in={dropOn === p.id}
class:lifted={dragging === p.id}
data-id={p.id}
role="treeitem"
aria-selected={store.currentId === p.id}
tabindex="0"
onclick={() => store.openPage(p.id)}
onkeydown={(e) => e.key === 'Enter' && store.openPage(p.id)}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}
>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="name">{p.title}</span>
<button
class="row-add"
tabindex="-1"
aria-label="Remove {p.title} from favorites"
title="Remove from favorites"
onclick={(e) => { e.stopPropagation(); store.togglePin(p.id); }}
><Icon name="x" /></button>
</div>
{/each}
</div>
{/if}
{#if store.searches.length}
<div class="sec"><span>Saved searches</span></div>
<div class="favs" role="list" aria-label="Saved searches">
{#each store.searches as s (s.q)}
<div class="row fav" role="listitem" tabindex="0" title="Search for {s.q}" onclick={() => store.runSearch(s.q)} onkeydown={(e) => e.key === 'Enter' && store.runSearch(s.q)}>
<span class="row-ico"><Icon name="search" /></span>
<span class="name">{s.q}</span>
<button class="row-add" tabindex="-1" aria-label="Remove saved search {s.q}" title="Remove" onclick={(e) => { e.stopPropagation(); store.removeSearch(s.q); }}><Icon name="x" /></button>
</div>
{/each}
</div>
{/if}
<div class="sec sec-pages" class:drop-root={dropOn === null}>
<span>Pages</span>
<button class="btn-icon" aria-label="New page" onclick={() => (store.templatesOpen = { parent: null })}><Icon name="plus" /></button>
</div>
<div
class="tree"
class:drop-root={dropOn === null}
bind:this={treeEl}
role="tree"
aria-label="Pages"
tabindex="0"
onfocus={focusTree}
onpointerdown={onTreeDown}
oncontextmenu={(e) => { e.preventDefault(); store.menu = { x: e.clientX, y: e.clientY, id: null }; }}>
{#if store.filterTag}
<div class="filter-head">
<span>Tagged <b>#{store.filterTag}</b></span>
<button onclick={() => (store.filterTag = null)}>Clear</button>
</div>
{#each tagged as p, i (p.id)}
<div
class="row flat"
class:active={store.currentId === p.id}
class:drop-in={dropOn === p.id}
class:lifted={dragging === p.id}
data-id={p.id}
style="--i:{i}"
role="treeitem"
aria-selected={store.currentId === p.id}
tabindex="0"
onclick={() => store.openPage(p.id)}
onkeydown={(e) => e.key === 'Enter' && store.openPage(p.id)}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, id: p.id }; }}
>
<span class="row-ico"><Icon name={iconOf(p)} /></span>
<span class="name">{p.title}</span>
</div>
{/each}
{:else}
{#each roots as p (p.id)}
<TreeNode page={p} {dropOn} {dragging} />
{:else}
<p class="empty-tree">No pages yet.</p>
{/each}
{/if}
</div>
<div class="sec sec-tags">
<button class="sec-toggle" aria-expanded={store.tagsOpen} title={store.tagsOpen ? 'Hide tags' : 'Show tags'} onclick={() => store.setTagsOpen(!store.tagsOpen)}>
<span class="chev" class:open={store.tagsOpen}><Icon name="chev" /></span>Tags{#if store.tagCounts.length}<em>{store.tagCounts.length}</em>{/if}
</button>
<!-- with the list folded away, the tag being filtered by stays in view, with a way to clear it -->
{#if store.tagCounts.length}<button class="btn-icon" aria-label="Manage tags" title="Manage tags" onclick={() => (store.tagManagerOpen = true)}><Icon name="gear" /></button>{/if}
{#if store.filterTag && !store.tagsOpen}
<button class="tag on sec-active" class:tinted={!!tagHex(store.tagColors[store.filterTag])} style={tagHex(store.tagColors[store.filterTag]) ? `--tc:${tagHex(store.tagColors[store.filterTag])}` : undefined} title="Stop filtering by this tag" onclick={() => (store.filterTag = null)}><span>#</span>{store.filterTag}<Icon name="x" /></button>
{/if}
</div>
<!-- always there, so it can slide open and shut; `inert` while shut keeps it out of the tab order -->
<div class="tags-wrap" class:open={store.tagsOpen} inert={!store.tagsOpen}>
<div class="tags-in">
{#if store.tagCounts.length > TAG_FIND_AT}
<div class="tag-find">
<Icon name="search" />
<input
placeholder="Find a tag…"
aria-label="Find a tag"
bind:value={tagQ}
onkeydown={(e) => { if (e.key === 'Escape') { tagQ = ''; e.currentTarget.blur(); } }}
/>
{#if tagQ}<button class="btn-icon" aria-label="Clear" onclick={() => (tagQ = '')}><Icon name="x" /></button>{/if}
</div>
{/if}
<div class="tags">
{#each shownTags as [tag, n] (tag)}
<button class="tag" class:tinted={!!tagHex(store.tagColors[tag])} class:on={store.filterTag === tag} style={tagHex(store.tagColors[tag]) ? `--tc:${tagHex(store.tagColors[tag])}` : undefined} onclick={() => (store.filterTag = store.filterTag === tag ? null : tag)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); store.menu = { x: e.clientX, y: e.clientY, tag }; }}>
<span>#</span>{tag}<em>{n}</em>
</button>
{:else}
<span class="muted">{store.tagCounts.length ? 'No tag matches' : 'No tags yet'}</span>
{/each}
</div>
</div>
</div>
<div class="sb-foot">
<span>{store.pages.filter((p) => p.exists).length} notes</span>
<button class="btn-icon foot-btn" aria-label="Tasks" title="Tasks ({key('tasks')})" onclick={() => (store.tasksOpen = true)}><Icon name="check" /></button>
<button class="btn-icon foot-btn foot-trash" aria-label="Trash" title="Trash" onclick={() => (store.trashOpen = true)}><Icon name="trash" /></button>
<button class="btn-icon foot-btn" aria-label="Settings" title="Settings ({key('settings')})" onclick={() => (store.settingsOpen = true)}><Icon name="gear" /></button>
</div>
</div>
</aside>