- frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then- push sync engine with conflict handling, versioned cache-first service worker + manifest (installable PWA) - server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with- conflict-copies push rules, subcommands: init, token rotate, import simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle - desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray), global Ctrl+Shift+Space hotkey, quit-on-close flag - Simplenote import (CLI + web upload), idempotent via source_id dedupe; verified against a real 242-note export - Makefile (frontend/server/server-all/desktop/test), GitHub release workflow, README with systemd/Caddy/backup docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
139 lines
3.7 KiB
Svelte
139 lines
3.7 KiB
Svelte
<script lang="ts">
|
|
import { createEventDispatcher, tick } from 'svelte';
|
|
import { noteTitle, noteBody, highlightRegex } from '../search';
|
|
import type { Note } from '../types';
|
|
|
|
export let filtered: Note[] = [];
|
|
export let selectedId: string | null = null;
|
|
export let query = '';
|
|
|
|
// DOM cap: filtering is in-memory over everything, but rendering thousands of
|
|
// rows would blow the <10 ms keystroke budget. Selection stays within the cap.
|
|
const MAX_ROWS = 100;
|
|
|
|
const dispatch = createEventDispatcher<{ open: string }>();
|
|
|
|
let listEl: HTMLElement;
|
|
|
|
$: visible = filtered.length > MAX_ROWS ? filtered.slice(0, MAX_ROWS) : filtered;
|
|
$: rx = highlightRegex(query);
|
|
|
|
$: if (selectedId) {
|
|
void tick().then(() => {
|
|
listEl?.querySelector('.row.selected')?.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function hl(s: string): string {
|
|
if (!rx) return esc(s);
|
|
rx.lastIndex = 0;
|
|
let out = '';
|
|
let last = 0;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = rx.exec(s))) {
|
|
out += esc(s.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>';
|
|
last = m.index + m[0].length;
|
|
if (m.index === rx.lastIndex) rx.lastIndex++;
|
|
}
|
|
return out + esc(s.slice(last));
|
|
}
|
|
|
|
function when(ms: number): string {
|
|
const d = new Date(ms);
|
|
const now = new Date();
|
|
if (d.toDateString() === now.toDateString())
|
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
if (d.getFullYear() === now.getFullYear())
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
</script>
|
|
|
|
<div class="list" bind:this={listEl} role="listbox" aria-label="Notes">
|
|
{#each visible as n (n.id)}
|
|
<div
|
|
class="row"
|
|
class:selected={n.id === selectedId}
|
|
role="option"
|
|
aria-selected={n.id === selectedId}
|
|
tabindex="-1"
|
|
on:click={() => dispatch('open', n.id)}
|
|
on:keydown={(e) => e.key === 'Enter' && dispatch('open', n.id)}
|
|
>
|
|
<span class="title">{@html hl(noteTitle(n.content))}</span>
|
|
<span class="snippet">{@html hl(noteBody(n.content).slice(0, 120))}</span>
|
|
<span class="meta">
|
|
{#each n.tags as t}<span class="tag">{t}</span>{/each}
|
|
<span class="date">{when(n.modified_at)}</span>
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<div class="none">{query.trim() ? 'No matches — Enter creates this note' : 'No notes yet'}</div>
|
|
{/each}
|
|
{#if filtered.length > MAX_ROWS}
|
|
<div class="none">…and {filtered.length - MAX_ROWS} more — keep typing to narrow down</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.list {
|
|
min-height: 100%;
|
|
}
|
|
.row {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 10px;
|
|
padding: 6px 12px;
|
|
line-height: 1.7;
|
|
border-bottom: 1px solid var(--sep);
|
|
cursor: default;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
.row.selected {
|
|
background: var(--sel-bg);
|
|
}
|
|
.title {
|
|
font-weight: 600;
|
|
flex-shrink: 0;
|
|
max-width: 55%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.snippet {
|
|
flex: 1;
|
|
color: var(--fg-dim);
|
|
font-size: 12.5px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.meta {
|
|
margin-left: auto;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.tag {
|
|
background: var(--accent-bg);
|
|
color: var(--accent);
|
|
font-size: 11px;
|
|
padding: 1px 7px;
|
|
border-radius: 9px;
|
|
}
|
|
.date {
|
|
color: var(--fg-dim);
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.none {
|
|
padding: 16px;
|
|
color: var(--fg-dim);
|
|
text-align: center;
|
|
}
|
|
</style>
|