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
+372
View File
@@ -0,0 +1,372 @@
<script>
/** One project: its sections, its tasks, and who it is shared with. */
import { page } from '$app/stores';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte';
let project = $state(null);
let sections = $state([]);
let members = $state([]);
let showShare = $state(false);
let inviteEmail = $state('');
let inviteRole = $state('editor');
let inviteLink = $state('');
let inviteError = $state('');
let id = $derived($page.params.id);
$effect(() => {
const projectId = id;
if (!projectId) return;
Promise.all([
api.getProject(projectId),
api.listSections(projectId),
app.loadTasks({ project_id: projectId })
])
.then(([p, s]) => {
project = p;
sections = s.sections;
})
.catch(() => {
project = null;
});
});
$effect(() => {
if (showShare && project) {
api.listMembers(project.id).then((r) => (members = r.members));
}
});
let canEdit = $derived(project?.role === 'owner' || project?.role === 'editor');
let isOwner = $derived(project?.role === 'owner');
let open = $derived(app.tasks.filter((t) => !t.completed && !t.parent_id));
let unsectioned = $derived(open.filter((t) => !t.section_id));
async function invite(event) {
event.preventDefault();
inviteError = '';
inviteLink = '';
try {
const result = await api.invite(project.id, inviteEmail, inviteRole);
inviteEmail = '';
if (result.link && !result.emailed) {
// No mail server, or delivery failed. The link is the only way this
// invite reaches anybody, so it is shown rather than swallowed.
inviteLink = result.link;
}
members = (await api.listMembers(project.id)).members;
} catch (e) {
inviteError = humanMessage(e);
}
}
</script>
<div class="view">
{#if project}
<header>
<h1>{project.name}</h1>
{#if !project.is_inbox}
<button class="share" onclick={() => (showShare = !showShare)}>
{project.shared ? `Delt · ${project.member_count}` : 'Del'}
</button>
{/if}
</header>
{#if showShare}
<div class="panel">
<ul class="members">
{#each members as member (member.user_id)}
<li>
<span class="avatar" style="background: {member.avatar_color}">
{member.name[0]?.toUpperCase()}
</span>
<span class="member-name">{member.name}</span>
<span class="role">{member.role}</span>
{#if isOwner && member.role !== 'owner'}
<button
class="remove"
onclick={async () => {
await api.removeMember(project.id, member.user_id);
members = (await api.listMembers(project.id)).members;
}}
aria-label="Fjern {member.name}">×</button
>
{/if}
</li>
{/each}
</ul>
{#if isOwner}
<form onsubmit={invite}>
<input
bind:value={inviteEmail}
type="email"
placeholder="e-mailadresse"
aria-label="Inviter via e-mail"
required
/>
<select bind:value={inviteRole} aria-label="Rolle">
<option value="editor">Kan redigere</option>
<option value="viewer">Kan kun se</option>
</select>
<button type="submit">Inviter</button>
</form>
{#if inviteLink}
<p class="link-out">
Ingen mailserver sat op — send selv linket:
<code>{inviteLink}</code>
</p>
{/if}
{#if inviteError}
<p class="error">{inviteError}</p>
{/if}
{/if}
</div>
{/if}
{#if canEdit}
<QuickAdd projectId={project.id} />
{/if}
<section>
{#each unsectioned as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{#each sections as section (section.id)}
{@const tasks = open.filter((t) => t.section_id === section.id)}
<section>
<h2>{section.name}</h2>
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
{#if !tasks.length}
<p class="empty">Tom</p>
{/if}
</section>
{/each}
{#if !open.length}
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ingenting her endnu.
</p>
{/if}
{#if project.role === 'viewer'}
<p class="readonly">Du kan se dette projekt, men ikke ændre det.</p>
{/if}
{:else}
<p class="clear">Projektet findes ikke, eller du har ikke adgang til det.</p>
{/if}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
header {
display: flex;
align-items: center;
gap: var(--s4);
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.share {
flex: none;
font-size: var(--text-sm);
color: var(--ink-muted);
padding: var(--s1) var(--s3);
border: 1px solid var(--line);
border-radius: var(--radius-full);
transition: border-color var(--fast) var(--ease);
}
.share:hover {
border-color: var(--line-strong);
color: var(--ink);
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--s4);
margin-bottom: var(--s5);
display: flex;
flex-direction: column;
gap: var(--s3);
}
.members {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--s2);
}
.members li {
display: flex;
align-items: center;
gap: var(--s3);
font-size: var(--text-sm);
}
.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;
}
.member-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role {
color: var(--ink-faint);
font-size: var(--text-xs);
}
.remove {
color: var(--ink-faint);
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
}
.remove:hover {
color: var(--danger);
background: var(--danger-sunken);
}
form {
display: flex;
gap: var(--s2);
flex-wrap: wrap;
}
form input {
flex: 1;
min-width: 160px;
}
form input,
form select {
padding: var(--s2) var(--s3);
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
outline: none;
}
form input:focus,
form select:focus {
border-color: var(--accent);
}
form button {
padding: var(--s2) var(--s4);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius);
font-size: var(--text-sm);
font-weight: 550;
}
.link-out {
margin: 0;
font-size: var(--text-xs);
color: var(--ink-muted);
}
.link-out code {
display: block;
margin-top: var(--s1);
padding: var(--s2);
background: var(--surface-sunken);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
overflow-wrap: anywhere;
color: var(--ink);
}
.error {
margin: 0;
color: var(--danger);
font-size: var(--text-sm);
}
section {
margin-top: var(--s5);
}
h2 {
font-size: var(--text-xs);
font-weight: 560;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ink-faint);
padding: 0 var(--s2) var(--s2);
border-bottom: 1px solid var(--line);
margin-bottom: var(--s2);
}
.empty,
.readonly {
margin: 0;
padding: var(--s2);
color: var(--ink-faint);
font-size: var(--text-sm);
}
.readonly {
margin-top: var(--s5);
text-align: center;
}
.clear {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
padding: var(--s8) var(--s4);
color: var(--ink-faint);
font-size: var(--text-sm);
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
opacity: 0.5;
}
</style>