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>
140 lines
5.8 KiB
Svelte
140 lines
5.8 KiB
Svelte
<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>
|