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:
parent
ef07e3f785
commit
f4816502ed
11 changed files with 80 additions and 51 deletions
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue