refactor: Norwegian URL paths

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
  /<username>/list   → /<username>/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 /<username>/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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 18:20:50 +02:00
commit f4816502ed
11 changed files with 80 additions and 51 deletions

View file

@ -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)
* /<username>/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)
* /<username>/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, /<u>/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' // "/<username>/list"
| 'permalink' // "/a/:id"
| 'public-list' // "/<username>/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/<urlencoded-tag>. We don't constrain charset here
// Etikett-sider: /etiketter/<urlencoded>. 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';
}

View file

@ -164,14 +164,14 @@
}
/**
* Share-link: copy /a/<id> (absolute URL) to the clipboard. Private rows
* Share-link: copy /aktivitet/<id> (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}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
<a href={`/a/${activity.id}`} style="color: inherit; text-decoration: none;">
<a href={`/aktivitet/${activity.id}`} style="color: inherit; text-decoration: none;">
{decrypted.title}
</a>
<span class="vis-badge private">Privat</span>
@ -215,7 +215,7 @@
{#if decrypted.tags.length}
<div>
{#each decrypted.tags as t}
<a href={`/tags/${encodeURIComponent(t)}`} class="tag private"
<a href={`/etiketter/${encodeURIComponent(t)}`} class="tag private"
style="text-decoration: none; color: inherit;">{t}</a>
{/each}
</div>
@ -236,7 +236,7 @@
{/if}
{:else}
<h3 id={`act-${activity.id}-h`} style="display: flex; align-items: center;">
<a href={`/a/${activity.id}`} style="color: inherit; text-decoration: none;">
<a href={`/aktivitet/${activity.id}`} style="color: inherit; text-decoration: none;">
{activity.title}
</a>
<span class="vis-badge {activity.visibility}">
@ -258,7 +258,7 @@
{#if activity.tags.length}
<div>
{#each activity.tags as t}
<a href={`/tags/${encodeURIComponent(t)}`} class="tag"
<a href={`/etiketter/${encodeURIComponent(t)}`} class="tag"
style="text-decoration: none; color: inherit;">{t}</a>
{/each}
</div>
@ -271,7 +271,7 @@
<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>
<a href={`/${activity.owner_username}/liste`}>{activity.owner_display}</a>
{:else}
{activity.owner_display}
{/if}

View file

@ -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(

View file

@ -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 @@
<input id="un" type="text" maxlength="31" bind:value={username}
placeholder="f.eks. ole" pattern="[a-z0-9_-]*" />
<p class="muted" style="margin: 0.25rem 0 0.5rem;">
Brukes i adressen <code>/{username.trim() || 'brukernavn'}/list</code>
Brukes i adressen <code>/{username.trim() || 'brukernavn'}/liste</code>
hvis du skrur på den offentlige listen nedenfor. Små bokstaver, tall,
<code>_</code> og <code>-</code>.
</p>
@ -276,7 +276,7 @@
<label class="row" style="gap: 0.5rem; align-items: center; margin-top: 0.75rem;">
<input type="checkbox" bind:checked={publicListEnabled}
disabled={!username.trim()} />
<span>Vis offentlig liste på <code>/{username.trim() || 'brukernavn'}/list</code></span>
<span>Vis offentlig liste på <code>/{username.trim() || 'brukernavn'}/liste</code></span>
</label>
{#if publicListEnabled && !username.trim()}
<p class="muted">Du må sette et brukernavn først.</p>

View file

@ -5,7 +5,7 @@
interface Props {
onAuthed: () => void;
onWantLogin: () => void;
/** Pre-filled invite token (e.g. from /invite/<token>). Sent on signup. */
/** Pre-filled invite token (e.g. from /invitasjon/<token>). Sent on signup. */
inviteToken?: string;
}
let { onAuthed, onWantLogin, inviteToken }: Props = $props();

View file

@ -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 /<username>/list. Returns null
* client uses that to link the attribution to /<username>/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 /<username>/list.
* attribution renders as a link to /<username>/liste.
*/
function ownerAttribution(ownerId: string): { display: string | null; username: string | null } {
const row = getDb()

View file

@ -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 "/<username>/list". Distinct from
-- activities are reachable at "/<username>/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 /<username>/list actually returns data.
-- Opt-in flag: gates whether /<username>/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 /<username>/list.
// layer) the user can attach to their profile. Shown on /<username>/liste.
// UNIQUE(user_id, position) keeps ordering stable across rewrites.
`CREATE TABLE IF NOT EXISTS user_links (
id TEXT PRIMARY KEY,

View file

@ -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 <head> 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) => {
// /<username>/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/<cuid>) 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/<token>, etc. — none need route-specific OG).
// /invitasjon/<token>, etc. — none need route-specific OG).
app.get('*', serveStatic({ path: './frontend/dist/index.html' }));
}

View file

@ -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`,
};
}

View file

@ -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 /<username>/list, so we already know the slug.
// The list itself is at /<username>/liste, so we already know the slug.
// Surfacing it on each row keeps ActivityRow's rendering uniform.
owner_username: username,
title: r.title ?? '',

View file

@ -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 /<owner_username>/list.
// client renders the owner attribution as a link to /<owner_username>/liste.
owner_username: string | null;
title: string;
/** Optional free-text body. Plain text. Empty string and null treated the same client-side. */