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:
2026-07-12 07:16:01 +02:00
commit 175c89e400
54 changed files with 7229 additions and 0 deletions

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>