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

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Tefter</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2136
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "tefter-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/node": "^26.1.1",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"vite": "^5.4.11"
},
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.6",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.35.3",
"@lezer/highlight": "^1.2.1",
"dompurify": "^3.2.3",
"marked": "^15.0.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

8
frontend/public/icon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2f6fde"/>
<rect x="96" y="120" width="320" height="52" rx="26" fill="#ffffff" opacity="0.95"/>
<rect x="96" y="216" width="320" height="14" rx="7" fill="#ffffff" opacity="0.7"/>
<rect x="96" y="258" width="250" height="14" rx="7" fill="#ffffff" opacity="0.55"/>
<rect x="96" y="300" width="290" height="14" rx="7" fill="#ffffff" opacity="0.4"/>
<rect x="96" y="342" width="200" height="14" rx="7" fill="#ffffff" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@@ -0,0 +1,15 @@
{
"name": "Tefter",
"short_name": "Tefter",
"description": "Self-hosted Notational Velocity style notes",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#2f6fde",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

134
frontend/src/app.css Normal file
View File

@@ -0,0 +1,134 @@
:root {
--bg: #fafafa;
--bg-panel: #ffffff;
--fg: #1a1a1a;
--fg-dim: #767676;
--sep: #e2e2e2;
--accent: #2f6fde;
--accent-bg: #e8f0fe;
--sel-bg: #dce8fb;
--mark-bg: #ffe9a8;
--mark-fg: inherit;
--dot-green: #2e9e44;
--dot-yellow: #d9a400;
--dot-gray: #9a9a9a;
--dot-red: #cc3333;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--bg-panel: #212121;
--fg: #e4e4e4;
--fg-dim: #8f8f8f;
--sep: #333333;
--accent: #6ba0f2;
--accent-bg: #263450;
--sel-bg: #2c3e5c;
--mark-bg: #6b5a1a;
--mark-fg: #ffe9a8;
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
font-family: var(--sans);
background: var(--bg);
color: var(--fg);
}
#app {
height: 100%;
}
mark {
background: var(--mark-bg);
color: var(--mark-fg);
border-radius: 2px;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
input,
select {
font: inherit;
color: var(--fg);
background: var(--bg-panel);
border: 1px solid var(--sep);
border-radius: 6px;
}
/* CodeMirror chrome */
.cm-editor {
height: 100%;
font-size: 14px;
background: var(--bg-panel);
}
.cm-editor .cm-content {
font-family: var(--mono);
caret-color: var(--fg);
padding: 12px 0;
}
.cm-editor .cm-line {
padding: 0 16px;
}
.cm-editor.cm-focused {
outline: none;
}
.cm-editor .cm-cursor {
border-left-color: var(--fg);
}
.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground {
background: var(--sel-bg) !important;
}
.cm-search-hit {
background: var(--mark-bg);
color: var(--mark-fg);
border-radius: 2px;
}
/* markdown token styling: bold headings, dim syntax marks */
.tok-heading {
font-weight: 700;
}
.tok-meta,
.tok-processingInstruction {
color: var(--fg-dim);
}
.tok-emphasis {
font-style: italic;
}
.tok-strong {
font-weight: 700;
}
.tok-link,
.tok-url {
color: var(--accent);
}
.tok-strikethrough {
text-decoration: line-through;
}
.tok-monospace {
color: var(--fg-dim);
}

67
frontend/src/db/idb.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { Note } from '../types';
const DB_NAME = 'tefter';
const DB_VERSION = 1;
let dbp: Promise<IDBDatabase> | null = null;
function open(): Promise<IDBDatabase> {
if (dbp) return dbp;
dbp = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('notes')) db.createObjectStore('notes', { keyPath: 'id' });
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbp;
}
function tx<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return open().then(
(db) =>
new Promise<T>((resolve, reject) => {
const t = db.transaction(store, mode);
const req = fn(t.objectStore(store));
t.oncomplete = () => resolve(req.result);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error);
})
);
}
export function idbGetAllNotes(): Promise<Note[]> {
return tx('notes', 'readonly', (s) => s.getAll());
}
export function idbPutNote(n: Note): Promise<unknown> {
return tx('notes', 'readwrite', (s) => s.put(n));
}
export async function idbPutNotes(notes: Note[]): Promise<void> {
if (!notes.length) return;
const db = await open();
await new Promise<void>((resolve, reject) => {
const t = db.transaction('notes', 'readwrite');
const s = t.objectStore('notes');
for (const n of notes) s.put(n);
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error);
});
}
export function idbDeleteNote(id: string): Promise<unknown> {
return tx('notes', 'readwrite', (s) => s.delete(id));
}
export function idbGetMeta<T>(key: string): Promise<T | undefined> {
return tx('meta', 'readonly', (s) => s.get(key)) as Promise<T | undefined>;
}
export function idbSetMeta(key: string, value: unknown): Promise<unknown> {
return tx('meta', 'readwrite', (s) => s.put(value, key));
}

137
frontend/src/db/store.ts Normal file
View File

