diff --git a/README.md b/README.md
index a5554ce..2ec0167 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,23 @@ podman run --replace --name vinterliste \
The container exposes `/api/health` for healthchecks and bakes the build date /
git revision into both OCI labels and `/etc/build-info`.
+## Installable (PWA) + mobile
+
+The SPA ships with a web app manifest (`/manifest.webmanifest`), an SVG icon
+(`/icon.svg`), and a small service worker (`/sw.js`) that caches the bundled
+shell for offline reads. The API itself is **never** cached — sessions and
+ciphertexts must come fresh from the server. On supported browsers
+(Chrome/Edge on Android and desktop, Firefox with the flag) you'll see an
+"Install" prompt; on iOS you can Add to Home Screen but iOS doesn't render
+SVG icons, so the home-screen icon will fall back to the page screenshot.
+
+Layout adapts to small screens via:
+
+- `viewport` set to `width=device-width, initial-scale=1, viewport-fit=cover`
+- safe-area insets in `padding` so content doesn't slip under iOS notches
+- `min-height: 44px` on buttons (WCAG 2.5.5 enhanced touch target)
+- `font-size: 16px` on inputs below 480px so iOS doesn't auto-zoom
+
## Promoting a moderator
Moderators can delete any `semi` or `public` activity (not `private` — those
diff --git a/frontend/index.html b/frontend/index.html
index 5abf4f8..cc7016f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,8 +2,15 @@
-
+
+
+
+
+
+
+
Vinterliste
diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg
new file mode 100644
index 0000000..bf53096
--- /dev/null
+++ b/frontend/public/icon.svg
@@ -0,0 +1,54 @@
+
+
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
new file mode 100644
index 0000000..85ff86d
--- /dev/null
+++ b/frontend/public/manifest.webmanifest
@@ -0,0 +1,20 @@
+{
+ "name": "Vinterliste",
+ "short_name": "Vinterliste",
+ "description": "Ende-til-ende-kryptert liste over vinteraktiviteter",
+ "lang": "nb-NO",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#161616",
+ "theme_color": "#1f6feb",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
new file mode 100644
index 0000000..2fc9c0f
--- /dev/null
+++ b/frontend/public/sw.js
@@ -0,0 +1,73 @@
+// Vinterliste service worker.
+//
+// Two strategies:
+// - Static assets (the bundled JS/CSS under /assets/, plus the SPA shell):
+// cache-first. The bundle filenames are hashed by Vite, so a new deploy
+// ships under a new URL and gets fetched fresh; old entries are pruned
+// when the SW version (CACHE_NAME) bumps.
+// - API calls (/api/*): network-only. We never cache responses that depend
+// on the caller's session — that'd be a privacy disaster for an E2E app.
+// Letting them fail with a network error when offline is the right shape:
+// the UI shows the existing "Kunne ikke laste …" path.
+//
+// IMPORTANT: never cache responses that contain ciphertext or any decrypted
+// payload. Private activity ciphertexts are technically safe to cache (they
+// require the in-memory DEK to read), but the simpler and safer rule is "no
+// API caching at all" so we don't accidentally land in trouble later.
+
+const CACHE_NAME = 'vinterliste-v1';
+const SHELL_PATHS = ['/', '/index.html', '/icon.svg', '/favicon.svg', '/manifest.webmanifest'];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_PATHS)).catch(() => null),
+ );
+ self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))),
+ ),
+ );
+ self.clients.claim();
+});
+
+self.addEventListener('fetch', (event) => {
+ const req = event.request;
+ if (req.method !== 'GET') return;
+
+ const url = new URL(req.url);
+
+ // Same-origin only — never touch third-party requests.
+ if (url.origin !== self.location.origin) return;
+
+ // API: network-only. Don't cache session-dependent data.
+ if (url.pathname.startsWith('/api/')) return;
+
+ // Everything else: cache-first with network fallback that backfills the cache.
+ event.respondWith(
+ caches.match(req).then((cached) => {
+ if (cached) return cached;
+ return fetch(req)
+ .then((res) => {
+ // Only cache successful, basic (same-origin) responses.
+ if (res && res.status === 200 && res.type === 'basic') {
+ const copy = res.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => null);
+ }
+ return res;
+ })
+ .catch(() => {
+ // Offline + nothing cached. For navigation requests, fall back to the
+ // SPA shell so the user at least gets a "you're offline" frame the
+ // app can render. For other requests, let the failure surface.
+ if (req.mode === 'navigate') {
+ return caches.match('/index.html');
+ }
+ throw new Error('offline');
+ });
+ }),
+ );
+});
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 16f19d2..9cc68cb 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -45,13 +45,15 @@
try {
const me = await api.me();
// We have an active server session but no DEK — the user reloaded the
- // page. Force them through the login screen so we can re-unlock.
+ // page. Drop the stale server session and pre-fill their email on the
+ // login form, but otherwise show the same public landing as any other
+ // logged-out visitor. They can hit "Logg inn" when they want to unlock.
defaultEmail = me.email;
- await api.logout(); // drop the stale server session
- view = 'login';
- } catch (err) {
- if (err instanceof ApiError && err.status === 401) view = 'login';
- else view = 'login';
+ await api.logout();
+ view = 'home';
+ } catch {
+ // No session (or expired). Show the public landing.
+ view = 'home';
}
});
@@ -77,17 +79,23 @@
diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte
index 3a9064d..161a834 100644
--- a/frontend/src/components/ActivityRow.svelte
+++ b/frontend/src/components/ActivityRow.svelte
@@ -68,20 +68,17 @@
// Authz mirrors server/activities.ts:
// - private: owner-only (server only returns yours anyway)
- // - public: owner OR moderator
- // - semi: owner OR moderator (but UI can't tell ownership from the
- // row because owner_id isn't serialised; we expose Delete only
- // to moderators; owners must use server-side ownership check
- // when they hit it — but in this UI we can't show a per-row
- // "you own this" hint without leaking. Acceptable tradeoff
- // for now; semi-owner-delete still works via Edit path that
- // the server authorises).
+ // - public: owner if session.user.id === owner_id
+ // - semi: owner if the server included owner_id in this row — the
+ // server only sends it to the owner, so its presence is the
+ // ownership signal. Anyone else sees no owner_id and no buttons.
+ // - moderators can delete (but not edit) any non-private row.
const isOwner = $derived(
- activity.visibility === 'public'
- ? session.user?.id === activity.owner_id
- : activity.visibility === 'private'
- ? true
- : false,
+ activity.visibility === 'private'
+ ? true
+ : activity.visibility === 'public'
+ ? session.user?.id === activity.owner_id
+ : 'owner_id' in activity && activity.owner_id === session.user?.id,
);
const isModerator = $derived(session.user?.is_moderator === true);
const canEdit = $derived(isOwner); // editing always requires ownership
@@ -156,7 +153,14 @@
{/if}
{#if activity.scheduled_at}
🕒 {formatDate(activity.scheduled_at)}
{/if}
{#if activity.visibility === 'public'}
-
Lagt til av {activity.owner_display}
+
+ Lagt til av
+ {#if activity.owner_username}
+ {activity.owner_display}
+ {:else}
+ {activity.owner_display}
+ {/if}
+