Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39f655565 | ||
|
|
7e981354c2 | ||
|
|
1f0a43ac90 | ||
|
|
61f8887319 | ||
|
|
72637a153a | ||
|
|
f06a25b43e |
@@ -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.
|
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
|
The calendar draws each task to its length — drag the foot to resize, the body
|
||||||
to move it.
|
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
|
- **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
|
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,
|
the text — or file it in a project; `#project` and `[[another note]]` are links,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -4360,3 +4360,158 @@ test('dagens plan står på I dag, og ikke kun som en besked', async ({ page })
|
|||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresserne på integrationssiden kan ses helt på en telefon.
|
||||||
|
*
|
||||||
|
* En URL er ét ord på et par hundrede tegn uden et mellemrum i, og den kan derfor
|
||||||
|
* ikke ombryde af sig selv. Det gav to forskellige fejl på den samme side, og
|
||||||
|
* prøven her holder øje med dem begge:
|
||||||
|
*
|
||||||
|
* Abonnementets adresse lå i en liste uden noget, der lod den bryde, og det
|
||||||
|
* bredeste element på en side bestemmer sidens bredde — så hele indholdsspalten fik
|
||||||
|
* et vandret rullepanel, ikke bare den ene linje.
|
||||||
|
*
|
||||||
|
* Feed'et, krogen og CalDAV-serveren lå i skrivebeskyttede <input>, som ruller
|
||||||
|
* indeni i stedet for at ombryde. Der var ikke noget rullepanel at se: adressen
|
||||||
|
* stoppede bare midt i kassen, og resten fandtes kun ved at trække inde i feltet.
|
||||||
|
*
|
||||||
|
* Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente
|
||||||
|
* kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve,
|
||||||
|
* der fejler en dag af en anden grund end sin egen.
|
||||||
|
*/
|
||||||
|
test('adresserne på integrationssiden kan ses helt på en telefon', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
|
||||||
|
await page.route('**/api/v1/calendar', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
connected: false,
|
||||||
|
calendars: [],
|
||||||
|
has_client: false,
|
||||||
|
read_only: true,
|
||||||
|
redirect_uri: 'http://localhost:8097/api/v1/calendar/callback',
|
||||||
|
subscriptions: [
|
||||||
|
{
|
||||||
|
id: '0198f000-0000-7000-8000-000000000001',
|
||||||
|
provider: 'ics',
|
||||||
|
account: 'kw@netic.dk',
|
||||||
|
url:
|
||||||
|
'https://calendar.google.com/calendar/ical/kw%40netic.dk/private-3f9a1c7e5b2d4a6f8c0e1d2b3a4f5e6d/basic.ics',
|
||||||
|
created_at: '2026-09-01T09:00:00+02:00'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// En iPhone i stående format. Bredden er det eneste, der betyder noget her.
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto('/indstillinger/integrationer');
|
||||||
|
await expect(page.getByText('kw@netic.dk')).toBeVisible();
|
||||||
|
|
||||||
|
// `.content` og ikke dokumentet. Skallen holder selv siden på skærmens bredde,
|
||||||
|
// så documentElement måler 390 uanset hvad — det er indholdsspalten indeni, der
|
||||||
|
// ruller, og det er den, man skubber med tommelfingeren. En prøve på dokumentet
|
||||||
|
// består med fejlen i behold; den er prøvet.
|
||||||
|
const spill = await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.content');
|
||||||
|
const url = document.querySelector('.subs-list .url');
|
||||||
|
return {
|
||||||
|
content: el.scrollWidth - el.clientWidth,
|
||||||
|
past: Math.round(url.getBoundingClientRect().right) - document.documentElement.clientWidth
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(spill.content).toBe(0);
|
||||||
|
expect(spill.past).toBeLessThanOrEqual(0);
|
||||||
|
|
||||||
|
// Og adresserne på siden står der helt. De lå i skrivebeskyttede <input>, som
|
||||||
|
// ikke kan ombryde: kassen var 306 px og indholdet 595, så resten af adressen
|
||||||
|
// fandtes kun ved at rulle inde i feltet — hvilket ingen opdager. Et felt, der
|
||||||
|
// er klippet, er et felt, hvor scrollWidth er større end clientWidth, og det er
|
||||||
|
// præcis det, der måles her.
|
||||||
|
//
|
||||||
|
// Felterne står ved navn og ikke som en forespørgsel på en klasse: en prøve, der
|
||||||
|
// måler `.field output` og finder nul, består — også den dag felterne er lavet om
|
||||||
|
// til noget andet klippet. Den skal fejle, hvis et af dem forsvinder.
|
||||||
|
const clipped = await page.evaluate(() =>
|
||||||
|
['feed', 'mail', 'hook', 'hookcurl', 'caldav']
|
||||||
|
.map((id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return `${id}: findes ikke`;
|
||||||
|
if (el.scrollWidth - el.clientWidth > 1) return `${id}: ${el.scrollWidth} > ${el.clientWidth}`;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
expect(clipped).toEqual([]);
|
||||||
|
|
||||||
|
// Selve teksten, ikke kun kassens mål: krogens adresse er den, der skal kunne
|
||||||
|
// læses af, hvis man taster den ind i en genvej på telefonen.
|
||||||
|
await expect(page.locator('#hook')).toHaveText(/^https?:\/\/.+\/inbound\/hook\/.+/);
|
||||||
|
|
||||||
|
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 { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { humanMessage } from '$lib/api.js';
|
import { humanMessage } from '$lib/api.js';
|
||||||
|
import { findSettings } from '$lib/settingsindex.js';
|
||||||
|
|
||||||
let { open = $bindable(false) } = $props();
|
let { open = $bindable(false) } = $props();
|
||||||
|
|
||||||
@@ -68,7 +69,20 @@
|
|||||||
}, 140);
|
}, 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([
|
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 })),
|
...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) })),
|
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
|
||||||
...tasks.map((t) => ({
|
...tasks.map((t) => ({
|
||||||
@@ -108,6 +122,9 @@
|
|||||||
|
|
||||||
function choose(item) {
|
function choose(item) {
|
||||||
open = false;
|
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}`);
|
if (item.kind === 'note') return goto(`/noter?note=${item.id}`);
|
||||||
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const da = {
|
|||||||
'palette.project': 'Projekt',
|
'palette.project': 'Projekt',
|
||||||
'palette.task': 'Opgave',
|
'palette.task': 'Opgave',
|
||||||
'palette.note': 'Note',
|
'palette.note': 'Note',
|
||||||
|
'palette.setting': 'Indstilling',
|
||||||
'nav.today': 'I dag',
|
'nav.today': 'I dag',
|
||||||
'nav.upcoming': 'Kommende',
|
'nav.upcoming': 'Kommende',
|
||||||
'nav.delegated': 'Venter på andre',
|
'nav.delegated': 'Venter på andre',
|
||||||
@@ -220,7 +221,7 @@ export const da = {
|
|||||||
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
'nav.signOutConfirm': 'Tryk igen for at logge ud',
|
||||||
'nav.main': 'Hovedmenu',
|
'nav.main': 'Hovedmenu',
|
||||||
'nav.search': 'Søg',
|
'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.noResults': 'Ingen resultater.',
|
||||||
'nav.showSidebar': 'Vis sidebjælken',
|
'nav.showSidebar': 'Vis sidebjælken',
|
||||||
'nav.hideSidebar': 'Skjul sidebjælken',
|
'nav.hideSidebar': 'Skjul sidebjælken',
|
||||||
@@ -301,6 +302,8 @@ export const da = {
|
|||||||
|
|
||||||
// --- settings: the frame -------------------------------------------------------
|
// --- settings: the frame -------------------------------------------------------
|
||||||
'settings.title': 'Indstillinger',
|
'settings.title': 'Indstillinger',
|
||||||
|
'settings.search': 'Søg i indstillinger',
|
||||||
|
'settings.searchNone': 'Ingen indstilling hedder det.',
|
||||||
'settings.tab.account': 'Konto',
|
'settings.tab.account': 'Konto',
|
||||||
'settings.tab.notifications': 'Notifikationer',
|
'settings.tab.notifications': 'Notifikationer',
|
||||||
'settings.tab.integrations': 'Integrationer',
|
'settings.tab.integrations': 'Integrationer',
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export const en = {
|
|||||||
'palette.project': 'Project',
|
'palette.project': 'Project',
|
||||||
'palette.task': 'Task',
|
'palette.task': 'Task',
|
||||||
'palette.note': 'Note',
|
'palette.note': 'Note',
|
||||||
|
'palette.setting': 'Setting',
|
||||||
'nav.today': 'Today',
|
'nav.today': 'Today',
|
||||||
'nav.upcoming': 'Upcoming',
|
'nav.upcoming': 'Upcoming',
|
||||||
'nav.delegated': 'Waiting on others',
|
'nav.delegated': 'Waiting on others',
|
||||||
@@ -217,7 +218,7 @@ export const en = {
|
|||||||
'nav.signOutConfirm': 'Click again to sign out',
|
'nav.signOutConfirm': 'Click again to sign out',
|
||||||
'nav.main': 'Main menu',
|
'nav.main': 'Main menu',
|
||||||
'nav.search': 'Search',
|
'nav.search': 'Search',
|
||||||
'nav.searchLong': 'Search tasks and projects',
|
'nav.searchLong': 'Search tasks, notes, projects and settings',
|
||||||
'nav.noResults': 'Nothing found.',
|
'nav.noResults': 'Nothing found.',
|
||||||
'nav.showSidebar': 'Show the sidebar',
|
'nav.showSidebar': 'Show the sidebar',
|
||||||
'nav.hideSidebar': 'Hide the sidebar',
|
'nav.hideSidebar': 'Hide the sidebar',
|
||||||
@@ -298,6 +299,8 @@ export const en = {
|
|||||||
|
|
||||||
// --- settings: the frame -------------------------------------------------------
|
// --- settings: the frame -------------------------------------------------------
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
|
'settings.search': 'Search settings',
|
||||||
|
'settings.searchNone': 'No setting goes by that name.',
|
||||||
'settings.tab.account': 'Account',
|
'settings.tab.account': 'Account',
|
||||||
'settings.tab.notifications': 'Notifications',
|
'settings.tab.notifications': 'Notifications',
|
||||||
'settings.tab.integrations': 'Integrations',
|
'settings.tab.integrations': 'Integrations',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
* is six places for them to drift apart.
|
* is six places for them to drift apart.
|
||||||
*/
|
*/
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { findSettings } from '$lib/settingsindex.js';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -37,6 +39,69 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
@@ -44,6 +109,34 @@
|
|||||||
<h1>{t('settings.title')}</h1>
|
<h1>{t('settings.title')}</h1>
|
||||||
</header>
|
</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')}>
|
<nav aria-label={t('settings.title')}>
|
||||||
{#each sections as section (section.href)}
|
{#each sections as section (section.href)}
|
||||||
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
<a href={section.href} class:active={current === section.href}>{section.label}</a>
|
||||||
@@ -109,9 +202,108 @@
|
|||||||
gap: var(--s5);
|
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 ------------------------------- */
|
/* --- shared form chrome, for the child routes ------------------------------- */
|
||||||
|
|
||||||
.settings :global(section.panel) {
|
.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);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -151,7 +343,8 @@
|
|||||||
|
|
||||||
.settings :global(.field input),
|
.settings :global(.field input),
|
||||||
.settings :global(.field select),
|
.settings :global(.field select),
|
||||||
.settings :global(.field textarea) {
|
.settings :global(.field textarea),
|
||||||
|
.settings :global(.field output) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--s2) var(--s3);
|
padding: var(--s2) var(--s3);
|
||||||
background: var(--surface-sunken);
|
background: var(--surface-sunken);
|
||||||
@@ -168,6 +361,22 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Adresserne — feed'et, krogen, CalDAV-serveren — vises i et <output> og ikke i
|
||||||
|
et skrivebeskyttet <input>. Et input kan ikke ombryde: det ruller indeni, så
|
||||||
|
på en telefon står halvdelen af adressen uden for kassen, og der er ingen måde
|
||||||
|
at se resten på. <output> er stadig et felt, en <label for> kan pege på, og det
|
||||||
|
er tekst, der ombryder.
|
||||||
|
|
||||||
|
`user-select: all` er der, fordi det var det eneste, inputtet gjorde bedre: ét
|
||||||
|
tryk tog hele adressen. Uden den skulle man trække hen over fire linjer. */
|
||||||
|
.settings :global(.field output) {
|
||||||
|
display: block;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* The field error sits under its own input rather than in a summary at the top:
|
/* The field error sits under its own input rather than in a summary at the top:
|
||||||
the server answers with a map of field to message, and putting each one where
|
the server answers with a map of field to message, and putting each one where
|
||||||
it belongs is the whole point of that shape. */
|
it belongs is the whole point of that shape. */
|
||||||
|
|||||||
@@ -276,7 +276,7 @@
|
|||||||
*/
|
*/
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="profil">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.profile')}</h2>
|
<h2>{t('account.profile')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="udseende">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.appearance')}</h2>
|
<h2>{t('account.appearance')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
||||||
they are separate settings because they are separate questions. -->
|
they are separate settings because they are separate questions. -->
|
||||||
<div class="looks">
|
<div class="looks">
|
||||||
<h3>{t('account.look')}</h3>
|
<h3 id="look">{t('account.look')}</h3>
|
||||||
<p class="hint">{t('account.lookHint')}</p>
|
<p class="hint">{t('account.lookHint')}</p>
|
||||||
<div class="look-row">
|
<div class="look-row">
|
||||||
{#each LOOKS as option (option.id)}
|
{#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
|
Separate on purpose — one is a glance down a list of names, the other is a
|
||||||
paragraph you sit and read. -->
|
paragraph you sit and read. -->
|
||||||
<div class="sizes">
|
<div class="sizes">
|
||||||
<h3>{t('account.sizes')}</h3>
|
<h3 id="stoerrelser">{t('account.sizes')}</h3>
|
||||||
<p class="hint">{t('account.sizesHint')}</p>
|
<p class="hint">{t('account.sizesHint')}</p>
|
||||||
<div class="size-row">
|
<div class="size-row">
|
||||||
<div class="size-field">
|
<div class="size-field">
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="links">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('link.title')}</h2>
|
<h2>{t('link.title')}</h2>
|
||||||
<p class="hint">{t('link.hint')}</p>
|
<p class="hint">{t('link.hint')}</p>
|
||||||
@@ -464,7 +464,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="adgangskode">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.password')}</h2>
|
<h2>{t('account.password')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -506,7 +506,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="to-faktor">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.totp')}</h2>
|
<h2>{t('account.totp')}</h2>
|
||||||
<p class="hint">{t('account.totpHint')}</p>
|
<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
|
<!-- 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. -->
|
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>
|
<header>
|
||||||
<h2>{t('passkey.title')}</h2>
|
<h2>{t('passkey.title')}</h2>
|
||||||
<p class="hint">{t('passkey.hint')}</p>
|
<p class="hint">{t('passkey.hint')}</p>
|
||||||
@@ -655,7 +655,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="enheder">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.devices')}</h2>
|
<h2>{t('account.devices')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -687,7 +687,7 @@
|
|||||||
work. A plain anchor with an inline SVG — no third-party script, no webfont,
|
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
|
no tracking — so it works offline and cannot phone home. See the coffee-cup
|
||||||
snippet for why not their generated button. -->
|
snippet for why not their generated button. -->
|
||||||
<section class="panel">
|
<section class="panel" id="stoette">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('account.support')}</h2>
|
<h2>{t('account.support')}</h2>
|
||||||
<p class="hint">{t('account.supportHint')}</p>
|
<p class="hint">{t('account.supportHint')}</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="ai">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.title')}</h2>
|
<h2>{t('ai.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if settings?.provider && settings?.has_key}
|
{#if settings?.provider && settings?.has_key}
|
||||||
<section class="panel">
|
<section class="panel" id="ugentlig">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.weekly')}</h2>
|
<h2>{t('ai.weekly')}</h2>
|
||||||
<p class="hint">{t('ai.weeklyHint')}</p>
|
<p class="hint">{t('ai.weeklyHint')}</p>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
const expires = shortDate;
|
const expires = shortDate;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="invitation">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.invite')}</h2>
|
<h2>{t('users.invite')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if invites.length}
|
{#if invites.length}
|
||||||
<section class="panel">
|
<section class="panel" id="afventende">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.pending')}</h2>
|
<h2>{t('users.pending')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -191,7 +191,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="konti">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('users.accounts')}</h2>
|
<h2>{t('users.accounts')}</h2>
|
||||||
<p class="hint">{t('users.accountsHint')}</p>
|
<p class="hint">{t('users.accountsHint')}</p>
|
||||||
|
|||||||
@@ -234,7 +234,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="import">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.import')}</h2>
|
<h2>{t('data.import')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
<!-- Færdige used to be a permanent entry in the sidebar. It is a rare errand next
|
<!-- 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
|
to the four that are read every day, and it belongs with the other "where did
|
||||||
that go" questions rather than above them. -->
|
that go" questions rather than above them. -->
|
||||||
<section class="panel">
|
<section class="panel" id="faerdige">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('done.title')}</h2>
|
<h2>{t('done.title')}</h2>
|
||||||
<p class="hint">{t('data.doneHint')}</p>
|
<p class="hint">{t('data.doneHint')}</p>
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="papirkurv">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.trash')}</h2>
|
<h2>{t('data.trash')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
<!-- 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. -->
|
is a complete copy of the database, so this panel hands out everybody's data. -->
|
||||||
{#if app.user?.is_admin}
|
{#if app.user?.is_admin}
|
||||||
<section class="panel">
|
<section class="panel" id="sikkerhedskopier">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.backups')}</h2>
|
<h2>{t('data.backups')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -380,7 +380,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="eksport">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.export')}</h2>
|
<h2>{t('data.export')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="skabeloner">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('data.templates')}</h2>
|
<h2>{t('data.templates')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -512,7 +512,7 @@
|
|||||||
Under Data, fordi det er her alt om "hvad forlader denne instans" står — en
|
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
|
sikkerhedskopi, en eksport, en import. Telemetri hører til i samme rude som
|
||||||
dem, ikke gemt væk under en fane, ingen åbner. -->
|
dem, ikke gemt væk under en fane, ingen åbner. -->
|
||||||
<section class="panel">
|
<section class="panel" id="beacon">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('beacon.title')}</h2>
|
<h2>{t('beacon.title')}</h2>
|
||||||
<p class="hint">{t('beacon.hint')}</p>
|
<p class="hint">{t('beacon.hint')}</p>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="fejl">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('errors.title')}</h2>
|
<h2>{t('errors.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="historik">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('history.title')}</h2>
|
<h2>{t('history.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -416,7 +416,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="gmail">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.gmail')}</h2>
|
<h2>{t('int.gmail')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
it has to match Google's copy exactly, and the error Google gives
|
it has to match Google's copy exactly, and the error Google gives
|
||||||
when it does not names neither value. Google afviser desuden
|
when it does not names neither value. Google afviser desuden
|
||||||
private IP-adresser over http — det skal være https. -->
|
private IP-adresser over http — det skal være https. -->
|
||||||
<input id="redirect" readonly value={client.redirect_uri} />
|
<output id="redirect">{client.redirect_uri}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={saveClient}>
|
<form onsubmit={saveClient}>
|
||||||
@@ -520,7 +520,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="kalender">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.calendar')}</h2>
|
<h2>{t('int.calendar')}</h2>
|
||||||
<p class="hint">{t('int.calendarHint')}</p>
|
<p class="hint">{t('int.calendarHint')}</p>
|
||||||
@@ -546,7 +546,7 @@
|
|||||||
{#if app.user?.is_admin}
|
{#if app.user?.is_admin}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
||||||
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
|
||||||
<p class="hint">{t('int.calendarSameClient')}</p>
|
<p class="hint">{t('int.calendarSameClient')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if !calendar.has_client}
|
{:else if !calendar.has_client}
|
||||||
@@ -605,7 +605,7 @@
|
|||||||
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
||||||
{#if calendar !== null}
|
{#if calendar !== null}
|
||||||
<div class="subs">
|
<div class="subs">
|
||||||
<h3>{t('int.subscriptions')}</h3>
|
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
|
||||||
<p class="hint">{t('int.subscriptionsHint')}</p>
|
<p class="hint">{t('int.subscriptionsHint')}</p>
|
||||||
|
|
||||||
{#if calendar.subscriptions?.length}
|
{#if calendar.subscriptions?.length}
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="kalenderfeed">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.feed')}</h2>
|
<h2>{t('int.feed')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -653,7 +653,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="feed">{t('int.address')}</label>
|
<label for="feed">{t('int.address')}</label>
|
||||||
<input id="feed" class="mono" value={feedURL} readonly />
|
<output id="feed" class="mono">{feedURL}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -665,7 +665,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="mail-til-opgave">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.mailToTask')}</h2>
|
<h2>{t('int.mailToTask')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -681,7 +681,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="mail">{t('int.yourAddress')}</label>
|
<label for="mail">{t('int.yourAddress')}</label>
|
||||||
<input id="mail" class="mono" value={mailAddress} readonly />
|
<output id="mail" class="mono">{mailAddress}</output>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="krog">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.hookToTask')}</h2>
|
<h2>{t('int.hookToTask')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -700,7 +700,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hook">{t('int.yourHook')}</label>
|
<label for="hook">{t('int.yourHook')}</label>
|
||||||
<input id="hook" class="mono" value={hookURL} readonly />
|
<output id="hook" class="mono">{hookURL}</output>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('int.hookHow')}
|
{t('int.hookHow')}
|
||||||
</p>
|
</p>
|
||||||
@@ -711,8 +711,8 @@
|
|||||||
kroppen bare er den linje, opgaven skal hedde. -->
|
kroppen bare er den linje, opgaven skal hedde. -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="hookcurl">{t('int.hookExample')}</label>
|
<label for="hookcurl">{t('int.hookExample')}</label>
|
||||||
<textarea id="hookcurl" class="mono" rows="3" readonly
|
<output id="hookcurl" class="mono"
|
||||||
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</textarea
|
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="caldav-server">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.caldav')}</h2>
|
<h2>{t('int.caldav')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -732,7 +732,7 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="caldav">{t('int.server')}</label>
|
<label for="caldav">{t('int.server')}</label>
|
||||||
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
|
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
{t('int.caldavAuth')}
|
{t('int.caldavAuth')}
|
||||||
</p>
|
</p>
|
||||||
@@ -743,7 +743,7 @@
|
|||||||
rather than a second mode inside the Gmail one, because they are not the same
|
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
|
thing wearing different clothes — one is an app you sign in through, the other
|
||||||
is a password you hold. -->
|
is a password you hold. -->
|
||||||
<section class="panel">
|
<section class="panel" id="postkasser">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('int.mailboxes')}</h2>
|
<h2>{t('int.mailboxes')}</h2>
|
||||||
<p class="hint">{t('int.mailboxesHint')}</p>
|
<p class="hint">{t('int.mailboxesHint')}</p>
|
||||||
@@ -817,7 +817,7 @@
|
|||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup input[readonly] {
|
.setup output {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--ink-muted);
|
color: var(--ink-muted);
|
||||||
@@ -884,4 +884,38 @@
|
|||||||
a {
|
a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The bullet list this was is why the page scrolled sideways on a phone. A
|
||||||
|
subscription address is one word with nothing to break at, so it cannot wrap
|
||||||
|
on its own — and the widest element on a page sets the width of the page, not
|
||||||
|
of its own line. Hence `anywhere`, which breaks mid-word when there is no
|
||||||
|
other opportunity, plus `min-width: 0` so the flex item is allowed to be
|
||||||
|
narrower than its content in the first place. */
|
||||||
|
.subs-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list li {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s1);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list .url {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subs-list button {
|
||||||
|
margin-top: var(--s1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="dagens-plan">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('ai.plan')}</h2>
|
<h2>{t('ai.plan')}</h2>
|
||||||
<p class="hint">{t('ai.planHint')}</p>
|
<p class="hint">{t('ai.planHint')}</p>
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="push">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.title')}</h2>
|
<h2>{t('push.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="seneste">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.recent')}</h2>
|
<h2>{t('push.recent')}</h2>
|
||||||
{#if unread > 0}
|
{#if unread > 0}
|
||||||
@@ -335,7 +335,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if version}
|
{#if version}
|
||||||
<section class="panel">
|
<section class="panel" id="version">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('push.version')}</h2>
|
<h2>{t('push.version')}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
const expired = (iso) => iso && new Date(iso) < new Date();
|
const expired = (iso) => iso && new Date(iso) < new Date();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel" id="tokens">
|
||||||
<header>
|
<header>
|
||||||
<h2>{t('tokens.title')}</h2>
|
<h2>{t('tokens.title')}</h2>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
|
|||||||
@@ -1279,7 +1279,7 @@
|
|||||||
lovet i en besked, der ikke kom af sted. -->
|
lovet i en besked, der ikke kom af sted. -->
|
||||||
<p class="invitelink">
|
<p class="invitelink">
|
||||||
{t('notes.inviteNoMail')}
|
{t('notes.inviteNoMail')}
|
||||||
<input readonly value={shareInviteLink} onclick={(e) => e.target.select()} />
|
<output>{shareInviteLink}</output>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -2216,11 +2216,23 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invitelink input {
|
/* <output> og ikke et skrivebeskyttet <input>: et input kan ikke ombryde, så et
|
||||||
|
invitationslink stod halvt uden for kassen på en telefon, og resten kunne kun
|
||||||
|
nås ved at rulle inde i feltet. `user-select: all` beholder det, inputtet var
|
||||||
|
god til — ét tryk tager hele linket. */
|
||||||
|
.invitelink output {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-size: var(--text-xs);
|
|
||||||
padding: 2px var(--s1);
|
padding: 2px var(--s1);
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addshare {
|
.addshare {
|
||||||
|
|||||||
Reference in New Issue
Block a user