diff --git a/.gitignore b/.gitignore index e97303a..39d05a8 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/internal/httpapi/csp.go b/internal/httpapi/csp.go new file mode 100644 index 0000000..0a88430 --- /dev/null +++ b/internal/httpapi/csp.go @@ -0,0 +1,86 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/base64" + "io/fs" + "regexp" + "strings" +) + +// inlineScript matches a `) + +// hasSrc distinguishes + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.js b/web/src/lib/api.js new file mode 100644 index 0000000..dd07dd1 --- /dev/null +++ b/web/src/lib/api.js @@ -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)}`) +}; diff --git a/web/src/lib/components/CommandPalette.svelte b/web/src/lib/components/CommandPalette.svelte new file mode 100644 index 0000000..634126e --- /dev/null +++ b/web/src/lib/components/CommandPalette.svelte @@ -0,0 +1,210 @@ + + +{#if open} + +
(open = false)}> + +
e.stopPropagation()} role="dialog" aria-label="Søg"> + + + {#if results.length} + + {:else if query.trim()} +

Ingen resultater.

+ {/if} +
+
+{/if} + + diff --git a/web/src/lib/components/QuickAdd.svelte b/web/src/lib/components/QuickAdd.svelte new file mode 100644 index 0000000..a92687b --- /dev/null +++ b/web/src/lib/components/QuickAdd.svelte @@ -0,0 +1,265 @@ + + +
+ + +
+ + + + +
+ + {#if text.trim()} + + {/if} +
+ + diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..633d7f4 --- /dev/null +++ b/web/src/lib/components/Sidebar.svelte @@ -0,0 +1,322 @@ + + + + + diff --git a/web/src/lib/components/SignIn.svelte b/web/src/lib/components/SignIn.svelte new file mode 100644 index 0000000..9b8cba2 --- /dev/null +++ b/web/src/lib/components/SignIn.svelte @@ -0,0 +1,298 @@ + + +
+
+
+ +

verdande

+
+ + {#if mode === 'loading'} +

 

+ {:else if mode === 'setup'} +

Opret den første konto. Den bliver administrator.

+ + + + + + + {:else if mode === 'totp'} +

Indtast koden fra din authenticator.

+ + + + + {:else if mode === 'forgot'} +

Vi sender et link til at vælge en ny adgangskode.

+ + + + + + {:else if mode === 'sent'} + +

+ Hvis adressen har en konto, er der et link på vej. Tjek også spamfilteret. +

+ + {:else} +

Log ind for at fortsætte.

+ + + + + + + {/if} + + {#if error} + + {/if} +
+
+ + diff --git a/web/src/lib/components/TaskRow.svelte b/web/src/lib/components/TaskRow.svelte new file mode 100644 index 0000000..6f30839 --- /dev/null +++ b/web/src/lib/components/TaskRow.svelte @@ -0,0 +1,239 @@ + + +
+ + + +
+ + diff --git a/web/src/lib/stores.svelte.js b/web/src/lib/stores.svelte.js new file mode 100644 index 0000000..1dd2996 --- /dev/null +++ b/web/src/lib/stores.svelte.js @@ -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 }; diff --git a/web/src/routes/+layout.js b/web/src/routes/+layout.js new file mode 100644 index 0000000..1e7d9ef --- /dev/null +++ b/web/src/routes/+layout.js @@ -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; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..ee94f31 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,286 @@ + + + + +{#if app.loading} + +
+{:else if !app.user} + +{:else} +
+ (sidebarOpen = false)} /> + +
+
+ + + + + +
+ +
+ {@render children()} +
+
+ + {#if sidebarOpen} + + {/if} +
+ + + + +
+ {#each app.toasts as toast (toast.id)} + + {/each} +
+{/if} + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..79d4078 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,136 @@ + + +
+
+

I dag

+

{date}

+
+ + + + {#if liveOverdue.length} +
+

Forsinket

+ {#each liveOverdue as task (task.id)} + + {/each} +
+ {/if} + +
+ {#if liveOverdue.length} +

I dag

+ {/if} + {#each liveToday as task (task.id)} + + {/each} + + {#if loaded && !liveToday.length && !liveOverdue.length} + +

+ + Ikke mere i dag. +

+ {/if} +
+
+ + diff --git a/web/src/routes/genveje/+page.svelte b/web/src/routes/genveje/+page.svelte new file mode 100644 index 0000000..327b012 --- /dev/null +++ b/web/src/routes/genveje/+page.svelte @@ -0,0 +1,90 @@ + + +
+

Genveje

+ {#each groups as group} +
+

{group.title}

+
+ {#each group.keys as [key, what]} +
{key}
+
{what}
+ {/each} +
+
+ {/each} +
+ + diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte new file mode 100644 index 0000000..9deef0c --- /dev/null +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -0,0 +1,372 @@ + + +
+ {#if project} +
+

{project.name}

+ {#if !project.is_inbox} + + {/if} +
+ + {#if showShare} +
+
    + {#each members as member (member.user_id)} +
  • + + {member.name[0]?.toUpperCase()} + + {member.name} + {member.role} + {#if isOwner && member.role !== 'owner'} + + {/if} +
  • + {/each} +
+ + {#if isOwner} +
+ + + +
+ + {#if inviteLink} + + {/if} + {#if inviteError} +

{inviteError}

+ {/if} + {/if} +
+ {/if} + + {#if canEdit} + + {/if} + +
+ {#each unsectioned as task (task.id)} + + {/each} +
+ + {#each sections as section (section.id)} + {@const tasks = open.filter((t) => t.section_id === section.id)} +
+

{section.name}

+ {#each tasks as task (task.id)} + + {/each} + {#if !tasks.length} +

Tom

+ {/if} +
+ {/each} + + {#if !open.length} +

+ + Ingenting her endnu. +

+ {/if} + + {#if project.role === 'viewer'} +

Du kan se dette projekt, men ikke ændre det.

+ {/if} + {:else} +

Projektet findes ikke, eller du har ikke adgang til det.

+ {/if} +
+ + diff --git a/web/src/routes/upcoming/+page.svelte b/web/src/routes/upcoming/+page.svelte new file mode 100644 index 0000000..dd193f8 --- /dev/null +++ b/web/src/routes/upcoming/+page.svelte @@ -0,0 +1,87 @@ + + +
+

Kommende

+ + + + {#each days as day (day.date)} + {@const tasks = live(day.date)} +
+

{heading(day.date)}

+ {#if tasks.length} + {#each tasks as task (task.id)} + + {/each} + {:else} +

—

+ {/if} +
+ {/each} +
+ + diff --git a/web/static/icon.svg b/web/static/icon.svg new file mode 100644 index 0000000..f0cf472 --- /dev/null +++ b/web/static/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest new file mode 100644 index 0000000..f5d6727 --- /dev/null +++ b/web/static/manifest.webmanifest @@ -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" } + ] +} diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..1ee9bba --- /dev/null +++ b/web/svelte.config.js @@ -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' + } + } +}; diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..ec086eb --- /dev/null +++ b/web/vite.config.js @@ -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 + } + } + } +};