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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user