feat(web): SvelteKit PWA with the Nordic design system

The interface for phase 1: sign-in and first-run setup, Today, Upcoming,
project views, sharing, Cmd+K search and the keyboard shortcuts.

Quick add highlights what the parser understood underneath the text as
you type — a mirrored layer behind a transparent input, sliced by byte
offset because the server counts bytes and JavaScript counts UTF-16
units, which disagree on exactly the Danish letters this app is full of.

Every local action is optimistic and reconciled afterwards: ticking a
task off strikes it through and animates it out before the request
leaves, and rolls back with a message if the server disagrees.

The Content-Security-Policy is computed at startup by hashing the inline
scripts in the built index.html. Both of them change on every frontend
build, so a hard-coded hash would go stale silently and show up only as
a blank page in production. The alternative, 'unsafe-inline', would drop
the one protection that makes an injected script in a task title inert.

Today is derived from the store by date rather than from the ids that
came back with the first request — filtering against a captured list
meant a task added through quick add never appeared until a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 00:03:20 +02:00
co-authored by Claude Opus 5
parent dff86dd23d
commit 12a9597919
25 changed files with 4776 additions and 15 deletions
+157
View File
@@ -0,0 +1,157 @@
/**
* The one place the app talks to the server.
*
* Every call goes through `request`, so authentication failures, error shapes and
* the CSRF header are handled once rather than at each of forty call sites.
*/
/** Thrown for any non-2xx response. Carries the server's stable `code` so callers
* can branch on the reason without matching on prose. */
export class ApiError extends Error {
constructor(status, code, message, fields) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.fields = fields ?? {};
}
/** True when the session has gone — the app should show the sign-in screen. */
get isAuthError() {
return this.status === 401;
}
}
/** 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. */
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.',
offline: 'Ingen forbindelse til serveren.'
};
export function humanMessage(err) {
if (!(err instanceof ApiError)) return MESSAGES.offline;
return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error;
}
async function request(method, path, body, options = {}) {
const init = {
method,
headers: {},
// The session cookie is httpOnly and same-origin; nothing here handles a token.
credentials: 'same-origin',
signal: options.signal
};
if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(`/api/v1${path}`, init);
} catch (e) {
// A network failure is not an HTTP status; give it one the app can handle
// the same way as everything else.
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
if (res.status === 204) return null;
let payload = null;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!res.ok) {
throw new ApiError(
res.status,
payload?.code ?? 'internal_error',
payload?.error ?? res.statusText,
payload?.fields
);
}
return payload;
}
const get = (path, options) => request('GET', path, undefined, options);
const post = (path, body, options) => request('POST', path, body ?? {}, options);
const patch = (path, body) => request('PATCH', path, body);
const del = (path) => request('DELETE', path);
export const api = {
// --- auth
setupState: () => get('/auth/setup'),
setup: (data) => post('/auth/setup', data),
login: (email, password) => post('/auth/login', { email, password }),
loginTOTP: (code) => post('/auth/login/totp', { code }),
logout: () => post('/auth/logout'),
me: () => get('/auth/me'),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
changePassword: (current_password, new_password) =>
post('/auth/password/change', { current_password, new_password }),
totpSetup: () => post('/auth/totp/setup'),
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
// --- projects
listProjects: (archived = false) => get(`/projects${archived ? '?archived=true' : ''}`),
getProject: (id) => get(`/projects/${id}`),
createProject: (data) => post('/projects', data),
updateProject: (id, data) => patch(`/projects/${id}`, data),
deleteProject: (id) => del(`/projects/${id}`),
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
listSections: (projectId) => get(`/projects/${projectId}/sections`),
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
updateSection: (id, data) => patch(`/sections/${id}`, data),
deleteSection: (id) => del(`/sections/${id}`),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`),
// --- tasks
listTasks: (params = {}) => {
const query = new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== '')
);
const suffix = query.toString();
return get(`/tasks${suffix ? `?${suffix}` : ''}`);
},
getTask: (id) => get(`/tasks/${id}`),
createTask: (data) => post('/tasks', data),
updateTask: (id, data) => patch(`/tasks/${id}`, data),
deleteTask: (id) => del(`/tasks/${id}`),
completeTask: (id) => post(`/tasks/${id}/complete`),
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
// --- views
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};
@@ -0,0 +1,210 @@
<script>
/** Cmd+K: search across everything the user can see. */
import { api } from '$lib/api.js';
import { goto } from '$app/navigation';
let { open = $bindable(false) } = $props();
let query = $state('');
let tasks = $state([]);
let projects = $state([]);
let selected = $state(0);
let input;
let controller = null;
let timer = null;
$effect(() => {
if (open) {
query = '';
tasks = [];
projects = [];
selected = 0;
queueMicrotask(() => input?.focus());
}
});
$effect(() => {
const value = query.trim();
clearTimeout(timer);
controller?.abort();
if (!value) {
tasks = [];
projects = [];
return;
}
timer = setTimeout(async () => {
try {
const result = await api.search(value);
tasks = result.tasks ?? [];
projects = result.projects ?? [];
selected = 0;
} catch {
// A failed search shows nothing rather than an error inside a
// palette somebody is about to close anyway.
}
}, 140);
});
let results = $derived([
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...tasks.map((t) => ({
kind: 'task',
id: t.id,
label: t.content,
project: t.project_id,
done: t.completed
}))
]);
function choose(item) {
open = false;
goto(item.kind === 'project' ? `/projekt/${item.id}` : `/projekt/${item.project}`);
}
function onkeydown(event) {
switch (event.key) {
case 'Escape':
open = false;
break;
case 'ArrowDown':
event.preventDefault();
selected = Math.min(selected + 1, results.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selected = Math.max(selected - 1, 0);
break;
case 'Enter':
event.preventDefault();
if (results[selected]) choose(results[selected]);
break;
}
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="scrim" onclick={() => (open = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Søg">
<input
bind:this={input}
bind:value={query}
{onkeydown}
placeholder="Søg i opgaver og projekter"
aria-label="Søg"
autocomplete="off"
spellcheck="false"
/>
{#if results.length}
<ul>
{#each results as item, i (item.kind + item.id)}
<li>
<button
class:selected={i === selected}
class:done={item.done}
onclick={() => choose(item)}
onmouseenter={() => (selected = i)}
>
<span class="kind">{item.kind === 'project' ? 'Projekt' : 'Opgave'}</span>
<span class="label">{item.label}</span>
</button>
</li>
{/each}
</ul>
{:else if query.trim()}
<p class="empty">Ingen resultater.</p>
{/if}
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.45);
display: flex;
justify-content: center;
/* Not centred: a palette pinned near the top does not jump as results
appear, and lands where the eye already is after ⌘K. */
align-items: flex-start;
padding-top: 12vh;
z-index: 70;
}
.palette {
width: min(560px, calc(100vw - var(--s6)));
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
input {
width: 100%;
padding: var(--s4);
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
font-size: var(--text-lg);
outline: none;
}
input::placeholder {
color: var(--ink-faint);
}
ul {
list-style: none;
margin: 0;
padding: var(--s2);
max-height: 45vh;
overflow-y: auto;
}
li button {
display: flex;
align-items: baseline;
gap: var(--s3);
width: 100%;
padding: var(--s2) var(--s3);
border-radius: var(--radius);
text-align: left;
color: var(--ink);
font-size: var(--text-sm);
}
li button.selected {
background: var(--surface);
}
li button.done .label {
color: var(--ink-faint);
text-decoration: line-through;
}
.kind {
flex: none;
font-size: var(--text-xs);
color: var(--ink-faint);
width: 52px;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
margin: 0;
padding: var(--s5);
text-align: center;
color: var(--ink-faint);
font-size: var(--text-sm);
}
</style>
+265
View File
@@ -0,0 +1,265 @@
<script>
/**
* The quick-add box.
*
* What makes it worth its own component is the highlighting: as you type, the
* parts the parser understood are tinted underneath the text. That is the only
* way "i morgen kl 10 p1" stops feeling like guesswork — you can see it being
* read before you commit to it.
*
* The tinting is a mirrored layer sitting exactly behind a transparent input.
* A contenteditable would let us style the text directly and would also break
* autocorrect, IME input and undo, which is a bad trade for a box people type
* one line into.
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
let spans = $state([]);
let submitting = $state(false);
let input;
let previewController = null;
let previewTimer = null;
/**
* Parsing runs on the server, so it is debounced and each request cancels the
* one before it. Without the cancel, a fast typist has six parses in flight and
* the highlighting flickers between whichever happens to land last.
*/
function schedulePreview(value) {
clearTimeout(previewTimer);
previewController?.abort();
if (!value.trim()) {
spans = [];
return;
}
previewTimer = setTimeout(async () => {
previewController = new AbortController();
try {
const result = await api.quickAddPreview(value, previewController.signal);
spans = result.spans ?? [];
} catch {
// An aborted or failed preview just means no highlighting; the text
// is still perfectly submittable.
}
}, 120);
}
$effect(() => {
schedulePreview(text);
});
$effect(() => {
if (autofocus && input) input.focus();
});
/** Splits the raw text into highlighted and plain runs, by byte offset. */
let segments = $derived.by(() => {
if (!spans.length) return [{ text, kind: null }];
// The server counts bytes; JavaScript counts UTF-16 units. For "køb" those
// disagree, and using one for the other paints the highlight over the wrong
// characters — so the string is converted and sliced as bytes.
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const bytes = encoder.encode(text);
const out = [];
let cursor = 0;
for (const span of [...spans].sort((a, b) => a.start - b.start)) {
if (span.start > cursor) {
out.push({ text: decoder.decode(bytes.slice(cursor, span.start)), kind: null });
}
out.push({ text: decoder.decode(bytes.slice(span.start, span.end)), kind: span.kind });
cursor = span.end;
}
if (cursor < bytes.length) {
out.push({ text: decoder.decode(bytes.slice(cursor)), kind: null });
}
return out;
});
async function submit(event) {
event?.preventDefault();
const value = text.trim();
if (!value || submitting) return;
submitting = true;
// Cleared before the response: the box has to be ready for the next thought
// immediately, and a failure puts the text back rather than blocking on it.
text = '';
spans = [];
const task = await app.quickAdd(value, projectId);
submitting = false;
if (task) onadded?.(task);
else text = value;
input?.focus();
}
function onkeydown(event) {
if (event.key === 'Escape') {
text = '';
spans = [];
input?.blur();
return;
}
// Submitting on Enter explicitly rather than relying on the browser's
// implicit form submission: that only fires when the form has a submit
// button, and this one is hidden while the field is empty. Tying the
// primary interaction of the app to a conditional element is not a
// dependency worth having.
if (event.key === 'Enter' && !event.isComposing) {
event.preventDefault();
submit();
}
}
</script>
<form class="quickadd" onsubmit={submit}>
<span class="plus" aria-hidden="true">+</span>
<div class="field">
<!-- The mirror sits behind the input and must match it exactly: same font,
same padding, same wrapping. Any difference shows up as highlighting
that drifts out of alignment as the line grows. -->
<div class="mirror" aria-hidden="true">
{#each segments as segment}
{#if segment.kind}
<mark data-kind={segment.kind}>{segment.text}</mark>
{:else}
<span>{segment.text}</span>
{/if}
{/each}
</div>
<input
bind:this={input}
bind:value={text}
{onkeydown}
type="text"
placeholder="Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”"
aria-label="Ny opgave"
autocomplete="off"
spellcheck="false"
/>
</div>
{#if text.trim()}
<button type="submit" class="submit" disabled={submitting}>Tilføj</button>
{/if}
</form>
<style>
.quickadd {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
}
.plus {
flex: none;
width: 20px;
height: 20px;
display: grid;
place-items: center;
color: var(--ink-faint);
font-size: var(--text-lg);
line-height: 1;
}
.quickadd:focus-within .plus {
color: var(--accent);
}
.field {
position: relative;
flex: 1;
min-width: 0;
}
/* Both layers share every metric that affects text layout. */
.mirror,
.field input {
font: inherit;
font-size: var(--text-base);
line-height: 1.5;
letter-spacing: normal;
padding: 0;
border: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mirror {
position: absolute;
inset: 0;
pointer-events: none;
color: transparent;
}
/* The tint is behind the glyphs, not on them: colouring the text itself would
fight the reading of the sentence, while a wash behind it reads as "this bit
was understood" without changing the words. */
.mirror mark {
background: var(--accent-sunken);
color: transparent;
border-radius: var(--radius-sm);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent);
}
.mirror mark[data-kind='priority'] {
background: color-mix(in srgb, var(--p1) 18%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--p1) 30%, transparent);
}
.mirror mark[data-kind='project'] {
background: color-mix(in srgb, var(--p3) 18%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--p3) 30%, transparent);
}
.mirror mark[data-kind='label'] {
background: color-mix(in srgb, var(--p2) 18%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--p2) 30%, transparent);
}
.field input {
position: relative;
width: 100%;
background: transparent;
color: var(--ink);
outline: none;
}
.field input::placeholder {
color: var(--ink-faint);
}
.submit {
flex: none;
background: var(--accent);
color: var(--accent-ink);
font-size: var(--text-sm);
font-weight: 550;
padding: var(--s1) var(--s3);
border-radius: var(--radius);
transition: background var(--fast) var(--ease);
}
.submit:hover:not(:disabled) {
background: var(--accent-hover);
}
.submit:disabled {
opacity: 0.5;
cursor: default;
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<script>
import { app } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let { onnavigate } = $props();
let adding = $state(false);
let newName = $state('');
async function createProject(event) {
event.preventDefault();
const name = newName.trim();
if (!name) return;
newName = '';
adding = false;
const project = await app.createProject(name);
if (project) goto(`/projekt/${project.id}`);
}
async function signOut() {
await api.logout();
location.href = '/';
}
let shared = $derived(app.projects.filter((p) => !p.is_inbox && p.shared));
let own = $derived(app.projects.filter((p) => !p.is_inbox && !p.shared));
let current = $derived($page.url.pathname);
</script>
<nav class="sidebar" aria-label="Hovedmenu">
<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 —
the name is in the tab and everywhere else already. -->
<span class="rune" aria-hidden="true">ᚹ</span>
<span class="name">verdande</span>
</div>
<div class="views">
<a href="/" class:active={current === '/'} onclick={onnavigate}>
<span class="dot today" aria-hidden="true"></span>
I dag
</a>
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
Kommende
</a>
{#if app.inbox}
<a
href="/projekt/{app.inbox.id}"
class:active={current === `/projekt/${app.inbox.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{app.inbox.name}
</a>
{/if}
</div>
<div class="group">
<div class="group-head">
<h2>Projekter</h2>
<button onclick={() => (adding = !adding)} aria-label="Nyt projekt">+</button>
</div>
{#if adding}
<form onsubmit={createProject}>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={newName}
autofocus
placeholder="Projektnavn"
aria-label="Projektnavn"
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
</form>
{/if}
{#each own as project (project.id)}
<a
href="/projekt/{project.id}"
class:active={current === `/projekt/${project.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
</a>
{/each}
{#if own.length === 0 && !adding}
<p class="empty">Ingen projekter endnu.</p>
{/if}
</div>
{#if shared.length}
<div class="group">
<div class="group-head"><h2>Delt med mig</h2></div>
{#each shared as project (project.id)}
<a
href="/projekt/{project.id}"
class:active={current === `/projekt/${project.id}`}
onclick={onnavigate}
>
<span class="dot" aria-hidden="true"></span>
{project.name}
<span class="count">{project.member_count}</span>
</a>
{/each}
</div>
{/if}
<div class="foot">
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#if !app.connected}
<span class="offline" title="Ændringer fra andre vises ikke lige nu">Offline</span>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">Log ud</span>
</button>
</div>
</nav>
<style>
.sidebar {
width: var(--sidebar-width);
flex: none;
display: flex;
flex-direction: column;
gap: var(--s5);
padding: var(--s4) var(--s3);
background: var(--surface-sunken);
border-right: 1px solid var(--line);
overflow-y: auto;
padding-left: max(var(--s3), env(safe-area-inset-left));
}
.brand {
display: flex;
align-items: center;
gap: var(--s2);
padding: 0 var(--s2);
}
.rune {
font-size: var(--text-xl);
color: var(--accent);
line-height: 1;
}
.name {
font-size: var(--text-lg);
font-weight: 560;
letter-spacing: -0.02em;
}
.views,
.group {
display: flex;
flex-direction: column;
gap: 1px;
}
.group-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--s2) var(--s2);
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
}
.group-head button {
width: 20px;
height: 20px;
display: grid;
place-items: center;
border-radius: var(--radius-sm);
color: var(--ink-faint);
font-size: var(--text-lg);
line-height: 1;
}
.group-head button:hover {
color: var(--ink);
background: var(--surface-raised);
}
a {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s2) var(--s2);
border-radius: var(--radius);
color: var(--ink-muted);
text-decoration: none;
font-size: var(--text-sm);
transition:
background var(--fast) var(--ease),
color var(--fast) var(--ease);
}
a:hover {
background: var(--surface);
color: var(--ink);
}
a.active {
background: var(--surface-raised);
color: var(--ink);
font-weight: 500;
}
.dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: var(--line-strong);
flex: none;
}
a.active .dot,
.dot.today {
background: var(--accent);
}
.count {
margin-left: auto;
font-size: var(--text-xs);
color: var(--ink-faint);
}
.empty {
margin: 0;
padding: var(--s2);
font-size: var(--text-sm);
color: var(--ink-faint);
}
form input {
width: 100%;
padding: var(--s2);
background: var(--surface);
border: 1px solid var(--accent);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
}
.foot {
margin-top: auto;
padding-top: var(--s4);
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: var(--s2);
}
.offline {
font-size: var(--text-xs);
color: var(--ink-faint);
padding: 0 var(--s2);
}
.user {
display: flex;
align-items: center;
gap: var(--s2);
padding: var(--s2);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink-muted);
width: 100%;
}
.user:hover {
background: var(--surface);
}
.avatar {
width: 22px;
height: 22px;
border-radius: var(--radius-full);
display: grid;
place-items: center;
font-size: var(--text-xs);
font-weight: 560;
color: #fff;
flex: none;
}
.user-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signout {
margin-left: auto;
font-size: var(--text-xs);
color: var(--ink-faint);
opacity: 0;
transition: opacity var(--fast) var(--ease);
}
.user:hover .signout {
opacity: 1;
}
</style>
+298
View File
@@ -0,0 +1,298 @@
<script>
/**
* Sign-in, first-run setup and the second factor, in one component.
*
* They are one screen because they are one decision tree: an instance with no
* accounts asks you to create one, an instance with accounts asks you to log
* in, and a login with 2FA asks for a code. Splitting them across routes would
* mean redirecting somebody mid-authentication, which is where sessions get
* lost.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let mode = $state('loading'); // loading | login | setup | totp | forgot | sent
let email = $state('');
let password = $state('');
let name = $state('');
let code = $state('');
let error = $state('');
let fields = $state({});
let busy = $state(false);
$effect(() => {
api
.setupState()
.then((state) => (mode = state.needs_setup ? 'setup' : 'login'))
.catch(() => (mode = 'login'));
});
async function run(fn) {
busy = true;
error = '';
fields = {};
try {
await fn();
} catch (e) {
error = humanMessage(e);
fields = e.fields ?? {};
} finally {
busy = false;
}
}
const submit = (event) => {
event.preventDefault();
if (busy) return;
run(async () => {
switch (mode) {
case 'setup':
await api.setup({
email,
name,
password,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: navigator.language?.startsWith('en') ? 'en' : 'da'
});
await app.load();
break;
case 'login': {
const result = await api.login(email, password);
if (result.totp_required) {
mode = 'totp';
password = '';
return;
}
await app.load();
break;
}
case 'totp':
await api.loginTOTP(code);
await app.load();
break;
case 'forgot':
await api.forgotPassword(email);
mode = 'sent';
break;
}
});
};
</script>
<div class="screen">
<form onsubmit={submit}>
<div class="brand">
<span class="rune" aria-hidden="true">ᚹ</span>
<h1>verdande</h1>
</div>
{#if mode === 'loading'}
<p class="lede">&nbsp;</p>
{:else if mode === 'setup'}
<p class="lede">Opret den første konto. Den bliver administrator.</p>
<label>
Navn
<input bind:value={name} autocomplete="name" required />
{#if fields.name}<span class="field-error">Skal udfyldes</span>{/if}
</label>
<label>
E-mail
<input bind:value={email} type="email" autocomplete="username" required />
{#if fields.email}<span class="field-error">Skal være en e-mailadresse</span>{/if}
</label>
<label>
Adgangskode
<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>
{#if fields.password}<span class="field-error">{fields.password}</span>{/if}
</label>
<button type="submit" disabled={busy}>Opret konto</button>
{:else if mode === 'totp'}
<p class="lede">Indtast koden fra din authenticator.</p>
<label>
Kode
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={code}
autofocus
inputmode="numeric"
autocomplete="one-time-code"
placeholder="123456"
class="code"
required
/>
<span class="hint">Har du mistet din telefon, virker en af dine gendannelseskoder her.</span>
</label>
<button type="submit" disabled={busy}>Fortsæt</button>
{:else if mode === 'forgot'}
<p class="lede">Vi sender et link til at vælge en ny adgangskode.</p>
<label>
E-mail
<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>
{: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.
</p>
<button type="button" class="link" onclick={() => (mode = 'login')}>Tilbage</button>
{:else}
<p class="lede">Log ind for at fortsætte.</p>
<label>
E-mail
<input bind:value={email} type="email" autocomplete="username" required />
</label>
<label>
Adgangskode
<input bind:value={password} type="password" autocomplete="current-password" required />
</label>
<button type="submit" disabled={busy}>Log ind</button>
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
Glemt adgangskode?
</button>
{/if}
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
</form>
</div>
<style>
.screen {
height: 100dvh;
display: grid;
place-items: center;
padding: var(--s4);
background: var(--ground);
}
form {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: var(--s4);
}
.brand {
display: flex;
align-items: center;
gap: var(--s3);
justify-content: center;
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
line-height: 1;
}
h1 {
font-size: var(--text-xl);
font-weight: 560;
letter-spacing: -0.02em;
}
.lede {
margin: 0;
text-align: center;
color: var(--ink-muted);
font-size: var(--text-sm);
}
label {
display: flex;
flex-direction: column;
gap: var(--s2);
font-size: var(--text-sm);
color: var(--ink-muted);
}
input {
padding: var(--s3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink);
outline: none;
transition: border-color var(--fast) var(--ease);
}
input:focus {
border-color: var(--accent);
}
.code {
font-family: var(--font-mono);
font-size: var(--text-lg);
letter-spacing: 0.2em;
text-align: center;
}
.hint,
.field-error {
font-size: var(--text-xs);
}
.hint {
color: var(--ink-faint);
}
.field-error {
color: var(--danger);
}
button[type='submit'] {
padding: var(--s3);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius);
font-weight: 550;
transition: background var(--fast) var(--ease);
}
button[type='submit']:hover:not(:disabled) {
background: var(--accent-hover);
}
button[type='submit']:disabled {
opacity: 0.6;
cursor: default;
}
.link {
color: var(--ink-faint);
font-size: var(--text-sm);
text-align: center;
}
.link:hover {
color: var(--ink);
}
.error {
margin: 0;
padding: var(--s3);
background: var(--danger-sunken);
border-radius: var(--radius);
color: var(--danger);
font-size: var(--text-sm);
text-align: center;
}
</style>
+239
View File
@@ -0,0 +1,239 @@
<script>
/**
* One task.
*
* The checkbox is the component's whole reason for existing, so it gets the
* care: a ring that fills, a checkmark that draws itself, and then the row
* fading out. The animation runs to completion locally *before* the row is
* removed from the list, because a row that vanishes the instant it is clicked
* feels like a mis-click rather than an accomplishment.
*/
import { app } from '$lib/stores.svelte.js';
let { task, onedit } = $props();
let leaving = $state(false);
const PRIORITY_LABEL = { 1: 'Prioritet 1', 2: 'Prioritet 2', 3: 'Prioritet 3', 4: 'Ingen prioritet' };
async function toggle() {
if (task.completed) {
app.reopen(task.id);
return;
}
leaving = true;
// Matches the fade below. The store's own update is optimistic, so the
// only thing being waited on here is the animation.
setTimeout(() => app.complete(task.id), 320);
}
function dueLabel(date) {
if (!date) return null;
const today = new Date();
const due = new Date(date + 'T00:00:00');
const days = Math.round((due - new Date(today.toDateString())) / 86400000);
if (days === 0) return { text: 'I dag', tone: 'today' };
if (days === 1) return { text: 'I morgen', tone: 'soon' };
if (days === -1) return { text: 'I går', tone: 'overdue' };
if (days < 0) return { text: `${Math.abs(days)} dage forsinket`, tone: 'overdue' };
if (days < 7)
return {
text: due.toLocaleDateString('da-DK', { weekday: 'long' }),
tone: 'soon'
};
return {
text: due.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' }),
tone: 'later'
};
}
let due = $derived(dueLabel(task.due_date));
let time = $derived(
task.due_datetime
? new Date(task.due_datetime).toLocaleTimeString('da-DK', {
hour: '2-digit',
minute: '2-digit'
})
: null
);
</script>
<div class="row" class:leaving class:completed={task.completed} data-priority={task.priority}>
<button
class="check"
class:checked={task.completed || leaving}
onclick={toggle}
aria-label={task.completed ? 'Genåbn opgave' : 'Markér som færdig'}
title={PRIORITY_LABEL[task.priority]}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path class="tick" d="M6 12.5l4 4 8-8.5" />
</svg>
</button>
<button class="body" onclick={() => onedit?.(task)}>
<span class="content">{task.content}</span>
{#if task.description}
<span class="description">{task.description}</span>
{/if}
{#if due || task.labels?.length}
<span class="meta">
{#if due}
<span class="due" data-tone={due.tone}>
{due.text}{#if time}&nbsp;{time}{/if}
</span>
{/if}
{#each task.labels ?? [] as label}
<span class="label">{label}</span>
{/each}
</span>
{/if}
</button>
</div>
<style>
.row {
display: flex;
gap: var(--s3);
align-items: flex-start;
padding: var(--s3) var(--s2);
border-bottom: 1px solid var(--line);
transition:
opacity var(--medium) var(--ease),
transform var(--medium) var(--ease);
}
.row:hover {
background: var(--surface);
}
/* The exit: a short lift and fade, so completing something reads as the task
leaving rather than as the list glitching. */
.row.leaving {
opacity: 0;
transform: translateX(6px);
}
.row.completed .content {
color: var(--ink-faint);
text-decoration: line-through;
text-decoration-color: var(--ink-faint);
}
.check {
flex: none;
width: 20px;
height: 20px;
margin-top: 2px;
border: 1.5px solid var(--line-strong);
border-radius: var(--radius-full);
display: grid;
place-items: center;
transition:
border-color var(--fast) var(--ease),
background var(--fast) var(--ease),
transform var(--fast) var(--ease);
}
/* Priority is carried by the checkbox ring rather than by a separate badge:
it is the thing your eye is already going to, and one signal beats two. */
.row[data-priority='1'] .check {
border-color: var(--p1);
background: color-mix(in srgb, var(--p1) 12%, transparent);
}
.row[data-priority='2'] .check {
border-color: var(--p2);
background: color-mix(in srgb, var(--p2) 12%, transparent);
}
.row[data-priority='3'] .check {
border-color: var(--p3);
background: color-mix(in srgb, var(--p3) 12%, transparent);
}
.check:hover {
border-color: var(--accent);
transform: scale(1.08);
}
.check.checked {
background: var(--accent);
border-color: var(--accent);
}
.check svg {
width: 14px;
height: 14px;
fill: none;
stroke: var(--accent-ink);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The checkmark draws itself rather than appearing. 18 is a little over the
path length, which keeps it fully hidden before the animation starts. */
.tick {
stroke-dasharray: 18;
stroke-dashoffset: 18;
transition: stroke-dashoffset var(--medium) var(--ease-out);
}
.check.checked .tick {
stroke-dashoffset: 0;
}
.body {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
min-width: 0;
}
.content {
color: var(--ink);
line-height: 1.45;
overflow-wrap: anywhere;
}
.description {
font-size: var(--text-sm);
color: var(--ink-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--s2);
align-items: center;
margin-top: var(--s1);
font-size: var(--text-xs);
}
.due[data-tone='overdue'] {
color: var(--p1);
}
.due[data-tone='today'] {
color: var(--accent);
}
.due[data-tone='soon'],
.due[data-tone='later'] {
color: var(--ink-muted);
}
.label {
color: var(--ink-muted);
background: var(--surface-raised);
border: 1px solid var(--line);
padding: 1px var(--s2);
border-radius: var(--radius-full);
}
</style>
+248
View File
@@ -0,0 +1,248 @@
/**
* Application state.
*
* Svelte 5 runes rather than stores: the task list is read by half the components
* in the app, and `$state` gives fine-grained reactivity without every one of them
* subscribing and unsubscribing by hand.
*
* The rule this file exists to enforce: **every local action is applied
* immediately and reconciled afterwards**. Ticking a checkbox must not wait for a
* round trip. When the server disagrees, the change is rolled back and the person
* is told — which is rare, and much better than a spinner on every click.
*/
import { api, ApiError, humanMessage } from './api.js';
class AppState {
user = $state(null);
projects = $state([]);
tasks = $state([]);
loading = $state(true);
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
#socket = null;
#reconnectDelay = 1000;
get inbox() {
return this.projects.find((p) => p.is_inbox);
}
projectById(id) {
return this.projects.find((p) => p.id === id);
}
async load() {
this.loading = true;
try {
this.user = await api.me();
const { projects } = await api.listProjects();
this.projects = projects;
this.connect();
} catch (e) {
this.user = null;
} finally {
this.loading = false;
}
}
// --- realtime ---------------------------------------------------------------
connect() {
if (this.#socket) return;
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${scheme}://${location.host}/api/v1/ws`);
this.#socket = socket;
socket.onopen = () => {
this.connected = true;
this.#reconnectDelay = 1000;
};
socket.onmessage = (event) => {
try {
this.#applyRemote(JSON.parse(event.data));
} catch {
// A message this build does not understand is not worth a crash.
}
};
socket.onclose = () => {
this.connected = false;
this.#socket = null;
// Backing off matters: a server that is restarting should not be met
// with a reconnect every 50ms from every open tab.
setTimeout(() => this.connect(), this.#reconnectDelay);
this.#reconnectDelay = Math.min(this.#reconnectDelay * 2, 30000);
};
socket.onerror = () => socket.close();
}
/** Applies a change made by somebody else. */
#applyRemote(event) {
const task = event.payload;
switch (event.type) {
case 'task.created':
case 'task.updated':
case 'task.completed':
case 'task.reopened':
case 'task.moved': {
if (!task?.id) return;
const i = this.tasks.findIndex((t) => t.id === task.id);
if (i >= 0) this.tasks[i] = task;
else this.tasks.push(task);
break;
}
case 'task.deleted':
this.tasks = this.tasks.filter((t) => t.id !== task?.id);
break;
}
}
// --- tasks ------------------------------------------------------------------
async loadTasks(params) {
const { tasks } = await api.listTasks(params);
this.tasks = tasks;
return tasks;
}
/**
* Ticks a task off. The row is struck through and gone before the request
* leaves — which is the single most-repeated action in the app, and the one
* place latency would be felt all day.
*/
async complete(id) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index < 0) return;
const previous = this.tasks[index];
this.tasks[index] = { ...previous, completed: true };
try {
const updated = await api.completeTask(id);
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = updated;
} catch (e) {
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = previous;
this.toast(humanMessage(e));
}
}
async reopen(id) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index < 0) return;
const previous = this.tasks[index];
this.tasks[index] = { ...previous, completed: false };
try {
const updated = await api.reopenTask(id);
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = updated;
} catch (e) {
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = previous;
this.toast(humanMessage(e));
}
}
async update(id, patch) {
const index = this.tasks.findIndex((t) => t.id === id);
if (index < 0) return;
const previous = this.tasks[index];
this.tasks[index] = { ...previous, ...patch };
try {
const updated = await api.updateTask(id, patch);
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = updated;
} catch (e) {
const i = this.tasks.findIndex((t) => t.id === id);
if (i >= 0) this.tasks[i] = previous;
this.toast(humanMessage(e));
}
}
async remove(id) {
const previous = [...this.tasks];
this.tasks = this.tasks.filter((t) => t.id !== id);
try {
await api.deleteTask(id);
} catch (e) {
this.tasks = previous;
this.toast(humanMessage(e));
}
}
/**
* Adds a task from the quick-add box.
*
* This one is *not* fully optimistic: the parse that turns one line into a
* date, a project and a priority happens on the server, so inventing a row
* here would show a task that visibly rearranges itself a moment later. The
* request is fast and the box clears immediately, which is where the
* responsiveness actually needs to be.
*/
async quickAdd(text, projectId) {
try {
const task = await api.quickAdd(text, projectId);
this.tasks.push(task);
return task;
} catch (e) {
this.toast(humanMessage(e));
return null;
}
}
// --- projects ---------------------------------------------------------------
async createProject(name) {
try {
const project = await api.createProject({ name });
this.projects.push(project);
return project;
} catch (e) {
this.toast(humanMessage(e));
return null;
}
}
async refreshProjects() {
const { projects } = await api.listProjects();
this.projects = projects;
}
// --- toasts -------------------------------------------------------------------
toast(message) {
const id = Math.random().toString(36).slice(2);
this.toasts.push({ id, message });
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
}, 5000);
}
dismissToast(id) {
this.toasts = this.toasts.filter((t) => t.id !== id);
}
}
export const app = new AppState();
/** Theme, kept in localStorage and applied to the document element. */
export const theme = {
get current() {
if (typeof document === 'undefined') return 'dark';
return document.documentElement.dataset.theme ?? 'dark';
},
toggle() {
const next = this.current === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
try {
localStorage.setItem('verdande:theme', next);
} catch {
// Private browsing; the choice simply will not persist.
}
}
};
export { ApiError, humanMessage };