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

+139
View File
@@ -0,0 +1,139 @@
<script>
// A text field for a page path with a list of existing pages under it, as you type: for choosing a folder.
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { cleanFolder, autoFolder } from '$lib/daily.js';
// value: the chosen path ('' = automatic); onchange(path or '') is called when the choice is made
let { value = '', onchange, label = 'Folder' } = $props();
let input = $state();
let open = $state(false);
let text = $state(value);
let active = $state(0);
let at = $state({ left: 0, top: 0, bottom: null, width: 260, max: 300 });
$effect(() => { if (!open) text = value; });
const auto = $derived(autoFolder(store.pages));
const pages = $derived(store.pages.filter((p) => p.exists && !/\.canvas$/i.test(p.id)));
const q = $derived(text.trim().toLowerCase());
// best matches first: names starting with it, then names containing it, then paths containing it
const found = $derived.by(() => {
if (!q) return pages.slice().sort((a, b) => a.id.localeCompare(b.id)).slice(0, 40);
const rank = (p) => {
const t = p.title.toLowerCase();
return t.startsWith(q) ? 0 : t.includes(q) ? 1 : p.id.toLowerCase().includes(q) ? 2 : 3;
};
return pages.map((p) => [rank(p), p]).filter(([r]) => r < 3).sort((a, b) => a[0] - b[0] || a[1].id.localeCompare(b[1].id)).map(([, p]) => p).slice(0, 40);
});
const typed = $derived(cleanFolder(text));
const isNew = $derived(!!typed && !pages.some((p) => p.id.toLowerCase() === typed.toLowerCase()));
// rows: the automatic choice, matching pages, and "use what I typed" for a folder that doesn't exist yet
const rows = $derived([
...(q ? [] : [{ kind: 'auto' }]),
...found.map((p) => ({ kind: 'page', p })),
...(isNew ? [{ kind: 'new', id: typed }] : [])
]);
function show() {
const r = input.getBoundingClientRect();
const width = Math.max(r.width, 280);
// below the field if there's room (or more room than above), otherwise above it
const below = innerHeight - r.bottom - 14, above = r.top - 14;
const up = below < 240 && above > below;
const left = Math.max(8, Math.min(r.right - width, innerWidth - width - 8));
at = up
? { left, top: null, bottom: innerHeight - r.top + 6, width, max: Math.min(300, above) }
: { left, top: r.bottom + 6, bottom: null, width, max: Math.min(300, below) };
active = 0;
open = true;
}
function choose(row) {
open = false;
if (!row) return;
const next = row.kind === 'auto' ? '' : row.kind === 'page' ? row.p.id : row.id;
text = next;
if (next !== value) onchange?.(next);
input?.blur();
}
function commitTyped() {
open = false;
const next = typed;
if (next !== value) { text = next; onchange?.(next); }
else text = value;
}
function onkeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (!open) return show();
const n = rows.length || 1;
active = (active + (e.key === 'ArrowDown' ? 1 : -1) + n) % n;
document.querySelector('.fp-list .mi.act')?.scrollIntoView({ block: 'nearest' });
} else if (e.key === 'Enter') {
e.preventDefault();
// a highlighted row if the list was used; otherwise exactly what was typed
if (open && rows[active] && !(text.trim() && rows[active].kind === 'auto')) choose(rows[active]);
else { commitTyped(); input.blur(); }
} else if (e.key === 'Escape' && open) {
e.preventDefault();
e.stopPropagation(); // the dialog stays
open = false;
text = value;
}
}
const mark = (s) => {
const i = q ? s.toLowerCase().indexOf(q) : -1;
return i < 0 ? [s, '', ''] : [s.slice(0, i), s.slice(i, i + q.length), s.slice(i + q.length)];
};
</script>
<svelte:window onresize={() => (open = false)} />
<div class="fp">
<Icon name="folder" />
<input
bind:this={input}
bind:value={text}
class="fp-input"
role="combobox"
aria-label={label}
aria-expanded={open}
aria-autocomplete="list"
placeholder={auto}
spellcheck="false"
autocomplete="off"
onfocus={(e) => { show(); e.currentTarget.select(); }}
onclick={(e) => { if (!open) show(); e.currentTarget.select(); }}
oninput={() => { open = true; active = 0; }}
onblur={() => { if (open) commitTyped(); }}
{onkeydown}
/>
<button class="fp-chev" tabindex="-1" aria-label="Show folders" onmousedown={(e) => { e.preventDefault(); open ? (open = false) : (input.focus(), show()); }}>
<Icon name="chev" />
</button>
</div>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pop fp-list" role="listbox" style="left:{at.left}px;{at.top !== null ? `top:${at.top}px;` : `bottom:${at.bottom}px;`}width:{at.width}px;max-height:{at.max}px" class:up={at.top === null} onmousedown={(e) => e.preventDefault()}>
{#each rows as row, i (row.kind + (row.p ? row.p.id : row.id || ''))}
{#if row.kind === 'auto'}
<button class="mi" class:act={i === active} role="option" aria-selected={!value} onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name="sparkle" /><span class="mi-label">Find automatically</span><em>{auto}</em>{#if !value}<Icon name="tick" />{/if}
</button>
{:else if row.kind === 'page'}
{@const [a, m, b] = mark(row.p.title)}
<button class="mi" class:act={i === active} role="option" aria-selected={value === row.p.id} onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name={iconOf(row.p)} /><span class="mi-label">{a}{#if m}<mark>{m}</mark>{/if}{b}</span>{#if row.p.parent}<em>{row.p.parent}</em>{/if}{#if value === row.p.id}<Icon name="tick" />{/if}
</button>
{:else}
<button class="mi" class:act={i === active} role="option" aria-selected="false" onmousemove={() => (active = i)} onclick={() => choose(row)}>
<Icon name="plus" /><span class="mi-label">Use “{row.id}”</span><em>new folder</em>
</button>
{/if}
{:else}
<p class="fp-none">No pages yet.</p>
{/each}
</div>
{/if}