@@ -0,0 +1,137 @@
import { writable, derived, get } from 'svelte/store';
import type { Note, ServerNote, Settings } from '../types';
import { idbGetAllNotes, idbPutNote, idbPutNotes, idbGetMeta, idbSetMeta } from './idb';
// All note metadata + content lives in RAM (SPEC §5: filter in memory, <10ms @ 10k notes).
export const notes = writable<Map<string, Note>>(new Map());
export const settings = writable<Settings>({ serverUrl: '', token: '', vertical: false });
export const cursor = writable<number>(0);
export const pendingCount = derived(notes, ($n) => {
let c = 0;
for (const note of $n.values()) if (note.dirty) c++;
return c;
});
export function uuidv4(): string {
if (crypto.randomUUID) return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
export async function loadAll(): Promise<void> {
const all = await idbGetAllNotes();
const map = new Map<string, Note>();
for (const n of all) map.set(n.id, n);
notes.set(map);
cursor.set((await idbGetMeta<number>('cursor')) ?? 0);
const s = await idbGetMeta<Partial<Settings>>('settings');
if (s) settings.set({ serverUrl: s.serverUrl ?? '', token: s.token ?? '', vertical: s.vertical ?? false });
}
export async function saveSettings(patch: Partial<Settings>): Promise<void> {
settings.update((s) => ({ ...s, ...patch }));
await idbSetMeta('settings', get(settings));
}
export async function setCursor(v: number): Promise<void> {
cursor.set(v);
await idbSetMeta('cursor', v);
}
function mutate(n: Note): void {
notes.update((m) => {
m.set(n.id, n);
return m;
});
}
/** Persist an edit: IndexedDB write happens before any network activity (SPEC §4.4). */
export async function updateContent(id: string, content: string): Promise<void> {
const n = get(notes).get(id);
if (!n || n.content === content) return; // no-op saves must not dirty the note
const upd: Note = { ...n, content, modified_at: Date.now(), dirty: true };
mutate(upd);
await idbPutNote(upd);
}
export async function createNote(firstLine: string): Promise<Note> {
const now = Date.now();
const n: Note = {
id: uuidv4(),
content: firstLine ? firstLine + '\n' : '',
tags: [],
created_at: now,
modified_at: now,
deleted: false,
version: 0,
baseVersion: 0,
dirty: true,
};
mutate(n);
await idbPutNote(n);
return n;
}
export async function deleteNote(id: string): Promise<Note | null> {
const n = get(notes).get(id);
if (!n) return null;
const upd: Note = { ...n, deleted: true, modified_at: Date.now(), dirty: true };
mutate(upd);
await idbPutNote(upd);
return n; // pre-delete copy, for undo
}
export async function restoreNote(prev: Note): Promise<void> {
const cur = get(notes).get(prev.id);
// Undo the tombstone but keep it dirty so the restore syncs too.
const upd: Note = { ...(cur ?? prev), content: prev.content, deleted: false, modified_at: Date.now(), dirty: true };
mutate(upd);
await idbPutNote(upd);
}
/** Apply a pulled server note. Local dirty copies win until pushed (SPEC §4 client loop). */
export async function applyServerNote(sn: ServerNote): Promise<void> {
await applyServerNotes([sn]);
}
/** Batch variant: one store update + one IndexedDB transaction per pulled page. */
export async function applyServerNotes(sns: ServerNote[]): Promise<void> {
const m = get(notes);
const updates: Note[] = [];
for (const sn of sns) {
const local = m.get(sn.id);
if (local && local.dirty) continue; // push will resolve
updates.push({ ...sn, baseVersion: sn.version, dirty: false });
}
if (!updates.length) return;
notes.update((map) => {
for (const u of updates) map.set(u.id, u);
return map;
});
await idbPutNotes(updates);
}
/** After an accepted push: clear dirty unless the note changed again mid-flight. */
export async function markPushed(id: string, sentModifiedAt: number, version: number): Promise<void> {
const n = get(notes).get(id);
if (!n) return;
const stillSame = n.modified_at === sentModifiedAt;
const upd: Note = { ...n, version, baseVersion: version, dirty: stillSame ? false : n.dirty };
mutate(upd);
await idbPutNote(upd);
}
/** After a conflict: server truth replaces local; conflict copy arrives via next pull. */
export async function replaceWithServer(sn: ServerNote): Promise<void> {
const upd: Note = { ...sn, baseVersion: sn.version, dirty: false };
mutate(upd);
await idbPutNote(upd);
}
export async function persistNotes(list: Note[]): Promise<void> {
await idbPutNotes(list);
}

2
frontend/src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/** Injected by Vite `define` (see vite.config.ts). */
declare const __TEFTER_VERSION__: string;

37
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,37 @@
import './app.css';
import App from './ui/App.svelte';
import { loadAll } from './db/store';
import { startSync } from './sync/engine';
async function boot() {
await loadAll();
const app = new App({ target: document.getElementById('app')! });
startSync();
// Desktop shell (Wails) integration: tray "Sync now" and the global shortcut.
const rt = (window as unknown as {
runtime?: { EventsOn(name: string, cb: () => void): void };
}).runtime;
if (rt?.EventsOn) {
rt.EventsOn('tefter:focus-omnibar', () => app.externalFocusOmnibar());
rt.EventsOn('tefter:sync', () => {
void import('./sync/engine').then((m) => m.syncNow());
});
}
// IndexedDB persistence (SPEC §8)
if (navigator.storage?.persist) {
navigator.storage.persist().catch(() => {});
}
// Service worker only where it exists and makes sense (not in the Wails webview)
if (import.meta.env.PROD && 'serviceWorker' in navigator && location.protocol.startsWith('http')) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
return app;
}
void boot();

119
frontend/src/search.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { Note } from './types';
// Diacritic folding: NFD decomposition strips combining marks (č→c, š→s, ž→z, ć→c…);
// đ/Đ don't decompose so they are mapped explicitly.
const EXTRA: Record<string, string> = { đ: 'd', Đ: 'd', ø: 'o', Ø: 'o', ł: 'l', Ł: 'l', ß: 'ss' };
export function fold(s: string): string {
return s
.toLowerCase()
.replace(/[đĐøØłŁß]/g, (c) => EXTRA[c])
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
/** First non-empty line, markdown heading markers stripped. */
export function noteTitle(content: string): string {
for (const raw of content.split('\n')) {
const line = raw.replace(/^#{1,6}\s+/, '').trim();
if (line) return line;
}
return 'Untitled';
}
export function noteBody(content: string): string {
const lines = content.split('\n');
let i = 0;
while (i < lines.length && !lines[i].trim()) i++;
i++; // skip the title line
return lines
.slice(i)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
// Folded title/content are cached per note object; notes are replaced (not
// mutated) on change, so WeakMap entries invalidate themselves.
const foldCache = new WeakMap<Note, { title: string; body: string }>();
function foldedOf(n: Note): { title: string; body: string } {
let c = foldCache.get(n);
if (!c) {
c = { title: fold(noteTitle(n.content)), body: fold(n.content) };
foldCache.set(n, c);
}
return c;
}
/**
* Rank categories: 0 exact title match, 1 title prefix, 2 all terms in title,
* 3 terms matched across title+body. Terms are AND-ed, case- and diacritic-insensitive.
* Returns matching notes sorted by (rank asc, modified_at desc).
*/
export function filterNotes(notes: Note[], query: string, tagFilter: string | null): Note[] {
let pool = notes.filter((n) => !n.deleted);
if (tagFilter) pool = pool.filter((n) => n.tags.includes(tagFilter));
const q = fold(query.trim());
if (!q) return pool.sort((a, b) => b.modified_at - a.modified_at);
const terms = q.split(/\s+/).filter(Boolean);
const ranked: { n: Note; rank: number }[] = [];
for (const n of pool) {
const { title, body } = foldedOf(n);
let ok = true;
let allInTitle = true;
for (const t of terms) {
if (title.includes(t)) continue;
allInTitle = false;
if (!body.includes(t)) {
ok = false;
break;
}
}
if (!ok) continue;
let rank = 3;
if (title === q) rank = 0;
else if (title.startsWith(q)) rank = 1;
else if (allInTitle) rank = 2;
ranked.push({ n, rank });
}
ranked.sort((a, b) => a.rank - b.rank || b.n.modified_at - a.n.modified_at);
return ranked.map((r) => r.n);
}
/**
* Build a case/diacritic-insensitive RegExp matching any of the query terms,
* for highlighting in the original (unfolded) text.
*/
export function highlightRegex(query: string): RegExp | null {
const terms = query.trim().split(/\s+/).filter(Boolean);
if (!terms.length) return null;
const classes: Record<string, string> = {
a: '[aàáâãäåā]', c: '[cçćč]', d: '[dđď]', e: '[eèéêëē]', g: '[gğ]',
i: '[iìíîïī]', l: '[lł]', n: '[nñń]', o: '[oòóôõöøō]', s: '[sšś]',
u: '[uùúûüū]', y: '[yýÿ]', z: '[zžźż]',
};
const pat = terms
.map((t) =>
fold(t)
.split('')
.map((ch) => classes[ch] || ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('')
)
.join('|');
try {
return new RegExp(pat, 'giu');
} catch {
return null;
}
}
export function allTags(notes: Note[]): string[] {
const set = new Set<string>();
for (const n of notes) if (!n.deleted) for (const t of n.tags) set.add(t);
return [...set].sort();
}

113
frontend/src/sync/engine.ts Normal file
View File

@@ -0,0 +1,113 @@
import { writable, get } from 'svelte/store';
import type { PushResult, ServerNote, SyncState } from '../types';
import { notes, settings, cursor, setCursor, applyServerNotes, markPushed, replaceWithServer } from '../db/store';
export const syncState = writable<SyncState>('local');
export const lastSyncAt = writable<number | null>(null);
export const syncError = writable<string>('');
let editTimer: ReturnType<typeof setTimeout> | null = null;
let interval: ReturnType<typeof setInterval> | null = null;
let running = false;
let queued = false;
function configured(): boolean {
const s = get(settings);
return !!s.serverUrl;
}
function api(path: string, init: RequestInit = {}): Promise<Response> {
const s = get(settings);
const base = s.serverUrl.replace(/\/+$/, '');
return fetch(base + path, {
...init,
headers: {
...(init.headers || {}),
Authorization: 'Bearer ' + s.token,
'Content-Type': 'application/json',
},
});
}
async function pull(): Promise<void> {
for (;;) {
const since = get(cursor);
const res = await api(`/api/v1/changes?since=${since}&limit=500`);
if (!res.ok) throw new Error(`pull: HTTP ${res.status}`);
const body: { notes: ServerNote[]; cursor: number; more: boolean } = await res.json();
await applyServerNotes(body.notes);
if (body.cursor > since) await setCursor(body.cursor);
if (!body.more) break;
}
}
async function push(): Promise<void> {
const dirty = [...get(notes).values()].filter((n) => n.dirty);
if (!dirty.length) return;
const payload = dirty.map((n) => ({
id: n.id,
content: n.content,
tags: n.tags,
created_at: n.created_at,
modified_at: n.modified_at,
deleted: n.deleted,
baseVersion: n.baseVersion,
}));
const res = await api('/api/v1/notes/batch', { method: 'POST', body: JSON.stringify({ notes: payload }) });
if (!res.ok) throw new Error(`push: HTTP ${res.status}`);
const body: { results: PushResult[] } = await res.json();
const sentAt = new Map(dirty.map((n) => [n.id, n.modified_at]));
for (const r of body.results) {
if (r.status === 'accepted' && r.version) {
await markPushed(r.id, sentAt.get(r.id) ?? 0, r.version);
} else if (r.status === 'conflict' && r.serverNote) {
await replaceWithServer(r.serverNote); // conflict copy arrives on next pull
}
}
}
/** Pull-then-push; safe to interrupt at any point (SPEC §4). */
export async function syncNow(): Promise<void> {
if (!configured()) {
syncState.set('local');
return;
}
if (running) {
queued = true;
return;
}
running = true;
syncState.set('syncing');
try {
await pull();
await push();
await pull(); // pick up conflict copies + versions created by our own push
syncState.set(navigator.onLine ? 'synced' : 'offline');
lastSyncAt.set(Date.now());
syncError.set('');
} catch (e) {
syncState.set(navigator.onLine ? 'error' : 'offline');
syncError.set(e instanceof Error ? e.message : String(e));
} finally {
running = false;
if (queued) {
queued = false;
void syncNow();
}
}
}
/** Debounced trigger: sync 3 s after the last edit (SPEC §4 client loop). */
export function notifyEdit(): void {
if (!configured()) return;
if (editTimer) clearTimeout(editTimer);
editTimer = setTimeout(() => void syncNow(), 3000);
}
export function startSync(): void {
window.addEventListener('online', () => void syncNow());
window.addEventListener('offline', () => syncState.set('offline'));
if (interval) clearInterval(interval);
interval = setInterval(() => void syncNow(), 60_000);
void syncNow();
}

37
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface Note {
id: string;
content: string;
tags: string[];
created_at: number; // unix ms
modified_at: number; // unix ms
deleted: boolean;
version: number; // server-assigned version of the copy we derived from (0 = never synced)
baseVersion: number; // server version this local copy was derived from
dirty: boolean; // locally modified, not yet pushed
}
export interface ServerNote {
id: string;
content: string;
tags: string[];
created_at: number;
modified_at: number;
deleted: boolean;
version: number;
}
export interface PushResult {
id: string;
status: 'accepted' | 'conflict';
version?: number;
conflictCopyId?: string;
serverNote?: ServerNote;
}
export type SyncState = 'synced' | 'syncing' | 'offline' | 'error' | 'local';
export interface Settings {
serverUrl: string;
token: string;
vertical: boolean; // vertical split (list left, editor right)
}

338
frontend/src/ui/App.svelte Normal file
View File

@@ -0,0 +1,338 @@
<script lang="ts">
import { onMount } from 'svelte';
import { notes, settings, createNote, deleteNote, restoreNote } from '../db/store';
import { filterNotes, noteTitle, fold, allTags } from '../search';
import { notifyEdit } from '../sync/engine';
import type { Note } from '../types';
import Omnibar from './Omnibar.svelte';
import NoteList from './NoteList.svelte';
import Editor from './Editor.svelte';
import Preview from './Preview.svelte';
import StatusDot from './StatusDot.svelte';
import SettingsMenu from './SettingsMenu.svelte';
import Toast from './Toast.svelte';
let query = '';
let selectedId: string | null = null;
let tagFilter: string | null = null;
let preview = false;
let menuOpen = false;
let narrow = false;
let mobileView: 'list' | 'editor' = 'list';
let toast: { message: string; onAction: () => void } | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let omnibar: Omnibar;
let editor: Editor;
$: notesArr = [...$notes.values()];
$: filtered = filterNotes(notesArr, query, tagFilter);
$: tags = allTags(notesArr);
$: selected = selectedId ? $notes.get(selectedId) ?? null : null;
$: vertical = $settings.vertical;
// Auto-select the best match while typing (NV behavior); empty query deselects.
let lastQuery = '';
$: if (query !== lastQuery) {
lastQuery = query;
selectedId = query.trim() ? (filtered[0]?.id ?? null) : null;
}
// Drop selection if the note vanished (deleted / filtered out by sync).
$: if (selectedId) {
const cur = $notes.get(selectedId);
if (!cur || cur.deleted) selectedId = null;
}
function openNote(id: string, focusEditor = true) {
selectedId = id;
preview = false;
if (narrow) mobileView = 'editor';
if (focusEditor) queueMicrotask(() => editor?.focusEnd());
}
async function onEnter() {
if (selectedId && filtered.some((n) => n.id === selectedId)) {
openNote(selectedId);
} else if (query.trim()) {
const n = await createNote(query.trim());
notifyEdit();
openNote(n.id);
}
}
function moveSelection(delta: number) {
if (!filtered.length) return;
const idx = filtered.findIndex((n) => n.id === selectedId);
const next = idx < 0 ? (delta > 0 ? 0 : filtered.length - 1) : Math.min(filtered.length - 1, Math.max(0, idx + delta));
selectedId = filtered[next].id;
}
function clearOmnibar() {
query = '';
selectedId = null;
omnibar?.focus();
}
function backToOmnibar() {
if (narrow) mobileView = 'list';
omnibar?.focus();
}
async function doDelete() {
if (!selectedId) return;
const idx = filtered.findIndex((n) => n.id === selectedId);
const prev = await deleteNote(selectedId);
if (!prev) return;
notifyEdit();
const rest = filtered.filter((n) => n.id !== prev.id);
selectedId = rest[Math.min(idx, rest.length - 1)]?.id ?? null;
showToast(`Deleted “${noteTitle(prev.content)}”`, () => {
void restoreNote(prev).then(() => notifyEdit());
});
}
function showToast(message: string, onAction: () => void) {
if (toastTimer) clearTimeout(toastTimer);
toast = { message, onAction };
toastTimer = setTimeout(() => (toast = null), 5000);
}
function cycleTag() {
if (!tags.length) return;
if (tagFilter === null) tagFilter = tags[0];
else {
const i = tags.indexOf(tagFilter);
tagFilter = i < 0 || i === tags.length - 1 ? null : tags[i + 1];
}
}
/** Open a [[wiki-link]] target: existing note by title, or create it. */
async function openWikiLink(title: string) {
const want = fold(title.trim());
const hit = notesArr.find((n) => !n.deleted && fold(noteTitle(n.content)) === want);
if (hit) {
openNote(hit.id);
} else {
const n = await createNote(title.trim());
notifyEdit();
openNote(n.id);
}
}
/** Called from the desktop shell (tray / global shortcut) via Wails events. */
export function externalFocusOmnibar() {
if (narrow) mobileView = 'list';
omnibar?.focusSelect();
}
function inEditor(): boolean {
return !!document.activeElement?.closest('.cm-editor');
}
function onKeydown(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === 'l') {
e.preventDefault();
if (narrow) mobileView = 'list';
omnibar?.focusSelect();
} else if (mod && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
void doDelete();
} else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
if (selected) preview = !preview;
} else if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
cycleTag();
} else if (mod && e.key.toLowerCase() === 'j') {
e.preventDefault();
const wasInEditor = inEditor();
moveSelection(e.shiftKey ? -1 : 1);
if (wasInEditor) queueMicrotask(() => editor?.focusEnd());
} else if (e.key === 'Escape' && inEditor()) {
e.preventDefault();
backToOmnibar(); // query preserved (SPEC §5.7)
}
}
onMount(() => {
const mq = window.matchMedia('(max-width: 639px)');
narrow = mq.matches;
const onMq = () => (narrow = mq.matches);
mq.addEventListener('change', onMq);
omnibar?.focus();
// First run: offer server config; Cancel = work locally only (SPEC §7).
if (!localStorage.getItem('tefter-first-run-done')) {
localStorage.setItem('tefter-first-run-done', '1');
if (!notesArr.length && !$settings.serverUrl) menuOpen = true;
}
return () => mq.removeEventListener('change', onMq);
});
</script>
<svelte:window on:keydown={onKeydown} />
<div class="app" class:vertical class:narrow>
{#if !narrow || mobileView === 'list'}
<header>
<Omnibar
bind:this={omnibar}
bind:value={query}
on:enter={onEnter}
on:up={() => moveSelection(-1)}
on:down={() => moveSelection(1)}
on:clear={clearOmnibar}
/>
{#if tagFilter}
<button class="tagchip" title="Clear tag filter (Ctrl+K cycles)" on:click={() => (tagFilter = null)}>
#{tagFilter}
</button>
{/if}
<StatusDot />
<button class="menu-btn" title="Settings" on:click={() => (menuOpen = !menuOpen)}>⋯</button>
</header>
{/if}
<div class="panes">
{#if !narrow || mobileView === 'list'}
<div class="list-pane">
<NoteList {filtered} {selectedId} {query} on:open={(e) => openNote(e.detail)} />
</div>
{/if}
{#if !narrow || mobileView === 'editor'}
<div class="editor-pane">
{#if narrow && mobileView === 'editor'}
<div class="mobile-bar">
<button on:click={backToOmnibar}> Notes</button>
<span class="mobile-title">{selected ? noteTitle(selected.content) : ''}</span>
<button on:click={() => (preview = !preview)}>{preview ? 'Edit' : 'MD'}</button>
</div>
{/if}
{#if selected && preview}
<Preview content={selected.content} on:wikilink={(e) => void openWikiLink(e.detail)} />
{:else if selected}
<Editor bind:this={editor} note={selected} {query} on:escape={backToOmnibar} />
{:else}
<div class="empty">
<p>Type to search — <kbd>Enter</kbd> creates a note when nothing matches.</p>
</div>
{/if}
</div>
{/if}
</div>
{#if menuOpen}
<SettingsMenu on:close={() => (menuOpen = false)} />
{/if}
{#if toast}
<Toast message={toast.message} actionLabel="Undo" onAction={toast.onAction} on:done={() => (toast = null)} />
{/if}
</div>
<style>
.app {
height: 100%;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--sep);
background: var(--bg);
}
.menu-btn {
font-size: 18px;
line-height: 1;
padding: 4px 8px;
border-radius: 6px;
color: var(--fg-dim);
}
.menu-btn:hover {
background: var(--sel-bg);
}
.tagchip {
white-space: nowrap;
background: var(--accent-bg);
color: var(--accent);
padding: 3px 8px;
border-radius: 10px;
font-size: 12px;
}
.panes {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.list-pane {
height: 34%;
min-height: 90px;
overflow-y: auto;
border-bottom: 1px solid var(--sep);
background: var(--bg);
}
.editor-pane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
}
.app.vertical .panes {
flex-direction: row;
}
.app.vertical .list-pane {
height: auto;
width: 320px;
min-width: 220px;
border-bottom: none;
border-right: 1px solid var(--sep);
}
.app.narrow .list-pane {
flex: 1;
height: auto;
width: auto;
border: none;
}
.mobile-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--sep);
}
.mobile-bar button {
color: var(--accent);
font-size: 15px;
}
.mobile-title {
flex: 1;
text-align: center;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-dim);
padding: 20px;
text-align: center;
}
kbd {
border: 1px solid var(--sep);
border-radius: 4px;
padding: 1px 5px;
font-family: var(--mono);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { EditorState, Compartment } from '@codemirror/state';
import {
EditorView,
keymap,
highlightSpecialChars,
drawSelection,
MatchDecorator,
Decoration,
ViewPlugin,
type DecorationSet,
type ViewUpdate,
} from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { updateContent } from '../db/store';
import { notifyEdit } from '../sync/engine';
import { highlightRegex } from '../search';
import type { Note } from '../types';
export let note: Note;
export let query = '';
const dispatch = createEventDispatcher<{ escape: void }>();
let host: HTMLElement;
let view: EditorView | null = null;
let currentId = '';
let saveTimer: ReturnType<typeof setTimeout> | null = null;
// The last content this editor and the store agreed on. A store value equal to
// it is an echo of our own save; anything else is an external (sync) change.
let lastKnown = '';
const searchMark = new Compartment();
function searchExtension(q: string) {
const rx = highlightRegex(q);
if (!rx) return [];
const deco = new MatchDecorator({
regexp: new RegExp(rx.source, 'gi'),
decoration: Decoration.mark({ class: 'cm-search-hit' }),
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(v: EditorView) {
this.decorations = deco.createDeco(v);
}
update(u: ViewUpdate) {
this.decorations = deco.updateDeco(u, this.decorations);
}
},
{ decorations: (v) => v.decorations }
);
}
function flushSave(id: string, content: string) {
if (id === currentId) lastKnown = content;
void updateContent(id, content).then(() => notifyEdit());
}
function makeState(content: string): EditorState {
return EditorState.create({
doc: content,
extensions: [
history(),
highlightSpecialChars(),
drawSelection(),
EditorView.lineWrapping,
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
markdown({ base: markdownLanguage }),
searchMark.of(searchExtension(query)),
keymap.of([
{
key: 'Escape',
run: () => {
dispatch('escape');
return true;
},
},
...defaultKeymap,
...historyKeymap,
]),
EditorView.updateListener.of((u) => {
if (u.docChanged) {
const id = currentId;
const text = u.state.doc.toString();
if (saveTimer) clearTimeout(saveTimer);
// Autosave: 400 ms after last keystroke → IndexedDB, mark dirty (SPEC §5).
saveTimer = setTimeout(() => {
saveTimer = null;
if (id === currentId) flushSave(id, text);
}, 400);
}
}),
],
});
}
export function focusEnd() {
if (!view) return;
view.focus();
view.dispatch({ selection: { anchor: view.state.doc.length } });
}
// Swap document when a different note is opened; apply external (sync) changes
// without clobbering in-flight typing.
$: if (view && note) {
if (note.id !== currentId) {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
flushSave(currentId, view.state.doc.toString());
}
currentId = note.id;
lastKnown = note.content;
view.setState(makeState(note.content));
} else if (note.content !== lastKnown) {
// Store changed underneath us (sync pull / conflict replacement) — not an
// echo of our own autosave. Replace the doc.
lastKnown = note.content;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (note.content !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: note.content } });
}
}
}
let lastQuery = '';
$: if (view && query !== lastQuery) {
lastQuery = query;
view.dispatch({ effects: searchMark.reconfigure(searchExtension(query)) });
}
onMount(() => {
currentId = note.id;
lastKnown = note.content;
view = new EditorView({ state: makeState(note.content), parent: host });
});
onDestroy(() => {
if (saveTimer && view) {
clearTimeout(saveTimer);
saveTimer = null;
flushSave(currentId, view.state.doc.toString());
}
view?.destroy();
view = null;
});
</script>
<div class="editor-host" bind:this={host}></div>
<style>
.editor-host {
flex: 1;
min-height: 0;
overflow: hidden;
}
.editor-host :global(.cm-editor) {
height: 100%;
}
.editor-host :global(.cm-scroller) {
overflow: auto;
}
</style>

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>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let value = '';
const dispatch = createEventDispatcher<{ enter: void; up: void; down: void; clear: void }>();
let input: HTMLInputElement;
export function focus() {
input?.focus();
}
export function focusSelect() {
input?.focus();
input?.select();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
dispatch('enter');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
dispatch('down');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
dispatch('up');
} else if (e.key === 'Escape') {
e.preventDefault();
dispatch('clear');
}
}
</script>
<input
bind:this={input}
bind:value
type="text"
class="omnibar"
placeholder="Search or Create"
spellcheck="false"
autocomplete="off"
autocapitalize="off"
enterkeyhint="go"
on:keydown={onKeydown}
/>
<style>
.omnibar {
flex: 1;
min-width: 0;
font-size: 16px; /* ≥16px: no zoom-on-focus on Android (SPEC §8) */
padding: 7px 12px;
border-radius: 8px;
}
.omnibar:focus {
outline: none;
border-color: var(--accent);
}
</style>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { Marked } from 'marked';
import DOMPurify from 'dompurify';
export let content = '';
const dispatch = createEventDispatcher<{ wikilink: string }>();
function escAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// GFM + [[wiki-link]] inline extension (SPEC §5 editor: NV Alt behavior)
const marked = new Marked({
gfm: true,
breaks: true,
extensions: [
{
name: 'wikilink',
level: 'inline',
start(src: string) {
const i = src.indexOf('[[');
return i < 0 ? undefined : i;
},
tokenizer(src: string) {
const m = /^\[\[([^\]\n]+)\]\]/.exec(src);
if (m) return { type: 'wikilink', raw: m[0], title: m[1].trim() };
return undefined;
},
renderer(tok) {
const t = escAttr(String((tok as { title?: string }).title ?? ''));
return `<a href="#" class="wikilink" data-title="${t}">${t}</a>`;
},
},
],
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && !node.classList.contains('wikilink')) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
$: html = DOMPurify.sanitize(marked.parse(content, { async: false }) as string, {
ADD_ATTR: ['data-title', 'target'],
});
function onClick(e: MouseEvent) {
const a = (e.target as HTMLElement).closest('a');
if (!a) return;
if (a.classList.contains('wikilink')) {
e.preventDefault();
const title = a.getAttribute('data-title');
if (title) dispatch('wikilink', title);
return;
}
const href = a.getAttribute('href') || '';
// In the Wails webview, open external links in the system browser.
const rt = (window as unknown as { runtime?: { BrowserOpenURL(u: string): void } }).runtime;
if (rt?.BrowserOpenURL && /^https?:/i.test(href)) {
e.preventDefault();
rt.BrowserOpenURL(href);
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="preview" on:click={onClick}>
{@html html}
</div>
<style>
.preview {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
line-height: 1.55;
font-size: 15px;
}
.preview :global(h1),
.preview :global(h2),
.preview :global(h3) {
line-height: 1.25;
}
.preview :global(pre) {
background: var(--bg);
border: 1px solid var(--sep);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
font-family: var(--mono);
font-size: 13px;
}
.preview :global(code) {
font-family: var(--mono);
font-size: 0.9em;
background: var(--bg);
border-radius: 4px;
padding: 1px 4px;
}
.preview :global(pre code) {
background: none;
padding: 0;
}
.preview :global(blockquote) {
margin: 0.6em 0;
padding: 0 1em;
border-left: 3px solid var(--sep);
color: var(--fg-dim);
}
.preview :global(table) {
border-collapse: collapse;
}
.preview :global(th),
.preview :global(td) {
border: 1px solid var(--sep);
padding: 4px 10px;
}
.preview :global(a) {
color: var(--accent);
}
.preview :global(input[type='checkbox']) {
margin-right: 6px;
}
.preview :global(.wikilink) {
text-decoration: none;
border-bottom: 1px dashed var(--accent);
}
</style>

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { settings, saveSettings } from '../db/store';
import { syncNow, syncState, lastSyncAt } from '../sync/engine';
import { get } from 'svelte/store';
const dispatch = createEventDispatcher<{ close: void }>();
const version: string = __TEFTER_VERSION__;
let serverUrl = get(settings).serverUrl;
let token = get(settings).token;
let vertical = get(settings).vertical;
let importing = false;
let importResult = '';
let fileInput: HTMLInputElement;
async function save() {
await saveSettings({ serverUrl: serverUrl.trim(), token: token.trim(), vertical });
void syncNow();
dispatch('close');
}
async function importZip() {
const f = fileInput?.files?.[0];
if (!f) return;
const s = get(settings);
const base = (serverUrl || s.serverUrl).replace(/\/+$/, '');
if (!base) {
importResult = 'Set a server URL first.';
return;
}
importing = true;
importResult = '';
try {
const fd = new FormData();
fd.append('file', f);
const res = await fetch(base + '/api/v1/import/simplenote', {
method: 'POST',
headers: { Authorization: 'Bearer ' + (token || s.token) },
body: fd,
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const r = await res.json();
importResult = `Imported ${r.imported}, skipped ${r.skipped} duplicates, ${r.trashed} trashed.`;
void syncNow();
} catch (e) {
importResult = 'Import failed: ' + (e instanceof Error ? e.message : String(e));
} finally {
importing = false;
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="backdrop" on:click|self={() => dispatch('close')}>
<div class="panel">
<h2>Settings</h2>
<section>
<h3>Sync</h3>
<label>
Server URL
<input type="url" bind:value={serverUrl} placeholder="https://notes.example.com" />
</label>
<label>
Token
<input type="password" bind:value={token} placeholder="paste token from tefterd init" />
</label>
<div class="hint">Status: {$syncState}{$lastSyncAt ? ` · last sync ${new Date($lastSyncAt).toLocaleTimeString()}` : ''}</div>
</section>
<section>
<h3>Layout</h3>
<label class="row">
<input type="checkbox" bind:checked={vertical} />
Vertical split (list left, editor right)
</label>
</section>
<section>
<h3>Import</h3>
<input type="file" accept=".zip" bind:this={fileInput} />
<button class="btn" disabled={importing} on:click={importZip}>
{importing ? 'Importing…' : 'Import Simplenote zip'}
</button>
{#if importResult}<div class="hint">{importResult}</div>{/if}
</section>
<section class="about">Tefter {version}</section>
<div class="actions">
<button class="btn" on:click={() => dispatch('close')}>Cancel</button>
<button class="btn primary" on:click={save}>Save</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8vh;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--sep);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: min(440px, 92vw);
max-height: 84vh;
overflow-y: auto;
padding: 18px 22px;
}
h2 {
margin: 0 0 6px;
font-size: 17px;
}
h3 {
margin: 14px 0 6px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-dim);
}
label {
display: block;
font-size: 13px;
color: var(--fg-dim);
margin-bottom: 8px;
}
label input[type='url'],
label input[type='password'] {
display: block;
width: 100%;
margin-top: 3px;
padding: 6px 10px;
font-size: 16px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg);
}
.hint {
font-size: 12.5px;
color: var(--fg-dim);
margin-top: 6px;
}
.btn {
border: 1px solid var(--sep);
border-radius: 6px;
padding: 5px 14px;
margin-top: 8px;
background: var(--bg);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn:disabled {
opacity: 0.5;
}
.about {
margin-top: 14px;
font-size: 12px;
color: var(--fg-dim);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { syncState, lastSyncAt, syncError, syncNow } from '../sync/engine';
import { pendingCount } from '../db/store';
let open = false;
const labels: Record<string, string> = {
synced: 'Synced',
syncing: 'Syncing…',
offline: 'Offline',
error: 'Sync error',
local: 'Local only (no server configured)',
};
$: color =
$syncState === 'synced'
? 'var(--dot-green)'
: $syncState === 'syncing'
? 'var(--dot-yellow)'
: $syncState === 'error'
? 'var(--dot-red)'
: 'var(--dot-gray)';
function fmt(ms: number | null): string {
if (!ms) return 'never';
return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
</script>
<div class="wrap">
<button class="dot" style="background:{color}" title={labels[$syncState]} aria-label="Sync status" on:click={() => (open = !open)}></button>
{#if open}
<div class="pop">
<div><strong>{labels[$syncState]}</strong></div>
<div>Last sync: {fmt($lastSyncAt)}</div>
<div>Pending changes: {$pendingCount}</div>
{#if $syncError}<div class="err">{$syncError}</div>{/if}
<button class="now" on:click={() => void syncNow()}>Sync now</button>
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
display: flex;
align-items: center;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.pop {
position: absolute;
top: 22px;
right: 0;
z-index: 30;
background: var(--bg-panel);
border: 1px solid var(--sep);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 10px 14px;
font-size: 13px;
line-height: 1.7;
white-space: nowrap;
}
.err {
color: var(--dot-red);
max-width: 240px;
white-space: normal;
}
.now {
margin-top: 4px;
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let message = '';
export let actionLabel = '';
export let onAction: () => void = () => {};
const dispatch = createEventDispatcher<{ done: void }>();
function act() {
onAction();
dispatch('done');
}
</script>
<div class="toast" role="status">
<span>{message}</span>
{#if actionLabel}
<button on:click={act}>{actionLabel}</button>
{/if}
</div>
<style>
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
display: flex;
align-items: center;
gap: 14px;
background: var(--fg);
color: var(--bg);
padding: 9px 16px;
border-radius: 8px;
font-size: 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
animation: rise 0.15s ease-out;
}
.toast button {
color: var(--mark-bg);
font-weight: 600;
}
@keyframes rise {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": false,
"skipLibCheck": true,
"types": ["svelte", "vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}

67
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,67 @@
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { createHash } from 'node:crypto';
const version = process.env.TEFTER_VERSION || 'dev';
// Emits sw.js at build time with the precache list of all emitted assets,
// versioned by a hash of the file names (which themselves carry content hashes).
function serviceWorker(): Plugin {
return {
name: 'tefter-sw',
apply: 'build',
generateBundle(_opts, bundle) {
const assets = Object.keys(bundle)
.filter((f) => !f.endsWith('.map'))
.map((f) => '/' + f);
assets.push('/', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png');
const hash = createHash('sha256').update(assets.join(',') + version).digest('hex').slice(0, 12);
const sw = `// generated by vite.config.ts — do not edit
const CACHE = 'tefter-${'${'}HASH}';
const ASSETS = ${'${'}ASSETS};
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api/')) return; // network-only; sync engine handles offline
e.respondWith(
caches.match(url.pathname === '/' || url.pathname === '/index.html' ? '/' : e.request).then(
(hit) => hit || fetch(e.request)
)
);
});
`
.replace('${HASH}', hash)
.replace('${ASSETS}', JSON.stringify(assets));
this.emitFile({ type: 'asset', fileName: 'sw.js', source: sw });
},
};
}
export default defineConfig({
plugins: [svelte(), serviceWorker()],
define: {
__TEFTER_VERSION__: JSON.stringify(version),
},
build: {
target: 'es2020',
sourcemap: false,
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8420',
},
},
});