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