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
+4
View File
@@ -26,3 +26,7 @@ node_modules/
# Copied in by the Docker build; never committed.
/cmd/verdande/webbuild/
# Frontend build output and dependencies
/web/build/
/web/.svelte-kit/
+86
View File
@@ -0,0 +1,86 @@
package httpapi
import (
"crypto/sha256"
"encoding/base64"
"io/fs"
"regexp"
"strings"
)
// inlineScript matches a <script> block that has no src attribute — that is, one
// whose body is executed inline and therefore needs to be named in the CSP.
var inlineScript = regexp.MustCompile(`(?is)<script(?:\s[^>]*)?>(.*?)</script>`)
// hasSrc distinguishes <script src="..."> from an inline block. Only the latter
// needs a hash; the former is already covered by 'self'.
var hasSrc = regexp.MustCompile(`(?is)^<script[^>]*\ssrc\s*=`)
// scriptHashes returns the CSP source expressions for every inline script in the
// built index.html.
//
// This is computed at startup from the shipped file rather than written down as a
// constant, because both inline scripts change: SvelteKit's bootstrap is rebuilt on
// every frontend build, and the theme script in app.html changes whenever it is
// edited. A hard-coded hash would go stale silently and the symptom would be a
// blank page in production only — the CSP is not enforced when the page is opened
// from a file, and a stale hash never fails a test.
//
// The alternative, 'unsafe-inline', would drop the one protection that makes an
// injected <script> in a task title inert. Two hashes are cheaper than that.
func scriptHashes(web fs.FS) []string {
if web == nil {
return nil
}
raw, err := fs.ReadFile(web, "index.html")
if err != nil {
return nil
}
var out []string
seen := map[string]bool{}
for _, match := range inlineScript.FindAllStringSubmatchIndex(string(raw), -1) {
tag := string(raw[match[0]:match[1]])
if hasSrc.MatchString(tag) {
continue
}
body := string(raw[match[2]:match[3]])
if strings.TrimSpace(body) == "" {
continue
}
// The hash is over the script's exact contents, byte for byte — including
// leading and trailing whitespace, which is why the body is not trimmed.
sum := sha256.Sum256([]byte(body))
expr := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
if !seen[expr] {
seen[expr] = true
out = append(out, expr)
}
}
return out
}
// contentSecurityPolicy assembles the policy served with every response.
//
// It is strict because verdande loads nothing from anywhere else: no CDN, no
// analytics, no webfont. The one concession is style-src 'unsafe-inline', which
// Svelte's scoped styles and the inline `style=` on an avatar colour both need;
// inline CSS cannot execute, so it is a far smaller allowance than the script
// equivalent.
func contentSecurityPolicy(hashes []string) string {
scriptSrc := append([]string{"'self'"}, hashes...)
return strings.Join([]string{
"default-src 'self'",
"script-src " + strings.Join(scriptSrc, " "),
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
// The WebSocket is same-origin, so 'self' covers ws: and wss: to this host.
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'",
}, "; ")
}
+8 -15
View File
@@ -36,6 +36,9 @@ type Server struct {
loginLimiter *limiter
resetLimiter *limiter
// Computed once from the built index.html; see csp.go.
csp string
router chi.Router
}
@@ -50,6 +53,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
loginLimiter: newLimiter(10, 15*time.Minute),
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
r := chi.NewRouter()
r.Use(middleware.RequestID)
@@ -57,7 +61,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(requestLogger(log))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(securityHeaders)
r.Use(s.securityHeaders)
// Unauthenticated, and deliberately outside /api/v1: the Rune healthcheck and
// Docker both need this to answer before anyone has logged in.
@@ -256,25 +260,14 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, name, info.ModTime(), seeker)
}
// securityHeaders sets the headers that are the same on every response. The CSP is
// strict because verdande serves only its own assets: there is no CDN to allow, and
// nothing in the app loads a script from anywhere else.
func securityHeaders(next http.Handler) http.Handler {
// securityHeaders sets the headers that are the same on every response.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy", strings.Join([]string{
"default-src 'self'",
"img-src 'self' data: blob:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"form-action 'self'",
}, "; "))
h.Set("Content-Security-Policy", s.csp)
next.ServeHTTP(w, r)
})
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"moduleResolution": "bundler"
}
}
+1607
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "verdande-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./jsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"svelte": "^5.1.0",
"svelte-check": "^4.0.0",
"vite": "^5.4.0"
}
}
+234
View File
@@ -0,0 +1,234 @@
/*
* verdande — design tokens.
*
* Nordic minimalism: a near-neutral ground, generous space, and one accent that
* does all the signalling. The greys are not pure grey — they carry a trace of
* green, which is what stops a dark interface from reading as cold blue-black and
* is most of the difference between "calm" and "unfinished".
*
* Everything is a token. A component that reaches for a raw hex is a component
* that will be wrong in one of the two themes.
*/
:root {
/* Neutrals, dark theme. Named by role, not by lightness, so the light theme
below can invert the scale without every name becoming a lie. */
--ground: #111312;
--surface: #171a19;
--surface-raised: #1d211f;
--surface-sunken: #0d0f0e;
--line: #262b29;
--line-strong: #343a37;
--ink: #e8ebe9;
--ink-muted: #9aa39e;
--ink-faint: #6b736f;
/* Nordlys-grøn. One accent, used for the checkmark, focus rings, and the
single primary action on a screen — and for nothing else, which is what
keeps it meaning "this one". */
--accent: #5fbf8f;
--accent-hover: #6fcf9f;
--accent-sunken: #1c3329;
--accent-ink: #0d1411;
/* Priorities. Red is reserved for P1, so it never appears decoratively — if
something is red on this screen, it is urgent. */
--p1: #cf6b6b;
--p2: #d69f5c;
--p3: #6b9ecf;
--p4: var(--ink-faint);
--danger: #cf6b6b;
--danger-sunken: #33201f;
/* Type. System stack: it renders instantly, matches the platform, and has
proper Danish glyphs on every device — a webfont would cost a round trip
and a flash of unstyled text on the one screen people open fifty times a day. */
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', system-ui, sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-lg: 1.0625rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
/* Spacing on a 4px grid. */
--s1: 0.25rem;
--s2: 0.5rem;
--s3: 0.75rem;
--s4: 1rem;
--s5: 1.5rem;
--s6: 2rem;
--s7: 3rem;
--s8: 4rem;
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-full: 999px;
/* Shadows are nearly absent by design. A flat surface separated by a hairline
reads as calmer than the same surface floating on a drop shadow. */
--shadow: 0 1px 2px rgb(0 0 0 / 0.2);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.35);
/* Motion. Fast enough to feel instant, slow enough to be seen. The
complete-animation is the one place a longer curve earns its keep. */
--ease: cubic-bezier(0.2, 0, 0.2, 1);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--fast: 120ms;
--medium: 200ms;
--slow: 400ms;
--sidebar-width: 268px;
--content-max: 720px;
}
:root[data-theme='light'] {
--ground: #faf9f7;
--surface: #ffffff;
--surface-raised: #ffffff;
--surface-sunken: #f2f1ee;
--line: #e5e3de;
--line-strong: #d3d0c9;
--ink: #1c201e;
--ink-muted: #5f6763;
--ink-faint: #8b918d;
--accent: #2f8f63;
--accent-hover: #277a54;
--accent-sunken: #e2f0e9;
--accent-ink: #ffffff;
--p1: #c04a4a;
--p2: #b57a2b;
--p3: #3d7ab5;
--p4: var(--ink-faint);
--danger: #c04a4a;
--danger-sunken: #f7e6e5;
--shadow: 0 1px 2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 8px 32px rgb(0 0 0 / 0.12);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
}
body {
background: var(--ground);
color: var(--ink);
font-family: var(--font);
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* The app owns its own scrolling regions; the document itself never scrolls,
which is what stops iOS rubber-banding the whole interface. */
overflow: hidden;
overscroll-behavior: none;
}
h1,
h2,
h3,
h4 {
margin: 0;
font-weight: 560;
letter-spacing: -0.015em;
line-height: 1.25;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
background: none;
border: none;
padding: 0;
}
/* One focus treatment everywhere, and only for keyboard users: a focus ring that
also appears on mouse clicks is noise that people learn to ignore. */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
::selection {
background: var(--accent-sunken);
color: var(--ink);
}
/* Scrollbars, made quiet. The default on Windows is a wide grey slab that
dominates a minimal layout. */
* {
scrollbar-width: thin;
scrollbar-color: var(--line-strong) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--line-strong);
border-radius: var(--radius-full);
border: 3px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* Somebody who has asked for less motion gets none of it. The complete-animation
is satisfying; it is not worth making a person nauseous. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="da" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#111312" />
<meta name="description" content="verdande — opgaver og projekter, delt." />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon-180.png" />
<title>verdande</title>
<script>
// Applied before first paint, so a dark-theme user never sees a white
// flash on load. Reading localStorage here is the only way to do that —
// anything inside the app runs after the document has already painted.
try {
const stored = localStorage.getItem('verdande:theme');
const theme =
stored ??
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = theme;
} catch (e) {
// Private browsing can refuse localStorage; the dark default stands.
}
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+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 };
+6
View File
@@ -0,0 +1,6 @@
// The whole app is client-rendered: every page needs a signed-in user, so there is
// nothing meaningful to render on a server that has no session. `ssr = false` also
// means the static adapter emits one index.html shell, which is exactly what the Go
// binary serves for any unrecognised route.
export const ssr = false;
export const prerender = false;
+286
View File
@@ -0,0 +1,286 @@
<script>
import '../app.css';
import { app, theme } from '$lib/stores.svelte.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import Sidebar from '$lib/components/Sidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
let { children } = $props();
let paletteOpen = $state(false);
let sidebarOpen = $state(false);
$effect(() => {
app.load();
});
/**
* Global shortcuts, mapped the way Todoist maps them so muscle memory carries
* over. They are ignored while a text field has focus — otherwise typing "t"
* in a task title would navigate away mid-sentence.
*/
function onkeydown(event) {
const el = event.target;
const typing =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el?.isContentEditable;
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
paletteOpen = true;
return;
}
if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
switch (event.key) {
case 'q':
event.preventDefault();
document.querySelector('input[aria-label="Ny opgave"]')?.focus();
break;
case 't':
goto('/');
break;
case 'u':
goto('/upcoming');
break;
case '?':
goto('/genveje');
break;
}
}
</script>
<svelte:window {onkeydown} />
{#if app.loading}
<!-- Deliberately blank. A spinner for a request that resolves in 30ms is a
flash of anxiety, not feedback. -->
<div class="booting"></div>
{:else if !app.user}
<SignIn />
{:else}
<div class="shell" class:sidebar-open={sidebarOpen}>
<Sidebar onnavigate={() => (sidebarOpen = false)} />
<main>
<header class="topbar">
<button
class="menu"
onclick={() => (sidebarOpen = !sidebarOpen)}
aria-label="Vis menu"
aria-expanded={sidebarOpen}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
<button class="search" onclick={() => (paletteOpen = true)}>
<span>Søg</span>
<kbd>⌘K</kbd>
</button>
<button
class="theme"
onclick={() => theme.toggle()}
aria-label="Skift mellem lyst og mørkt tema"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z" />
</svg>
</button>
</header>
<div class="content">
{@render children()}
</div>
</main>
{#if sidebarOpen}
<button
class="scrim"
onclick={() => (sidebarOpen = false)}
aria-label="Luk menu"
tabindex="-1"
></button>
{/if}
</div>
<CommandPalette bind:open={paletteOpen} />
<!-- Toasts are for things that failed after the interface already said they
had succeeded. Nothing else earns an interruption. -->
<div class="toasts" role="status" aria-live="polite">
{#each app.toasts as toast (toast.id)}
<button class="toast" onclick={() => app.dismissToast(toast.id)}>
{toast.message}
</button>
{/each}
</div>
{/if}
<style>
.booting {
height: 100dvh;
background: var(--ground);
}
.shell {
display: flex;
height: 100dvh;
overflow: hidden;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: var(--ground);
}
.topbar {
display: flex;
align-items: center;
gap: var(--s3);
padding: var(--s3) var(--s4);
border-bottom: 1px solid var(--line);
flex: none;
/* Clears the notch on a phone in landscape. */
padding-left: max(var(--s4), env(safe-area-inset-left));
padding-right: max(var(--s4), env(safe-area-inset-right));
}
.menu {
display: none;
width: 32px;
height: 32px;
place-items: center;
border-radius: var(--radius);
color: var(--ink-muted);
}
.menu svg,
.theme svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.search {
flex: 1;
max-width: 320px;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s3);
padding: var(--s2) var(--s3);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-faint);
font-size: var(--text-sm);
transition: border-color var(--fast) var(--ease);
}
.search:hover {
border-color: var(--line-strong);
}
kbd {
font-family: var(--font);
font-size: var(--text-xs);
color: var(--ink-faint);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 1px var(--s1);
}
.theme {
margin-left: auto;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: var(--radius);
color: var(--ink-muted);
transition: color var(--fast) var(--ease);
}
.theme:hover {
color: var(--ink);
}
.content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
}
.scrim {
display: none;
position: fixed;
inset: 0;
background: rgb(0 0 0 / 0.4);
border: 0;
z-index: 40;
}
.toasts {
position: fixed;
bottom: var(--s4);
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: var(--s2);
z-index: 60;
padding-bottom: env(safe-area-inset-bottom);
}
.toast {
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-left: 2px solid var(--danger);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: var(--s3) var(--s4);
font-size: var(--text-sm);
color: var(--ink);
max-width: 420px;
text-align: left;
}
/* Below the tablet breakpoint the sidebar becomes a drawer. The layout is
mobile-first in behaviour even though the rules read desktop-first: the
drawer is the exception, and writing it as one keeps it in a single block. */
@media (max-width: 820px) {
.menu {
display: grid;
}
.scrim {
display: block;
}
.shell :global(.sidebar) {
position: fixed;
inset: 0 auto 0 0;
z-index: 50;
transform: translateX(-100%);
transition: transform var(--medium) var(--ease-out);
}
.shell.sidebar-open :global(.sidebar) {
transform: translateX(0);
}
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<script>
/** I dag: what is overdue, and what is due today. */
import { api } 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 loaded = $state(false);
// The local date, not the server's: "today" is a question about where the
// person is standing.
const isoToday = (() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(
now.getDate()
).padStart(2, '0')}`;
})();
async function load() {
const data = await api.today();
app.tasks = [...data.overdue, ...data.today];
loaded = true;
}
$effect(() => {
load();
});
// Derived from the store by *date*, not from the ids that came back with the
// first request. Filtering against a captured list would mean a task added
// through quick add — which is pushed into the store, not into that list —
// never appeared until a reload, which is exactly the moment the interface has
// to feel immediate.
let liveOverdue = $derived(
app.tasks.filter((t) => !t.completed && t.due_date && t.due_date < isoToday)
);
let liveToday = $derived(app.tasks.filter((t) => !t.completed && t.due_date === isoToday));
let date = new Date().toLocaleDateString('da-DK', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
</script>
<div class="view">
<header>
<h1>I dag</h1>
<p>{date}</p>
</header>
<QuickAdd />
{#if liveOverdue.length}
<section>
<h2 class="overdue">Forsinket</h2>
{#each liveOverdue as task (task.id)}
<TaskRow {task} />
{/each}
</section>
{/if}
<section>
{#if liveOverdue.length}
<h2>I dag</h2>
{/if}
{#each liveToday as task (task.id)}
<TaskRow {task} />
{/each}
{#if loaded && !liveToday.length && !liveOverdue.length}
<!-- An empty Today is the goal, not a failure state. It should feel
like finishing, not like something is missing. -->
<p class="clear">
<span class="rune" aria-hidden="true">ᚹ</span>
Ikke mere i dag.
</p>
{/if}
</section>
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
header {
margin-bottom: var(--s5);
}
h1 {
font-size: var(--text-2xl);
}
header p {
margin: var(--s1) 0 0;
color: var(--ink-faint);
font-size: var(--text-sm);
text-transform: capitalize;
}
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);
}
h2.overdue {
color: var(--p1);
}
.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>
+90
View File
@@ -0,0 +1,90 @@
<script>
/** The shortcuts, listed. Mapped to Todoist's so muscle memory carries over. */
const groups = [
{
title: 'Navigation',
keys: [
['q', 'Hop til feltet “tilføj opgave”'],
['t', 'I dag'],
['u', 'Kommende'],
['⌘K / Ctrl+K', 'Søg'],
['?', 'Denne side']
]
},
{
title: 'I quick add',
keys: [
['#projekt', 'Læg opgaven i et projekt'],
['@etiket', 'Sæt en etiket'],
['p1 – p4', 'Prioritet'],
['i morgen, på fredag, om 3 dage', 'Dato — dansk og engelsk'],
['kl 10, 14:30, 7pm', 'Klokkeslæt'],
['Esc', 'Ryd feltet']
]
}
];
</script>
<div class="view">
<h1>Genveje</h1>
{#each groups as group}
<section>
<h2>{group.title}</h2>
<dl>
{#each group.keys as [key, what]}
<dt><kbd>{key}</kbd></dt>
<dd>{what}</dd>
{/each}
</dl>
</section>
{/each}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
}
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-bottom: var(--s2);
border-bottom: 1px solid var(--line);
}
dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--s3) var(--s4);
margin: var(--s4) 0 0;
align-items: baseline;
}
dt {
margin: 0;
}
dd {
margin: 0;
color: var(--ink-muted);
font-size: var(--text-sm);
}
kbd {
font-family: var(--font);
font-size: var(--text-xs);
color: var(--ink);
background: var(--surface-raised);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 2px var(--s2);
white-space: nowrap;
}
</style>
+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>
+87
View File
@@ -0,0 +1,87 @@
<script>
/** Kommende: the next seven days, empty ones included. */
import { api } 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 days = $state([]);
$effect(() => {
api.upcoming().then((data) => {
days = data.days;
app.tasks = data.days.flatMap((d) => d.tasks);
});
});
function heading(date) {
const day = new Date(date + 'T00:00:00');
const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000);
if (diff === 0) return 'I dag';
if (diff === 1) return 'I morgen';
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
}
// Reading tasks back out of the store keeps a completed row disappearing
// immediately rather than on the next load.
const live = (date) => app.tasks.filter((t) => t.due_date === date && !t.completed);
</script>
<div class="view">
<header><h1>Kommende</h1></header>
<QuickAdd />
{#each days as day (day.date)}
{@const tasks = live(day.date)}
<section>
<h2>{heading(day.date)}</h2>
{#if tasks.length}
{#each tasks as task (task.id)}
<TaskRow {task} />
{/each}
{:else}
<p class="empty">—</p>
{/if}
</section>
{/each}
</div>
<style>
.view {
max-width: var(--content-max);
margin: 0 auto;
padding: var(--s6) var(--s4) var(--s8);
}
h1 {
font-size: var(--text-2xl);
margin-bottom: var(--s5);
}
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);
}
/* An em dash rather than "ingen opgaver": seven repetitions of a sentence is
noise, and the reader only needs to know the day is clear. */
.empty {
margin: 0;
padding: var(--s2);
color: var(--ink-faint);
font-size: var(--text-sm);
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Wunjo, the rune Verdande's name begins with. Drawn as strokes rather than
as a filled glyph so it stays legible at 16px in a browser tab. -->
<rect width="64" height="64" rx="14" fill="#111312"/>
<g fill="none" stroke="#5fbf8f" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 14v36"/>
<path d="M22 17l17 8-17 8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 447 B

+15
View File
@@ -0,0 +1,15 @@
{
"name": "verdande",
"short_name": "verdande",
"description": "Opgaver og projekter, delt.",
"start_url": "/",
"display": "standalone",
"background_color": "#111312",
"theme_color": "#111312",
"lang": "da",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+19
View File
@@ -0,0 +1,19 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
export default {
kit: {
// A single-page app: the Go binary embeds `build/` and serves index.html for
// any route it does not recognise, so the client router owns navigation.
// Prerendering is off because every page needs a signed-in user.
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false
}),
alias: {
$lib: 'src/lib'
}
}
};
+19
View File
@@ -0,0 +1,19 @@
import { sveltekit } from '@sveltejs/kit/vite';
export default {
plugins: [sveltekit()],
server: {
port: 5173,
// In development the app runs on 5173 and the API on 8080. Proxying rather
// than pointing fetch at another origin keeps the session cookie
// first-party, which is what production looks like — otherwise every
// cookie and CSRF behaviour would differ between dev and the real thing.
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: false,
ws: true
}
}
}
};