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>
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
import { store, iconOf } from '$lib/state.svelte.js';
import { api, errorText } from '$lib/api.js';
import { toast } from '$lib/toast.svelte.js';
let items = $state(null);
let closing = $state(false);
let status = $state('open'); // 'open' | 'done' | 'all'
let when = $state('any'); // 'any' | 'overdue' | 'today' | 'week' | 'none'
let tag = $state('');
let q = $state('');
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const now = new Date();
const today = iso(now);
const weekEnd = iso(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7));
async function load() {
try {
await store.flush(); // what is on screen is on disk before it is read here
items = (await api.tasks()) || [];
} catch (err) {
items = [];
toast(errorText(err), 'x');
}
}
onMount(load);
const plain = (t) => t
.replace(/\[\[([^\]|]*)\|([^\]]*)\]\]/g, '$2')
.replace(/\[\[([^\]]*)\]\]/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/(?:📅|\bdue:)\s*\d{4}-\d{2}-\d{2}/g, '')
.replace(/\*\*|__|==|`|\*/g, '')
.replace(/\s+/g, ' ')
.trim();
let tags = $derived([...new Set((items || []).flatMap((t) => t.tags))].sort((a, b) => a.localeCompare(b)));
let shown = $derived.by(() => {
const needle = q.trim().toLowerCase();
return (items || []).filter((t) => {
if (status === 'open' && t.done) return false;
if (status === 'done' && !t.done) return false;
if (tag && !t.tags.includes(tag)) return false;
if (when === 'none' && t.due) return false;
if (when === 'overdue' && !(t.due && t.due < today && !t.done)) return false;
if (when === 'today' && t.due !== today) return false;
if (when === 'week' && !(t.due && t.due >= today && t.due <= weekEnd)) return false;
if (needle && !(t.text.toLowerCase().includes(needle) || t.title.toLowerCase().includes(needle))) return false;
return true;
});
});
let groups = $derived.by(() => {
const map = new Map();
for (const t of shown) {
if (!map.has(t.id)) map.set(t.id, { id: t.id, title: t.title, icon: t.icon, tasks: [] });
map.get(t.id).tasks.push(t);
}
return [...map.values()].sort((a, b) => a.title.localeCompare(b.title));
});
let openCount = $derived((items || []).filter((t) => !t.done).length);
async function toggle(t) {
const done = !t.done;
t.done = done; // optimistic; undone below if the file refuses
try {
await api.setTask(t.id, t.line, t.text, done);
await store.refresh();
} catch (err) {
toast(errorText(err), 'x');
await load();
}
}
function openPage(id) {
close();
setTimeout(() => store.openPage(id), 120);
}
function close() {
closing = true;
setTimeout(() => { store.tasksOpen = false; closing = false; }, 150);
}
const dueText = (d) => (d === today ? 'Today' : new Date(d + 'T00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' }));
</script>
<svelte:window onkeydown={(e) => e.key === 'Escape' && close()} />
<div class="overlay" class:closing role="presentation" onmousedown={(e) => e.target === e.currentTarget && close()}>
<div class="panel tasks-panel" role="dialog" aria-label="Tasks">
<div class="trash-top">
<Icon name="check" />
<h2>Tasks{#if items}<span class="muted"> · {openCount} open</span>{/if}</h2>
<button class="btn-icon" aria-label="Close" onclick={close}><Icon name="x" /></button>
</div>
<div class="tk-bar">
<div class="seg3" role="radiogroup" aria-label="Status">
{#each [['open', 'Open'], ['done', 'Done'], ['all', 'All']] as [v, label] (v)}
<button class:on={status === v} role="radio" aria-checked={status === v} onclick={() => (status = v)}>{label}</button>
{/each}
</div>
<select class="tk-sel" aria-label="Due date" bind:value={when}>
<option value="any">Any date</option>
<option value="overdue">Overdue</option>
<option value="today">Today</option>
<option value="week">Next 7 days</option>
<option value="none">No date</option>
</select>
<select class="tk-sel" aria-label="Tag" bind:value={tag}>
<option value="">Any tag</option>
{#each tags as t (t)}<option value={t}>#{t}</option>{/each}
</select>
<input class="tk-q" placeholder="Filter by text or page" aria-label="Filter tasks" bind:value={q} />
</div>
<div class="trash-list tk-list">
{#if items === null}
<div class="trash-empty">Loading…</div>
{:else if !groups.length}
<div class="trash-empty"><Icon name="check" />{items.length ? 'No tasks match.' : 'No tasks yet. Write “- [ ] something” on any page.'}</div>
{:else}
{#each groups as g (g.id)}
<div class="tk-group">
<button class="tk-page" onclick={() => openPage(g.id)}><span class="row-ico"><Icon name={iconOf(g)} /></span>{g.title}</button>
{#each g.tasks as t (t.line)}
<label class="tk" class:done={t.done} style="padding-left:{10 + Math.min(t.depth, 4) * 18}px">
<input type="checkbox" checked={t.done} onchange={() => toggle(t)} />
<span class="tk-t">{plain(t.text)}</span>
{#if t.due}<span class="tk-due" class:over={!t.done && t.due < today}>{dueText(t.due)}</span>{/if}
{#each t.tags as x (x)}<span class="tk-tag">#{x}</span>{/each}
</label>
{/each}
</div>
{/each}
{/if}
</div>
<div class="trash-foot"><span>Add a due date with 📅 2026-03-01 or due:2026-03-01 in the task. Boards are left out.</span></div>
</div>
</div>