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:
co-authored by
Claude Opus 5
parent
dff86dd23d
commit
12a9597919
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> </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>
|
||||
@@ -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} {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>
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user