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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
}
|
||||
|
||||
|
|
|
|||
10
server/og.ts
10
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`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? '',
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue