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();