Public landing, owner-list links, owner-conditional semi, PWA + mobile
Four related UX/privacy/install changes.
1. **Logged-out lands on the public list.** The root route now shows the
same Home view as a logged-in user, minus their own private rows and
the "Ny aktivitet" button. The nav exposes a "Logg inn" button when
no session is present. Login becomes one click away, not the forced
landing — anyone can browse the public + semi list anonymously.
2. **Public activities link to /<owner_username>/list.** When a public
activity's owner has opted into a public list, the "Lagt til av X"
line renders X as a link to /<username>/list. Server populates
`owner_username` on every public-row serialisation (null when the
owner hasn't opted in, so the client just renders plain text).
3. **Conditional owner_id on semi rows.** The server now serialises
`owner_id` on a semi row ONLY when the viewer IS the owner. The
wire type's `ActivitySemi.owner_id` is therefore optional. This
solves the semi-delete UX without leaking attribution: owners see
Edit/Delete buttons on their own semi rows; non-owners get the same
bare row they got before. The privacy property is enforced at the
API boundary, not in client-side render logic.
4. **Mobile-friendly + installable PWA.**
- `manifest.webmanifest` with name, theme color, standalone display,
and a maskable SVG icon (icon.svg).
- Service worker (sw.js): cache-first for the bundled shell;
network-only for /api/* (we never cache session-dependent or
ciphertext data — see the comment in sw.js for the rationale).
Falls back to the SPA shell for navigation requests when offline.
- SW registered in main.ts only in production builds (import.meta.env.PROD).
- viewport-fit=cover + env(safe-area-inset-*) padding so content
doesn't slip under iOS notches when installed.
- WCAG 2.5.5 touch-target sizing: min-height: 44px on buttons,
with an explicit opt-out for tag-close buttons (24×24 still meets
the 2.5.8 minimum).
- 16px font on form inputs below 480px so iOS doesn't auto-zoom.
Server-side: server/index.ts now serves manifest, icon, and sw.js
from frontend/dist alongside /assets/*. The catch-all still serves
index.html so the SPA's /<username>/list path routing keeps working.
Smoke-tested with a production-mode server: manifest returns the
correct application/manifest+json MIME, SVG renders, sw.js is
loadable, and unknown paths fall through to index.html as expected.
26 tests still pass; both tsconfigs typecheck (frontend now pulls
vite/client types for import.meta.env.PROD); Vite build succeeds.
This commit is contained in:
parent
6f4c11c7a6
commit
f0b4d735b5
15 changed files with 317 additions and 54 deletions
|
|
@ -2,8 +2,15 @@
|
|||
<html lang="nb">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- viewport-fit=cover lets us paint under iOS notches; the safe-area
|
||||
insets in styles.css take care of keeping content readable. -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#1f6feb" />
|
||||
<meta name="description" content="Ende-til-ende-kryptert liste over vinteraktiviteter." />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Vinterliste</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
54
frontend/public/icon.svg
Normal file
54
frontend/public/icon.svg
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Vinterliste">
|
||||
<!-- Background tile, large enough to satisfy `maskable` safe zone (centre 40% padding). -->
|
||||
<rect width="512" height="512" rx="96" fill="#1f6feb"/>
|
||||
|
||||
<!-- Stylised snowflake — 6 arms with side branches, drawn from the centre. -->
|
||||
<g stroke="#ffffff" stroke-width="22" stroke-linecap="round" fill="none">
|
||||
<!-- Six main arms, rotated 60° each. -->
|
||||
<g transform="translate(256 256)">
|
||||
<g>
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(60)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(120)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(180)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(240)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
<g transform="rotate(300)">
|
||||
<line x1="0" y1="0" x2="0" y2="-150"/>
|
||||
<line x1="0" y1="-90" x2="-30" y2="-120"/>
|
||||
<line x1="0" y1="-90" x2="30" y2="-120"/>
|
||||
<line x1="0" y1="-50" x2="-22" y2="-72"/>
|
||||
<line x1="0" y1="-50" x2="22" y2="-72"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
20
frontend/public/manifest.webmanifest
Normal file
20
frontend/public/manifest.webmanifest
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
73
frontend/public/sw.js
Normal file
73
frontend/public/sw.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -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 @@
|
|||
<main>
|
||||
<nav class="top">
|
||||
<h1 style="margin: 0;">Vinterliste</h1>
|
||||
{#if session.user && view !== 'public-list'}
|
||||
{#if view !== 'public-list'}
|
||||
<div class="row">
|
||||
<button type="button" onclick={() => (view = 'feedback')}
|
||||
aria-label="Send tilbakemelding">
|
||||
Tilbakemelding
|
||||
</button>
|
||||
<button type="button" onclick={() => (view = 'profile')}
|
||||
aria-label="Profil">
|
||||
{session.user.display_name?.trim() || session.user.email}
|
||||
</button>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
{#if session.user}
|
||||
<button type="button" onclick={() => (view = 'feedback')}
|
||||
aria-label="Send tilbakemelding">
|
||||
Tilbakemelding
|
||||
</button>
|
||||
<button type="button" onclick={() => (view = 'profile')}
|
||||
aria-label="Profil">
|
||||
{session.user.display_name?.trim() || session.user.email}
|
||||
</button>
|
||||
<button onclick={onLogout}>Logg ut</button>
|
||||
{:else if view !== 'login' && view !== 'signup' && view !== 'recovery'}
|
||||
<button class="primary" type="button" onclick={() => (view = 'login')}>
|
||||
Logg inn
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -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}<p class="muted">🕒 {formatDate(activity.scheduled_at)}</p>{/if}
|
||||
{#if activity.visibility === 'public'}
|
||||
<p class="muted" style="font-size: 0.8rem;">Lagt til av {activity.owner_display}</p>
|
||||
<p class="muted" style="font-size: 0.8rem;">
|
||||
Lagt til av
|
||||
{#if activity.owner_username}
|
||||
<a href={`/${activity.owner_username}/list`}>{activity.owner_display}</a>
|
||||
{:else}
|
||||
{activity.owner_display}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,12 +93,19 @@
|
|||
|
||||
<section aria-label="Aktiviteter">
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 1rem;">
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user?.display_name?.trim() || session.user?.email}.
|
||||
Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm && !editing}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{#if session.user}
|
||||
<p class="muted" style="margin: 0;">
|
||||
Velkommen, {session.user.display_name?.trim() || session.user.email}.
|
||||
Her er aktivitetene dine for vinteren.
|
||||
</p>
|
||||
{#if !showForm && !editing}
|
||||
<button class="primary" onclick={() => (showForm = true)}>Ny aktivitet</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted" style="margin: 0;">
|
||||
Offentlige og halv-offentlige aktiviteter. Logg inn for å legge til
|
||||
dine egne — privat eller offentlig.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,3 +6,15 @@ const target = document.getElementById('app');
|
|||
if (!target) throw new Error('No #app element in DOM');
|
||||
|
||||
mount(App, { target });
|
||||
|
||||
// Register the service worker so the SPA is installable + cached for offline
|
||||
// reads of the shell. Production only — Vite's dev server already does its
|
||||
// own SW dance and registering ours would step on hot module reload. Errors
|
||||
// are swallowed: the SW is an enhancement, not a requirement for the app.
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW unavailable (file://, private mode in some browsers, etc.). Ignore.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,17 @@ html, body {
|
|||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
/* iOS double-tap zoom is annoying for an app like this; the meta viewport
|
||||
handles the rest. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
/* Use env() for safe-area insets so content doesn't slip under iOS notches
|
||||
or under Android gesture areas when installed as a PWA. */
|
||||
padding: 1.5rem max(1rem, env(safe-area-inset-right)) calc(4rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
h1, h2, h3 { line-height: 1.2; margin-top: 0; }
|
||||
|
|
@ -68,6 +73,9 @@ input:focus-visible, button:focus-visible, select:focus-visible, textarea:focus-
|
|||
|
||||
button {
|
||||
cursor: pointer;
|
||||
/* WCAG 2.5.5 enhanced target size is 44×44 CSS px; we hit that with the
|
||||
vertical padding + line-height. Don't shrink below this on small viewports. */
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -117,6 +125,14 @@ label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 500; }
|
|||
margin: 0.15rem 0.2rem 0.15rem 0;
|
||||
}
|
||||
.tag.private { background: rgba(31,111,235,0.15); }
|
||||
/* Tag-close buttons opt out of the 44px touch target — they're 24×24 (WCAG
|
||||
2.5.8 minimum) which is still large enough to tap reliably and keeps the
|
||||
pill from ballooning. The button itself remains keyboard-accessible. */
|
||||
.tag button {
|
||||
min-height: 24px;
|
||||
min-width: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recovery-code {
|
||||
display: block;
|
||||
|
|
@ -137,6 +153,24 @@ nav.top {
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
nav.top > .row {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Narrow phones: drop the nav-button gap a little and let the title shrink. */
|
||||
@media (max-width: 480px) {
|
||||
main { padding-top: 1rem; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
nav.top h1 { flex: 1 1 100%; }
|
||||
/* Inputs are wide by default; just make sure they don't overflow on tiny screens. */
|
||||
input[type="text"], input[type="email"], input[type="password"],
|
||||
input[type="datetime-local"], input[type="search"], textarea, select {
|
||||
font-size: 16px; /* prevents iOS from auto-zooming on focus */
|
||||
}
|
||||
}
|
||||
|
||||
.vis-badge {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"allowImportingTsExtensions": false,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": []
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "../shared/**/*"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue