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:
17
frontend/index.html
Normal file
17
frontend/index.html
Normal 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
2136
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icon-512-maskable.png
Normal file
BIN
frontend/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
8
frontend/public/icon.svg
Normal file
8
frontend/public/icon.svg
Normal 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 |
15
frontend/public/manifest.webmanifest
Normal file
15
frontend/public/manifest.webmanifest
Normal 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
134
frontend/src/app.css
Normal 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
67
frontend/src/db/idb.ts
Normal 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
137
frontend/src/db/store.ts
Normal 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
2
frontend/src/globals.d.ts
vendored
Normal 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
37
frontend/src/main.ts
Normal 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
119
frontend/src/search.ts
Normal 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
113
frontend/src/sync/engine.ts
Normal 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
37
frontend/src/types.ts
Normal 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
338
frontend/src/ui/App.svelte
Normal 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>
|
||||
170
frontend/src/ui/Editor.svelte
Normal file
170
frontend/src/ui/Editor.svelte
Normal 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>
|
||||
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>
|
||||
59
frontend/src/ui/Omnibar.svelte
Normal file
59
frontend/src/ui/Omnibar.svelte
Normal 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>
|
||||
131
frontend/src/ui/Preview.svelte
Normal file
131
frontend/src/ui/Preview.svelte
Normal 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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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>
|
||||
182
frontend/src/ui/SettingsMenu.svelte
Normal file
182
frontend/src/ui/SettingsMenu.svelte
Normal 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>
|
||||
78
frontend/src/ui/StatusDot.svelte
Normal file
78
frontend/src/ui/StatusDot.svelte
Normal 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>
|
||||
55
frontend/src/ui/Toast.svelte
Normal file
55
frontend/src/ui/Toast.svelte
Normal 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>
|
||||
5
frontend/svelte.config.js
Normal file
5
frontend/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
17
frontend/tsconfig.json
Normal file
17
frontend/tsconfig.json
Normal 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
67
frontend/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user