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:
co-authored by
Claude Opus 5
parent
e3c7fc792a
commit
4d8b70918b
@@ -1081,6 +1081,70 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async (
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The account's language changes the interface, not only the parser.
|
||||
*
|
||||
* `locale` has been on the account since the beginning and 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".
|
||||
*
|
||||
* Asserted on both directions and on a date, because the date formatter is the
|
||||
* half that is easy to leave behind: `toLocaleDateString('da-DK', …)` was written
|
||||
* into a dozen components, and an English interface still saying "24. aug." is the
|
||||
* shape of a half-finished translation.
|
||||
*
|
||||
* It puts Danish back at the end. Everything after this reads Danish labels, and a
|
||||
* test that leaves the account in English fails the ones that follow it rather
|
||||
* than itself — which is the worst way for a suite to report anything.
|
||||
*/
|
||||
test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Sprog').selectOption('en');
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
|
||||
// The sidebar redraws without a reload: i18n.locale is $state, which is the
|
||||
// whole reason that module is .svelte.js.
|
||||
const english = page.getByRole('navigation', { name: 'Main menu' });
|
||||
await expect(english.getByRole('link', { name: 'Today' })).toBeVisible();
|
||||
await expect(english.getByRole('link', { name: 'Upcoming' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
|
||||
|
||||
// And it survives a reload, which is the difference between the page keeping up
|
||||
// and the account actually holding it.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Today' })
|
||||
).toBeVisible();
|
||||
|
||||
// A date, in the half that is easy to leave in Danish. The month grid's heading
|
||||
// is `toLocaleDateString`, which was `'da-DK'` in a dozen components before
|
||||
// `tag()` existed — an English interface still saying "december" is exactly the
|
||||
// shape of a half-finished translation.
|
||||
await page.goto('/upcoming');
|
||||
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
||||
await expect(page.locator('.calendar h2')).not.toContainText(
|
||||
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
||||
);
|
||||
|
||||
// The Inbox keeps its name: it is a project row created at signup, not a
|
||||
// string in the interface, and the person can rename it like any other.
|
||||
|
||||
// Danish again, for everything that runs after this.
|
||||
await page.goto('/indstillinger');
|
||||
await page.getByLabel('Language').selectOption('da');
|
||||
await page.getByRole('button', { name: 'Save' }).first().click();
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'I dag' })
|
||||
).toBeVisible();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
|
||||
+35
-27
@@ -22,46 +22,54 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Danish messages for the codes the API can return. The server sends English
|
||||
* prose for logs; what a person reads is decided here, where the locale is known. */
|
||||
import { t } from './i18n.svelte.js';
|
||||
|
||||
/**
|
||||
* The key for each code the API can return.
|
||||
*
|
||||
* Keys rather than sentences, because the server's own prose is English and
|
||||
* written for a log — what a person reads is decided here, where the locale is
|
||||
* known. `errorcodes_test.go` walks this table against the codes Go can emit, so
|
||||
* a code the server gained and this did not fails the build rather than falling
|
||||
* through to that log prose.
|
||||
*/
|
||||
const MESSAGES = {
|
||||
unauthorized: 'Forkert e-mail eller adgangskode.',
|
||||
totp_required: 'Indtast koden fra din authenticator.',
|
||||
forbidden: 'Det har du ikke adgang til.',
|
||||
not_found: 'Findes ikke.',
|
||||
conflict: 'Det er der allerede.',
|
||||
rate_limited: 'For mange forsøg. Vent et øjeblik.',
|
||||
validation_failed: 'Tjek felterne herunder.',
|
||||
payload_too_large: 'Det er for stort.',
|
||||
internal_error: 'Noget gik galt. Prøv igen.',
|
||||
bad_request: 'Anmodningen kunne ikke læses.',
|
||||
unauthorized: 'error.unauthorized',
|
||||
totp_required: 'error.totpRequired',
|
||||
forbidden: 'error.forbidden',
|
||||
not_found: 'error.notFound',
|
||||
conflict: 'error.conflict',
|
||||
rate_limited: 'error.rateLimited',
|
||||
validation_failed: 'error.validation',
|
||||
payload_too_large: 'error.tooLarge',
|
||||
internal_error: 'error.internal',
|
||||
bad_request: 'error.badRequest',
|
||||
// Client-only: a network failure is not an HTTP status, so the server has no
|
||||
// code for it.
|
||||
offline: 'Ingen forbindelse til serveren.',
|
||||
offline: 'error.offline',
|
||||
|
||||
// Situations that used to arrive as a plain `conflict` and were therefore
|
||||
// shown as "Det er der allerede." — which was not merely vague but wrong. An
|
||||
// shown as "that already exists" — which was not merely vague but wrong. An
|
||||
// unconfigured Gmail is not a Gmail that is already connected.
|
||||
gmail_not_configured:
|
||||
'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.',
|
||||
ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
totp_not_enabled: 'To-faktor er ikke slået til.',
|
||||
inbox_protected: 'Indbakken kan ikke slettes.',
|
||||
last_admin:
|
||||
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.'
|
||||
gmail_not_configured: 'error.gmailNotConfigured',
|
||||
ai_not_configured: 'error.aiNotConfigured',
|
||||
totp_not_enabled: 'error.totpNotEnabled',
|
||||
inbox_protected: 'error.inboxProtected',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show a person for a failed request.
|
||||
*
|
||||
* A code with no message here falls back to the server's prose, which is English
|
||||
* and written for a log — not good, but better than swallowing the only
|
||||
* explanation there is. A code that turns up in that fallback is a code this
|
||||
* table is missing.
|
||||
* A code with no key here falls back to the server's prose, which is English and
|
||||
* written for a log — not good, but better than swallowing the only explanation
|
||||
* there is. A code that turns up in that fallback is a code this table is missing.
|
||||
*/
|
||||
export function humanMessage(err) {
|
||||
if (!(err instanceof ApiError)) return MESSAGES.offline;
|
||||
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
|
||||
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
return err.message ?? t(MESSAGES.internal_error);
|
||||
}
|
||||
|
||||
async function request(method, path, body, options = {}) {
|
||||
|
||||
+13
-10
@@ -21,17 +21,20 @@
|
||||
* carries a `--color-<id>` token for each, and a Go test reads this file to check
|
||||
* the server's list of accepted names has not drifted from it.
|
||||
*/
|
||||
// `name` is a key rather than a word: the picker calls `t()` on it. The ids stay
|
||||
// exactly as they are — they are what the database stores, and `internal/store`
|
||||
// has a test that reads them out of this file.
|
||||
export const COLORS = [
|
||||
{ id: 'graphite', name: 'Grafit' },
|
||||
{ id: 'tomato', name: 'Tomat' },
|
||||
{ id: 'amber', name: 'Rav' },
|
||||
{ id: 'lime', name: 'Lime' },
|
||||
{ id: 'green', name: 'Grøn' },
|
||||
{ id: 'teal', name: 'Petrol' },
|
||||
{ id: 'blue', name: 'Blå' },
|
||||
{ id: 'indigo', name: 'Indigo' },
|
||||
{ id: 'violet', name: 'Violet' },
|
||||
{ id: 'magenta', name: 'Magenta' }
|
||||
{ id: 'graphite', name: 'color.graphite' },
|
||||
{ id: 'tomato', name: 'color.tomato' },
|
||||
{ id: 'amber', name: 'color.amber' },
|
||||
{ id: 'lime', name: 'color.lime' },
|
||||
{ id: 'green', name: 'color.green' },
|
||||
{ id: 'teal', name: 'color.teal' },
|
||||
{ id: 'blue', name: 'color.blue' },
|
||||
{ id: 'indigo', name: 'color.indigo' },
|
||||
{ id: 'violet', name: 'color.violet' },
|
||||
{ id: 'magenta', name: 'color.magenta' }
|
||||
];
|
||||
|
||||
/** The CSS variable a colour id paints with, falling back to the default. */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { TASK, startDrag, carries, accept } from '$lib/dnd.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import TaskRow from './TaskRow.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let { project, sections, canEdit, onsectionadded } = $props();
|
||||
|
||||
@@ -143,7 +144,7 @@
|
||||
{/each}
|
||||
|
||||
{#if !tasks.length}
|
||||
<p class="empty">Tom</p>
|
||||
<p class="empty">{t('view.emptySection')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -156,8 +157,8 @@
|
||||
<input
|
||||
bind:value={name}
|
||||
use:focusOnMount
|
||||
placeholder="Sektionens navn"
|
||||
aria-label="Ny sektion"
|
||||
placeholder={t('view.sectionName')}
|
||||
aria-label={t('view.newSection')}
|
||||
onblur={() => !name.trim() && (adding = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
|
||||
/>
|
||||
@@ -168,7 +169,7 @@
|
||||
onclick={() => {
|
||||
adding = true;
|
||||
name = '';
|
||||
}}>+ Tilføj sektion</button
|
||||
}}>{t('view.addSection')}</button
|
||||
>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
let {
|
||||
/**
|
||||
@@ -53,7 +54,21 @@
|
||||
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
|
||||
const WEEKDAYS = ['man', 'tir', 'ons', 'tor', 'fre', 'lør', 'søn'];
|
||||
/**
|
||||
* The weekday names, from Intl rather than written out.
|
||||
*
|
||||
* Not to save seven strings — so the abbreviations are the ones the language
|
||||
* actually uses. Every language shortens its weekdays differently, and guessing
|
||||
* at that from a translation table is how a calendar ends up saying something
|
||||
* no reader of that language would write.
|
||||
*
|
||||
* Monday first, because the grid is. The 1st of January 2024 was a Monday; any
|
||||
* Monday would do, and a constant one keeps this out of the render path.
|
||||
*/
|
||||
const WEEKDAYS = $derived.by(() => {
|
||||
const format = new Intl.DateTimeFormat(tag(), { weekday: 'short' });
|
||||
return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 1 + i))));
|
||||
});
|
||||
|
||||
function startOfMonth(d) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
@@ -123,7 +138,7 @@
|
||||
);
|
||||
|
||||
const monthName = $derived(
|
||||
cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' })
|
||||
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
||||
);
|
||||
|
||||
// "24. aug. – 30. aug. 2026", collapsed to one month name when the week does not
|
||||
@@ -134,9 +149,9 @@
|
||||
const day = { day: 'numeric', month: 'short' };
|
||||
const left =
|
||||
from.getMonth() === to.getMonth()
|
||||
? from.toLocaleDateString('da-DK', { day: 'numeric' })
|
||||
: from.toLocaleDateString('da-DK', day);
|
||||
return `${left}–${to.toLocaleDateString('da-DK', day)} ${to.getFullYear()}`;
|
||||
? from.toLocaleDateString(tag(), { day: 'numeric' })
|
||||
: from.toLocaleDateString(tag(), day);
|
||||
return `${left}–${to.toLocaleDateString(tag(), day)} ${to.getFullYear()}`;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -196,17 +211,17 @@
|
||||
|
||||
<div class="calendar" class:week={span === 'week'}>
|
||||
<header>
|
||||
<button onclick={() => step(-1)} aria-label={span === 'week' ? 'Forrige uge' : 'Forrige måned'}
|
||||
<button onclick={() => step(-1)} aria-label={span === 'week' ? t('view.prevWeek') : t('view.prevMonth')}
|
||||
>‹</button
|
||||
>
|
||||
<h2 class:week={span === 'week'}>
|
||||
{heading}
|
||||
{#if weekNumber}<span class="weekno">uge {weekNumber}</span>{/if}
|
||||
{#if weekNumber}<span class="weekno">{t('view.weekNumber', { n: weekNumber })}</span>{/if}
|
||||
</h2>
|
||||
<button onclick={() => step(1)} aria-label={span === 'week' ? 'Næste uge' : 'Næste måned'}
|
||||
<button onclick={() => step(1)} aria-label={span === 'week' ? t('view.nextWeek') : t('view.nextMonth')}
|
||||
>›</button
|
||||
>
|
||||
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>I dag</button>
|
||||
<button class="today" onclick={() => (cursor = startOfSpan(new Date()))}>{t('task.today')}</button>
|
||||
</header>
|
||||
|
||||
<div class="weekdays" aria-hidden="true">
|
||||
@@ -257,7 +272,7 @@
|
||||
{/each}
|
||||
|
||||
{#if tasks.length > chipLimit}
|
||||
<span class="more">+{tasks.length - chipLimit} mere</span>
|
||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -328,7 +328,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="sidebar" aria-label="Hovedmenu">
|
||||
<nav class="sidebar" aria-label={t('nav.main')}>
|
||||
<div class="brand">
|
||||
<!-- Verdande's mark: the rune Wunjo, which is the letter the Norn's name
|
||||
starts with in the elder futhark. One glyph, no wordmark beside it —
|
||||
@@ -573,8 +573,8 @@
|
||||
class="swatch"
|
||||
class:chosen={(group.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={(group.color ?? 'graphite') === color.id}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => app.setGroupColor(group.id, color.id)}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
@@ -144,31 +145,31 @@
|
||||
{#if mode === 'loading'}
|
||||
<p class="lede"> </p>
|
||||
{:else if mode === 'setup'}
|
||||
<p class="lede">Opret den første konto. Den bliver administrator.</p>
|
||||
<p class="lede">{t('auth.firstAccount')}</p>
|
||||
|
||||
<label>
|
||||
Navn
|
||||
{t('auth.name')}
|
||||
<input bind:value={name} autocomplete="name" required />
|
||||
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
|
||||
{#if fields.name}<span class="field-error">{t('auth.nameRequired')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
E-mail
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
{#if fields.email}<span class="field-error">Skal være en e-mailadresse</span>{/if}
|
||||
{#if fields.email}<span class="field-error">{t('auth.emailInvalid')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
{t('auth.password')}
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
|
||||
<span class="hint">{t('auth.passwordHint')}</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Opret konto</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.createAccount')}</button>
|
||||
{:else if mode === 'totp'}
|
||||
<p class="lede">Indtast koden fra din authenticator.</p>
|
||||
<p class="lede">{t('auth.totpPrompt')}</p>
|
||||
|
||||
<label>
|
||||
Kode
|
||||
{t('auth.code')}
|
||||
<input
|
||||
bind:value={code}
|
||||
use:focusOnMount
|
||||
@@ -178,34 +179,34 @@
|
||||
class="code"
|
||||
required
|
||||
/>
|
||||
<span class="hint">Har du mistet din telefon, virker en af dine gendannelseskoder her.</span>
|
||||
<span class="hint">{t('auth.totpRecovery')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Fortsæt</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.continue')}</button>
|
||||
{:else if mode === 'forgot'}
|
||||
<p class="lede">Vi sender et link til at vælge en ny adgangskode.</p>
|
||||
<p class="lede">{t('auth.resetPrompt')}</p>
|
||||
|
||||
<label>
|
||||
E-mail
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Send link</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.sendLink')}</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>{t('auth.back')}</button>
|
||||
{:else if mode === 'invite' || mode === 'reset'}
|
||||
{#if !token}
|
||||
<!-- A link that lost its token on the way. Saying so beats a form that
|
||||
cannot succeed and a message that arrives only after it is filled in. -->
|
||||
<p class="lede">
|
||||
Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.
|
||||
{t('auth.linkBroken')}
|
||||
</p>
|
||||
<a class="link" href="/">Til log ind</a>
|
||||
<a class="link" href="/">{t('auth.toSignIn')}</a>
|
||||
{:else if app.user}
|
||||
<!-- Already somebody else. The invite is tied to an address, so using it
|
||||
from this session would create a second account for a person who is
|
||||
right here in the first one. -->
|
||||
<p class="lede">
|
||||
Du er logget ind som {app.user.name}. Log ud først, og åbn så linket igen.
|
||||
{t('auth.alreadySignedIn', { name: app.user.name })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -213,61 +214,61 @@
|
||||
onclick={async () => {
|
||||
await api.logout();
|
||||
location.reload();
|
||||
}}>Log ud</button
|
||||
}}>{t('auth.signOut')}</button
|
||||
>
|
||||
{:else if mode === 'invite'}
|
||||
<p class="lede">Du er inviteret. Vælg et navn og en adgangskode.</p>
|
||||
<p class="lede">{t('auth.invited')}</p>
|
||||
|
||||
<label>
|
||||
Navn
|
||||
{t('auth.name')}
|
||||
<input bind:value={name} autocomplete="name" required />
|
||||
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
|
||||
{#if fields.name}<span class="field-error">{t('auth.nameRequired')}</span>{/if}
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
{t('auth.password')}
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. En sætning er både nemmere og stærkere.</span>
|
||||
<span class="hint">{t('auth.passwordHint')}</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Opret konto</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.createAccount')}</button>
|
||||
{:else}
|
||||
<p class="lede">Vælg en ny adgangskode.</p>
|
||||
<p class="lede">{t('auth.pickNew')}</p>
|
||||
|
||||
<label>
|
||||
Ny adgangskode
|
||||
{t('auth.newPassword')}
|
||||
<input bind:value={password} type="password" autocomplete="new-password" required />
|
||||
<span class="hint">Mindst 10 tegn. Alle andre enheder bliver logget ud.</span>
|
||||
<span class="hint">{t('auth.newPasswordHint')}</span>
|
||||
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Gem adgangskoden</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.savePassword')}</button>
|
||||
{/if}
|
||||
{:else if mode === 'done'}
|
||||
<p class="lede">Adgangskoden er skiftet. Log ind med den nye.</p>
|
||||
<a class="link" href="/">Til log ind</a>
|
||||
<p class="lede">{t('auth.passwordChanged')}</p>
|
||||
<a class="link" href="/">{t('auth.toSignIn')}</a>
|
||||
{:else if mode === 'sent'}
|
||||
<!-- Deliberately not "we sent an email": that would confirm the address
|
||||
has an account here to anybody who tried it. -->
|
||||
<p class="lede">
|
||||
Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.
|
||||
{t('auth.resetSent')}
|
||||
</p>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'login')}>{t('auth.back')}</button>
|
||||
{:else}
|
||||
<p class="lede">Log ind for at fortsætte.</p>
|
||||
<p class="lede">{t('auth.signInPrompt')}</p>
|
||||
|
||||
<label>
|
||||
E-mail
|
||||
{t('auth.email')}
|
||||
<input bind:value={email} type="email" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Adgangskode
|
||||
{t('auth.password')}
|
||||
<input bind:value={password} type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>Log ind</button>
|
||||
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
|
||||
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
|
||||
Glemt adgangskode?
|
||||
{t('auth.forgot')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -35,8 +36,8 @@
|
||||
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
|
||||
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
|
||||
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
|
||||
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
|
||||
];
|
||||
|
||||
let subtasks = $state([]);
|
||||
@@ -176,7 +177,7 @@
|
||||
if (patch.priority) priority = patch.priority;
|
||||
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||
if (patch.labels) labels = patch.labels.join(', ');
|
||||
app.toast(`Læst som ${took.join(', ')}.`);
|
||||
app.toast(t('detail.readAs', { what: took.join(', ') }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,9 +364,9 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
||||
<div class="scrim" onclick={onclose} role="presentation"></div>
|
||||
|
||||
<aside class="drawer" aria-label="Opgave">
|
||||
<aside class="drawer" aria-label={t('detail.task')}>
|
||||
<header>
|
||||
<button class="close" onclick={onclose} aria-label="Luk">
|
||||
<button class="close" onclick={onclose} aria-label={t('detail.close')}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
@@ -379,38 +380,38 @@
|
||||
use:focusOnMount
|
||||
bind:value={content}
|
||||
onblur={saveContent}
|
||||
aria-label="Opgavens tekst"
|
||||
aria-label={t('detail.text')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description">Beskrivelse</label>
|
||||
<label for="description">{t('detail.description')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="4"
|
||||
bind:value={description}
|
||||
onblur={saveDescription}
|
||||
placeholder="Detaljer, links, hvad der skal huskes"
|
||||
placeholder={t('detail.descriptionPlaceholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="priority">Prioritet</label>
|
||||
<label for="priority">{t('detail.priority')}</label>
|
||||
<select
|
||||
id="priority"
|
||||
bind:value={priority}
|
||||
onchange={() => save({ priority: Number(priority) })}
|
||||
>
|
||||
<option value={1}>P1 — haster</option>
|
||||
<option value={1}>{t('detail.p1')}</option>
|
||||
<option value={2}>P2</option>
|
||||
<option value={3}>P3</option>
|
||||
<option value={4}>Ingen</option>
|
||||
<option value={4}>{t('detail.none')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="due">Forfalder</label>
|
||||
<label for="due">{t('detail.due')}</label>
|
||||
<input
|
||||
id="due"
|
||||
type="date"
|
||||
@@ -421,18 +422,18 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="labels">Etiketter</label>
|
||||
<label for="labels">{t('detail.labels')}</label>
|
||||
<input
|
||||
id="labels"
|
||||
bind:value={labels}
|
||||
onblur={saveLabels}
|
||||
placeholder="adskilt af komma"
|
||||
placeholder={t('detail.labelsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="project">Projekt</label>
|
||||
<label for="project">{t('detail.project')}</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
@@ -441,7 +442,7 @@
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="repeat">Gentages</label>
|
||||
<label for="repeat">{t('detail.repeats')}</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
@@ -458,9 +459,9 @@
|
||||
|
||||
{#if members.length > 1}
|
||||
<div class="field">
|
||||
<label for="assignee">Ansvarlig</label>
|
||||
<label for="assignee">{t('detail.assignee')}</label>
|
||||
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
||||
<option value="">Ingen</option>
|
||||
<option value="">{t('detail.none')}</option>
|
||||
{#each members as member (member.user_id)}
|
||||
<option value={member.user_id}>{member.name}</option>
|
||||
{/each}
|
||||
@@ -469,7 +470,7 @@
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3>Undertasks</h3>
|
||||
<h3>{t('detail.subtasks')}</h3>
|
||||
|
||||
{#each subtasks as subtask (subtask.id)}
|
||||
<div class="subtask" class:done={subtask.completed}>
|
||||
@@ -477,26 +478,26 @@
|
||||
class="check"
|
||||
class:checked={subtask.completed}
|
||||
onclick={() => toggleSubtask(subtask)}
|
||||
aria-label={subtask.completed ? 'Genåbn' : 'Markér som færdig'}
|
||||
aria-label={subtask.completed ? t('detail.reopen') : t('task.complete')}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 12.5l4 4 8-8.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="subtask-text">{subtask.content}</span>
|
||||
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label="Slet">
|
||||
<button class="remove" onclick={() => removeSubtask(subtask)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form onsubmit={addSubtask}>
|
||||
<input bind:value={newSubtask} placeholder="Tilføj en undertask" aria-label="Ny undertask" />
|
||||
<input bind:value={newSubtask} placeholder={t('detail.addSubtask')} aria-label={t('detail.newSubtask')} />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Filer</h3>
|
||||
<h3>{t('detail.files')}</h3>
|
||||
|
||||
{#each attachments as attachment (attachment.id)}
|
||||
<div class="file">
|
||||
@@ -504,7 +505,7 @@
|
||||
in place would run its own script on this origin. -->
|
||||
<a href={attachment.url} download={attachment.filename}>{attachment.filename}</a>
|
||||
<span class="size">{kb(attachment.size)}</span>
|
||||
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label="Slet">
|
||||
<button class="remove" onclick={() => removeAttachment(attachment)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -512,18 +513,18 @@
|
||||
|
||||
<label class="upload">
|
||||
<input type="file" onchange={upload} disabled={uploading} />
|
||||
<span>{uploading ? 'Lægger op …' : 'Vedhæft en fil'}</span>
|
||||
<span>{uploading ? t('detail.uploading') : t('detail.attachFile')}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Påmindelser</h3>
|
||||
<h3>{t('detail.reminders')}</h3>
|
||||
|
||||
{#each reminders as reminder (reminder.id)}
|
||||
<div class="reminder">
|
||||
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||
{#if reminder.sent}<span class="sent">sendt</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label="Slet">
|
||||
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -533,14 +534,14 @@
|
||||
<input
|
||||
type="datetime-local"
|
||||
bind:value={newReminder}
|
||||
aria-label="Ny påmindelse"
|
||||
aria-label={t('detail.newReminder')}
|
||||
/>
|
||||
<button class="add" type="submit">Tilføj</button>
|
||||
<button class="add" type="submit">{t('detail.add')}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Kommentarer</h3>
|
||||
<h3>{t('detail.comments')}</h3>
|
||||
|
||||
{#each comments as comment (comment.id)}
|
||||
<article class="comment">
|
||||
@@ -560,7 +561,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#if comment.user_id === app.user?.id}
|
||||
<button class="remove" onclick={() => removeComment(comment)} aria-label="Slet">
|
||||
<button class="remove" onclick={() => removeComment(comment)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
@@ -571,10 +572,10 @@
|
||||
<textarea
|
||||
rows="2"
|
||||
bind:value={newComment}
|
||||
placeholder="Skriv en kommentar"
|
||||
aria-label="Ny kommentar"
|
||||
placeholder={t('detail.writeComment')}
|
||||
aria-label={t('detail.newComment')}
|
||||
></textarea>
|
||||
<button class="add" type="submit" disabled={posting}>Skriv</button>
|
||||
<button class="add" type="submit" disabled={posting}>{t('detail.post')}</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+512
-1
@@ -72,5 +72,516 @@ export const da = {
|
||||
'task.noPriority': 'Ingen prioritet',
|
||||
|
||||
// --- a project group -----------------------------------------------------------
|
||||
'group.colorOf': 'Farve på {name}'
|
||||
'group.colorOf': 'Farve på {name}',
|
||||
|
||||
|
||||
// --- settings: the frame -------------------------------------------------------
|
||||
'settings.title': 'Indstillinger',
|
||||
'settings.tab.account': 'Konto',
|
||||
'settings.tab.notifications': 'Notifikationer',
|
||||
'settings.tab.integrations': 'Integrationer',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.tokens': 'API-tokens',
|
||||
'settings.tab.users': 'Brugere',
|
||||
'settings.tab.history': 'Historik',
|
||||
'settings.tab.errors': 'Fejl',
|
||||
'settings.tab.data': 'Data og skabeloner',
|
||||
|
||||
// --- settings: the account -----------------------------------------------------
|
||||
'account.profile': 'Profil',
|
||||
'account.profileHint':
|
||||
'Tidszonen er ikke pynt: alle datoer i appen bliver afgjort i den, så “i morgen kl. 9” betyder noget andet, når du skifter den.',
|
||||
'account.name': 'Navn',
|
||||
'account.email': 'E-mail',
|
||||
'account.emailFixed': 'Kan ikke ændres her.',
|
||||
'account.timezone': 'Tidszone',
|
||||
'account.parseLanguage': 'Sprog',
|
||||
'account.danish': 'Dansk',
|
||||
'account.english': 'English',
|
||||
'account.parseHint':
|
||||
'Både fladens sprog og den grammatik, hurtig tilføjelse tolker en linje med — “hver mandag” eller “every monday”.',
|
||||
'account.save': 'Gem',
|
||||
'account.saved': 'Gemt.',
|
||||
'account.appearance': 'Udseende',
|
||||
'account.appearanceHint':
|
||||
'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.',
|
||||
'account.password': 'Adgangskode',
|
||||
'account.passwordHint':
|
||||
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
|
||||
'account.currentPassword': 'Nuværende adgangskode',
|
||||
'account.newPassword': 'Ny adgangskode',
|
||||
'account.changePassword': 'Skift adgangskode',
|
||||
'account.passwordChanged': 'Skiftet.',
|
||||
'account.totp': 'To-faktor',
|
||||
'account.totpHint': 'En engangskode fra en authenticator-app oven i adgangskoden.',
|
||||
'account.writeThemDown': 'Skriv dem ned nu.',
|
||||
'account.recoveryHint': 'De vises kun denne ene gang — serveren har kun deres hash.',
|
||||
'account.gotThem': 'Jeg har dem',
|
||||
'account.passwordRequired':
|
||||
'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.',
|
||||
'account.newRecoveryCodes': 'Nye gendannelseskoder',
|
||||
'account.totpOff': 'Slå to-faktor fra',
|
||||
'account.scanHint': 'Scan i din authenticator-app, eller indtast nøglen manuelt.',
|
||||
'account.codeFromApp': 'Koden fra appen',
|
||||
'account.totpProofHint':
|
||||
'To-faktor er først slået til, når en kode har bevist, at appen faktisk har nøglen.',
|
||||
'account.confirm': 'Bekræft',
|
||||
'account.cancel': 'Fortryd',
|
||||
'account.totpOn': 'Slå to-faktor til',
|
||||
'account.devices': 'Enheder',
|
||||
'account.devicesHint':
|
||||
'Hvor du er logget ind. Genkender du ikke en af dem, så log den ud og skift adgangskode.',
|
||||
'account.thisDevice': 'denne enhed',
|
||||
'account.signOutDevice': 'Log ud',
|
||||
|
||||
'account.totpEnabled': 'Slået til.',
|
||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
||||
'account.recoveryLeftMany': '{n} gendannelseskoder tilbage.',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifikationer på enheden',
|
||||
'push.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.',
|
||||
'push.unsupported':
|
||||
'Denne browser understøtter det ikke. Safari kræver, at appen er lagt på hjemmeskærmen; over almindelig HTTP virker det kun på localhost.',
|
||||
'push.blocked':
|
||||
'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.',
|
||||
'push.on': 'Slået til på denne enhed.',
|
||||
'push.turnOff': 'Slå fra',
|
||||
'push.turnOn': 'Slå til på denne enhed',
|
||||
'push.perBrowser': 'Gælder kun den browser, du sidder i nu.',
|
||||
'push.recent': 'Seneste',
|
||||
'push.none': 'Ingenting endnu.',
|
||||
'push.open': 'Åbn',
|
||||
'push.markAllRead': 'Markér alle som læst',
|
||||
'push.version': 'Version',
|
||||
'push.running': 'Kører',
|
||||
'push.seeRelease': 'Se udgivelsen',
|
||||
|
||||
// --- settings: API tokens ------------------------------------------------------
|
||||
'tokens.title': 'API-tokens',
|
||||
'tokens.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.',
|
||||
'tokens.copyNow': 'Kopiér den nu.',
|
||||
'tokens.onceOnly': 'Den vises kun denne ene gang — serveren har kun dens hash.',
|
||||
'tokens.copy': 'Kopiér',
|
||||
'tokens.copied': 'Kopieret.',
|
||||
'tokens.done': 'Færdig',
|
||||
'tokens.mcpBefore': 'Skal du bruge den som',
|
||||
'tokens.mcpLink': 'MCP-forbindelse',
|
||||
'tokens.mcpAfter': 'i Claude? Så er det denne adresse, dialogen skal have:',
|
||||
'tokens.copyAddress': 'Kopiér adressen',
|
||||
'tokens.name': 'Navn',
|
||||
'tokens.namePlaceholder': 'fx min bærbare, eller backup-scriptet',
|
||||
'tokens.expires': 'Udløber',
|
||||
'tokens.never': 'Aldrig',
|
||||
'tokens.in30': 'Om 30 dage',
|
||||
'tokens.in90': 'Om 90 dage',
|
||||
'tokens.inYear': 'Om et år',
|
||||
'tokens.expiryHint':
|
||||
'En kalenderklient, der har abonneret i årevis, har brug for “aldrig”. Et script, du kører én gang, har ikke.',
|
||||
'tokens.create': 'Lav en token',
|
||||
'tokens.none': 'Ingen tokens endnu.',
|
||||
'tokens.revoke': 'Tilbagekald',
|
||||
|
||||
// --- settings: AI --------------------------------------------------------------
|
||||
'ai.title': 'AI',
|
||||
'ai.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.',
|
||||
'ai.provider': 'Udbyder',
|
||||
'ai.off': 'Slået fra',
|
||||
'ai.model': 'Model',
|
||||
'ai.baseURL': 'Adresse',
|
||||
'ai.apiKey': 'API-nøgle',
|
||||
'ai.save': 'Gem',
|
||||
'ai.saved': 'Gemt.',
|
||||
'ai.weekly': 'Ugentligt overblik',
|
||||
'ai.weeklyHint': 'En kort note om, hvad der ser vigtigst ud lige nu.',
|
||||
|
||||
'push.unreadOne': '{n} ulæst.',
|
||||
'push.unreadMany': '{n} ulæste.',
|
||||
|
||||
'ai.keyStored': 'Der er gemt en nøgle — lad feltet stå tomt',
|
||||
'ai.keyBlankHint':
|
||||
'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.',
|
||||
'ai.keyNewHint': 'Gemmes på serveren og vises aldrig igen.',
|
||||
'tokens.lastUsed': 'sidst brugt {when}',
|
||||
'tokens.neverUsed': 'aldrig brugt',
|
||||
'tokens.expired': 'udløbet {when}',
|
||||
'tokens.expiresOn': 'udløber {when}',
|
||||
'tokens.revokeNamed': 'Tilbagekald {name}',
|
||||
|
||||
// --- settings: people ----------------------------------------------------------
|
||||
'users.invite': 'Inviter en bruger',
|
||||
'users.inviteHint':
|
||||
'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.',
|
||||
'users.emailAddress': 'E-mailadresse',
|
||||
'users.emailPlaceholder': 'navn@example.dk',
|
||||
'users.mustBeEmail': 'Skal være en e-mailadresse.',
|
||||
'users.sendInvite': 'Send invitation',
|
||||
'users.inviteMade': 'Invitationen er oprettet. Send linket til personen:',
|
||||
'users.pending': 'Afventer svar',
|
||||
'users.pendingHint':
|
||||
'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.',
|
||||
'users.revoke': 'Træk tilbage',
|
||||
'users.accounts': 'Konti',
|
||||
'users.accountsHint': 'Alle på denne instans.',
|
||||
'users.admin': 'administrator',
|
||||
'users.you': 'dig',
|
||||
'users.makeAdmin': 'Gør til administrator',
|
||||
'users.removeAdmin': 'Fjern administrator',
|
||||
'users.delete': 'Slet',
|
||||
'users.deleteQuestion': 'Slet {name} <{email}> for altid?',
|
||||
'users.deleteTakes': 'Det sletter {projects} og {tasks} i dem.',
|
||||
'users.deleteLeaves': '{tasks} i andres projekter bliver stående uden forfatter.',
|
||||
'users.deleteFinal': 'Det kan ikke fortrydes. Der er ingen papirkurv for konti.',
|
||||
'users.revokeQuestion':
|
||||
'Træk invitationen til {email} tilbage? Linket holder op med at virke.',
|
||||
'users.projectOne': 'projekt',
|
||||
'users.projectMany': 'projekter',
|
||||
'users.taskOne': 'opgave',
|
||||
'users.taskMany': 'opgaver',
|
||||
'users.never': 'aldrig set',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'Historik',
|
||||
'history.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.',
|
||||
'history.person': 'Person',
|
||||
'history.all': 'Alle',
|
||||
'history.project': 'Projekt',
|
||||
'history.event': 'Hændelse',
|
||||
'history.clear': 'Ryd',
|
||||
'history.more': 'Hent flere',
|
||||
'history.loading': 'Henter…',
|
||||
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
|
||||
'history.nothing': 'Der er ikke registreret noget endnu.',
|
||||
'history.deletedAccount': 'En slettet konto',
|
||||
'errors.title': 'Serverfejl',
|
||||
'errors.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.',
|
||||
'errors.none': 'Ingen fejl registreret. Det er den rigtige tilstand.',
|
||||
|
||||
// --- the views -------------------------------------------------------------------
|
||||
'view.delegated': 'Venter på andre',
|
||||
'view.delegatedEmpty': 'Du venter ikke på nogen.',
|
||||
'view.labelEmpty': 'Ingen åbne opgaver med denne etiket.',
|
||||
'view.filterEmpty': 'Ingenting matcher lige nu.',
|
||||
'view.filterFailed': 'Filteret kunne ikke køres.',
|
||||
'view.upcoming': 'Kommende',
|
||||
'view.mode': 'Visning',
|
||||
'view.list': 'Liste',
|
||||
'view.week': 'Uge',
|
||||
'view.month': 'Måned',
|
||||
'view.board': 'Board',
|
||||
'view.calendar': 'Kalender',
|
||||
'view.emptySection': 'Tom',
|
||||
'view.addSection': '+ Tilføj sektion',
|
||||
'view.sectionName': 'Sektionens navn',
|
||||
'view.newSection': 'Ny sektion',
|
||||
'view.prevMonth': 'Forrige måned',
|
||||
'view.nextMonth': 'Næste måned',
|
||||
'view.prevWeek': 'Forrige uge',
|
||||
'view.nextWeek': 'Næste uge',
|
||||
'view.weekNumber': 'uge {n}',
|
||||
'view.more': '+{n} mere',
|
||||
|
||||
// --- how long ago ----------------------------------------------------------------
|
||||
'when.justNow': 'lige nu',
|
||||
'when.minutes': 'for {n} min. siden',
|
||||
'when.hours': 'for {n} timer siden',
|
||||
'when.days': 'for {n} dage siden',
|
||||
'when.never': 'aldrig',
|
||||
'when.neverSignedIn': 'aldrig logget ind',
|
||||
|
||||
// --- settings: data ---------------------------------------------------------------
|
||||
'data.import': 'Importér fra Todoist',
|
||||
'data.csvFile': 'CSV-fil',
|
||||
'data.importing': 'Importerer …',
|
||||
'data.openProject': 'Åbn projektet',
|
||||
'data.trash': 'Papirkurv',
|
||||
'data.trashEmpty': 'Papirkurven er tom.',
|
||||
'data.restore': 'Hent tilbage',
|
||||
'data.backups': 'Sikkerhedskopier',
|
||||
'data.backupNow': 'Tag en kopi nu',
|
||||
'data.backingUp': 'Kopierer…',
|
||||
'data.backupMade': 'Kopi taget — {size}',
|
||||
'data.backupFailed': 'mislykkedes',
|
||||
'data.download': 'Hent',
|
||||
'data.noBackups': 'Der er ikke taget nogen kopi endnu.',
|
||||
'data.swept': 'ryddet væk',
|
||||
'data.empty': 'tom',
|
||||
'data.export': 'Eksport',
|
||||
'data.exportAccount': 'Hele kontoen som JSON',
|
||||
'data.exportOne': 'Ét projekt',
|
||||
'data.pickProject': 'Vælg et projekt',
|
||||
'data.csv': 'CSV',
|
||||
'data.ics': 'Kalenderfil',
|
||||
'data.templates': 'Skabeloner',
|
||||
'data.saveAsTemplate': 'Gem et projekt som skabelon',
|
||||
'data.templateName': 'Navn på skabelonen',
|
||||
'data.saveTemplate': 'Gem som skabelon',
|
||||
'data.noTemplates': 'Ingen skabeloner endnu.',
|
||||
'data.use': 'Brug',
|
||||
'data.delete': 'Slet',
|
||||
'data.newProjectName': 'Navn på det nye projekt',
|
||||
'data.startDate': 'Startdato',
|
||||
'data.startDateHint': 'Dag nul for skabelonens datoer. Tom betyder i dag.',
|
||||
'data.createProject': 'Opret projektet',
|
||||
|
||||
// --- a group's own page ------------------------------------------------------------
|
||||
'group.notFound': 'Gruppen findes ikke, eller den er ikke din.',
|
||||
'group.failed': 'Gruppen kunne ikke hentes.',
|
||||
'group.retry': 'Prøv igen',
|
||||
'group.about': 'Om gruppen',
|
||||
'group.aboutPlaceholder': 'Hvad er det her for et stykke arbejde?',
|
||||
'group.writeAbout': 'Skriv hvad det her er for et stykke arbejde.',
|
||||
'group.saving': 'Gemmer…',
|
||||
'group.projects': 'Projekter',
|
||||
'group.projectOne': '1 projekt',
|
||||
'group.projectMany': '{n} projekter',
|
||||
'group.noProjects':
|
||||
'Ingen projekter i gruppen endnu. Træk et herop i sidebjælken, eller vælg gruppen, når du opretter et.',
|
||||
'group.documents': 'Dokumenter',
|
||||
'group.documentsHint':
|
||||
'Filer, der hører til hele gruppen frem for til én opgave i den — kontrakten, tegningen, den aftale alt det andet hviler på.',
|
||||
'group.remove': 'Fjern',
|
||||
'group.removeNamed': 'Fjern {name}',
|
||||
'group.removeQuestion': 'Fjern "{name}"?',
|
||||
'group.addFile': '+ Læg en fil op',
|
||||
'group.uploading': 'Lægger op…',
|
||||
|
||||
// --- keyboard shortcuts --------------------------------------------------------------
|
||||
'keys.title': 'Genveje',
|
||||
|
||||
'data.importHint':
|
||||
'Vælg CSV-eksporten af ét projekt. Prioriteterne bliver konverteret, ikke kopieret: Todoist skriver 4 for det, grænsefladen kalder P1.',
|
||||
'data.trashHint':
|
||||
'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.',
|
||||
'data.backupsHint':
|
||||
'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.',
|
||||
'data.backupsAttachments':
|
||||
'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 /data, hvis du vil have det hele.',
|
||||
'data.restoreHint':
|
||||
'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 verdande.db i datamappen, slet -wal og -shm ved siden af, og start den igen.',
|
||||
'data.exportHint':
|
||||
'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.',
|
||||
'data.templatesHint':
|
||||
'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.',
|
||||
|
||||
'keys.navigation': 'Navigation',
|
||||
'keys.jumpToQuickAdd': 'Hop til feltet “tilføj opgave”',
|
||||
'keys.thisPage': 'Denne side',
|
||||
'keys.inQuickAdd': 'I hurtig tilføjelse',
|
||||
'keys.intoProject': 'Læg opgaven i et projekt',
|
||||
'keys.setLabel': 'Sæt en etiket',
|
||||
'keys.priority': 'Prioritet',
|
||||
'keys.date': 'Dato — dansk og engelsk',
|
||||
'keys.time': 'Klokkeslæt',
|
||||
'keys.clear': 'Ryd feltet',
|
||||
|
||||
// --- signing in ------------------------------------------------------------------
|
||||
'auth.firstAccount': 'Opret den første konto. Den bliver administrator.',
|
||||
'auth.name': 'Navn',
|
||||
'auth.nameRequired': 'Skal udfyldes',
|
||||
'auth.email': 'E-mail',
|
||||
'auth.emailInvalid': 'Skal være en e-mailadresse',
|
||||
'auth.password': 'Adgangskode',
|
||||
'auth.passwordHint': 'Mindst 10 tegn. En sætning er både nemmere og stærkere.',
|
||||
'auth.createAccount': 'Opret konto',
|
||||
'auth.totpPrompt': 'Indtast koden fra din authenticator.',
|
||||
'auth.code': 'Kode',
|
||||
'auth.totpRecovery': 'Har du mistet din telefon, virker en af dine gendannelseskoder her.',
|
||||
'auth.continue': 'Fortsæt',
|
||||
'auth.resetPrompt': 'Vi sender et link til at vælge en ny adgangskode.',
|
||||
'auth.sendLink': 'Send link',
|
||||
'auth.back': 'Tilbage',
|
||||
'auth.linkBroken': 'Linket er ikke komplet. Åbn det fra e-mailen igen, eller bed om et nyt.',
|
||||
'auth.toSignIn': 'Til log ind',
|
||||
'auth.signOut': 'Log ud',
|
||||
'auth.invited': 'Du er inviteret. Vælg et navn og en adgangskode.',
|
||||
'auth.pickNew': 'Vælg en ny adgangskode.',
|
||||
'auth.newPassword': 'Ny adgangskode',
|
||||
'auth.newPasswordHint': 'Mindst 10 tegn. Alle andre enheder bliver logget ud.',
|
||||
'auth.savePassword': 'Gem adgangskoden',
|
||||
'auth.passwordChanged': 'Adgangskoden er skiftet. Log ind med den nye.',
|
||||
'auth.resetSent': 'Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret.',
|
||||
'auth.signInPrompt': 'Log ind for at fortsætte.',
|
||||
'auth.signIn': 'Log ind',
|
||||
'auth.forgot': 'Glemt adgangskode?',
|
||||
|
||||
// --- a task, opened ---------------------------------------------------------------
|
||||
'detail.task': 'Opgave',
|
||||
'detail.close': 'Luk',
|
||||
'detail.text': 'Opgavens tekst',
|
||||
'detail.description': 'Beskrivelse',
|
||||
'detail.descriptionPlaceholder': 'Detaljer, links, hvad der skal huskes',
|
||||
'detail.priority': 'Prioritet',
|
||||
'detail.p1': 'P1 — haster',
|
||||
'detail.none': 'Ingen',
|
||||
'detail.due': 'Forfalder',
|
||||
'detail.labels': 'Etiketter',
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
'detail.repeats': 'Gentages',
|
||||
'detail.monthly': 'Hver måned',
|
||||
'detail.yearly': 'Hvert år',
|
||||
'detail.assignee': 'Ansvarlig',
|
||||
'detail.subtasks': 'Undertasks',
|
||||
'detail.addSubtask': 'Tilføj en undertask',
|
||||
'detail.newSubtask': 'Ny undertask',
|
||||
'detail.files': 'Filer',
|
||||
'detail.delete': 'Slet',
|
||||
'detail.reminders': 'Påmindelser',
|
||||
'detail.newReminder': 'Ny påmindelse',
|
||||
'detail.sent': 'sendt',
|
||||
'detail.add': 'Tilføj',
|
||||
'detail.comments': 'Kommentarer',
|
||||
'detail.writeComment': 'Skriv en kommentar',
|
||||
'detail.newComment': 'Ny kommentar',
|
||||
'detail.post': 'Skriv',
|
||||
'detail.readAs': 'Læst som {what}',
|
||||
|
||||
'auth.alreadySignedIn': 'Du er logget ind som {name}. Log ud først, og åbn så linket igen.',
|
||||
|
||||
'detail.reopen': 'Genåbn',
|
||||
'detail.uploading': 'Lægger op …',
|
||||
'detail.attachFile': 'Vedhæft en fil',
|
||||
|
||||
// --- settings: integrations --------------------------------------------------------
|
||||
'int.gmail': 'Gmail',
|
||||
'int.gmailHint':
|
||||
'Stjernemarkér en mail, eller giv den en etiket, og den bliver en opgave. Envejs: at fjerne stjernen gør ikke noget ved opgaven.',
|
||||
'int.connectGmail': 'Forbind Gmail',
|
||||
'int.fromEnv':
|
||||
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), 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.',
|
||||
'int.oauthClient': 'OAuth-klient hos Google',
|
||||
'int.registerHint':
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, 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.',
|
||||
'int.redirectURI': 'Godkendt redirect-URI',
|
||||
'int.clientID': 'Klient-id',
|
||||
'int.clientSecret': 'Klienthemmelighed',
|
||||
'int.secretStored': 'Gemt — udfyld kun for at skifte den',
|
||||
'int.save': 'Gem',
|
||||
'int.saved': 'Gemt.',
|
||||
'int.noClient':
|
||||
'Der er ikke registreret en OAuth-klient hos Google endnu. Det skal en administrator gøre, før knappen her kan bruges.',
|
||||
'int.connectedAs': 'Forbundet som',
|
||||
'int.unknownAccount': 'ukendt konto',
|
||||
'int.whatMakesATask': 'Hvad laver en opgave',
|
||||
'int.starred': 'Stjernemarkerede',
|
||||
'int.aLabel': 'En bestemt etiket',
|
||||
'int.both': 'Begge dele',
|
||||
'int.gmailLabelName': 'Etikettens navn i Gmail',
|
||||
'int.gmailLabelExample': 'fx Opgaver',
|
||||
'int.fetchNow': 'Hent nu',
|
||||
'int.disconnect': 'Afbryd',
|
||||
'int.feed': 'Kalenderfeed',
|
||||
'int.feedHint':
|
||||
'Abonnér i Apple Kalender, Google eller Thunderbird. En kalenderklient kan ikke logge ind, så nøglen i adressen er hele adgangskoden — del den ikke.',
|
||||
'int.address': 'Adresse',
|
||||
'int.copy': 'Kopiér',
|
||||
'int.newLink': 'Nyt link',
|
||||
'int.newLinkHint':
|
||||
'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.',
|
||||
'int.mailToTask': 'Mail til opgave',
|
||||
'int.mailHint':
|
||||
'Send eller videresend en mail hertil, og emnelinjen bliver tolket som en hurtig tilføjelse — “Fakturer Anders p1 #Firma” virker også her.',
|
||||
'int.noMailServer':
|
||||
'Der er ingen mailserver sat op på denne server, så adressen herunder kan ikke modtage noget endnu. Sæt VERDANDE_SMTP_HOST og de øvrige SMTP-indstillinger, og få mailserveren til at aflevere post til todo+* videre til /inbound/mail.',
|
||||
'int.yourAddress': 'Din adresse',
|
||||
'int.newAddress': 'Ny adresse',
|
||||
'int.caldav': 'CalDAV',
|
||||
'int.caldavHint':
|
||||
'Tovejs, i modsætning til feedet ovenfor: Apple Påmindelser og Thunderbird kan også skrive tilbage.',
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||
'int.gmailConnected': 'Gmail er forbundet.',
|
||||
'int.gmailState':
|
||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||
'int.gmailExpired': 'Forsøget udløb undervejs. Prøv igen.',
|
||||
'int.gmailInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
|
||||
'int.gmailFailed': 'Google afviste ombytningen af koden.',
|
||||
'int.gmailNoRefresh':
|
||||
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
|
||||
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.gmailOther': 'Gmail svarede: {what}',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'Projektets navn',
|
||||
'project.clickToRename': 'Klik for at omdøbe',
|
||||
'project.color': 'Farve på projektet',
|
||||
'project.pickColor': 'Vælg farve',
|
||||
'project.delete': 'Slet projektet',
|
||||
'project.share': 'Del',
|
||||
'project.history': 'Historik',
|
||||
'project.noHistory': 'Ingenting er sket her endnu.',
|
||||
'project.canEdit': 'Kan redigere',
|
||||
'project.canView': 'Kan kun se',
|
||||
'project.invite': 'Inviter',
|
||||
'project.inviteByEmail': 'Inviter via e-mail',
|
||||
'project.emailAddress': 'e-mailadresse',
|
||||
'project.role': 'Rolle',
|
||||
'project.sendLinkYourself': 'Ingen mailserver sat op — send selv linket:',
|
||||
'project.noSection': 'Uden sektion',
|
||||
'project.rename': 'Omdøb',
|
||||
'project.deleteSection': 'Slet',
|
||||
'project.readOnly': 'Du kan se dette projekt, men ikke ændre det.',
|
||||
'project.notFound': 'Projektet findes ikke, eller du har ikke adgang til det.',
|
||||
'project.loadFailed': 'Kunne ikke hente projektet. Serveren svarede ikke.',
|
||||
'project.retry': 'Prøv igen',
|
||||
'project.nothingHere': 'Ingenting her endnu.',
|
||||
|
||||
'project.sharedWith': 'Delt · {n}',
|
||||
|
||||
// --- the palette ---------------------------------------------------------------
|
||||
'color.graphite': 'Grafit',
|
||||
'color.tomato': 'Tomat',
|
||||
'color.amber': 'Rav',
|
||||
'color.lime': 'Lime',
|
||||
'color.green': 'Grøn',
|
||||
'color.teal': 'Petrol',
|
||||
'color.blue': 'Blå',
|
||||
'color.indigo': 'Indigo',
|
||||
'color.violet': 'Violet',
|
||||
'color.magenta': 'Magenta',
|
||||
|
||||
// --- what a failed request says -----------------------------------------------
|
||||
'error.unauthorized': 'Forkert e-mail eller adgangskode.',
|
||||
'error.totpRequired': 'Indtast koden fra din authenticator.',
|
||||
'error.forbidden': 'Det har du ikke adgang til.',
|
||||
'error.notFound': 'Findes ikke.',
|
||||
'error.conflict': 'Det er der allerede.',
|
||||
'error.rateLimited': 'For mange forsøg. Vent et øjeblik.',
|
||||
'error.validation': 'Tjek felterne herunder.',
|
||||
'error.tooLarge': 'Det er for stort.',
|
||||
'error.internal': 'Noget gik galt. Prøv igen.',
|
||||
'error.badRequest': 'Anmodningen kunne ikke læses.',
|
||||
'error.offline': 'Ingen forbindelse til serveren.',
|
||||
'error.gmailNotConfigured':
|
||||
'Gmail er ikke sat op på denne server. En administrator skal registrere en OAuth-klient hos Google under Indstillinger → Integrationer.',
|
||||
'error.aiNotConfigured': 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.',
|
||||
'error.totpNotEnabled': 'To-faktor er ikke slået til.',
|
||||
'error.inboxProtected': 'Indbakken kan ikke slettes.',
|
||||
'error.lastAdmin':
|
||||
'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.',
|
||||
|
||||
// --- the themes -------------------------------------------------------------------
|
||||
'theme.dark': 'Nordlys',
|
||||
'theme.darkNote': 'Mørk med grøn accent.',
|
||||
'theme.dusk': 'Skumring',
|
||||
'theme.duskNote': 'Mørk og varm, med rav.',
|
||||
'theme.light': 'Dagslys',
|
||||
'theme.lightNote': 'Lys og køligt neutral.',
|
||||
'theme.paper': 'Papir',
|
||||
'theme.paperNote': 'Lys og varm, som papir.',
|
||||
'theme.contrast': 'Kontrast',
|
||||
'theme.contrastNote': 'Sort på hvidt, til skarpt lys.',
|
||||
|
||||
// --- push, in the browser's own words ----------------------------------------------
|
||||
'push.deniedByBrowser':
|
||||
'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.',
|
||||
'push.notEnabled': 'Notifikationer blev ikke slået til.',
|
||||
'push.noVapidKey': 'Serveren har ingen VAPID-nøgle.'
|
||||
};
|
||||
|
||||
+511
-1
@@ -68,5 +68,515 @@ export const en = {
|
||||
'task.noPriority': 'No priority',
|
||||
|
||||
// --- a project group -----------------------------------------------------------
|
||||
'group.colorOf': 'Colour of {name}'
|
||||
'group.colorOf': 'Colour of {name}',
|
||||
|
||||
|
||||
// --- settings: the frame -------------------------------------------------------
|
||||
'settings.title': 'Settings',
|
||||
'settings.tab.account': 'Account',
|
||||
'settings.tab.notifications': 'Notifications',
|
||||
'settings.tab.integrations': 'Integrations',
|
||||
'settings.tab.ai': 'AI',
|
||||
'settings.tab.tokens': 'API tokens',
|
||||
'settings.tab.users': 'People',
|
||||
'settings.tab.history': 'History',
|
||||
'settings.tab.errors': 'Errors',
|
||||
'settings.tab.data': 'Data and templates',
|
||||
|
||||
// --- settings: the account -----------------------------------------------------
|
||||
'account.profile': 'Profile',
|
||||
'account.profileHint':
|
||||
'The timezone is not decoration: every date in the app is resolved in it, so “tomorrow at 9” means something else once you change it.',
|
||||
'account.name': 'Name',
|
||||
'account.email': 'Email',
|
||||
'account.emailFixed': 'Cannot be changed here.',
|
||||
'account.timezone': 'Timezone',
|
||||
'account.parseLanguage': 'Language',
|
||||
'account.danish': 'Dansk',
|
||||
'account.english': 'English',
|
||||
'account.parseHint':
|
||||
'Both the interface’s language and the grammar quick add reads a line with — “hver mandag” or “every monday”.',
|
||||
'account.save': 'Save',
|
||||
'account.saved': 'Saved.',
|
||||
'account.appearance': 'Appearance',
|
||||
'account.appearanceHint':
|
||||
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
||||
'account.password': 'Password',
|
||||
'account.passwordHint':
|
||||
'Changing it signs every other session out — which is usually the reason to do it.',
|
||||
'account.currentPassword': 'Current password',
|
||||
'account.newPassword': 'New password',
|
||||
'account.changePassword': 'Change password',
|
||||
'account.passwordChanged': 'Changed.',
|
||||
'account.totp': 'Two-factor',
|
||||
'account.totpHint': 'A one-time code from an authenticator app, on top of the password.',
|
||||
'account.writeThemDown': 'Write them down now.',
|
||||
'account.recoveryHint': 'They are shown this once — the server only has their hash.',
|
||||
'account.gotThem': 'I have them',
|
||||
'account.passwordRequired':
|
||||
'Required for both actions below. Turning two-factor off is exactly what a stranger holding your session would do first.',
|
||||
'account.newRecoveryCodes': 'New recovery codes',
|
||||
'account.totpOff': 'Turn two-factor off',
|
||||
'account.scanHint': 'Scan it in your authenticator app, or type the key in by hand.',
|
||||
'account.codeFromApp': 'The code from the app',
|
||||
'account.totpProofHint':
|
||||
'Two-factor is only on once a code has proved the app really has the key.',
|
||||
'account.confirm': 'Confirm',
|
||||
'account.cancel': 'Cancel',
|
||||
'account.totpOn': 'Turn two-factor on',
|
||||
'account.devices': 'Devices',
|
||||
'account.devicesHint':
|
||||
'Where you are signed in. If you do not recognise one, sign it out and change your password.',
|
||||
'account.thisDevice': 'this device',
|
||||
'account.signOutDevice': 'Sign out',
|
||||
|
||||
'account.totpEnabled': 'On.',
|
||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||
'account.recoveryLeftMany': '{n} recovery codes left.',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifications on this device',
|
||||
'push.hint':
|
||||
'Reminders and messages from other people, even with the tab closed. The message is encrypted to your browser on the way — the push service cannot read it.',
|
||||
'push.unsupported':
|
||||
'This browser does not support it. Safari needs the app added to the home screen; over plain HTTP it only works on localhost.',
|
||||
'push.blocked':
|
||||
'The browser has blocked notifications for this site. That can only be changed in the browser’s own settings — a page cannot ask again once the answer has been no.',
|
||||
'push.on': 'On for this device.',
|
||||
'push.turnOff': 'Turn off',
|
||||
'push.turnOn': 'Turn on for this device',
|
||||
'push.perBrowser': 'Applies only to the browser you are in right now.',
|
||||
'push.recent': 'Recent',
|
||||
'push.none': 'Nothing yet.',
|
||||
'push.open': 'Open',
|
||||
'push.markAllRead': 'Mark all as read',
|
||||
'push.version': 'Version',
|
||||
'push.running': 'Running',
|
||||
'push.seeRelease': 'See the release',
|
||||
|
||||
// --- settings: API tokens ------------------------------------------------------
|
||||
'tokens.title': 'API tokens',
|
||||
'tokens.hint':
|
||||
'What a script, a CalDAV client or an MCP connector signs in with. A token carries exactly your permissions — it can do nothing you cannot.',
|
||||
'tokens.copyNow': 'Copy it now.',
|
||||
'tokens.onceOnly': 'It is shown this once — the server only has its hash.',
|
||||
'tokens.copy': 'Copy',
|
||||
'tokens.copied': 'Copied.',
|
||||
'tokens.done': 'Done',
|
||||
'tokens.mcpBefore': 'Using it as an',
|
||||
'tokens.mcpLink': 'MCP connector',
|
||||
'tokens.mcpAfter': 'in Claude? Then this is the address the dialog wants:',
|
||||
'tokens.copyAddress': 'Copy the address',
|
||||
'tokens.name': 'Name',
|
||||
'tokens.namePlaceholder': 'e.g. my laptop, or the backup script',
|
||||
'tokens.expires': 'Expires',
|
||||
'tokens.never': 'Never',
|
||||
'tokens.in30': 'In 30 days',
|
||||
'tokens.in90': 'In 90 days',
|
||||
'tokens.inYear': 'In a year',
|
||||
'tokens.expiryHint':
|
||||
'A calendar client that has been subscribed for years needs “never”. A script you run once does not.',
|
||||
'tokens.create': 'Create a token',
|
||||
'tokens.none': 'No tokens yet.',
|
||||
'tokens.revoke': 'Revoke',
|
||||
|
||||
// --- settings: AI --------------------------------------------------------------
|
||||
'ai.title': 'AI',
|
||||
'ai.hint':
|
||||
'Used in two places: splitting a task into sub-tasks, and writing a short overview of what is outstanding. The key stays on your server and is only sent to the provider you pick here.',
|
||||
'ai.provider': 'Provider',
|
||||
'ai.off': 'Off',
|
||||
'ai.model': 'Model',
|
||||
'ai.baseURL': 'Address',
|
||||
'ai.apiKey': 'API key',
|
||||
'ai.save': 'Save',
|
||||
'ai.saved': 'Saved.',
|
||||
'ai.weekly': 'Weekly overview',
|
||||
'ai.weeklyHint': 'A short note on what looks most important right now.',
|
||||
|
||||
'push.unreadOne': '{n} unread.',
|
||||
'push.unreadMany': '{n} unread.',
|
||||
|
||||
'ai.keyStored': 'A key is stored — leave this blank',
|
||||
'ai.keyBlankHint':
|
||||
'The field is blank on purpose. The key is never sent back to the browser — a settings page that refills a password field is one that will one day leak a key into a screenshot.',
|
||||
'ai.keyNewHint': 'Stored on the server and never shown again.',
|
||||
'tokens.lastUsed': 'last used {when}',
|
||||
'tokens.neverUsed': 'never used',
|
||||
'tokens.expired': 'expired {when}',
|
||||
'tokens.expiresOn': 'expires {when}',
|
||||
'tokens.revokeNamed': 'Revoke {name}',
|
||||
|
||||
// --- settings: people ----------------------------------------------------------
|
||||
'users.invite': 'Invite somebody',
|
||||
'users.inviteHint':
|
||||
'There is no open registration. You send a link and the person picks their own name and password — so there is never a password two people know.',
|
||||
'users.emailAddress': 'Email address',
|
||||
'users.emailPlaceholder': 'name@example.com',
|
||||
'users.mustBeEmail': 'Must be an email address.',
|
||||
'users.sendInvite': 'Send invitation',
|
||||
'users.inviteMade': 'The invitation is ready. Send the link to them:',
|
||||
'users.pending': 'Awaiting a reply',
|
||||
'users.pendingHint':
|
||||
'Invitations sent and not used yet. Only the link’s hash is stored, so it cannot be shown again — if it went to the wrong person, revoke it.',
|
||||
'users.revoke': 'Revoke',
|
||||
'users.accounts': 'Accounts',
|
||||
'users.accountsHint': 'Everybody on this instance.',
|
||||
'users.admin': 'administrator',
|
||||
'users.you': 'you',
|
||||
'users.makeAdmin': 'Make administrator',
|
||||
'users.removeAdmin': 'Remove administrator',
|
||||
'users.delete': 'Delete',
|
||||
'users.deleteQuestion': 'Delete {name} <{email}> for good?',
|
||||
'users.deleteTakes': 'That deletes {projects} and the {tasks} in them.',
|
||||
'users.deleteLeaves': '{tasks} in other people’s projects stay, without an author.',
|
||||
'users.deleteFinal': 'It cannot be undone. There is no trash for accounts.',
|
||||
'users.revokeQuestion': 'Revoke the invitation to {email}? The link stops working.',
|
||||
'users.projectOne': 'project',
|
||||
'users.projectMany': 'projects',
|
||||
'users.taskOne': 'task',
|
||||
'users.taskMany': 'tasks',
|
||||
'users.never': 'never seen',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'History',
|
||||
'history.hint':
|
||||
'Everything that has been done on this server, across every project — including the ones you are not in. A project’s own history panel shows the same rows one project at a time; this is the question those cannot answer. Rows stay when an account is deleted: what was done stops having a name, not having happened.',
|
||||
'history.person': 'Person',
|
||||
'history.all': 'All',
|
||||
'history.project': 'Project',
|
||||
'history.event': 'Event',
|
||||
'history.clear': 'Clear',
|
||||
'history.more': 'Load more',
|
||||
'history.loading': 'Loading…',
|
||||
'history.noMatch': 'Nothing matches the filters.',
|
||||
'history.nothing': 'Nothing has been recorded yet.',
|
||||
'history.deletedAccount': 'A deleted account',
|
||||
'errors.title': 'Server errors',
|
||||
'errors.hint':
|
||||
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
|
||||
'errors.none': 'No errors recorded. That is the right state.',
|
||||
|
||||
// --- the views -------------------------------------------------------------------
|
||||
'view.delegated': 'Waiting on others',
|
||||
'view.delegatedEmpty': 'You are not waiting on anybody.',
|
||||
'view.labelEmpty': 'No open tasks with this label.',
|
||||
'view.filterEmpty': 'Nothing matches right now.',
|
||||
'view.filterFailed': 'The filter could not be run.',
|
||||
'view.upcoming': 'Upcoming',
|
||||
'view.mode': 'View',
|
||||
'view.list': 'List',
|
||||
'view.week': 'Week',
|
||||
'view.month': 'Month',
|
||||
'view.board': 'Board',
|
||||
'view.calendar': 'Calendar',
|
||||
'view.emptySection': 'Empty',
|
||||
'view.addSection': '+ Add a section',
|
||||
'view.sectionName': 'The section’s name',
|
||||
'view.newSection': 'New section',
|
||||
'view.prevMonth': 'Previous month',
|
||||
'view.nextMonth': 'Next month',
|
||||
'view.prevWeek': 'Previous week',
|
||||
'view.nextWeek': 'Next week',
|
||||
'view.weekNumber': 'week {n}',
|
||||
'view.more': '+{n} more',
|
||||
|
||||
// --- how long ago ----------------------------------------------------------------
|
||||
'when.justNow': 'just now',
|
||||
'when.minutes': '{n} min. ago',
|
||||
'when.hours': '{n} hours ago',
|
||||
'when.days': '{n} days ago',
|
||||
'when.never': 'never',
|
||||
'when.neverSignedIn': 'never signed in',
|
||||
|
||||
// --- settings: data ---------------------------------------------------------------
|
||||
'data.import': 'Import from Todoist',
|
||||
'data.csvFile': 'CSV file',
|
||||
'data.importing': 'Importing …',
|
||||
'data.openProject': 'Open the project',
|
||||
'data.trash': 'Trash',
|
||||
'data.trashEmpty': 'The trash is empty.',
|
||||
'data.restore': 'Restore',
|
||||
'data.backups': 'Backups',
|
||||
'data.backupNow': 'Take one now',
|
||||
'data.backingUp': 'Copying…',
|
||||
'data.backupMade': 'Backup written — {size}',
|
||||
'data.backupFailed': 'failed',
|
||||
'data.download': 'Download',
|
||||
'data.noBackups': 'No backup has been taken yet.',
|
||||
'data.swept': 'swept away',
|
||||
'data.empty': 'empty',
|
||||
'data.export': 'Export',
|
||||
'data.exportAccount': 'The whole account as JSON',
|
||||
'data.exportOne': 'One project',
|
||||
'data.pickProject': 'Pick a project',
|
||||
'data.csv': 'CSV',
|
||||
'data.ics': 'Calendar file',
|
||||
'data.templates': 'Templates',
|
||||
'data.saveAsTemplate': 'Save a project as a template',
|
||||
'data.templateName': 'Name for the template',
|
||||
'data.saveTemplate': 'Save as a template',
|
||||
'data.noTemplates': 'No templates yet.',
|
||||
'data.use': 'Use',
|
||||
'data.delete': 'Delete',
|
||||
'data.newProjectName': 'Name for the new project',
|
||||
'data.startDate': 'Start date',
|
||||
'data.startDateHint': 'Day zero for the template’s dates. Blank means today.',
|
||||
'data.createProject': 'Create the project',
|
||||
|
||||
// --- a group's own page ------------------------------------------------------------
|
||||
'group.notFound': 'That group does not exist, or it is not yours.',
|
||||
'group.failed': 'The group could not be loaded.',
|
||||
'group.retry': 'Try again',
|
||||
'group.about': 'About this group',
|
||||
'group.aboutPlaceholder': 'What kind of work is this?',
|
||||
'group.writeAbout': 'Write what kind of work this is.',
|
||||
'group.saving': 'Saving…',
|
||||
'group.projects': 'Projects',
|
||||
'group.projectOne': '1 project',
|
||||
'group.projectMany': '{n} projects',
|
||||
'group.noProjects':
|
||||
'No projects in the group yet. Drag one up here in the sidebar, or pick the group when you create one.',
|
||||
'group.documents': 'Documents',
|
||||
'group.documentsHint':
|
||||
'Files that belong to the whole group rather than to one task in it — the contract, the drawing, the agreement everything else rests on.',
|
||||
'group.remove': 'Remove',
|
||||
'group.removeNamed': 'Remove {name}',
|
||||
'group.removeQuestion': 'Remove "{name}"?',
|
||||
'group.addFile': '+ Upload a file',
|
||||
'group.uploading': 'Uploading…',
|
||||
|
||||
// --- keyboard shortcuts --------------------------------------------------------------
|
||||
'keys.title': 'Shortcuts',
|
||||
|
||||
'data.importHint':
|
||||
'Pick the CSV export of one project. Priorities are converted, not copied: Todoist writes 4 for what its interface calls P1.',
|
||||
'data.trashHint':
|
||||
'Deleted projects, with their tasks. They come back exactly as they were — except for tasks you had deleted individually first; there was a reason for those.',
|
||||
'data.backupsHint':
|
||||
'The database is copied every night and the most recent fourteen are kept. Counted, not dated: a container that has been off for a month must not come back and delete all of them for being too old.',
|
||||
'data.backupsAttachments':
|
||||
'Attachments are not included. They are content-addressed and only ever added to, so fourteen identical copies would fill the volume they were meant to protect — take the whole of /data if you want everything.',
|
||||
'data.restoreHint':
|
||||
'Putting a copy back is deliberately not a button here: a database cannot be swapped under a server that is writing to it, and a half-finished swap destroys the very thing it was meant to save. Stop the container, put the file in place of verdande.db in the data directory, delete the -wal and -shm beside it, and start it again.',
|
||||
'data.exportHint':
|
||||
'Everything, in its raw form. That is the guarantee that you can leave — an export that has been tidied up is an export that has lost something.',
|
||||
'data.templatesHint':
|
||||
'A project saved as a shape you can use again. The dates in a template are relative, so they are worked out from the start date you pick.',
|
||||
|
||||
'keys.navigation': 'Navigation',
|
||||
'keys.jumpToQuickAdd': 'Jump to the “add a task” field',
|
||||
'keys.thisPage': 'This page',
|
||||
'keys.inQuickAdd': 'In quick add',
|
||||
'keys.intoProject': 'Put the task in a project',
|
||||
'keys.setLabel': 'Set a label',
|
||||
'keys.priority': 'Priority',
|
||||
'keys.date': 'A date — Danish and English',
|
||||
'keys.time': 'A time',
|
||||
'keys.clear': 'Clear the field',
|
||||
|
||||
// --- signing in ------------------------------------------------------------------
|
||||
'auth.firstAccount': 'Create the first account. It becomes the administrator.',
|
||||
'auth.name': 'Name',
|
||||
'auth.nameRequired': 'Required',
|
||||
'auth.email': 'Email',
|
||||
'auth.emailInvalid': 'Must be an email address',
|
||||
'auth.password': 'Password',
|
||||
'auth.passwordHint': 'At least 10 characters. A sentence is both easier and stronger.',
|
||||
'auth.createAccount': 'Create account',
|
||||
'auth.totpPrompt': 'Enter the code from your authenticator.',
|
||||
'auth.code': 'Code',
|
||||
'auth.totpRecovery': 'If you have lost your phone, one of your recovery codes works here.',
|
||||
'auth.continue': 'Continue',
|
||||
'auth.resetPrompt': 'We will send a link to choose a new password.',
|
||||
'auth.sendLink': 'Send the link',
|
||||
'auth.back': 'Back',
|
||||
'auth.linkBroken': 'That link is incomplete. Open it from the email again, or ask for a new one.',
|
||||
'auth.toSignIn': 'To sign in',
|
||||
'auth.signOut': 'Sign out',
|
||||
'auth.invited': 'You have been invited. Choose a name and a password.',
|
||||
'auth.pickNew': 'Choose a new password.',
|
||||
'auth.newPassword': 'New password',
|
||||
'auth.newPasswordHint': 'At least 10 characters. Every other device is signed out.',
|
||||
'auth.savePassword': 'Save the password',
|
||||
'auth.passwordChanged': 'The password has been changed. Sign in with the new one.',
|
||||
'auth.resetSent':
|
||||
'If that address has an account, a link is on its way. Check the spam filter too.',
|
||||
'auth.signInPrompt': 'Sign in to continue.',
|
||||
'auth.signIn': 'Sign in',
|
||||
'auth.forgot': 'Forgotten your password?',
|
||||
|
||||
// --- a task, opened ---------------------------------------------------------------
|
||||
'detail.task': 'Task',
|
||||
'detail.close': 'Close',
|
||||
'detail.text': 'The task’s text',
|
||||
'detail.description': 'Description',
|
||||
'detail.descriptionPlaceholder': 'Details, links, whatever has to be remembered',
|
||||
'detail.priority': 'Priority',
|
||||
'detail.p1': 'P1 — urgent',
|
||||
'detail.none': 'None',
|
||||
'detail.due': 'Due',
|
||||
'detail.labels': 'Labels',
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
'detail.repeats': 'Repeats',
|
||||
'detail.monthly': 'Every month',
|
||||
'detail.yearly': 'Every year',
|
||||
'detail.assignee': 'Assigned to',
|
||||
'detail.subtasks': 'Sub-tasks',
|
||||
'detail.addSubtask': 'Add a sub-task',
|
||||
'detail.newSubtask': 'New sub-task',
|
||||
'detail.files': 'Files',
|
||||
'detail.delete': 'Delete',
|
||||
'detail.reminders': 'Reminders',
|
||||
'detail.newReminder': 'New reminder',
|
||||
'detail.sent': 'sent',
|
||||
'detail.add': 'Add',
|
||||
'detail.comments': 'Comments',
|
||||
'detail.writeComment': 'Write a comment',
|
||||
'detail.newComment': 'New comment',
|
||||
'detail.post': 'Post',
|
||||
'detail.readAs': 'Read as {what}',
|
||||
|
||||
'auth.alreadySignedIn': 'You are signed in as {name}. Sign out first, then open the link again.',
|
||||
|
||||
'detail.reopen': 'Reopen',
|
||||
'detail.uploading': 'Uploading …',
|
||||
'detail.attachFile': 'Attach a file',
|
||||
|
||||
// --- settings: integrations --------------------------------------------------------
|
||||
'int.gmail': 'Gmail',
|
||||
'int.gmailHint':
|
||||
'Star an email, or give it a label, and it becomes a task. One way: removing the star does nothing to the task.',
|
||||
'int.connectGmail': 'Connect Gmail',
|
||||
'int.fromEnv':
|
||||
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
|
||||
'int.oauthClient': 'OAuth client at Google',
|
||||
'int.registerHint':
|
||||
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, enable the Gmail API, and paste the two values here. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
|
||||
'int.redirectURI': 'Authorised redirect URI',
|
||||
'int.clientID': 'Client ID',
|
||||
'int.clientSecret': 'Client secret',
|
||||
'int.secretStored': 'Stored — fill this in only to change it',
|
||||
'int.save': 'Save',
|
||||
'int.saved': 'Saved.',
|
||||
'int.noClient':
|
||||
'No OAuth client has been registered with Google yet. An administrator has to do that before this button can be used.',
|
||||
'int.connectedAs': 'Connected as',
|
||||
'int.unknownAccount': 'an unknown account',
|
||||
'int.whatMakesATask': 'What makes a task',
|
||||
'int.starred': 'Starred',
|
||||
'int.aLabel': 'A particular label',
|
||||
'int.both': 'Both',
|
||||
'int.gmailLabelName': 'The label’s name in Gmail',
|
||||
'int.gmailLabelExample': 'e.g. Tasks',
|
||||
'int.fetchNow': 'Fetch now',
|
||||
'int.disconnect': 'Disconnect',
|
||||
'int.feed': 'Calendar feed',
|
||||
'int.feedHint':
|
||||
'Subscribe in Apple Calendar, Google or Thunderbird. A calendar client cannot sign in, so the key in the address is the whole password — do not share it.',
|
||||
'int.address': 'Address',
|
||||
'int.copy': 'Copy',
|
||||
'int.newLink': 'New link',
|
||||
'int.newLinkHint':
|
||||
'A new link breaks every existing subscription immediately. That is the point: it is what you do when the old one has ended up somewhere it should not be.',
|
||||
'int.mailToTask': 'Mail to task',
|
||||
'int.mailHint':
|
||||
'Send or forward an email here and the subject line is read as a quick add — “Invoice Anders p1 #Company” works here too.',
|
||||
'int.noMailServer':
|
||||
'No mail server is configured on this server, so the address below cannot receive anything yet. Set VERDANDE_SMTP_HOST and the other SMTP settings, and have the mail server deliver post for todo+* to /inbound/mail.',
|
||||
'int.yourAddress': 'Your address',
|
||||
'int.newAddress': 'New address',
|
||||
'int.caldav': 'CalDAV',
|
||||
'int.caldavHint':
|
||||
'Two-way, unlike the feed above: Apple Reminders and Thunderbird can write back as well.',
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'The username is your email. The password is an API token — not your real password.',
|
||||
'int.gmailConnected': 'Gmail is connected.',
|
||||
'int.gmailState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||
'int.gmailExpired': 'The attempt expired on the way. Try again.',
|
||||
'int.gmailInvalid': 'Google’s reply could not be read. Try again.',
|
||||
'int.gmailFailed': 'Google refused to exchange the code.',
|
||||
'int.gmailNoRefresh':
|
||||
'Google sent no refresh token. Remove verdande from your Google account and connect again.',
|
||||
'int.gmailDenied': 'Access was refused at Google.',
|
||||
'int.gmailOther': 'Gmail replied: {what}',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'The project’s name',
|
||||
'project.clickToRename': 'Click to rename',
|
||||
'project.color': 'The project’s colour',
|
||||
'project.pickColor': 'Pick a colour',
|
||||
'project.delete': 'Delete the project',
|
||||
'project.share': 'Share',
|
||||
'project.history': 'History',
|
||||
'project.noHistory': 'Nothing has happened here yet.',
|
||||
'project.canEdit': 'Can edit',
|
||||
'project.canView': 'Can only view',
|
||||
'project.invite': 'Invite',
|
||||
'project.inviteByEmail': 'Invite by email',
|
||||
'project.emailAddress': 'email address',
|
||||
'project.role': 'Role',
|
||||
'project.sendLinkYourself': 'No mail server configured — send the link yourself:',
|
||||
'project.noSection': 'No section',
|
||||
'project.rename': 'Rename',
|
||||
'project.deleteSection': 'Delete',
|
||||
'project.readOnly': 'You can see this project, but not change it.',
|
||||
'project.notFound': 'That project does not exist, or you do not have access to it.',
|
||||
'project.loadFailed': 'The project could not be loaded. The server did not answer.',
|
||||
'project.retry': 'Try again',
|
||||
'project.nothingHere': 'Nothing here yet.',
|
||||
|
||||
'project.sharedWith': 'Shared · {n}',
|
||||
|
||||
// --- the palette ---------------------------------------------------------------
|
||||
'color.graphite': 'Graphite',
|
||||
'color.tomato': 'Tomato',
|
||||
'color.amber': 'Amber',
|
||||
'color.lime': 'Lime',
|
||||
'color.green': 'Green',
|
||||
'color.teal': 'Teal',
|
||||
'color.blue': 'Blue',
|
||||
'color.indigo': 'Indigo',
|
||||
'color.violet': 'Violet',
|
||||
'color.magenta': 'Magenta',
|
||||
|
||||
// --- what a failed request says -----------------------------------------------
|
||||
'error.unauthorized': 'Wrong email or password.',
|
||||
'error.totpRequired': 'Enter the code from your authenticator.',
|
||||
'error.forbidden': 'You do not have access to that.',
|
||||
'error.notFound': 'Not found.',
|
||||
'error.conflict': 'That already exists.',
|
||||
'error.rateLimited': 'Too many attempts. Wait a moment.',
|
||||
'error.validation': 'Check the fields below.',
|
||||
'error.tooLarge': 'That is too large.',
|
||||
'error.internal': 'Something went wrong. Try again.',
|
||||
'error.badRequest': 'The request could not be read.',
|
||||
'error.offline': 'No connection to the server.',
|
||||
'error.gmailNotConfigured':
|
||||
'Gmail is not set up on this server. An administrator has to register an OAuth client with Google under Settings → Integrations.',
|
||||
'error.aiNotConfigured': 'No AI provider has been chosen. Set one up under Settings → AI.',
|
||||
'error.totpNotEnabled': 'Two-factor is not on.',
|
||||
'error.inboxProtected': 'The Inbox cannot be deleted.',
|
||||
'error.lastAdmin':
|
||||
'That is the last administrator. Make somebody else an administrator first — otherwise nobody can get back into this page.',
|
||||
|
||||
// --- the themes -------------------------------------------------------------------
|
||||
'theme.dark': 'Northern lights',
|
||||
'theme.darkNote': 'Dark, with a green accent.',
|
||||
'theme.dusk': 'Dusk',
|
||||
'theme.duskNote': 'Dark and warm, with amber.',
|
||||
'theme.light': 'Daylight',
|
||||
'theme.lightNote': 'Light and coolly neutral.',
|
||||
'theme.paper': 'Paper',
|
||||
'theme.paperNote': 'Light and warm, like paper.',
|
||||
'theme.contrast': 'Contrast',
|
||||
'theme.contrastNote': 'Black on white, for bright light.',
|
||||
|
||||
// --- push, in the browser's own words ----------------------------------------------
|
||||
'push.deniedByBrowser':
|
||||
'The browser has blocked notifications for this site. It has to be turned on in the browser’s own settings.',
|
||||
'push.notEnabled': 'Notifications were not turned on.',
|
||||
'push.noVapidKey': 'The server has no VAPID key.'
|
||||
};
|
||||
|
||||
+4
-3
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { api } from './api.js';
|
||||
import { t } from './i18n.svelte.js';
|
||||
|
||||
/** Whether this browser can do it at all. Safari before 16.4 cannot, and neither
|
||||
* can any browser on a page served over plain HTTP from a non-localhost host. */
|
||||
@@ -56,14 +57,14 @@ export async function subscribe() {
|
||||
if (granted !== 'granted') {
|
||||
throw new Error(
|
||||
granted === 'denied'
|
||||
? 'Browseren har blokeret notifikationer for dette site. Det skal slås til i browserens indstillinger.'
|
||||
: 'Notifikationer blev ikke slået til.'
|
||||
? t('push.deniedByBrowser')
|
||||
: t('push.notEnabled')
|
||||
);
|
||||
}
|
||||
|
||||
const reg = await registration();
|
||||
const { public_key } = await api.pushKey();
|
||||
if (!public_key) throw new Error('Serveren har ingen VAPID-nøgle.');
|
||||
if (!public_key) throw new Error(t('push.noVapidKey'));
|
||||
|
||||
const subscription = await reg.pushManager.subscribe({
|
||||
// Not optional in Chrome: a subscription that is not userVisibleOnly is
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { api, ApiError, humanMessage } from './api.js';
|
||||
import { i18n } from './i18n.svelte.js';
|
||||
|
||||
class AppState {
|
||||
user = $state(null);
|
||||
@@ -93,6 +94,9 @@ class AppState {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.user = await api.me();
|
||||
// The interface's language follows the account, so it is set the moment
|
||||
// the session is known — before anything has drawn a string.
|
||||
i18n.set(this.user.locale);
|
||||
const [{ projects }, { groups }, { people }] = await Promise.all([
|
||||
api.listProjects(),
|
||||
api.listProjectGroups(),
|
||||
@@ -666,11 +670,11 @@ export const upcomingView = new UpcomingView();
|
||||
* the picker rather than a missing block.
|
||||
*/
|
||||
export const THEMES = [
|
||||
{ id: 'dark', name: 'Nordlys', note: 'Mørk med grøn accent.', dark: true },
|
||||
{ id: 'dusk', name: 'Skumring', note: 'Mørk og varm, med rav.', dark: true },
|
||||
{ id: 'light', name: 'Dagslys', note: 'Lys og køligt neutral.', dark: false },
|
||||
{ id: 'paper', name: 'Papir', note: 'Lys og varm, som papir.', dark: false },
|
||||
{ id: 'contrast', name: 'Kontrast', note: 'Sort på hvidt, til skarpt lys.', dark: false }
|
||||
{ id: 'dark', name: 'theme.dark', note: 'theme.darkNote', dark: true },
|
||||
{ id: 'dusk', name: 'theme.dusk', note: 'theme.duskNote', dark: true },
|
||||
{ id: 'light', name: 'theme.light', note: 'theme.lightNote', dark: false },
|
||||
{ id: 'paper', name: 'theme.paper', note: 'theme.paperNote', dark: false },
|
||||
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
|
||||
];
|
||||
|
||||
/** Theme, kept in localStorage and applied to the document element. */
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* How long ago something was, in the account's language.
|
||||
*
|
||||
* Four pages had a copy of this — the user list, the error log, the history page
|
||||
* and the backup panel — and they had already drifted: one said "aldrig logget
|
||||
* ind", another "aldrig", and two of them stopped at days while the others carried
|
||||
* on to a date. Four copies is three chances for the fifth one to be different
|
||||
* again.
|
||||
*
|
||||
* Written by hand rather than with `Intl.RelativeTimeFormat`, which would say
|
||||
* "3 hours ago" and "for 3 timer siden" correctly and cannot say "lige nu" — and
|
||||
* "just now" is the answer for most of what these lists show.
|
||||
*/
|
||||
import { t, tag } from './i18n.svelte.js';
|
||||
|
||||
export function ago(iso, { never = 'when.never' } = {}) {
|
||||
if (!iso) return t(never);
|
||||
|
||||
const then = new Date(iso);
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
|
||||
if (seconds < 60) return t('when.justNow');
|
||||
if (seconds < 3600) return t('when.minutes', { n: Math.floor(seconds / 60) });
|
||||
if (seconds < 86400) return t('when.hours', { n: Math.floor(seconds / 3600) });
|
||||
if (seconds < 604800) return t('when.days', { n: Math.floor(seconds / 86400) });
|
||||
return then.toLocaleDateString(tag(), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** A date and a time, for a list where the time of day matters. */
|
||||
export function stamp(iso) {
|
||||
return new Date(iso).toLocaleString(tag(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/** A day, without the year — for something inside the next few months. */
|
||||
export function shortDate(iso) {
|
||||
return new Date(iso).toLocaleDateString(tag(), { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
/** A day with the year, for something that could be any distance away. */
|
||||
export function fullDate(iso) {
|
||||
return new Date(iso).toLocaleDateString(tag(), {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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å
|
||||
“i morgen kl. 9” 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 — “hver mandag”
|
||||
eller “every monday”.
|
||||
{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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &
|
||||
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 — “Fakturer Anders p1 #Firma” 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
|
||||
“aldrig”. 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user