feat(indstillinger): en indstilling kan findes ved at søge efter den

Indstillingerne er ti faner med syvogtredive afsnit, og den eneste vej til et
bestemt af dem var at huske, hvilken fane det lå under. ⌘K kunne opgaver,
projekter og noter — alt det, man selv har skrevet, og intet af det,
programmet består af.

Ét indeks, to steder det bruges: et felt øverst i indstillingerne og paletten.
To lister over de samme afsnit ville være to steder at glemme et nyt et.

Overskrifterne står ikke i indekset. Hver post peger på den nøgle, afsnittets
egen overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af
sig selv, og søgningen kan begge sprog uden at kende nogen af dem.

`words` er derimod skrevet i indekset, og det er med vilje: det er søgeord,
ingen får at se. Det danske afsnit hedder "Tælling af installationer", men det
ord, man kender det ved, er "beacon" — og på samme måde 2fa, imap, webcal,
webhook. De er de samme på begge sprog, hvilket er hele grunden til, at de
virker som søgeord.

Ankrene er nye id'er på afsnittene. Tre af dem stødte sammen med et felt inde i
afsnittet — to ens id'er gør <label for> tvetydig og getElementById tilfældig —
og hedder derfor to-faktor, kalenderfeed og caldav-server.

Springet dertil kan ikke overlades til browseren: siden hentes af ruteren, og
afsnittet findes først, når dens data er hjemme. Der prøves i to sekunder og
gives så op. Blinket bagefter er ikke pynt — man er landet midt i en side med
otte afsnit, man ikke selv rullede hen til.

Prøverne læser filerne frem for at klikke: halvdelen af afsnittene vises kun
under en betingelse, så en prøve, der ledte efter dem i en browser, ville melde
fejl på det, der med rette var skjult.

