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
|
|
@ -41,15 +41,24 @@ interface ActivityRow {
|
|||
* back to the part before the `@` in the email so accounts that haven't set
|
||||
* one still get something less hostile than a UUID slice. Email itself is
|
||||
* NOT surfaced — that's a contact identifier, not an attribution.
|
||||
*
|
||||
* Also returns the user's URL slug if they've opted into a public list; the
|
||||
* client uses that to link the attribution to /<username>/list. Returns null
|
||||
* for the slug whenever the user hasn't opted in, so the link decision is
|
||||
* purely server-side.
|
||||
*/
|
||||
function ownerDisplay(ownerId: string): string {
|
||||
function ownerAttribution(ownerId: string): { display: string; username: string | null } {
|
||||
const row = getDb()
|
||||
.prepare('SELECT display_name, email FROM users WHERE id = ?')
|
||||
.get(ownerId) as { display_name: string | null; email: string } | null;
|
||||
if (!row) return 'ukjent';
|
||||
if (row.display_name && row.display_name.trim()) return row.display_name;
|
||||
const at = row.email.indexOf('@');
|
||||
return at > 0 ? row.email.slice(0, at) : row.email;
|
||||
.prepare('SELECT display_name, email, username, public_list_enabled FROM users WHERE id = ?')
|
||||
.get(ownerId) as
|
||||
| { display_name: string | null; email: string; username: string | null; public_list_enabled: number | null }
|
||||
| null;
|
||||
if (!row) return { display: 'ukjent', username: null };
|
||||
const display = (row.display_name && row.display_name.trim())
|
||||
? row.display_name
|
||||
: (row.email.indexOf('@') > 0 ? row.email.slice(0, row.email.indexOf('@')) : row.email);
|
||||
const username = row.public_list_enabled === 1 ? row.username : null;
|
||||
return { display, username };
|
||||
}
|
||||
|
||||
function isModerator(userId: string): boolean {
|
||||
|
|
@ -82,7 +91,9 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
}
|
||||
const tags = tagsFor(row.id);
|
||||
if (row.visibility === 'semi') {
|
||||
// owner_id deliberately omitted — see SECURITY.md
|
||||
// owner_id is included ONLY when the viewer IS the owner — that lets the
|
||||
// client render Edit/Delete on the user's own semi rows without leaking
|
||||
// attribution to anyone else. See SECURITY.md.
|
||||
const a: ActivitySemi = {
|
||||
id: row.id,
|
||||
visibility: 'semi',
|
||||
|
|
@ -95,13 +106,16 @@ function serialize(row: ActivityRow, viewerId: string | null): Activity {
|
|||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
if (viewerId === row.owner_id) a.owner_id = row.owner_id;
|
||||
return a;
|
||||
}
|
||||
const attrib = ownerAttribution(row.owner_id);
|
||||
const a: ActivityPublic = {
|
||||
id: row.id,
|
||||
visibility: 'public',
|
||||
owner_id: row.owner_id,
|
||||
owner_display: ownerDisplay(row.owner_id),
|
||||
owner_display: attrib.display,
|
||||
owner_username: attrib.username,
|
||||
title: row.title ?? '',
|
||||
tags,
|
||||
loc_label: row.loc_label,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ app.route('/api/tags', tagsRoutes);
|
|||
app.route('/api/users', usersRoutes);
|
||||
app.route('/api/feedback', feedbackRoutes);
|
||||
|
||||
// In production, serve the built Svelte SPA. Hono's bun static helper handles
|
||||
// asset MIME types; everything else falls through to index.html for SPA routing.
|
||||
// In production, serve the built Svelte SPA. The static helper is registered
|
||||
// for the asset directory and for the top-level files that Vite copies from
|
||||
// `frontend/public/` (manifest, icons, service worker). Unknown paths fall
|
||||
// through to index.html so the SPA's path routing (/<username>/list) works.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use('/assets/*', serveStatic({ root: './frontend/dist' }));
|
||||
app.get('/favicon.svg', serveStatic({ path: './frontend/dist/favicon.svg' }));
|
||||
for (const file of ['favicon.svg', 'icon.svg', 'manifest.webmanifest', 'sw.js']) {
|
||||
app.get(`/${file}`, serveStatic({ path: `./frontend/dist/${file}` }));
|
||||
}
|
||||
app.get('*', serveStatic({ path: './frontend/dist/index.html' }));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ usersRoutes.get('/:username/list', (c) => {
|
|||
visibility: 'public',
|
||||
owner_id: r.owner_id,
|
||||
owner_display: user.display_name?.trim() || username,
|
||||
// The list itself is at /<username>/list, so we already know the slug.
|
||||
// Surfacing it on each row keeps ActivityRow's rendering uniform.
|
||||
owner_username: username,
|
||||
title: r.title ?? '',
|
||||
tags: tagsFor(r.id),
|
||||
loc_label: r.loc_label,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue