From f4816502ed538421cada5774ba874719f478d655 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 18:20:50 +0200 Subject: [PATCH] refactor: Norwegian URL paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-visible SPA routes were a mix of English and Norwegian. Bring them in line with the rest of the project's language: /home → /hjem /a/:id → /aktivitet/:id //list → //liste /tags/:tag → /etiketter/:etikett /invite/:token → /invitasjon/:token The English forms remain accepted by the SPA router (parsePath) and the server OG handlers so links shared before the rename — invite URLs in particular — still resolve. Outgoing links always use the Norwegian forms. API paths (/api/users/:username/list etc.) stay in English — they're internal contracts between client and server, not user-visible URLs. Server OG registration orders //liste before /aktivitet/:id so a hypothetical user with the slug "aktivitet" still gets their profile page rather than an activity-not-found. For normal activity URLs the user-list route doesn't match (second segment must be the literal "liste"). Profile copy referencing the URL slug also updated. --- frontend/src/App.svelte | 48 +++++++++++++--------- frontend/src/components/ActivityRow.svelte | 14 +++---- frontend/src/components/Home.svelte | 2 +- frontend/src/components/Profile.svelte | 6 +-- frontend/src/components/Signup.svelte | 2 +- server/activities.ts | 4 +- server/db.ts | 6 +-- server/index.ts | 33 ++++++++++++--- server/og.ts | 10 ++--- server/users.ts | 2 +- shared/types.ts | 4 +- 11 files changed, 80 insertions(+), 51 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 673f468..3534856 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -18,13 +18,19 @@ import TagPage from './components/TagPage.svelte'; /** - * URL contract: - * / — always the public landing (anyone), even when logged in - * /home — authenticated dashboard (private+semi+public) - * /a/:id — permalink to a single activity (any visibility) - * //list — opt-in public list for that user - * /personvern — privacy + how-it-works long-form page - * /tags/:tag — activities matching a tag, scoped to viewer visibility + * URL contract — all in Norwegian: + * / — public landing (anyone), even when logged in + * /hjem — authenticated dashboard (private+semi+public) + * /aktivitet/:id — permalink to a single activity (any visibility) + * //liste — opt-in public list for that user + * /personvern — privacy + how-it-works long-form page + * /etiketter/:etikett — activities matching a tag, scoped to viewer visibility + * /invitasjon/:token — invite-claim landing (routes into signup with token pre-filled) + * + * Back-compat: parsePath also accepts the previous English aliases + * (/home, /a/:id, //list, /tags/:t, /invite/:t) so links shared + * before the rename still resolve. Outgoing links always use the + * Norwegian forms — pushUrl(...) below. * * Anything else (signup, login, recovery, profile, feedback, admin) is an * in-app view state, not a URL. We update window.history on view changes @@ -34,12 +40,12 @@ | 'loading' | 'login' | 'signup' | 'recovery' | 'public-home' // "/" — public/semi only, anyone - | 'home' // "/home" — full authenticated dashboard + | 'home' // "/hjem" — full authenticated dashboard | 'profile' | 'feedback' | 'admin' | 'moderate-tags' - | 'public-list' // "//list" - | 'permalink' // "/a/:id" + | 'public-list' // "//liste" + | 'permalink' // "/aktivitet/:id" | 'personvern' // "/personvern" - | 'tag'; // "/tags/:tag" + | 'tag'; // "/etiketter/:etikett" let view: View = $state('loading'); let publicListUsername = $state(''); @@ -57,13 +63,15 @@ function parsePath(): Route { const path = window.location.pathname; if (path === '/' || path === '') return { view: 'public-home' }; - if (path === '/home' || path === '/home/') return { view: 'home' }; + if (path === '/hjem' || path === '/hjem/' || path === '/home' || path === '/home/') { + return { view: 'home' }; + } if (path === '/personvern' || path === '/personvern/') return { view: 'personvern' }; - // Tag pages: /tags/. We don't constrain charset here + // Etikett-sider: /etiketter/. We don't constrain charset here // because tags allow spaces and any character (they're normalised // server-side to lowercase + trim). decodeURIComponent below gets the - // visible value back. - const tagMatch = path.match(/^\/tags\/([^/]+)\/?$/); + // visible value back. /tags/ is the old English form, still accepted. + const tagMatch = path.match(/^\/(?:etiketter|tags)\/([^/]+)\/?$/); if (tagMatch) { try { return { view: 'tag', payload: decodeURIComponent(tagMatch[1]!) }; @@ -71,11 +79,11 @@ return { view: 'public-home' }; } } - const userList = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/); + const userList = path.match(/^\/([a-z0-9_-]{2,31})\/(?:liste|list)\/?$/); if (userList) return { view: 'public-list', payload: userList[1] }; - const perma = path.match(/^\/a\/([A-Za-z0-9-]+)\/?$/); + const perma = path.match(/^\/(?:aktivitet|a)\/([A-Za-z0-9-]+)\/?$/); if (perma) return { view: 'permalink', payload: perma[1] }; - const invite = path.match(/^\/invite\/([A-Za-z0-9_-]+)\/?$/); + const invite = path.match(/^\/(?:invitasjon|invite)\/([A-Za-z0-9_-]+)\/?$/); if (invite) return { view: 'invite', payload: invite[1] }; // Unknown path: treat as the public landing rather than 404. The server // returns index.html for anything non-API anyway, so this keeps deep @@ -175,7 +183,7 @@ } function onAuthed() { - pushUrl('/home'); + pushUrl('/hjem'); view = 'home'; } @@ -186,7 +194,7 @@ } function goHome() { - pushUrl('/home'); + pushUrl('/hjem'); view = 'home'; } diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index f98c88e..a330d7d 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -164,14 +164,14 @@ } /** - * Share-link: copy /a/ (absolute URL) to the clipboard. Private rows + * Share-link: copy /aktivitet/ (absolute URL) to the clipboard. Private rows * are shareable in principle but only the owner can decrypt — the receiver * sees a "private, can't view" message. We still show the button so the * owner can save the link. */ let copiedAt: number | null = $state(null); async function copyPermalink() { - const url = `${window.location.origin}/a/${activity.id}`; + const url = `${window.location.origin}/aktivitet/${activity.id}`; try { await navigator.clipboard.writeText(url); copiedAt = Date.now(); @@ -199,7 +199,7 @@ {#if activity.visibility === 'private'} {#if decrypted}

- + {decrypted.title} Privat @@ -215,7 +215,7 @@ {#if decrypted.tags.length}
{#each decrypted.tags as t} - {t} {/each}
@@ -236,7 +236,7 @@ {/if} {:else}

- + {activity.title} @@ -258,7 +258,7 @@ {#if activity.tags.length}
{#each activity.tags as t} - {t} {/each}
@@ -271,7 +271,7 @@

Lagt til av {#if activity.owner_username} - {activity.owner_display} + {activity.owner_display} {:else} {activity.owner_display} {/if} diff --git a/frontend/src/components/Home.svelte b/frontend/src/components/Home.svelte index 9f850d0..7985fd7 100644 --- a/frontend/src/components/Home.svelte +++ b/frontend/src/components/Home.svelte @@ -77,7 +77,7 @@ // The list is unified — one column. On the public landing ("/", which // anonymous and logged-in users both see), the order is strictly // newest-first regardless of any personal sort the viewer may have - // applied on /home. On the authenticated dashboard, sort by the + // applied on /hjem. On the authenticated dashboard, sort by the // viewer's effective sort_position (custom if dragged, else // -created_at, both surfaced by the server). const filtered = $derived( diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index daffd77..30a7f5b 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -172,7 +172,7 @@ * not the SPA host (port 5173). The token is the canonical artefact; * the URL is just a presentation concern the SPA owns. */ function inviteUrl(inv: InviteEntry): string { - return `${window.location.origin}/invite/${inv.token}`; + return `${window.location.origin}/invitasjon/${inv.token}`; } async function copyInviteUrl(inv: InviteEntry) { @@ -268,7 +268,7 @@

- Brukes i adressen /{username.trim() || 'brukernavn'}/list + Brukes i adressen /{username.trim() || 'brukernavn'}/liste hvis du skrur på den offentlige listen nedenfor. Små bokstaver, tall, _ og -.

@@ -276,7 +276,7 @@ {#if publicListEnabled && !username.trim()}

Du må sette et brukernavn først.

diff --git a/frontend/src/components/Signup.svelte b/frontend/src/components/Signup.svelte index 455533d..26da2ce 100644 --- a/frontend/src/components/Signup.svelte +++ b/frontend/src/components/Signup.svelte @@ -5,7 +5,7 @@ interface Props { onAuthed: () => void; onWantLogin: () => void; - /** Pre-filled invite token (e.g. from /invite/). Sent on signup. */ + /** Pre-filled invite token (e.g. from /invitasjon/). Sent on signup. */ inviteToken?: string; } let { onAuthed, onWantLogin, inviteToken }: Props = $props(); diff --git a/server/activities.ts b/server/activities.ts index f400c3b..985ba7d 100644 --- a/server/activities.ts +++ b/server/activities.ts @@ -49,7 +49,7 @@ interface ActivityRow { * 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 //list. Returns null + * client uses that to link the attribution to //liste. Returns null * for the slug whenever the user hasn't opted in, so the link decision is * purely server-side. */ @@ -88,7 +88,7 @@ function viewerBookmarked(activityId: string, viewerId: string | null): boolean * * `username` (the link target) is returned independently and is only non-null * when the owner has opted into a public list — drives whether the - * attribution renders as a link to //list. + * attribution renders as a link to //liste. */ function ownerAttribution(ownerId: string): { display: string | null; username: string | null } { const row = getDb() diff --git a/server/db.ts b/server/db.ts index f362595..5fdaac0 100644 --- a/server/db.ts +++ b/server/db.ts @@ -42,11 +42,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [ -- Inviter, if this account was created via an invite token. Nullable. invited_by TEXT REFERENCES users(id), -- Optional public URL slug. When set + opt-in, the user's public - -- activities are reachable at "//list". Distinct from + -- activities are reachable at "//liste". Distinct from -- display_name because URL slugs need uniqueness and shape constraints -- (lowercase, [a-z0-9_-]). username TEXT UNIQUE, - -- Opt-in flag: gates whether //list actually returns data. + -- Opt-in flag: gates whether //liste actually returns data. -- Defaults to 0 so the route stays opt-in. public_list_enabled INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL @@ -174,7 +174,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ )`, `CREATE INDEX IF NOT EXISTS user_blocks_blocked_idx ON user_blocks(blocked_id)`, // External profile links: a small ordered list (max enforced at the app - // layer) the user can attach to their profile. Shown on //list. + // layer) the user can attach to their profile. Shown on //liste. // UNIQUE(user_id, position) keeps ordering stable across rewrites. `CREATE TABLE IF NOT EXISTS user_links ( id TEXT PRIMARY KEY, diff --git a/server/index.ts b/server/index.ts index b3f44c0..121a84b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -54,23 +54,44 @@ if (process.env.NODE_ENV === 'production') { // OG-aware routes: render the SPA shell with route-specific OpenGraph // meta injected, so shared links get rich previews. The SPA still // bootstraps the same way — only the changes per route. - // Order matters: specific paths first so /personvern doesn't get - // captured by /:username/list (10 chars matches the slug regex). + // + // Both Norwegian (canonical) and English (legacy) paths are registered so + // links shared before the rename still get rich previews. Order matters: + // specific paths first so /personvern doesn't get captured by + // /:username/liste (10 chars matches the slug regex). const html = (body: string) => new Response(body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); app.get('/', (c) => html(renderWithOG(ogForHome(c.req.raw)))); app.get('/personvern', (c) => html(renderWithOG(ogForPersonvern(c.req.raw)))); - app.get('/a/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id'))))); - app.get('/tags/:tag', (c) => { + + // //liste registered before /aktivitet/:id so the literal path + // /aktivitet/liste (a hypothetical user with username "aktivitet") routes + // to their public list rather than an activity-not-found page. For normal + // activity URLs (/aktivitet/) this route doesn't match anyway — + // second segment must literally be "liste". + app.get('/:username/liste', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username'))))); + app.get('/:username/list', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username'))))); // legacy alias + + app.get('/aktivitet/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id'))))); + app.get('/a/:id', (c) => html(renderWithOG(ogForActivity(c.req.raw, c.req.param('id'))))); // legacy alias + + // Hono types each route's context generically, so we can't share a single + // handler closure across two different paths. Inline both — they're tiny. + app.get('/etiketter/:tag', (c) => { + let tag: string; + try { tag = decodeURIComponent(c.req.param('tag')); } + catch { tag = c.req.param('tag'); } + return html(renderWithOG(ogForTag(c.req.raw, tag))); + }); + app.get('/tags/:tag', (c) => { // legacy alias let tag: string; try { tag = decodeURIComponent(c.req.param('tag')); } catch { tag = c.req.param('tag'); } return html(renderWithOG(ogForTag(c.req.raw, tag))); }); - app.get('/:username/list', (c) => html(renderWithOG(ogForUserList(c.req.raw, c.req.param('username'))))); // SPA fallback for anything else (login/signup/profile/admin/feedback views, - // /invite/, etc. — none need route-specific OG). + // /invitasjon/, etc. — none need route-specific OG). app.get('*', serveStatic({ path: './frontend/dist/index.html' })); } diff --git a/server/og.ts b/server/og.ts index 0f11f30..4b00a3a 100644 --- a/server/og.ts +++ b/server/og.ts @@ -133,13 +133,13 @@ export function ogForActivity(req: Request, id: string): OG { // Private and friends-only behave like nonexistent rows for the scraper: // we don't reveal that the id resolves to a hidden activity. if (!row || row.visibility === 'private' || row.visibility === 'friends') { - return NOT_FOUND_OG(req, `/a/${id}`); + return NOT_FOUND_OG(req, `/aktivitet/${id}`); } return { title: `${row.title ?? 'Vinteraktivitet'} · Vinterliste`, description: row.description ?? 'Vinteraktivitet delt på Vinterliste.', type: 'article', - url: `${baseUrl(req)}/a/${id}`, + url: `${baseUrl(req)}/aktivitet/${id}`, image: `${baseUrl(req)}/icon.svg`, }; } @@ -149,7 +149,7 @@ export function ogForTag(req: Request, tag: string): OG { return { title: `Etikett: ${cleaned} · Vinterliste`, description: `Aktiviteter merket med «${cleaned}» på Vinterliste.`, - url: `${baseUrl(req)}/tags/${encodeURIComponent(cleaned)}`, + url: `${baseUrl(req)}/etiketter/${encodeURIComponent(cleaned)}`, image: `${baseUrl(req)}/icon.svg`, }; } @@ -164,14 +164,14 @@ export function ogForUserList(req: Request, username: string): OG { // Same "not found" treatment for not-opted-in users as for truly missing // usernames — matches what GET /api/users/:username/list returns. if (!row || row.public_list_enabled !== 1) { - return NOT_FOUND_OG(req, `/${username}/list`); + return NOT_FOUND_OG(req, `/${username}/liste`); } const name = row.display_name?.trim() || username; return { title: `${name} · Vinterliste`, description: `Offentlige vinteraktiviteter fra ${name}.`, type: 'profile', - url: `${baseUrl(req)}/${username}/list`, + url: `${baseUrl(req)}/${username}/liste`, image: `${baseUrl(req)}/icon.svg`, }; } diff --git a/server/users.ts b/server/users.ts index bfe9d43..17ac796 100644 --- a/server/users.ts +++ b/server/users.ts @@ -75,7 +75,7 @@ usersRoutes.get('/:username/list', (c) => { // Prefer the display name; fall back to the username slug (which is // what the URL already shows). Never falls through to email/id. owner_display: (user.display_name && user.display_name.trim()) || username, - // The list itself is at //list, so we already know the slug. + // The list itself is at //liste, so we already know the slug. // Surfacing it on each row keeps ActivityRow's rendering uniform. owner_username: username, title: r.title ?? '', diff --git a/shared/types.ts b/shared/types.ts index 1b53aec..9d79426 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -43,7 +43,7 @@ export interface SettingsUpdateRequest { // --- Invites --------------------------------------------------------------- export interface InviteEntry { /** The token itself. The shareable URL is built client-side as - * `${window.location.origin}/invite/${token}` — server-side URL + * `${window.location.origin}/invitasjon/${token}` — server-side URL * construction would point at the API host in split-process dev * environments. */ token: string; @@ -203,7 +203,7 @@ export interface ActivityPublic { */ owner_display: string | null; // Owner's URL slug, if they've opted into a public list. When non-null, the - // client renders the owner attribution as a link to //list. + // client renders the owner attribution as a link to //liste. owner_username: string | null; title: string; /** Optional free-text body. Plain text. Empty string and null treated the same client-side. */