diff --git a/docs/index.md b/docs/index.md index 5b24b10..7d6f563 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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, diff --git a/web/e2e/settingsindex.spec.js b/web/e2e/settingsindex.spec.js new file mode 100644 index 0000000..7879031 --- /dev/null +++ b/web/e2e/settingsindex.spec.js @@ -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'); +}); diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index b2324ff..ed12448 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -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([]); +}); diff --git a/web/src/lib/components/CommandPalette.svelte b/web/src/lib/components/CommandPalette.svelte index 97505e4..7f80611 100644 --- a/web/src/lib/components/CommandPalette.svelte +++ b/web/src/lib/components/CommandPalette.svelte @@ -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}`); } diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 36b872d..275302d 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -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', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 00437e7..a1472b6 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -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', diff --git a/web/src/lib/settingsindex.js b/web/src/lib/settingsindex.js new file mode 100644 index 0000000..4516d0b --- /dev/null +++ b/web/src/lib/settingsindex.js @@ -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); +} diff --git a/web/src/routes/indstillinger/+layout.svelte b/web/src/routes/indstillinger/+layout.svelte index 948b4e1..561cb8c 100644 --- a/web/src/routes/indstillinger/+layout.svelte +++ b/web/src/routes/indstillinger/+layout.svelte @@ -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); + });
@@ -44,6 +109,34 @@

{t('settings.title')}

+
+ + {#if query.trim()} + {#if hits.length} + + {:else} +

{t('settings.searchNone')}

+ {/if} + {/if} +
+
-
+

{t('data.trash')}

@@ -322,7 +322,7 @@ {#if app.user?.is_admin} -

+

{t('data.backups')}

@@ -380,7 +380,7 @@

{/if} -
+

{t('data.export')}

@@ -428,7 +428,7 @@ {/if}

-
+

{t('data.templates')}

@@ -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. --> -

+

{t('beacon.title')}

{t('beacon.hint')}

diff --git a/web/src/routes/indstillinger/fejl/+page.svelte b/web/src/routes/indstillinger/fejl/+page.svelte index e074f52..87a4ab3 100644 --- a/web/src/routes/indstillinger/fejl/+page.svelte +++ b/web/src/routes/indstillinger/fejl/+page.svelte @@ -28,7 +28,7 @@ -
+

{t('errors.title')}

diff --git a/web/src/routes/indstillinger/historik/+page.svelte b/web/src/routes/indstillinger/historik/+page.svelte index 92f0c35..5fd797e 100644 --- a/web/src/routes/indstillinger/historik/+page.svelte +++ b/web/src/routes/indstillinger/historik/+page.svelte @@ -106,7 +106,7 @@ } -

+

{t('history.title')}

diff --git a/web/src/routes/indstillinger/integrationer/+page.svelte b/web/src/routes/indstillinger/integrationer/+page.svelte index cbe8bcf..f7b3e20 100644 --- a/web/src/routes/indstillinger/integrationer/+page.svelte +++ b/web/src/routes/indstillinger/integrationer/+page.svelte @@ -416,7 +416,7 @@ } -

+

{t('int.gmail')}

@@ -520,7 +520,7 @@ {/if}

-
+

{t('int.calendar')}

{t('int.calendarHint')}

@@ -605,7 +605,7 @@ Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. --> {#if calendar !== null}
-

{t('int.subscriptions')}

+

{t('int.subscriptions')}

{t('int.subscriptionsHint')}

{#if calendar.subscriptions?.length} @@ -643,7 +643,7 @@ {/if}
-
+

{t('int.feed')}

@@ -665,7 +665,7 @@

-
+

{t('int.mailToTask')}

@@ -690,7 +690,7 @@

-
+

{t('int.hookToTask')}

@@ -722,7 +722,7 @@

-
+

{t('int.caldav')}

@@ -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. --> -

+

{t('int.mailboxes')}

{t('int.mailboxesHint')}

diff --git a/web/src/routes/indstillinger/notifikationer/+page.svelte b/web/src/routes/indstillinger/notifikationer/+page.svelte index 66b4aec..8f8b205 100644 --- a/web/src/routes/indstillinger/notifikationer/+page.svelte +++ b/web/src/routes/indstillinger/notifikationer/+page.svelte @@ -213,7 +213,7 @@ }); -
+

{t('ai.plan')}

{t('ai.planHint')}

@@ -252,7 +252,7 @@ {/if}
-
+

{t('push.title')}

@@ -296,7 +296,7 @@ {/if}

-
+

{t('push.recent')}

{#if unread > 0} @@ -335,7 +335,7 @@
{#if version} -
+

{t('push.version')}

diff --git a/web/src/routes/indstillinger/tokens/+page.svelte b/web/src/routes/indstillinger/tokens/+page.svelte index 1479194..f4270e8 100644 --- a/web/src/routes/indstillinger/tokens/+page.svelte +++ b/web/src/routes/indstillinger/tokens/+page.svelte @@ -93,7 +93,7 @@ const expired = (iso) => iso && new Date(iso) < new Date(); -
+

{t('tokens.title')}