feat(i18n): the interface speaks both languages

The account has had a `locale` since the beginning and it only chose which grammar
quick add read a line with — so picking English changed nothing anybody could see,
and the field said so honestly: "Sprog i hurtig tilføjelse". It says "Sprog" now,
because it means it.

Every component goes through `t()`. Danish is the source: a key missing from
English falls through to Danish rather than to the key, because a Danish sentence
in an English screen can be read and `settings.notifications.title` cannot.

`tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a
dozen components, and that is the half of a translation that gets left behind — an
English interface still saying "24. aug." is exactly the shape of a job half done.
There is a smoke test that switches to English and refuses to see a Danish month
name in the calendar's heading.

Colour and theme names are keys now. `colors.js` keeps its ids untouched — they
are what the database stores, and `internal/store` reads them out of that file —
but `name` is `'color.tomato'`, resolved where it is drawn.

The Inbox keeps its name. It is a project row the server creates at signup, not a
string in the interface, and it can be renamed like any other project.

Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had
already drifted: one said "aldrig logget ind", another "aldrig", and two stopped
at days where the others carried on to a date.

Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and
they are in every smoke test; translating them buys nothing and costs both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:46:08 +02:00
co-authored by Claude Opus 5
parent e3c7fc792a
commit 4d8b70918b
30 changed files with 1657 additions and 566 deletions
+2 -1
View File
@@ -10,6 +10,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import { t } from '$lib/i18n.svelte.js';
let loaded = $state(false);
let name = $derived(decodeURIComponent($page.params.navn ?? ''));
@@ -42,7 +43,7 @@
{#if loaded && !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingen åbne opgaver med denne etiket.
{t('view.labelEmpty')}
</p>
{/if}
</div>
+3 -2
View File
@@ -4,6 +4,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import { t } from '$lib/i18n.svelte.js';
let filter = $state(null);
let error = $state('');
@@ -25,7 +26,7 @@
app.tasks = result?.tasks ?? [];
})
.catch((e) => {
error = e.fields?.query ?? 'Filteret kunne ikke køres.';
error = e.fields?.query ?? t('view.filterFailed');
app.tasks = [];
});
});
@@ -51,7 +52,7 @@
{#if !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingenting matcher lige nu.
{t('view.filterEmpty')}
</p>
{/if}
{/if}
+20 -16
View File
@@ -1,32 +1,36 @@
<script>
/** The shortcuts, listed. Mapped to Todoist's so muscle memory carries over. */
const groups = [
import { t } from '$lib/i18n.svelte.js';
const groups = $derived([
{
title: 'Navigation',
title: t('keys.navigation'),
keys: [
['q', 'Hop til feltet “tilføj opgave”'],
['t', 'I dag'],
['u', 'Kommende'],
['⌘K / Ctrl+K', 'Søg'],
['?', 'Denne side']
['q', t('keys.jumpToQuickAdd')],
['t', t('nav.today')],
['u', t('nav.upcoming')],
['⌘K / Ctrl+K', t('nav.search')],
['?', t('keys.thisPage')]
]
},
{
title: 'I quick add',
title: t('keys.inQuickAdd'),
keys: [
['#projekt', 'Læg opgaven i et projekt'],
['@etiket', 'Sæt en etiket'],
['p1 – p4', 'Prioritet'],
['i morgen, på fredag, om 3 dage', 'Dato — dansk og engelsk'],
['kl 10, 14:30, 7pm', 'Klokkeslæt'],
['Esc', 'Ryd feltet']
['#projekt', t('keys.intoProject')],
['@etiket', t('keys.setLabel')],
['p1 – p4', t('keys.priority')],
// The examples stay in both languages, because the parser takes both
// and the point of the line is what you can actually type.
['i morgen, på fredag, om 3 dage', t('keys.date')],
['kl 10, 14:30, 7pm', t('keys.time')],
['Esc', t('keys.clear')]
]
}
];
]);
</script>
<div class="view">
<h1>Genveje</h1>
<h1>{t('keys.title')}</h1>
{#each groups as group}
<section>
<h2>{group.title}</h2>
+18 -19
View File
@@ -16,6 +16,7 @@
import { app } from '$lib/stores.svelte.js';
import { colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
let group = $state(null);
let projects = $state([]);
@@ -94,7 +95,7 @@
}
async function removeFile(attachment) {
if (!confirm(`Fjern "${attachment.filename}"?`)) return;
if (!confirm(t('group.removeQuestion', { name: attachment.filename }))) return;
const previous = attachments;
attachments = attachments.filter((a) => a.id !== attachment.id);
@@ -112,21 +113,21 @@
}
</script>
<svelte:head><title>{group?.name ?? 'Gruppe'} — verdande</title></svelte:head>
<svelte:head><title>{group?.name ?? t('group.projects')} — verdande</title></svelte:head>
<div class="view">
{#if status === 'denied'}
<p class="empty">Gruppen findes ikke, eller den er ikke din.</p>
<p class="empty">{t('group.notFound')}</p>
{:else if status === 'failed'}
<p class="empty">
Gruppen kunne ikke hentes.
<button class="link" onclick={() => load(id)}>Prøv igen</button>
{t('group.failed')}
<button class="link" onclick={() => load(id)}>{t('group.retry')}</button>
</p>
{:else if group}
<header>
<span class="dot" style="background: {colorVar(group.color)}" aria-hidden="true"></span>
<h1>{group.name}</h1>
<span class="count">{projects.length === 1 ? '1 projekt' : `${projects.length} projekter`}</span>
<span class="count">{projects.length === 1 ? t('group.projectOne') : t('group.projectMany', { n: projects.length })}</span>
</header>
<!-- Click to write, blur to save. The same shape as a task's description,
@@ -138,8 +139,8 @@
bind:value={description}
use:focusOnMount
rows="4"
aria-label="Om gruppen"
placeholder="Hvad er det her for et stykke arbejde?"
aria-label={t('group.about')}
placeholder={t('group.aboutPlaceholder')}
onblur={saveDescription}
onkeydown={(e) => e.key === 'Escape' && (editing = false)}
></textarea>
@@ -148,15 +149,15 @@
{#if group.description}
{group.description}
{:else}
<span class="placeholder">Skriv hvad det her er for et stykke arbejde.</span>
<span class="placeholder">{t('group.writeAbout')}</span>
{/if}
</button>
{/if}
{#if saving}<span class="saved">Gemmer…</span>{/if}
{#if saving}<span class="saved">{t('group.saving')}</span>{/if}
</section>
<section class="projects">
<h2>Projekter</h2>
<h2>{t('group.projects')}</h2>
{#if projects.length}
<ul>
{#each projects as project (project.id)}
@@ -175,17 +176,15 @@
</ul>
{:else}
<p class="empty">
Ingen projekter i gruppen endnu. Træk et herop i sidebjælken, eller vælg gruppen,
når du opretter et.
{t('group.noProjects')}
</p>
{/if}
</section>
<section class="files">
<h2>Dokumenter</h2>
<h2>{t('group.documents')}</h2>
<p class="hint">
Filer, der hører til hele gruppen frem for til én opgave i den — kontrakten,
tegningen, den aftale alt det andet hviler på.
{t('group.documentsHint')}
</p>
{#if attachments.length}
@@ -196,8 +195,8 @@
{attachment.filename}
</a>
<span class="secondary">{size(attachment.size)}</span>
<button class="remove" onclick={() => removeFile(attachment)} aria-label="Fjern {attachment.filename}">
Fjern
<button class="remove" onclick={() => removeFile(attachment)} aria-label={t('group.removeNamed', { name: attachment.filename })}>
{t('group.remove')}
</button>
</li>
{/each}
@@ -206,7 +205,7 @@
<label class="add">
<input type="file" onchange={addFile} disabled={uploading} />
<span>{uploading ? 'Lægger op…' : '+ Læg en fil op'}</span>
<span>{uploading ? t('group.uploading') : t('group.addFile')}</span>
</label>
</section>
{/if}
+12 -11
View File
@@ -12,6 +12,7 @@
*/
import { page } from '$app/stores';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let { children } = $props();
@@ -20,19 +21,19 @@
// than disabled: this is not a feature to upsell, it is one that does not
// apply to you.
let sections = $derived([
{ href: '/indstillinger', label: 'Konto' },
{ href: '/indstillinger/notifikationer', label: 'Notifikationer' },
{ href: '/indstillinger/integrationer', label: 'Integrationer' },
{ href: '/indstillinger/ai', label: 'AI' },
{ href: '/indstillinger/tokens', label: 'API-tokens' },
{ href: '/indstillinger', label: t('settings.tab.account') },
{ href: '/indstillinger/notifikationer', label: t('settings.tab.notifications') },
{ href: '/indstillinger/integrationer', label: t('settings.tab.integrations') },
{ href: '/indstillinger/ai', label: t('settings.tab.ai') },
{ href: '/indstillinger/tokens', label: t('settings.tab.tokens') },
...(app.user?.is_admin
? [
{ href: '/indstillinger/brugere', label: 'Brugere' },
{ href: '/indstillinger/historik', label: 'Historik' },
{ href: '/indstillinger/fejl', label: 'Fejl' }
{ href: '/indstillinger/brugere', label: t('settings.tab.users') },
{ href: '/indstillinger/historik', label: t('settings.tab.history') },
{ href: '/indstillinger/fejl', label: t('settings.tab.errors') }
]
: []),
{ href: '/indstillinger/data', label: 'Data og skabeloner' }
{ href: '/indstillinger/data', label: t('settings.tab.data') }
]);
let current = $derived($page.url.pathname.replace(/\/$/, '') || '/indstillinger');
@@ -40,10 +41,10 @@
<div class="settings">
<header>
<h1>Indstillinger</h1>
<h1>{t('settings.title')}</h1>
</header>
<nav aria-label="Indstillinger">
<nav aria-label={t('settings.title')}>
{#each sections as section (section.href)}
<a href={section.href} class:active={current === section.href}>{section.label}</a>
{/each}
+47 -50
View File
@@ -2,6 +2,7 @@
/** Konto: who you are, your password, and the second factor. */
import { api, humanMessage } from '$lib/api.js';
import { app, theme, THEMES } from '$lib/stores.svelte.js';
import { t, plural, i18n } from '$lib/i18n.svelte.js';
// --- profile ---------------------------------------------------------------
@@ -43,6 +44,10 @@
profileSaved = false;
try {
app.user = await api.updateProfile({ name, timezone, locale });
// Applied straight away rather than on the next load. `i18n.locale` is
// $state, so every component that calls t() redraws — which is the whole
// reason the module is .svelte.js.
i18n.set(app.user.locale);
profileSaved = true;
} catch (e) {
profileErrors = e.fields ?? {};
@@ -196,16 +201,15 @@
<section class="panel">
<header>
<h2>Profil</h2>
<h2>{t('account.profile')}</h2>
<p class="hint">
Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så
&ldquo;i morgen kl. 9&rdquo; betyder noget andet, når du skifter den.
{t('account.profileHint')}
</p>
</header>
<form onsubmit={saveProfile}>
<div class="field">
<label for="name">Navn</label>
<label for="name">{t('account.name')}</label>
<input
id="name"
bind:value={name}
@@ -215,15 +219,15 @@
</div>
<div class="field">
<label for="email">E-mail</label>
<label for="email">{t('account.email')}</label>
<!-- Read-only: the address identifies the account and is what invitations
were sent to. Changing it is a re-verification flow, not a field. -->
<input id="email" value={app.user?.email ?? ''} readonly disabled />
<p class="hint">Kan ikke ændres her.</p>
<p class="hint">{t('account.emailFixed')}</p>
</div>
<div class="field">
<label for="timezone">Tidszone</label>
<label for="timezone">{t('account.timezone')}</label>
<select id="timezone" bind:value={timezone}>
{#each zones as zone (zone)}
<option value={zone}>{zone}</option>
@@ -233,31 +237,29 @@
</div>
<div class="field">
<label for="locale">Sprog i hurtig tilføjelse</label>
<label for="locale">{t('account.parseLanguage')}</label>
<select id="locale" bind:value={locale}>
<option value="da">Dansk</option>
<option value="en">English</option>
<option value="da">{t('account.danish')}</option>
<option value="en">{t('account.english')}</option>
</select>
<p class="hint">
Afgør hvilken grammatik der tolker en linje — &ldquo;hver mandag&rdquo;
eller &ldquo;every monday&rdquo;.
{t('account.parseHint')}
</p>
{#if profileErrors.locale}<p class="error">{profileErrors.locale}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingProfile}>Gem</button>
{#if profileSaved}<span class="saved">Gemt.</span>{/if}
<button class="primary" type="submit" disabled={savingProfile}>{t('account.save')}</button>
{#if profileSaved}<span class="saved">{t('account.saved')}</span>{/if}
</div>
</form>
</section>
<section class="panel">
<header>
<h2>Udseende</h2>
<h2>{t('account.appearance')}</h2>
<p class="hint">
Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved —
en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.
{t('account.appearanceHint')}
</p>
</header>
@@ -278,8 +280,8 @@
<span class="swatch-line short"></span>
<span class="swatch-dot"></span>
</span>
<span class="theme-name">{option.name}</span>
<span class="theme-note">{option.note}</span>
<span class="theme-name">{t(option.name)}</span>
<span class="theme-note">{t(option.note)}</span>
</button>
{/each}
</div>
@@ -287,16 +289,15 @@
<section class="panel">
<header>
<h2>Adgangskode</h2>
<h2>{t('account.password')}</h2>
<p class="hint">
At skifte den logger alle andre sessioner ud — hvilket for det meste er
grunden til at gøre det.
{t('account.passwordHint')}
</p>
</header>
<form onsubmit={changePassword}>
<div class="field">
<label for="current">Nuværende adgangskode</label>
<label for="current">{t('account.currentPassword')}</label>
<input
id="current"
type="password"
@@ -310,7 +311,7 @@
</div>
<div class="field">
<label for="new">Ny adgangskode</label>
<label for="new">{t('account.newPassword')}</label>
<input
id="new"
type="password"
@@ -322,16 +323,16 @@
</div>
<div class="row">
<button class="primary" type="submit">Skift adgangskode</button>
{#if passwordSaved}<span class="saved">Skiftet.</span>{/if}
<button class="primary" type="submit">{t('account.changePassword')}</button>
{#if passwordSaved}<span class="saved">{t('account.passwordChanged')}</span>{/if}
</div>
</form>
</section>
<section class="panel">
<header>
<h2>To-faktor</h2>
<p class="hint">En engangskode fra en authenticator-app oven i adgangskoden.</p>
<h2>{t('account.totp')}</h2>
<p class="hint">{t('account.totpHint')}</p>
</header>
{#if recoveryCodes.length}
@@ -339,8 +340,7 @@
so there is no later screen that could show them again. -->
<div class="field">
<p class="hint">
<strong>Skriv dem ned nu.</strong> De vises kun denne ene gang — serveren
har kun deres hash.
<strong>{t('account.writeThemDown')}</strong> {t('account.recoveryHint')}
</p>
<ul class="codes mono">
{#each recoveryCodes as code (code)}
@@ -348,18 +348,18 @@
{/each}
</ul>
<div class="row">
<button class="secondary" onclick={() => (recoveryCodes = [])}>Jeg har dem</button>
<button class="secondary" onclick={() => (recoveryCodes = [])}>{t('account.gotThem')}</button>
</div>
</div>
{:else if app.user?.totp_enabled}
<p class="hint">
Slået til.{#if remaining !== null}
{remaining} gendannelseskode{remaining === 1 ? '' : 'r'} tilbage.{/if}
{t('account.totpEnabled')}{#if remaining !== null}
{plural(remaining, 'account.recoveryLeftOne', 'account.recoveryLeftMany')}{/if}
</p>
<form onsubmit={disableTOTP}>
<div class="field">
<label for="disable-pw">Adgangskode</label>
<label for="disable-pw">{t('account.password')}</label>
<input
id="disable-pw"
type="password"
@@ -369,26 +369,25 @@
/>
{#if totpErrors.password}<p class="error">{totpErrors.password}</p>{/if}
<p class="hint">
Kræves til begge handlinger nedenfor. At slå to-faktor fra er præcis
det, en fremmed med din session ville gøre først.
{t('account.passwordRequired')}
</p>
</div>
<div class="row">
<button class="secondary" onclick={regenerate}>Nye gendannelseskoder</button>
<button class="danger" type="submit">Slå to-faktor fra</button>
<button class="secondary" onclick={regenerate}>{t('account.newRecoveryCodes')}</button>
<button class="danger" type="submit">{t('account.totpOff')}</button>
</div>
</form>
{:else if totpSecret}
<div class="field">
<p class="hint">Scan i din authenticator-app, eller indtast nøglen manuelt.</p>
<p class="hint">{t('account.scanHint')}</p>
<p class="mono secret">{totpSecret}</p>
<p class="hint mono uri">{totpURI}</p>
</div>
<form onsubmit={confirmTOTP}>
<div class="field">
<label for="totp">Koden fra appen</label>
<label for="totp">{t('account.codeFromApp')}</label>
<input
id="totp"
bind:value={totpCode}
@@ -398,29 +397,27 @@
/>
{#if totpErrors.code}<p class="error">{totpErrors.code}</p>{/if}
<p class="hint">
To-faktor er først slået til, når en kode har bevist, at appen faktisk
har nøglen.
{t('account.totpProofHint')}
</p>
</div>
<div class="row">
<button class="primary" type="submit">Bekræft</button>
<button class="secondary" onclick={() => (totpSecret = '')}>Fortryd</button>
<button class="primary" type="submit">{t('account.confirm')}</button>
<button class="secondary" onclick={() => (totpSecret = '')}>{t('account.cancel')}</button>
</div>
</form>
{:else}
<div class="row">
<button class="primary" onclick={beginTOTP}>Slå to-faktor til</button>
<button class="primary" onclick={beginTOTP}>{t('account.totpOn')}</button>
</div>
{/if}
</section>
<section class="panel">
<header>
<h2>Enheder</h2>
<h2>{t('account.devices')}</h2>
<p class="hint">
Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift
adgangskode.
{t('account.devicesHint')}
</p>
</header>
@@ -430,7 +427,7 @@
<div class="what">
<span class="device">
{session.device}
{#if session.current}<span class="badge">denne enhed</span>{/if}
{#if session.current}<span class="badge">{t('account.thisDevice')}</span>{/if}
</span>
<!-- The address and the time, small: they are what settles "was that
me?", and they are not what you read first. -->
@@ -438,7 +435,7 @@
{ago(session.last_seen_at)}{#if session.ip}{' · ' + session.ip}{/if}
</span>
</div>
<button class="secondary" onclick={() => endSession(session)}>Log ud</button>
<button class="secondary" onclick={() => endSession(session)}>{t('account.signOutDevice')}</button>
</li>
{/each}
</ul>
+15 -18
View File
@@ -2,6 +2,7 @@
/** The AI provider: which one, which model, and the key. */
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let settings = $state(null);
let apiKey = $state('');
@@ -13,7 +14,7 @@
let summarising = $state(false);
const PROVIDERS = [
{ value: '', label: 'Slået fra' },
{ value: '', label: t('ai.off') },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'google', label: 'Google' },
@@ -76,11 +77,9 @@
<section class="panel">
<header>
<h2>AI</h2>
<h2>{t('ai.title')}</h2>
<p class="hint">
Bruges to steder: at dele en opgave op i undertasks, og at skrive et kort
overblik over det, der er udestående. Nøglen ligger på din server og sendes
kun til den udbyder, du vælger her.
{t('ai.hint')}
</p>
</header>
@@ -89,7 +88,7 @@
{:else}
<form onsubmit={save}>
<div class="field">
<label for="provider">Udbyder</label>
<label for="provider">{t('ai.provider')}</label>
<select id="provider" bind:value={settings.provider}>
{#each PROVIDERS as provider (provider.value)}
<option value={provider.value}>{provider.label}</option>
@@ -100,7 +99,7 @@
{#if settings.provider}
<div class="field">
<label for="model">Model</label>
<label for="model">{t('ai.model')}</label>
<input
id="model"
bind:value={settings.model}
@@ -110,7 +109,7 @@
{#if settings.provider === 'compatible'}
<div class="field">
<label for="base">Adresse</label>
<label for="base">{t('ai.baseURL')}</label>
<input
id="base"
class="mono"
@@ -121,29 +120,27 @@
{/if}
<div class="field">
<label for="key">API-nøgle</label>
<label for="key">{t('ai.apiKey')}</label>
<input
id="key"
type="password"
autocomplete="off"
bind:value={apiKey}
placeholder={settings.has_key ? 'Der er gemt en nøgle — lad feltet stå tomt' : ''}
placeholder={settings.has_key ? t('ai.keyStored') : ''}
/>
<p class="hint">
{#if settings.has_key}
Feltet er tomt med vilje. Nøglen sendes aldrig tilbage til browseren
— en indstillingsside, der fylder et kodeordsfelt ud igen, er en side,
der en dag lækker en nøgle ud i et skærmbillede.
{t('ai.keyBlankHint')}
{:else}
Gemmes på serveren og vises aldrig igen.
{t('ai.keyNewHint')}
{/if}
</p>
</div>
{/if}
<div class="row">
<button class="primary" type="submit" disabled={saving}>Gem</button>
{#if saved}<span class="saved">Gemt.</span>{/if}
<button class="primary" type="submit" disabled={saving}>{t('ai.save')}</button>
{#if saved}<span class="saved">{t('ai.saved')}</span>{/if}
</div>
</form>
{/if}
@@ -152,8 +149,8 @@
{#if settings?.provider && settings?.has_key}
<section class="panel">
<header>
<h2>Ugentligt overblik</h2>
<p class="hint">En kort note om, hvad der ser vigtigst ud lige nu.</p>
<h2>{t('ai.weekly')}</h2>
<p class="hint">{t('ai.weeklyHint')}</p>
</header>
<div class="row">
@@ -10,6 +10,8 @@
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { ago, shortDate } from '$lib/when.js';
let users = $state([]);
let invites = $state([]);
@@ -76,22 +78,25 @@
* warning.
*/
async function remove(user) {
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
const parts = [t('users.deleteQuestion', { name: user.name, email: user.email }), ''];
if (user.project_count || user.task_count) {
parts.push(
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
`${count(user.task_count, 'opgave', 'opgaver')} i dem.`,
t('users.deleteTakes', {
projects: count(user.project_count, 'users.projectOne', 'users.projectMany'),
tasks: count(user.task_count, 'users.taskOne', 'users.taskMany')
}),
''
);
}
if (user.authored_elsewhere) {
parts.push(
`${count(user.authored_elsewhere, 'opgave', 'opgaver')} i andres projekter ` +
`bliver stående uden forfatter.`,
t('users.deleteLeaves', {
tasks: count(user.authored_elsewhere, 'users.taskOne', 'users.taskMany')
}),
''
);
}
parts.push('Det kan ikke fortrydes. Der er ingen papirkurv for konti.');
parts.push(t('users.deleteFinal'));
if (!confirm(parts.join('\n'))) return;
const previous = users;
@@ -105,8 +110,7 @@
}
async function revoke(pending) {
if (!confirm(`Træk invitationen til ${pending.email} tilbage? Linket holder op med at virke.`))
return;
if (!confirm(t('users.revokeQuestion', { email: pending.email }))) return;
const previous = invites;
invites = invites.filter((i) => i.id !== pending.id);
@@ -118,53 +122,41 @@
}
}
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const count = (n, one, many) => `${n} ${t(n === 1 ? one : many)}`;
function ago(iso) {
if (!iso) return 'aldrig logget ind';
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
const expires = (iso) =>
new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
const expires = shortDate;
</script>
<section class="panel">
<header>
<h2>Inviter en bruger</h2>
<h2>{t('users.invite')}</h2>
<p class="hint">
Der er ingen åben registrering. Du sender et link, og personen vælger selv navn
og adgangskode — så er der ikke en adgangskode, to mennesker kender.
{t('users.inviteHint')}
</p>
</header>
<form onsubmit={invite}>
<div class="field">
<label for="ny-email">E-mailadresse</label>
<label for="ny-email">{t('users.emailAddress')}</label>
<input
id="ny-email"
type="email"
bind:value={email}
placeholder="navn@example.dk"
placeholder={t('users.emailPlaceholder')}
required
aria-invalid={errors.email ? 'true' : undefined}
/>
{#if errors.email}<p class="error">Skal være en e-mailadresse.</p>{/if}
{#if errors.email}<p class="error">{t('users.mustBeEmail')}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={busy}>Send invitation</button>
<button class="primary" type="submit" disabled={busy}>{t('users.sendInvite')}</button>
</div>
</form>
{#if link}
<div class="field">
<p class="hint">Invitationen er oprettet. Send linket til personen:</p>
<p class="hint">{t('users.inviteMade')}</p>
<code class="link-out">{link}</code>
</div>
{/if}
@@ -173,10 +165,9 @@
{#if invites.length}
<section class="panel">
<header>
<h2>Afventer svar</h2>
<h2>{t('users.pending')}</h2>
<p class="hint">
Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så
det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.
{t('users.pendingHint')}
</p>
</header>
@@ -187,11 +178,11 @@
<span class="primary-line">{pending.email}</span>
<span class="secondary">
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
· udløber {expires(pending.expires_at)}
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
</span>
</div>
<button class="secondary" onclick={() => revoke(pending)}>Træk tilbage</button>
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
</li>
{/each}
</ul>
@@ -200,8 +191,8 @@
<section class="panel">
<header>
<h2>Konti</h2>
<p class="hint">Alle på denne instans.</p>
<h2>{t('users.accounts')}</h2>
<p class="hint">{t('users.accountsHint')}</p>
</header>
<ul class="rows">
@@ -213,11 +204,11 @@
<div class="what">
<span class="primary-line">
{user.name}
{#if user.is_admin}<span class="badge">administrator</span>{/if}
{#if user.self}<span class="badge quiet">dig</span>{/if}
{#if user.is_admin}<span class="badge">{t('users.admin')}</span>{/if}
{#if user.self}<span class="badge quiet">{t('users.you')}</span>{/if}
</span>
<span class="secondary">
{user.email} · {ago(user.last_seen_at)}
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
</span>
</div>
@@ -227,9 +218,9 @@
wastes a click to teach you a rule. -->
{#if !user.self}
<button class="secondary" onclick={() => setAdmin(user, !user.is_admin)}>
{user.is_admin ? 'Fjern administrator' : 'Gør til administrator'}
{user.is_admin ? t('users.removeAdmin') : t('users.makeAdmin')}
</button>
<button class="danger" onclick={() => remove(user)}>Slet</button>
<button class="danger" onclick={() => remove(user)}>{t('users.delete')}</button>
{/if}
</li>
{/each}
+43 -67
View File
@@ -3,6 +3,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { goto } from '$app/navigation';
import { ago } from '$lib/when.js';
import { t } from '$lib/i18n.svelte.js';
// --- backups ---------------------------------------------------------------------
@@ -36,7 +38,7 @@
try {
const made = await api.runBackup();
backups = [made, ...backups];
lastBackup = `Kopi taget — ${size(made.size_bytes)}`;
lastBackup = t('data.backupMade', { size: size(made.size_bytes) });
} catch (e) {
app.toast(humanMessage(e));
} finally {
@@ -45,25 +47,12 @@
}
function size(bytes) {
if (!bytes) return 'tom';
if (!bytes) return t('data.empty');
const mb = bytes / 1024 / 1024;
if (mb >= 1) return `${mb.toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
return then.toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
// --- Todoist import ------------------------------------------------------------
@@ -196,20 +185,19 @@
<section class="panel">
<header>
<h2>Importér fra Todoist</h2>
<h2>{t('data.import')}</h2>
<p class="hint">
Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke
kopieret: Todoist skriver 4 for det, grænsefladen kalder P1.
{t('data.importHint')}
</p>
</header>
<div class="field">
<label for="todoist">CSV-fil</label>
<label for="todoist">{t('data.csvFile')}</label>
<input id="todoist" type="file" accept=".csv,text/csv" onchange={importTodoist} disabled={importing} />
</div>
{#if importing}
<p class="hint">Importerer …</p>
<p class="hint">{t('data.importing')}</p>
{/if}
{#if result}
@@ -230,7 +218,7 @@
{/if}
{#if result.project_id}
<div class="row">
<a class="link" href="/projekt/{result.project_id}">Åbn projektet</a>
<a class="link" href="/projekt/{result.project_id}">{t('data.openProject')}</a>
</div>
{/if}
</div>
@@ -239,18 +227,16 @@
<section class="panel">
<header>
<h2>Papirkurv</h2>
<h2>{t('data.trash')}</h2>
<p class="hint">
Slettede projekter, med deres opgaver. De bliver hentet tilbage præcis som
de var — bortset fra opgaver, du havde slettet hver for sig først; dem var
der en grund til.
{t('data.trashHint')}
</p>
</header>
{#if loadingTrash}
<p class="empty">…</p>
{:else if trashed.length === 0}
<p class="empty">Papirkurven er tom.</p>
<p class="empty">{t('data.trashEmpty')}</p>
{:else}
<ul class="list">
{#each trashed as project (project.id)}
@@ -262,7 +248,7 @@
{daysLeft(project.purge_after)} dage tilbage
</span>
</div>
<button class="use" onclick={() => restore(project)}>Hent tilbage</button>
<button class="use" onclick={() => restore(project)}>{t('data.restore')}</button>
</li>
{/each}
</ul>
@@ -274,22 +260,18 @@
{#if app.user?.is_admin}
<section class="panel">
<header>
<h2>Sikkerhedskopier</h2>
<h2>{t('data.backups')}</h2>
<p class="hint">
Databasen kopieres hver nat og de seneste fjorten gemmes. Kopierne er
talt, ikke dateret: en container, der har stået slukket en måned, må ikke
komme tilbage og slette dem alle for at være for gamle.
{t('data.backupsHint')}
</p>
<p class="hint">
Vedhæftede filer er ikke med. De er indholdsadresserede og bliver kun lagt
til, så fjorten ens kopier af dem ville fylde det drev op, de skulle
beskytte — tag hele <code>/data</code>, hvis du vil have det hele.
{t('data.backupsAttachments')}
</p>
</header>
<div class="row">
<button class="secondary" onclick={backupNow} disabled={backingUp}>
{backingUp ? 'Kopierer…' : 'Tag en kopi nu'}
{backingUp ? t('data.backingUp') : t('data.backupNow')}
</button>
{#if lastBackup}<span class="saved">{lastBackup}</span>{/if}
</div>
@@ -300,56 +282,51 @@
<li>
<div class="what">
<span class="primary-line">
{when(backup.started_at)}
{#if backup.error}<span class="failed">mislykkedes</span>{/if}
{ago(backup.started_at)}
{#if backup.error}<span class="failed">{t('data.backupFailed')}</span>{/if}
</span>
<span class="secondary">
{#if backup.error}
{backup.error}
{:else}
{size(backup.size_bytes)}{#if !backup.present}{' · '}ryddet væk{/if}
{size(backup.size_bytes)}{#if !backup.present}{' · '}{t('data.swept')}{/if}
{/if}
</span>
</div>
{#if backup.present && !backup.error}
<a class="link" href={api.backupURL(backup.id)} download>Hent</a>
<a class="link" href={api.backupURL(backup.id)} download>{t('data.download')}</a>
{/if}
</li>
{/each}
</ul>
{:else if backupsLoaded}
<p class="empty">Der er ikke taget nogen kopi endnu.</p>
<p class="empty">{t('data.noBackups')}</p>
{/if}
<p class="hint">
At lægge en kopi tilbage er ikke en knap her, og det er med vilje: en database
kan ikke skiftes ud under en server, der skriver i den, og en halvt gennemført
udskiftning ødelægger netop det, den skulle redde. Stop containeren, læg filen
i stedet for <code>verdande.db</code> i datamappen, slet <code>-wal</code> og
<code>-shm</code> ved siden af, og start den igen.
{t('data.restoreHint')}
</p>
</section>
{/if}
<section class="panel">
<header>
<h2>Eksport</h2>
<h2>{t('data.export')}</h2>
<p class="hint">
Alt, i den rå form. Det er garantien for, at du kan gå igen — en
eksport, der er blevet pyntet, er en eksport, der har mistet noget.
{t('data.exportHint')}
</p>
</header>
<div class="row">
<!-- Plain links, not fetch: the browser's own download handling knows what to
do with a Content-Disposition, and the session cookie rides along. -->
<a class="link" href={api.exportAccountURL()} download>Hele kontoen som JSON</a>
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
</div>
<div class="field">
<label for="export-project">Ét projekt</label>
<label for="export-project">{t('data.exportOne')}</label>
<select id="export-project" bind:value={exportProject}>
<option value="">Vælg et projekt</option>
<option value="">{t('data.pickProject')}</option>
{#each app.projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
@@ -358,26 +335,25 @@
{#if exportProject}
<div class="row">
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>CSV</a>
<a class="link" href={api.exportProjectICSURL(exportProject)} download>Kalenderfil</a>
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>{t('data.csv')}</a>
<a class="link" href={api.exportProjectICSURL(exportProject)} download>{t('data.ics')}</a>
</div>
{/if}
</section>
<section class="panel">
<header>
<h2>Skabeloner</h2>
<h2>{t('data.templates')}</h2>
<p class="hint">
Et projekt gemt som en form, der kan bruges igen. Datoerne i skabelonen er
relative, så de bliver regnet ud fra den startdato, du vælger.
{t('data.templatesHint')}
</p>
</header>
<form onsubmit={saveTemplate}>
<div class="field">
<label for="save-from">Gem et projekt som skabelon</label>
<label for="save-from">{t('data.saveAsTemplate')}</label>
<select id="save-from" bind:value={saveFrom}>
<option value="">Vælg et projekt</option>
<option value="">{t('data.pickProject')}</option>
{#each projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
@@ -386,7 +362,7 @@
{#if saveFrom}
<div class="field">
<label for="template-name">Navn på skabelonen</label>
<label for="template-name">{t('data.templateName')}</label>
<input
id="template-name"
bind:value={templateName}
@@ -395,7 +371,7 @@
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingTemplate}>Gem som skabelon</button>
<button class="primary" type="submit" disabled={savingTemplate}>{t('data.saveTemplate')}</button>
</div>
{/if}
</form>
@@ -403,7 +379,7 @@
{#if loadingTemplates}
<p class="empty">…</p>
{:else if templates.length === 0}
<p class="empty">Ingen skabeloner endnu.</p>
<p class="empty">{t('data.noTemplates')}</p>
{:else}
<ul class="list">
{#each templates as template (template.id)}
@@ -415,27 +391,27 @@
</span>
</div>
<button class="use" onclick={() => (usingId = usingId === template.id ? '' : template.id)}>
Brug
{t('data.use')}
</button>
<button class="remove" onclick={() => deleteTemplate(template)}>Slet</button>
<button class="remove" onclick={() => deleteTemplate(template)}>{t('data.delete')}</button>
</li>
{#if usingId === template.id}
<li class="use-form">
<form onsubmit={useTemplate}>
<div class="field">
<label for="new-name">Navn på det nye projekt</label>
<label for="new-name">{t('data.newProjectName')}</label>
<input id="new-name" bind:value={newProjectName} placeholder={template.name} />
</div>
<div class="field">
<label for="start">Startdato</label>
<label for="start">{t('data.startDate')}</label>
<input id="start" type="date" bind:value={startDate} />
<p class="hint">Dag nul for skabelonens datoer. Tom betyder i dag.</p>
<p class="hint">{t('data.startDateHint')}</p>
</div>
<div class="row">
<button class="primary" type="submit">Opret projektet</button>
<button class="primary" type="submit">{t('data.createProject')}</button>
</div>
</form>
</li>
+6 -20
View File
@@ -10,6 +10,8 @@
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { ago } from '$lib/when.js';
let errors = $state([]);
let loaded = $state(false);
@@ -24,29 +26,13 @@
.catch((e) => app.toast(humanMessage(e)));
});
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
return then.toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<section class="panel">
<header>
<h2>Serverfejl</h2>
<h2>{t('errors.title')}</h2>
<p class="hint">
Hver 500, API'et har svaret, med hvad handleren var i gang med og fejlen den
ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra
ved hver genstart, så forklaringen er som regel væk, når nogen kigger.
Ryddes efter 30 dage.
{t('errors.hint')}
</p>
</header>
@@ -66,12 +52,12 @@
error.request_id}{/if}
</span>
</div>
<span class="when">{when(error.at)}</span>
<span class="when">{ago(error.at)}</span>
</li>
{/each}
</ul>
{:else if loaded}
<p class="hint">Ingen fejl registreret. Det er den rigtige tilstand.</p>
<p class="hint">{t('errors.none')}</p>
{/if}
</section>
@@ -16,6 +16,8 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { eventName, eventDetail } from '$lib/events.js';
import { t } from '$lib/i18n.svelte.js';
import { ago } from '$lib/when.js';
let entries = $state([]);
let cursor = $state(null);
@@ -94,19 +96,6 @@
}
}
function when(iso) {
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
return then.toLocaleString('da-DK', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
let filtered = $derived(Boolean(filterUser || filterProject || filterEvent));
@@ -119,21 +108,17 @@
<section class="panel">
<header>
<h2>Historik</h2>
<h2>{t('history.title')}</h2>
<p class="hint">
Alt, der er blevet gjort på denne server, på tværs af alle projekter — også
dem, du ikke selv er med i. Projekternes egne historik-paneler viser det
samme, ét projekt ad gangen; det her er spørgsmålet, de ikke kan svare på.
Rækkerne bliver stående, når en konto slettes: det, der blev gjort, holder op
med at have et navn, ikke med at være sket.
{t('history.hint')}
</p>
</header>
<div class="filters">
<label>
<span>Person</span>
<span>{t('history.person')}</span>
<select bind:value={filterUser}>
<option value="">Alle</option>
<option value="">{t('history.all')}</option>
{#each people as person (person.id)}
<option value={person.id}>{person.name}</option>
{/each}
@@ -141,9 +126,9 @@
</label>
<label>
<span>Projekt</span>
<span>{t('history.project')}</span>
<select bind:value={filterProject}>
<option value="">Alle</option>
<option value="">{t('history.all')}</option>
{#each projects as project (project.id)}
<option value={project.id}>{project.name}</option>
{/each}
@@ -151,9 +136,9 @@
</label>
<label>
<span>Hændelse</span>
<span>{t('history.event')}</span>
<select bind:value={filterEvent}>
<option value="">Alle</option>
<option value="">{t('history.all')}</option>
{#each events as event (event.event)}
<option value={event.event}>{eventName(event.event)} ({event.count})</option>
{/each}
@@ -161,7 +146,7 @@
</label>
{#if filtered}
<button class="secondary" onclick={clearFilters}>Ryd</button>
<button class="secondary" onclick={clearFilters}>{t('history.clear')}</button>
{/if}
</div>
@@ -172,7 +157,7 @@
<div class="what">
<span class="primary-line">
<span class="who" class:gone={!entry.user_name}
>{entry.user_name || 'En slettet konto'}</span
>{entry.user_name || t('history.deletedAccount')}</span
>
<span class="event"
>{eventName(entry.event)}{#if eventDetail(entry)}
@@ -181,7 +166,7 @@
</span>
<span class="secondary">{entry.project_name}</span>
</div>
<span class="when">{when(entry.at)}</span>
<span class="when">{ago(entry.at)}</span>
</li>
{/each}
</ul>
@@ -189,15 +174,15 @@
{#if cursor}
<div class="row">
<button class="secondary" onclick={more} disabled={loading}>
{loading ? 'Henter…' : 'Hent flere'}
{loading ? t('history.loading') : t('history.more')}
</button>
</div>
{/if}
{:else if loaded}
<p class="hint">
{filtered
? 'Ingen hændelser matcher de valgte filtre.'
: 'Der er ikke registreret noget endnu.'}
? t('history.noMatch')
: t('history.nothing')}
</p>
{/if}
</section>
@@ -3,6 +3,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { page } from '$app/stores';
import { t } from '$lib/i18n.svelte.js';
// --- Gmail ------------------------------------------------------------------
@@ -58,25 +59,25 @@
* whose response could have carried it.
*/
const CALLBACK = {
connected: { tone: 'ok', text: 'Gmail er forbundet.' },
connected: { tone: 'ok', text: t('int.gmailConnected') },
state: {
tone: 'bad',
text: 'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.'
text: t('int.gmailState')
},
expired: { tone: 'bad', text: 'Forsøget udløb undervejs. Prøv igen.' },
invalid: { tone: 'bad', text: 'Svaret fra Google kunne ikke læses. Prøv igen.' },
failed: { tone: 'bad', text: 'Google afviste ombytningen af koden.' },
expired: { tone: 'bad', text: t('int.gmailExpired') },
invalid: { tone: 'bad', text: t('int.gmailInvalid') },
failed: { tone: 'bad', text: t('int.gmailFailed') },
norefresh: {
tone: 'bad',
text: 'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.'
text: t('int.gmailNoRefresh')
},
access_denied: { tone: 'bad', text: 'Adgangen blev afvist hos Google.' }
access_denied: { tone: 'bad', text: t('int.gmailDenied') }
};
let callback = $derived.by(() => {
const value = $page.url.searchParams.get('gmail');
if (!value) return null;
return CALLBACK[value] ?? { tone: 'bad', text: `Gmail svarede: ${value}` };
return CALLBACK[value] ?? { tone: 'bad', text: t('int.gmailOther', { what: value }) };
});
$effect(() => {
@@ -183,10 +184,9 @@
<section class="panel">
<header>
<h2>Gmail</h2>
<h2>{t('int.gmail')}</h2>
<p class="hint">
Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave.
Envejs: at fjerne stjernen gør ikke noget ved opgaven.
{t('int.gmailHint')}
</p>
</header>
@@ -199,7 +199,7 @@
{:else if !gmail.connected}
<div class="row">
<button class="primary" onclick={connect} disabled={client && !client.client_id}>
Forbind Gmail
{t('int.connectGmail')}
</button>
</div>
@@ -210,24 +210,18 @@
{#if app.user?.is_admin && client}
{#if client.from_env}
<p class="hint">
Klienten er sat i miljøet (<span class="mono">VERDANDE_GMAIL_CLIENT_ID</span>),
så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er
en beslutning, der bliver anvendt igen ved hver genstart, og en formular,
der kunne overskrive den, ville gøre manifestet til en løgn.
{t('int.fromEnv')}
</p>
{:else}
<details class="setup" open={!client.client_id}>
<summary>OAuth-klient hos Google</summary>
<summary>{t('int.oauthClient')}</summary>
<p class="hint">
Registrér en klient i <span class="mono">Google Cloud Console → APIs &amp;
Services → Credentials → OAuth client ID → Web application</span>, slå
Gmail API til, og indsæt de to værdier her. Din egen konto skal stå som
testbruger, så længe appen ikke er verificeret hos Google.
{t('int.registerHint')}
</p>
<div class="field">
<label for="redirect">Godkendt redirect-URI</label>
<label for="redirect">{t('int.redirectURI')}</label>
<!-- Read-only and spelled out: it is derived from VERDANDE_BASE_URL,
it has to match Google's copy exactly, and the error Google gives
when it does not names neither value. Google afviser desuden
@@ -237,57 +231,56 @@
<form onsubmit={saveClient}>
<div class="field">
<label for="client-id">Klient-id</label>
<label for="client-id">{t('int.clientID')}</label>
<input id="client-id" bind:value={clientId} autocomplete="off" />
</div>
<div class="field">
<label for="client-secret">Klienthemmelighed</label>
<label for="client-secret">{t('int.clientSecret')}</label>
<input
id="client-secret"
type="password"
bind:value={clientSecret}
autocomplete="off"
placeholder={client.has_secret ? 'Gemt — udfyld kun for at skifte den' : ''}
placeholder={client.has_secret ? t('int.secretStored') : ''}
/>
</div>
<div class="row">
<button class="primary" type="submit" disabled={savingClient}>Gem</button>
{#if clientSaved}<span class="saved">Gemt.</span>{/if}
<button class="primary" type="submit" disabled={savingClient}>{t('int.save')}</button>
{#if clientSaved}<span class="saved">{t('int.saved')}</span>{/if}
</div>
</form>
</details>
{/if}
{:else if client && !client.client_id}
<p class="hint">
Der er ikke registreret en OAuth-klient hos Google endnu. Det skal en
administrator gøre, før knappen her kan bruges.
{t('int.noClient')}
</p>
{/if}
{:else}
<p class="hint">Forbundet som <strong>{gmail.email || 'ukendt konto'}</strong>.</p>
<p class="hint">{t('int.connectedAs')} <strong>{gmail.email || t('int.unknownAccount')}</strong>.</p>
<form onsubmit={saveGmail}>
<div class="field">
<label for="trigger">Hvad laver en opgave</label>
<label for="trigger">{t('int.whatMakesATask')}</label>
<select id="trigger" bind:value={gmail.trigger}>
<option value="starred">Stjernemarkerede</option>
<option value="label">En bestemt etiket</option>
<option value="both">Begge dele</option>
<option value="starred">{t('int.starred')}</option>
<option value="label">{t('int.aLabel')}</option>
<option value="both">{t('int.both')}</option>
</select>
</div>
{#if gmail.trigger === 'label' || gmail.trigger === 'both'}
<div class="field">
<label for="gmail-label">Etikettens navn i Gmail</label>
<input id="gmail-label" bind:value={gmail.label} placeholder="fx Opgaver" />
<label for="gmail-label">{t('int.gmailLabelName')}</label>
<input id="gmail-label" bind:value={gmail.label} placeholder={t('int.gmailLabelExample')} />
</div>
{/if}
<div class="row">
<button class="primary" type="submit" disabled={savingGmail}>Gem</button>
{#if gmailSaved}<span class="saved">Gemt.</span>{/if}
<button class="secondary" onclick={syncNow} disabled={syncing}>Hent nu</button>
<button class="danger" onclick={disconnectGmail}>Afbryd</button>
<button class="primary" type="submit" disabled={savingGmail}>{t('int.save')}</button>
{#if gmailSaved}<span class="saved">{t('int.saved')}</span>{/if}
<button class="secondary" onclick={syncNow} disabled={syncing}>{t('int.fetchNow')}</button>
<button class="danger" onclick={disconnectGmail}>{t('int.disconnect')}</button>
</div>
</form>
{/if}
@@ -295,74 +288,64 @@
<section class="panel">
<header>
<h2>Kalenderfeed</h2>
<h2>{t('int.feed')}</h2>
<p class="hint">
Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan
ikke logge ind, så nøglen i adressen <em>er</em> hele adgangskoden — del den
ikke.
{t('int.feedHint')}
</p>
</header>
<div class="field">
<label for="feed">Adresse</label>
<label for="feed">{t('int.address')}</label>
<input id="feed" class="mono" value={feedURL} readonly />
</div>
<div class="row">
<button class="secondary" onclick={() => copy(feedURL)}>Kopiér</button>
<button class="danger" onclick={rotateFeed}>Nyt link</button>
<button class="secondary" onclick={() => copy(feedURL)}>{t('int.copy')}</button>
<button class="danger" onclick={rotateFeed}>{t('int.newLink')}</button>
</div>
<p class="hint">
Et nyt link bryder alle eksisterende abonnementer med det samme. Det er
pointen: det er det, man gør, når det gamle er havnet et forkert sted.
{t('int.newLinkHint')}
</p>
</section>
<section class="panel">
<header>
<h2>Mail til opgave</h2>
<h2>{t('int.mailToTask')}</h2>
<p class="hint">
Send eller videresend en mail hertil, og emnelinjen bliver tolket som en
hurtig tilføjelse — &ldquo;Fakturer Anders p1 #Firma&rdquo; virker også her.
{t('int.mailHint')}
</p>
</header>
{#if mailConfigured === false}
<p class="callback" data-tone="bad">
Der er ingen mailserver sat op på denne server, så adressen herunder kan
ikke modtage noget endnu. Sæt <span class="mono">VERDANDE_SMTP_HOST</span> og
de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til
<span class="mono">todo+*</span> videre til
<span class="mono">/inbound/mail</span>.
{t('int.noMailServer')}
</p>
{/if}
<div class="field">
<label for="mail">Din adresse</label>
<label for="mail">{t('int.yourAddress')}</label>
<input id="mail" class="mono" value={mailAddress} readonly />
</div>
<div class="row">
<button class="secondary" onclick={() => copy(mailAddress)}>Kopiér</button>
<button class="danger" onclick={rotateMail}>Ny adresse</button>
<button class="secondary" onclick={() => copy(mailAddress)}>{t('int.copy')}</button>
<button class="danger" onclick={rotateMail}>{t('int.newAddress')}</button>
</div>
</section>
<section class="panel">
<header>
<h2>CalDAV</h2>
<h2>{t('int.caldav')}</h2>
<p class="hint">
Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird
kan også skrive tilbage.
{t('int.caldavHint')}
</p>
</header>
<div class="field">
<label for="caldav">Server</label>
<label for="caldav">{t('int.server')}</label>
<input id="caldav" class="mono" value={`${location.origin}/caldav/`} readonly />
<p class="hint">
Brugernavn er din e-mail. Adgangskoden er en
<a href="/indstillinger/tokens">API-token</a> — ikke din rigtige adgangskode.
{t('int.caldavAuth')}
</p>
</div>
</section>
@@ -3,6 +3,7 @@
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import * as push from '$lib/push.js';
import { t, plural } from '$lib/i18n.svelte.js';
// --- web push ----------------------------------------------------------------
@@ -95,10 +96,9 @@
<section class="panel">
<header>
<h2>Notifikationer på enheden</h2>
<h2>{t('push.title')}</h2>
<p class="hint">
Påmindelser og beskeder fra andre, også når fanen er lukket. Beskeden
krypteres til din browser undervejs — push-tjenesten kan ikke læse den.
{t('push.hint')}
</p>
</header>
@@ -106,42 +106,39 @@
<p class="empty">…</p>
{:else if pushState === 'unsupported'}
<p class="hint">
Denne browser understøtter det ikke. Safari kræver, at appen er lagt på
hjemmeskærmen; over almindelig HTTP virker det kun på localhost.
{t('push.unsupported')}
</p>
{:else if pushState === 'blocked'}
<p class="hint">
Browseren har blokeret notifikationer for dette site. Det kan kun laves om i
browserens egne indstillinger — en side kan ikke spørge igen, når svaret har
været nej.
{t('push.blocked')}
</p>
{:else if pushState === 'on'}
<p class="hint">Slået til på denne enhed.</p>
<p class="hint">{t('push.on')}</p>
<div class="row">
<button class="secondary" onclick={disablePush} disabled={busy}>Slå fra</button>
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
</div>
{:else}
<div class="row">
<button class="primary" onclick={enablePush} disabled={busy}>
Slå til på denne enhed
{t('push.turnOn')}
</button>
</div>
<p class="hint">Gælder kun den browser, du sidder i nu.</p>
<p class="hint">{t('push.perBrowser')}</p>
{/if}
</section>
<section class="panel">
<header>
<h2>Seneste</h2>
<h2>{t('push.recent')}</h2>
{#if unread > 0}
<p class="hint">{unread} ulæst{unread === 1 ? '' : 'e'}.</p>
<p class="hint">{plural(unread, 'push.unreadOne', 'push.unreadMany')}</p>
{/if}
</header>
{#if loading}
<p class="empty">…</p>
{:else if notifications.length === 0}
<p class="empty">Ingenting endnu.</p>
<p class="empty">{t('push.none')}</p>
{:else}
<ul class="list">
{#each notifications as note (note.id)}
@@ -154,7 +151,7 @@
</span>
</div>
{#if note.task_id && note.project_id}
<a class="go" href="/projekt/{note.project_id}">Åbn</a>
<a class="go" href="/projekt/{note.project_id}">{t('push.open')}</a>
{/if}
</li>
{/each}
@@ -162,7 +159,7 @@
{#if unread > 0}
<div class="row">
<button class="secondary" onclick={markAllRead}>Markér alle som læst</button>
<button class="secondary" onclick={markAllRead}>{t('push.markAllRead')}</button>
</div>
{/if}
{/if}
@@ -171,11 +168,11 @@
{#if version}
<section class="panel">
<header>
<h2>Version</h2>
<h2>{t('push.version')}</h2>
</header>
<p class="hint">
Kører <span class="mono">{version.current}</span>.
{t('push.running')} <span class="mono">{version.current}</span>.
{#if version.disabled}
Der bliver ikke set efter opdateringer.
{:else if version.update_available}
@@ -192,7 +189,7 @@
{#if version.url}
<div class="row">
<a class="release" href={version.url} target="_blank" rel="noreferrer noopener">
Se udgivelsen
{t('push.seeRelease')}
</a>
</div>
{/if}
@@ -8,6 +8,7 @@
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let tokens = $state([]);
let loading = $state(true);
@@ -94,24 +95,23 @@
<section class="panel">
<header>
<h2>API-tokens</h2>
<h2>{t('tokens.title')}</h2>
<p class="hint">
Det, et script, en CalDAV-klient eller en MCP-forbindelse logger ind med. En
token bærer dine rettigheder præcist — den kan intet, du ikke selv kan.
{t('tokens.hint')}
</p>
</header>
{#if fresh}
<div class="fresh">
<p class="hint">
<strong>Kopiér den nu.</strong> Den vises kun denne ene gang — serveren har
<strong>{t('tokens.copyNow')}</strong> Den vises kun denne ene gang — serveren har
kun dens hash.
</p>
<p class="mono value">{fresh.token}</p>
<div class="row">
<button class="primary" onclick={copy}>Kopiér</button>
{#if copied}<span class="saved">Kopieret.</span>{/if}
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
<button class="primary" onclick={copy}>{t('tokens.copy')}</button>
{#if copied}<span class="saved">{t('tokens.copied')}</span>{/if}
<button class="secondary" onclick={() => (fresh = null)}>{t('tokens.done')}</button>
</div>
<!-- Shown here and nowhere else, because it contains the token, and the
@@ -120,13 +120,13 @@
the form it has to be in. -->
<div class="also">
<p class="hint">
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
i Claude? Så er det denne adresse, dialogen skal have:
{t('tokens.mcpBefore')} <a href="/indstillinger/integrationer">{t('tokens.mcpLink')}</a>
{t('tokens.mcpAfter')}
</p>
<p class="mono value">{connectorURL}</p>
<div class="row">
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
<button class="secondary" onclick={copyConnector}>{t('tokens.copyAddress')}</button>
{#if connectorCopied}<span class="saved">{t('tokens.copied')}</span>{/if}
</div>
</div>
</div>
@@ -134,40 +134,39 @@
<form onsubmit={create}>
<div class="field">
<label for="token-name">Navn</label>
<label for="token-name">{t('tokens.name')}</label>
<input
id="token-name"
bind:value={name}
placeholder="fx min bærbare, eller backup-scriptet"
placeholder={t('tokens.namePlaceholder')}
aria-invalid={errors.name ? 'true' : undefined}
/>
{#if errors.name}<p class="error">{errors.name}</p>{/if}
</div>
<div class="field">
<label for="token-expiry">Udløber</label>
<label for="token-expiry">{t('tokens.expires')}</label>
<select id="token-expiry" bind:value={expiresInDays}>
<option value={0}>Aldrig</option>
<option value={30}>Om 30 dage</option>
<option value={90}>Om 90 dage</option>
<option value={365}>Om et år</option>
<option value={0}>{t('tokens.never')}</option>
<option value={30}>{t('tokens.in30')}</option>
<option value={90}>{t('tokens.in90')}</option>
<option value={365}>{t('tokens.inYear')}</option>
</select>
<p class="hint">
En kalenderklient, der har abonneret i årevis, har brug for
&ldquo;aldrig&rdquo;. Et script, du kører én gang, har ikke.
{t('tokens.expiryHint')}
</p>
{#if errors.expires_in_days}<p class="error">{errors.expires_in_days}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={creating}>Lav en token</button>
<button class="primary" type="submit" disabled={creating}>{t('tokens.create')}</button>
</div>
</form>
{#if loading}
<p class="empty">…</p>
{:else if tokens.length === 0}
<p class="empty">Ingen tokens endnu.</p>
<p class="empty">{t('tokens.none')}</p>
{:else}
<ul class="list">
{#each tokens as token (token.id)}
@@ -177,18 +176,19 @@
<span class="meta mono">
{token.prefix}…
{#if token.last_used_at}
· sidst brugt {date(token.last_used_at)}
· {t('tokens.lastUsed', { when: date(token.last_used_at) })}
{:else}
· aldrig brugt
· {t('tokens.neverUsed')}
{/if}
{#if token.expires_at}
· {expired(token.expires_at) ? 'udløbet' : 'udløber'}
{date(token.expires_at)}
· {expired(token.expires_at)
? t('tokens.expired', { when: date(token.expires_at) })
: t('tokens.expiresOn', { when: date(token.expires_at) })}
{/if}
</span>
</div>
<button class="revoke" onclick={() => revoke(token)} aria-label="Tilbagekald {token.name}">
Tilbagekald
<button class="revoke" onclick={() => revoke(token)} aria-label={t('tokens.revokeNamed', { name: token.name })}>
{t('tokens.revoke')}
</button>
</li>
{/each}
+34 -33
View File
@@ -12,6 +12,7 @@
import BoardView from '$lib/components/BoardView.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
let project = $state(null);
/**
@@ -326,7 +327,7 @@
class="title"
use:focusOnMount
value={project.name}
aria-label="Projektets navn"
aria-label={t('project.name')}
onblur={(e) => rename(e.currentTarget)}
onkeydown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
@@ -339,7 +340,7 @@
{:else}
<h1 class:renameable={isOwner}>
{#if isOwner}
<button onclick={() => (editing = true)} title="Klik for at omdøbe">
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
{project.name}
</button>
{:else}
@@ -353,13 +354,13 @@
style="background: {colorVar(project.color)}"
onclick={() => (choosingColor = !choosingColor)}
aria-expanded={choosingColor}
aria-label="Farve på projektet"
title="Farve på projektet"
aria-label={t('project.color')}
title={t('project.color')}
></button>
{/if}
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]}
<div class="views" role="group" aria-label={t('view.mode')}>
{#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]}
<button
class:active={mode === value}
onclick={() => setView(value)}
@@ -370,18 +371,18 @@
{#if !project.is_inbox}
<button class="share" onclick={() => (showShare = !showShare)}>
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
{project.shared ? t('project.sharedWith', { n: project.member_count }) : t('project.share')}
</button>
{/if}
{#if !project.is_inbox}
<button class="share" onclick={() => (showLog = !showLog)} aria-expanded={showLog}>
Historik
{t('project.history')}
</button>
{/if}
{#if isOwner && !project.is_inbox}
<button class="remove" onclick={remove} aria-label="Slet projektet">
<button class="remove" onclick={remove} aria-label={t('project.delete')}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2M6 7l1 13h10l1-13" />
</svg>
@@ -408,20 +409,20 @@
{/each}
</ul>
{#if !activity.length}
<p class="empty">Ingenting er sket her endnu.</p>
<p class="empty">{t('project.noHistory')}</p>
{/if}
</div>
{/if}
{#if choosingColor}
<div class="panel swatches" role="group" aria-label="Vælg farve">
<div class="panel swatches" role="group" aria-label={t('project.pickColor')}>
{#each COLORS as color (color.id)}
<button
class="swatch"
class:chosen={(project.color ?? 'graphite') === color.id}
style="background: {colorVar(color.id)}"
title={color.name}
aria-label={color.name}
title={t(color.name)}
aria-label={t(color.name)}
aria-pressed={(project.color ?? 'graphite') === color.id}
onclick={() => setColor(color.id)}
></button>
@@ -473,20 +474,20 @@
<input
bind:value={inviteEmail}
type="email"
placeholder="e-mailadresse"
aria-label="Inviter via e-mail"
placeholder={t('project.emailAddress')}
aria-label={t('project.inviteByEmail')}
required
/>
<select bind:value={inviteRole} aria-label="Rolle">
<option value="editor">Kan redigere</option>
<option value="viewer">Kan kun se</option>
<select bind:value={inviteRole} aria-label={t('project.role')}>
<option value="editor">{t('project.canEdit')}</option>
<option value="viewer">{t('project.canView')}</option>
</select>
<button type="submit">Inviter</button>
<button type="submit">{t('project.invite')}</button>
</form>
{#if inviteLink}
<p class="link-out">
Ingen mailserver sat op — send selv linket:
{t('project.sendLinkYourself')}
<code>{inviteLink}</code>
</p>
{/if}
@@ -523,7 +524,7 @@
the label is a heading for the only list there is, and the empty
state below already says the project is empty. -->
{#if sections.length && !unsectioned.length}
<p class="empty">Uden sektion</p>
<p class="empty">{t('project.noSection')}</p>
{/if}
</section>
@@ -542,7 +543,7 @@
<input
bind:value={sectionName}
use:focusOnMount
aria-label="Sektionens navn"
aria-label={t('view.sectionName')}
onblur={() => (renamingSection = null)}
onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)}
/>
@@ -555,17 +556,17 @@
onclick={() => {
renamingSection = section.id;
sectionName = section.name;
}}>Omdøb</button
}}>{t('project.rename')}</button
>
<button class="section-action remove" onclick={() => removeSection(section)}>
Slet
{t('project.deleteSection')}
</button>
{/if}
{/if}
</div>
<TaskList {tasks} projectId={project.id} sectionId={section.id} {canEdit} />
{#if !tasks.length}
<p class="empty">Tom</p>
<p class="empty">{t('view.emptySection')}</p>
{/if}
</section>
{/each}
@@ -577,8 +578,8 @@
<input
bind:value={sectionName}
use:focusOnMount
placeholder="Sektionens navn"
aria-label="Ny sektion"
placeholder={t('view.sectionName')}
aria-label={t('view.newSection')}
onblur={() => !sectionName.trim() && (addingSection = false)}
onkeydown={(e) => e.key === 'Escape' && (addingSection = false)}
/>
@@ -591,7 +592,7 @@
sectionName = '';
}}
>
+ Tilføj sektion
{t('view.addSection')}
</button>
{/if}
</section>
@@ -600,25 +601,25 @@
{#if !open.length && !sections.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingenting her endnu.
{t('project.nothingHere')}
</p>
{/if}
{/if}
{#if project.role === 'viewer'}
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
<p class="readonly">{t('project.readOnly')}</p>
{/if}
{:else if status === 'loading'}
<!-- Deliberately blank, as on first load elsewhere: a spinner for a request
that usually resolves in 30ms is a flash of anxiety, not feedback. -->
<div class="booting"></div>
{:else if status === 'denied'}
<p class="clear">Projektet findes ikke, eller du har ikke adgang til det.</p>
<p class="clear">{t('project.notFound')}</p>
{:else}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Kunne ikke hente projektet. Serveren svarede ikke.
<button class="retry" onclick={() => load(id)}>Prøv igen</button>
{t('project.loadFailed')}
<button class="retry" onclick={() => load(id)}>{t('project.retry')}</button>
</p>
{/if}
</div>
+3 -2
View File
@@ -10,6 +10,7 @@
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import { t } from '$lib/i18n.svelte.js';
let people = $state([]);
let loaded = $state(false);
@@ -31,7 +32,7 @@
</script>
<div class="view">
<header><h1>Venter på andre</h1></header>
<header><h1>{t('view.delegated')}</h1></header>
{#each people as person (person.user_id)}
{@const tasks = live(person)}
@@ -54,7 +55,7 @@
{#if loaded && !people.some((p) => live(p).length)}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Du venter ikke på nogen.
{t('view.delegatedEmpty')}
</p>
{/if}
</div>
+4 -3
View File
@@ -20,6 +20,7 @@
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
import CalendarView from '$lib/components/CalendarView.svelte';
import { t } from '$lib/i18n.svelte.js';
let days = $state([]);
@@ -80,9 +81,9 @@
<div class="view" class:wide={upcomingView.mode !== 'list'}>
<header>
<h1>Kommende</h1>
<div class="views" role="group" aria-label="Visning">
{#each [['list', 'Liste'], ['week', 'Uge'], ['calendar', 'Måned']] as [value, label]}
<h1>{t('view.upcoming')}</h1>
<div class="views" role="group" aria-label={t('view.mode')}>
{#each [['list', t('view.list')], ['week', t('view.week')], ['calendar', t('view.month')]] as [value, label]}
<button
class:active={upcomingView.mode === value}
onclick={() => upcomingView.set(value)}