Og palettens pladsholder sagde "Søg i opgaver og projekter". Den har kunnet
noter længe, og kan nu også indstillinger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-12 09:56:00 +02:00
co-authored by Claude Opus 5
parent 1f0a43ac90
commit 7e981354c2
17 changed files with 548 additions and 39 deletions
+4
View File
@@ -26,6 +26,10 @@ forward mail to, and an MCP endpoint so Claude can work your task list directly.
board and calendar views, and foldable groups over the projects in the sidebar.
The calendar draws each task to its length — drag the foot to resize, the body
to move it.
- **Find it by searching for it.** ⌘K searches tasks, notes and projects — and the
settings themselves, by what they *do* rather than only by what they are called,
so `webhook` finds *Push from other programs* and `2fa` finds *Two-factor*. The
settings pages carry the same field at the top of their own list.
- **Notes, beside the work.** Rich text to write in, Markdown on disk. Share a
single note with a person — by name, by email address, or by writing `@Sofie` in
the text — or file it in a project; `#project` and `[[another note]]` are links,
+89
View File
@@ -0,0 +1,89 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { SETTINGS, findSettings } from '../src/lib/settingsindex.js';
/**
* Indekset over indstillingerne skal passe på siderne.
*
* Et indeks ved siden af det, det beskriver, er en kopi, og en kopi driver. Den
* dag et afsnit flyttes til en anden fane eller får et nyt anker, holder
* søgningen op med at kunne finde det — og det fejler ikke: man får bare ingen
* træffere, hvilket ligner en søgning, der virker, på noget der ikke findes.
*
* Derfor læses filerne her frem for at blive klikket igennem. Halvdelen af
* afsnittene vises kun under en betingelse — en kalenderkonto, en afventende
* invitation, en administrator — så en prøve, der leder efter dem i en browser,
* ville melde fejl på det, der med rette var skjult.
*/
const here = dirname(fileURLToPath(import.meta.url));
const routes = join(here, '..', 'src', 'routes');
const locales = join(here, '..', 'src', 'lib', 'locales');
/** `/indstillinger/data` → filen, den side tegnes af. */
function pageFile(path) {
return join(routes, path, '+page.svelte');
}
test('hvert afsnit i indekset findes på den side, det peger på', () => {
const missing = [];
for (const entry of SETTINGS) {
const src = readFileSync(pageFile(entry.path), 'utf8');
const hits = src.split(`id="${entry.anchor}"`).length - 1;
if (hits !== 1) missing.push(`${entry.path}#${entry.anchor}: ${hits} steder, ventede 1`);
}
expect(missing).toEqual([]);
});
test('to afsnit deler ikke et anker', () => {
// Ankeret slås op med getElementById, som vælger vilkårligt mellem to ens. Det
// er kun et problem inden for den samme side, så nøglen er sti plus anker.
const seen = new Set();
const dupes = [];
for (const entry of SETTINGS) {
const key = `${entry.path}#${entry.anchor}`;
if (seen.has(key)) dupes.push(key);
seen.add(key);
}
expect(dupes).toEqual([]);
});
test('overskrifterne, indekset peger på, findes på begge sprog', () => {
const missing = [];
for (const locale of ['da', 'en']) {
const src = readFileSync(join(locales, `${locale}.js`), 'utf8');
for (const entry of SETTINGS) {
for (const key of [entry.key, entry.tab]) {
if (!src.includes(`'${key}':`)) missing.push(`${locale}: ${key}`);
}
}
}
expect(missing).toEqual([]);
});
test('søgningen finder det, man ville skrive', () => {
// t() er her den, der ikke oversætter: prøven skal måle indekset og ikke
// sprogfilen, og de tekniske ord er dem, der bærer en søgning uanset sprog.
const t = (key) => key;
const found = (q, admin = true) =>
findSettings(q, { t, isAdmin: admin }).map((hit) => `${hit.path}#${hit.anchor}`);
expect(found('beacon')).toContain('/indstillinger/data#beacon');
expect(found('webhook')).toContain('/indstillinger/integrationer#krog');
expect(found('imap')).toContain('/indstillinger/integrationer#postkasser');
expect(found('2fa')).toContain('/indstillinger#to-faktor');
expect(found('ical')).toContain('/indstillinger/integrationer#kalenderfeed');
// Tomt felt giver ingenting frem for alt: en liste over seksogtredive afsnit
// er ikke et svar på et spørgsmål, nogen endnu ikke har stillet.
expect(found('')).toEqual([]);
expect(found(' ')).toEqual([]);
// Det, der kun er en administrators, må ikke kunne findes af andre. Siden
// afviser dem, og en træffer, der fører til en 403, er et løfte, fladen ikke
// kan holde.
expect(found('backup', false)).toEqual([]);
expect(found('backup', true)).toContain('/indstillinger/data#sikkerhedskopier');
});
+61
View File
@@ -4454,3 +4454,64 @@ test('adresserne på integrationssiden kan ses helt på en telefon', async ({ pa
expect(trouble).toEqual([]);
});
/**
* En indstilling kan findes ved at søge efter den.
*
* Indstillingerne er ti faner med en snes afsnit, og den eneste vej til et
* bestemt af dem var at huske, hvilken fane det lå under. Prøven går begge veje
* ind: feltet øverst i indstillingerne, og ⌘K, som ikke kunne andet end det, man
* selv har skrevet.
*
* "beacon" er valgt med vilje. Afsnittet hedder "Tælling af installationer" på
* dansk, så en søgning, der kun læste overskrifterne, ville ikke finde det — og
* "beacon" er præcis det ord, man kender det ved. Det er derfor indekset har et
* felt med søgeord, ingen får at se.
*/
test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const find = page.getByPlaceholder('Søg i indstillinger');
await find.fill('beacon');
const hit = page.locator('.hits button', { hasText: 'Tælling af installationer' });
await expect(hit).toBeVisible();
// Hvor det ligger, står ved siden af hvad det hedder: halvdelen af det, man
// søgte efter, er svaret på hvilken fane.
await expect(hit).toContainText('Data');
await hit.click();
await expect(page).toHaveURL(/\/indstillinger\/data#beacon$/);
const section = page.locator('#beacon');
await expect(section.getByRole('heading', { name: 'Tælling af installationer' })).toBeVisible();
// Rullet derhen, ikke bare tegnet: afsnittet er det syvende på siden, så en
// side, der bare blev åbnet, ville vise toppen af den.
await expect(section).toBeInViewport();
// Feltet tømmer sig selv, når man er landet. En søgning, der bliver stående
// over det, man fandt, dækker det.
await expect(find).toHaveValue('');
// Og det, der ikke findes, siger det frem for at vise en tom liste.
await find.fill('hestevogn');
await expect(page.getByText('Ingen indstilling hedder det.')).toBeVisible();
// ⌘K spørger det samme indeks. Her er det den anden vej ind, og "webhook" er
// igen et ord, der ikke står i overskriften.
await page.goto('/');
// Knappen og ikke tastetrykket: ⌘K lyttes der efter i en effekt, så et tryk
// sendt lige efter goto kan nå frem, før siden er hydreret. Knappen venter
// Playwright på af sig selv — og den er også den, man har på en telefon.
await page.getByRole('button', { name: /Søg/ }).click();
await page.getByRole('dialog').getByRole('textbox').fill('webhook');
const row = page.getByRole('dialog').locator('li button', { hasText: 'Skub fra andre programmer' });
await expect(row).toBeVisible();
await expect(row).toContainText('Indstilling');
await row.click();
await expect(page).toHaveURL(/\/indstillinger\/integrationer#krog$/);
await expect(page.locator('#krog')).toBeInViewport();
expect(trouble).toEqual([]);
});
@@ -5,6 +5,7 @@
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
import { humanMessage } from '$lib/api.js';
import { findSettings } from '$lib/settingsindex.js';
let { open = $bindable(false) } = $props();
@@ -68,7 +69,20 @@
}, 140);
});
/**
* Indstillingerne, og hvorfor de ligger øverst.
*
* De kommer fra et indeks i browseren og ikke fra serveren, så de står der,
* mens søgningen efter noter og opgaver stadig er undervejs. Øverst, fordi de
* er et svar på "hvor er den indstilling" — et spørgsmål med ét rigtigt svar,
* modsat en søgning i noget, man selv har skrevet.
*/
let settings = $derived(
findSettings(query, { t, isAdmin: app.user?.is_admin ?? false, limit: 5 })
);
let results = $derived([
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
@@ -108,6 +122,9 @@
function choose(item) {
open = false;
// En indstilling bærer sin egen adresse i sit id — der er ingen række at slå
// op, den ER stedet.
if (item.kind === 'setting') return goto(item.id);
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
+4 -1
View File
@@ -210,6 +210,7 @@ export const da = {
'palette.project': 'Projekt',
'palette.task': 'Opgave',
'palette.note': 'Note',
'palette.setting': 'Indstilling',
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
@@ -220,7 +221,7 @@ export const da = {
'nav.signOutConfirm': 'Tryk igen for at logge ud',
'nav.main': 'Hovedmenu',
'nav.search': 'Søg',
'nav.searchLong': 'Søg i opgaver og projekter',
'nav.searchLong': 'Søg i opgaver, noter, projekter og indstillinger',
'nav.noResults': 'Ingen resultater.',
'nav.showSidebar': 'Vis sidebjælken',
'nav.hideSidebar': 'Skjul sidebjælken',
@@ -301,6 +302,8 @@ export const da = {
// --- settings: the frame -------------------------------------------------------
'settings.title': 'Indstillinger',
'settings.search': 'Søg i indstillinger',
'settings.searchNone': 'Ingen indstilling hedder det.',
'settings.tab.account': 'Konto',
'settings.tab.notifications': 'Notifikationer',
'settings.tab.integrations': 'Integrationer',
+4 -1
View File
@@ -207,6 +207,7 @@ export const en = {
'palette.project': 'Project',
'palette.task': 'Task',
'palette.note': 'Note',
'palette.setting': 'Setting',
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
@@ -217,7 +218,7 @@ export const en = {
'nav.signOutConfirm': 'Click again to sign out',
'nav.main': 'Main menu',
'nav.search': 'Search',
'nav.searchLong': 'Search tasks and projects',
'nav.searchLong': 'Search tasks, notes, projects and settings',
'nav.noResults': 'Nothing found.',
'nav.showSidebar': 'Show the sidebar',
'nav.hideSidebar': 'Hide the sidebar',
@@ -298,6 +299,8 @@ export const en = {
// --- settings: the frame -------------------------------------------------------
'settings.title': 'Settings',
'settings.search': 'Search settings',
'settings.searchNone': 'No setting goes by that name.',
'settings.tab.account': 'Account',
'settings.tab.notifications': 'Notifications',
'settings.tab.integrations': 'Integrations',
+140
View File
@@ -0,0 +1,140 @@
/**
* Hvor hver indstilling står, så den kan findes ved at søge efter den.
*
* Indstillingerne er ti sider med en snes afsnit i alt, og der var ingen vej til
* et bestemt af dem andet end at huske, hvilken fane det lå under. ⌘K kunne
* opgaver, projekter og noter — altså alt det, man selv har skrevet, og intet af
* det, programmet består af.
*
* Ét indeks og ikke to søgninger. Feltet øverst i indstillingerne og paletten
* stiller det samme spørgsmål, og to lister over de samme afsnit ville være to
* steder at glemme et nyt et.
*
* Overskrifterne står ikke her. Hver post peger på den nøgle, afsnittets egen
* overskrift allerede bruger, så et afsnit, der bliver omdøbt, følger med af sig
* selv — og søgningen kan begge sprog uden at kende nogen af dem.
*
* `words` er derimod skrevet her, og det er med vilje: det er søgeord, ingen får
* at se. De er de tekniske navne — `imap`, `webcal`, `2fa`, `webhook` — som er de
* samme på dansk og engelsk, og som er dem, man taster, når man leder efter det,
* man kender andetsteds fra. Det, der skal oversættes, er overskriften, og den
* ligger i sprogfilen.
*/
/** Hver post: siden, ankeret på den, overskriftens nøgle, fanens nøgle. */
export const SETTINGS = [
// Konto
{ path: '/indstillinger', anchor: 'profil', key: 'account.profile', tab: 'settings.tab.account', words: 'profil profile navn name email mail tidszone timezone sprog language' },
{ path: '/indstillinger', anchor: 'udseende', key: 'account.appearance', tab: 'settings.tab.account', words: 'tema theme dark light farve color' },
{ path: '/indstillinger', anchor: 'look', key: 'account.look', tab: 'settings.tab.account', words: 'serif terminal' },
{ path: '/indstillinger', anchor: 'stoerrelser', key: 'account.sizes', tab: 'settings.tab.account', words: 'skrift font zoom' },
{ path: '/indstillinger', anchor: 'links', key: 'link.title', tab: 'settings.tab.account', words: 'browser safari chrome firefox' },
{ path: '/indstillinger', anchor: 'adgangskode', key: 'account.password', tab: 'settings.tab.account', words: 'password kodeord' },
{ path: '/indstillinger', anchor: 'to-faktor', key: 'account.totp', tab: 'settings.tab.account', words: '2fa totp authenticator to-faktor' },
{ path: '/indstillinger', anchor: 'passkey', key: 'passkey.title', tab: 'settings.tab.account', words: 'passkey webauthn touch id face' },
{ path: '/indstillinger', anchor: 'enheder', key: 'account.devices', tab: 'settings.tab.account', words: 'session sessioner devices log ud' },
{ path: '/indstillinger', anchor: 'stoette', key: 'account.support', tab: 'settings.tab.account', words: 'donate kaffe coffee' },
// Notifikationer
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
// Integrationer
{ path: '/indstillinger/integrationer', anchor: 'gmail', key: 'int.gmail', tab: 'settings.tab.integrations', words: 'gmail google oauth' },
{ path: '/indstillinger/integrationer', anchor: 'kalender', key: 'int.calendar', tab: 'settings.tab.integrations', words: 'google calendar' },
{ path: '/indstillinger/integrationer', anchor: 'abonnementer', key: 'int.subscriptions', tab: 'settings.tab.integrations', words: 'ics webcal abonnement subscribe' },
{ path: '/indstillinger/integrationer', anchor: 'kalenderfeed', key: 'int.feed', tab: 'settings.tab.integrations', words: 'ical ics feed apple thunderbird' },
{ path: '/indstillinger/integrationer', anchor: 'mail-til-opgave', key: 'int.mailToTask', tab: 'settings.tab.integrations', words: 'mail email forward videresend' },
{ path: '/indstillinger/integrationer', anchor: 'krog', key: 'int.hookToTask', tab: 'settings.tab.integrations', words: 'webhook hook curl shortcut genvej' },
{ path: '/indstillinger/integrationer', anchor: 'caldav-server', key: 'int.caldav', tab: 'settings.tab.integrations', words: 'caldav reminders' },
{ path: '/indstillinger/integrationer', anchor: 'postkasser', key: 'int.mailboxes', tab: 'settings.tab.integrations', words: 'imap icloud fastmail postkasse' },
// AI
{ path: '/indstillinger/ai', anchor: 'ai', key: 'ai.title', tab: 'settings.tab.ai', words: 'ai model anthropic openai claude gpt gemini api key' },
{ path: '/indstillinger/ai', anchor: 'ugentlig', key: 'ai.weekly', tab: 'settings.tab.ai', words: 'uge weekly opsamling' },
// Tokens
{ path: '/indstillinger/tokens', anchor: 'tokens', key: 'tokens.title', tab: 'settings.tab.tokens', words: 'token api mcp connector bearer' },
// Brugere, historik, fejl — kun for administratorer
{ path: '/indstillinger/brugere', anchor: 'invitation', key: 'users.invite', tab: 'settings.tab.users', words: 'invite invitation bruger user', admin: true },
{ path: '/indstillinger/brugere', anchor: 'afventende', key: 'users.pending', tab: 'settings.tab.users', words: 'pending afventer', admin: true },
{ path: '/indstillinger/brugere', anchor: 'konti', key: 'users.accounts', tab: 'settings.tab.users', words: 'konti accounts admin', admin: true },
{ path: '/indstillinger/historik', anchor: 'historik', key: 'history.title', tab: 'settings.tab.history', words: 'aktivitet activity log', admin: true },
{ path: '/indstillinger/fejl', anchor: 'fejl', key: 'errors.title', tab: 'settings.tab.errors', words: 'error 500 log', admin: true },
// Data
{ path: '/indstillinger/data', anchor: 'import', key: 'data.import', tab: 'settings.tab.data', words: 'import todoist csv json' },
{ path: '/indstillinger/data', anchor: 'faerdige', key: 'done.title', tab: 'settings.tab.data', words: 'done completed oprydning' },
{ path: '/indstillinger/data', anchor: 'papirkurv', key: 'data.trash', tab: 'settings.tab.data', words: 'trash slettet deleted' },
{ path: '/indstillinger/data', anchor: 'sikkerhedskopier', key: 'data.backups', tab: 'settings.tab.data', words: 'backup sikkerhedskopi', admin: true },
{ path: '/indstillinger/data', anchor: 'eksport', key: 'data.export', tab: 'settings.tab.data', words: 'export eksport zip markdown' },
{ path: '/indstillinger/data', anchor: 'skabeloner', key: 'data.templates', tab: 'settings.tab.data', words: 'template skabelon' },
{ path: '/indstillinger/data', anchor: 'beacon', key: 'beacon.title', tab: 'settings.tab.data', words: 'beacon tæller collector telemetri' }
];
/**
* Samme foldning som serverens `fold`-kolonne.
*
* `remove_diacritics` i SQLite gør intet ved ø, æ og å — de er egne bogstaver og
* ikke accenter — og en dansk søgning, der ikke kan finde "sikkerhedskopier" fra
* "sikkerhedskopier" skrevet på et fremmed tastatur, er brudt for sine egne
* brugere. Derfor samme oversættelse her som der, og `normalize` ovenpå til alt
* det, Unicode faktisk regner for accenter.
*/
export function fold(text) {
return (text ?? '')
.toLowerCase()
.replace(/ø/g, 'o')
.replace(/æ/g, 'ae')
.replace(/å/g, 'aa')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
/**
* Indstillingerne, der svarer til det skrevne.
*
* Alle ordene skal findes, hvert af dem som begyndelsen af et ord — "sik kop"
* finder sikkerhedskopierne. Den, der søger, skriver forfra; en søgning, der også
* matcher midt i et ord, finder "opgave" i "hovedopgaven" og larmer.
*
* `t` gives med udefra frem for at blive importeret. Det holder filen fri af
* Sveltes runes, så den kan læses af en almindelig prøve uden en komponent
* omkring sig.
*/
export function findSettings(query, { t, isAdmin = false, limit = 8 } = {}) {
const terms = fold(query).split(/\s+/).filter(Boolean);
if (!terms.length) return [];
const hits = [];
for (const entry of SETTINGS) {
if (entry.admin && !isAdmin) continue;
const title = t(entry.key);
const tab = t(entry.tab);
const words = fold(`${title} ${tab} ${entry.words}`)
.split(/[\s/·,-]+/)
.filter(Boolean);
// Alle ordene skal findes, og hvert af dem forfra i et ord: "sikker" finder
// sikkerhedskopierne, "kopier" gør ikke. Den, der søger, skriver
// begyndelsen af det, den leder efter — en søgning, der også rammer midt i
// et ord, finder "opgave" inde i "hovedopgaven" og larmer.
if (!terms.every((term) => words.some((word) => word.startsWith(term)))) continue;
// Det, der hedder det, man søgte efter, før det, der blot nævner det. Uden
// den her ligger rækkefølgen fast som filens, og "beacon" ville få hele
// Data-siden ned over sig, fordi den står først.
const folded = fold(title);
const rank = folded.startsWith(terms[0]) ? 0 : folded.includes(terms[0]) ? 1 : 2;
hits.push({ rank, entry: { ...entry, title, tab, href: `${entry.path}#${entry.anchor}` } });
}
return hits
.sort((a, b) => a.rank - b.rank)
.slice(0, limit)
.map((hit) => hit.entry);
}
+192
View File
@@ -11,8 +11,10 @@
* is six places for them to drift apart.
*/
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { findSettings } from '$lib/settingsindex.js';
let { children } = $props();
@@ -37,6 +39,69 @@
]);
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
/**
* Søgningen efter en indstilling.
*
* Ti faner med en snes afsnit i alt, og den eneste vej til et bestemt af dem
* var at huske, hvilken fane det lå under. Feltet her og ⌘K spørger det samme
* indeks — se settingsindex.js — så et nyt afsnit kun skal skrives ét sted for
* at kunne findes to.
*/
let query = $state('');
let hits = $derived(findSettings(query, { t, isAdmin: app.user?.is_admin ?? false }));
function go(hit) {
query = '';
goto(hit.href);
}
function onkeydown(event) {
if (event.key === 'Escape') {
query = '';
return;
}
if (event.key === 'Enter' && hits.length) {
event.preventDefault();
go(hits[0]);
}
}
/**
* Ruller hen til afsnittet, når adressen nævner det, og blinker det.
*
* Browserens egen springen til et anker kan ikke bruges her: siden hentes af
* ruteren, og afsnittet findes først, når dens data er hjemme — så et spring,
* der sker med det samme, lander på en side, der endnu er tom. Derfor prøves
* det, indtil elementet er der, og gives op efter to sekunder frem for at
* prøve for evigt.
*
* Og blinket er ikke pynt. Man er landet et sted, man ikke selv rullede hen
* til, midt i en side med otte afsnit, der ligner hinanden; uden det skal man
* læse sig frem til, hvor man er.
*/
$effect(() => {
const anchor = $page.url.hash.slice(1);
if (!anchor) return;
let tries = 0;
let timer;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const look = () => {
const el = document.getElementById(anchor);
if (!el) {
if (tries++ < 40) timer = setTimeout(look, 50);
return;
}
el.scrollIntoView({ block: 'start', behavior: reduced ? 'auto' : 'smooth' });
el.classList.add('landed');
timer = setTimeout(() => el.classList.remove('landed'), 1600);
};
look();
return () => clearTimeout(timer);
});
</script>
<div class="settings">
@@ -44,6 +109,34 @@
<h1>{t('settings.title')}</h1>
</header>
<div class="find">
<input
type="search"
bind:value={query}
{onkeydown}
placeholder={t('settings.search')}
aria-label={t('settings.search')}
autocomplete="off"
spellcheck="false"
/>
{#if query.trim()}
{#if hits.length}
<ul class="hits">
{#each hits as hit (hit.href)}
<li>
<button onclick={() => go(hit)}>
<span class="where">{hit.tab}</span>
<span class="what">{hit.title}</span>
</button>
</li>
{/each}
</ul>
{:else}
<p class="nohits">{t('settings.searchNone')}</p>
{/if}
{/if}
</div>
<nav aria-label={t('settings.title')}>
{#each sections as section (section.href)}
<a href={section.href} class:active={current === section.href}>{section.label}</a>
@@ -109,9 +202,108 @@
gap: var(--s5);
}
.find {
position: relative;
margin-bottom: var(--s3);
}
.find input {
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
transition: border-color var(--fast) var(--ease);
}
.find input:focus {
border-color: var(--accent);
}
/* Over indholdet frem for at skubbe det ned: listen kommer og går for hvert
bogstav, og en side, der hopper under det, man læser, er svær at sigte i. */
.hits {
position: absolute;
z-index: 20;
left: 0;
right: 0;
margin: var(--s1) 0 0;
padding: var(--s1);
list-style: none;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hits button {
display: flex;
align-items: baseline;
gap: var(--s2);
width: 100%;
padding: var(--s2);
border-radius: var(--radius-sm);
text-align: left;
color: var(--ink);
}
.hits button:hover {
background: var(--surface-sunken);
}
/* Fanen står før navnet, fordi svaret på "hvor ligger det?" er halvdelen af
det, man søgte efter. */
.where {
flex: none;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.what {
font-size: var(--text-sm);
}
.nohits {
margin: var(--s2) 0 0;
font-size: var(--text-sm);
color: var(--ink-muted);
}
.settings :global(h3[id]) {
scroll-margin-top: var(--s5);
}
/* Blinket, når man er landet et sted, man ikke selv rullede hen til. Kanten og
ikke baggrunden: en baggrund, der skifter, gør teksten svær at læse i netop
det øjeblik, man er kommet for at læse den. */
.settings :global(.landed) {
animation: landed 1.6s var(--ease);
}
@keyframes landed {
0%,
60% {
box-shadow: 0 0 0 2px var(--accent);
}
100% {
box-shadow: 0 0 0 2px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.settings :global(.landed) {
animation: none;
}
}
/* --- shared form chrome, for the child routes ------------------------------- */
.settings :global(section.panel) {
/* Der er en fast linje øverst på skærmen. Uden den her lander afsnittets
overskrift bag den, når man springer hertil fra en søgning. */
scroll-margin-top: var(--s5);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
+10 -10
View File
@@ -276,7 +276,7 @@
*/
</script>
<section class="panel">
<section class="panel" id="profil">
<header>
<h2>{t('account.profile')}</h2>
<p class="hint">
@@ -332,7 +332,7 @@
</form>
</section>
<section class="panel">
<section class="panel" id="udseende">
<header>
<h2>{t('account.appearance')}</h2>
<p class="hint">
@@ -366,7 +366,7 @@
<!-- The second axis. A theme says how bright, a look says how it reads — and
they are separate settings because they are separate questions. -->
<div class="looks">
<h3>{t('account.look')}</h3>
<h3 id="look">{t('account.look')}</h3>
<p class="hint">{t('account.lookHint')}</p>
<div class="look-row">
{#each LOOKS as option (option.id)}
@@ -390,7 +390,7 @@
Separate on purpose — one is a glance down a list of names, the other is a
paragraph you sit and read. -->
<div class="sizes">
<h3>{t('account.sizes')}</h3>
<h3 id="stoerrelser">{t('account.sizes')}</h3>
<p class="hint">{t('account.sizesHint')}</p>
<div class="size-row">
<div class="size-field">
@@ -421,7 +421,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="links">
<header>
<h2>{t('link.title')}</h2>
<p class="hint">{t('link.hint')}</p>
@@ -464,7 +464,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="adgangskode">
<header>
<h2>{t('account.password')}</h2>
<p class="hint">
@@ -506,7 +506,7 @@
</form>
</section>
<section class="panel">
<section class="panel" id="to-faktor">
<header>
<h2>{t('account.totp')}</h2>
<p class="hint">{t('account.totpHint')}</p>
@@ -616,7 +616,7 @@
<!-- Between two-factor and the device list, because it belongs to both: a passkey
is a way in, and it is a thing on a device you might later want to revoke. -->
<section class="panel">
<section class="panel" id="passkey">
<header>
<h2>{t('passkey.title')}</h2>
<p class="hint">{t('passkey.hint')}</p>
@@ -655,7 +655,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="enheder">
<header>
<h2>{t('account.devices')}</h2>
<p class="hint">
@@ -687,7 +687,7 @@
work. A plain anchor with an inline SVG — no third-party script, no webfont,
no tracking — so it works offline and cannot phone home. See the coffee-cup
snippet for why not their generated button. -->
<section class="panel">
<section class="panel" id="stoette">
<header>
<h2>{t('account.support')}</h2>
<p class="hint">{t('account.supportHint')}</p>
+2 -2
View File
@@ -83,7 +83,7 @@
}
</script>
<section class="panel">
<section class="panel" id="ai">
<header>
<h2>{t('ai.title')}</h2>
<p class="hint">
@@ -157,7 +157,7 @@
</section>
{#if settings?.provider && settings?.has_key}
<section class="panel">
<section class="panel" id="ugentlig">
<header>
<h2>{t('ai.weekly')}</h2>
<p class="hint">{t('ai.weeklyHint')}</p>
@@ -128,7 +128,7 @@
const expires = shortDate;
</script>
<section class="panel">
<section class="panel" id="invitation">
<header>
<h2>{t('users.invite')}</h2>
<p class="hint">
@@ -163,7 +163,7 @@
</section>
{#if invites.length}
<section class="panel">
<section class="panel" id="afventende">
<header>
<h2>{t('users.pending')}</h2>
<p class="hint">
@@ -191,7 +191,7 @@
</section>
{/if}
<section class="panel">
<section class="panel" id="konti">
<header>
<h2>{t('users.accounts')}</h2>
<p class="hint">{t('users.accountsHint')}</p>
@@ -234,7 +234,7 @@
}
</script>
<section class="panel">
<section class="panel" id="import">
<header>
<h2>{t('data.import')}</h2>
<p class="hint">
@@ -279,7 +279,7 @@
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
to the four that are read every day, and it belongs with the other "where did
that go" questions rather than above them. -->
<section class="panel">
<section class="panel" id="faerdige">
<header>
<h2>{t('done.title')}</h2>
<p class="hint">{t('data.doneHint')}</p>
@@ -289,7 +289,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="papirkurv">
<header>
<h2>{t('data.trash')}</h2>
<p class="hint">
@@ -322,7 +322,7 @@
<!-- Administrators only, like the error log and for a sharper reason: a backup file
is a complete copy of the database, so this panel hands out everybody's data. -->
{#if app.user?.is_admin}
<section class="panel">
<section class="panel" id="sikkerhedskopier">
<header>
<h2>{t('data.backups')}</h2>
<p class="hint">
@@ -380,7 +380,7 @@
</section>
{/if}
<section class="panel">
<section class="panel" id="eksport">
<header>
<h2>{t('data.export')}</h2>
<p class="hint">
@@ -428,7 +428,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="skabeloner">
<header>
<h2>{t('data.templates')}</h2>
<p class="hint">
@@ -512,7 +512,7 @@
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
dem, ikke gemt væk under en fane, ingen åbner. -->
<section class="panel">
<section class="panel" id="beacon">
<header>
<h2>{t('beacon.title')}</h2>
<p class="hint">{t('beacon.hint')}</p>
@@ -28,7 +28,7 @@
</script>
<section class="panel">
<section class="panel" id="fejl">
<header>
<h2>{t('errors.title')}</h2>
<p class="hint">
@@ -106,7 +106,7 @@
}
</script>
<section class="panel">
<section class="panel" id="historik">
<header>
<h2>{t('history.title')}</h2>
<p class="hint">
@@ -416,7 +416,7 @@
}
</script>
<section class="panel">
<section class="panel" id="gmail">
<header>
<h2>{t('int.gmail')}</h2>
<p class="hint">
@@ -520,7 +520,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="kalender">
<header>
<h2>{t('int.calendar')}</h2>
<p class="hint">{t('int.calendarHint')}</p>
@@ -605,7 +605,7 @@
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
{#if calendar !== null}
<div class="subs">
<h3>{t('int.subscriptions')}</h3>
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
<p class="hint">{t('int.subscriptionsHint')}</p>
{#if calendar.subscriptions?.length}
@@ -643,7 +643,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="kalenderfeed">
<header>
<h2>{t('int.feed')}</h2>
<p class="hint">
@@ -665,7 +665,7 @@
</p>
</section>
<section class="panel">
<section class="panel" id="mail-til-opgave">
<header>
<h2>{t('int.mailToTask')}</h2>
<p class="hint">
@@ -690,7 +690,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="krog">
<header>
<h2>{t('int.hookToTask')}</h2>
<p class="hint">
@@ -722,7 +722,7 @@
</div>
</section>
<section class="panel">
<section class="panel" id="caldav-server">
<header>
<h2>{t('int.caldav')}</h2>
<p class="hint">
@@ -743,7 +743,7 @@
rather than a second mode inside the Gmail one, because they are not the same
thing wearing different clothes — one is an app you sign in through, the other
is a password you hold. -->
<section class="panel">
<section class="panel" id="postkasser">
<header>
<h2>{t('int.mailboxes')}</h2>
<p class="hint">{t('int.mailboxesHint')}</p>
@@ -213,7 +213,7 @@
});
</script>
<section class="panel">
<section class="panel" id="dagens-plan">
<header>
<h2>{t('ai.plan')}</h2>
<p class="hint">{t('ai.planHint')}</p>
@@ -252,7 +252,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="push">
<header>
<h2>{t('push.title')}</h2>
<p class="hint">
@@ -296,7 +296,7 @@
{/if}
</section>
<section class="panel">
<section class="panel" id="seneste">
<header>
<h2>{t('push.recent')}</h2>
{#if unread > 0}
@@ -335,7 +335,7 @@
</section>
{#if version}
<section class="panel">
<section class="panel" id="version">
<header>
<h2>{t('push.version')}</h2>
</header>
@@ -93,7 +93,7 @@
const expired = (iso) => iso && new Date(iso) < new Date();
</script>
<section class="panel">
<section class="panel" id="tokens">
<header>
<h2>{t('tokens.title')}</h2>
<p class="hint">