Implement Tefter per SPEC.md: NV-style notes with server, sync, PWA, desktop
- 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>
This commit is contained in:
138
frontend/src/ui/NoteList.svelte
Normal file
138
frontend/src/ui/NoteList.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user