Clickable tags + /tags/:tag pages

Tag pills in ActivityRow now navigate to /tags/<tag> when clicked.
The destination shows every activity matching the tag that the
viewer would otherwise be able to see — same visibility rules as the
dashboard: anonymous gets public + semi, authenticated adds own
private + friends-only.

Design choice: client-side filter, no new server endpoint. The
dashboard's `GET /api/activities` already returns exactly the rowset
that should be filterable by tag, so TagPage.svelte loads the list
the normal way and runs `.tags.includes(needle)` on it (private rows
decrypt locally first — same pattern Home.svelte uses for search).
Avoids duplicating the 4-clause visibility filter SQL into a second
endpoint, and the private-tag case wouldn't have worked on the
server anyway since private tags are inside the encrypted payload.

Routing:
  - parsePath gains `/tags/:tag` with decodeURIComponent for any
    character (tags allow spaces, special chars, etc — they're
    normalised server-side to lowercase + trim)
  - applyRoute branch sets view='tag' and tagName from the URL
  - leaveTag() returns to /home (logged in) or / (logged out)
  - Nav header is hidden on the tag view (it has its own back button,
    same pattern as PublicList and ActivityPermalink)

ActivityRow tag pills changed from <span> to <a href="/tags/...">,
inheriting colour and dropping text-decoration so the visual pill
stays as-is. The :focus-visible underline added in commit 6313f36
still applies, so keyboard users see focus clearly.

Also, while in profile copy: removed the stale "delen av eposten din"
hint in Profile.svelte — the email-prefix fallback was removed in
commit 43c24ec, the docstring lagged. New copy: "Er feltet tomt og du
har satt et brukernavn, brukes det i stedet. Ellers vises ingen
attribusjon."

Typecheck clean; build succeeds.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 15:42:54 +02:00
commit b9a312668e
4 changed files with 154 additions and 7 deletions

View file

@ -14,6 +14,7 @@
import Admin from './components/Admin.svelte';
import ActivityPermalink from './components/ActivityPermalink.svelte';
import Personvern from './components/Personvern.svelte';
import TagPage from './components/TagPage.svelte';
/**
* URL contract:
@ -22,6 +23,7 @@
* /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
*
* Anything else (signup, login, recovery, profile, feedback, admin) is an
* in-app view state, not a URL. We update window.history on view changes
@ -35,17 +37,19 @@
| 'profile' | 'feedback' | 'admin'
| 'public-list' // "/<username>/list"
| 'permalink' // "/a/:id"
| 'personvern'; // "/personvern"
| 'personvern' // "/personvern"
| 'tag'; // "/tags/:tag"
let view: View = $state('loading');
let publicListUsername = $state('');
let activityId = $state('');
let tagName = $state('');
let defaultEmail = $state('');
let inviteToken = $state('');
let selfRegistryEnabled = $state(true);
interface Route {
view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite' | 'personvern';
view: 'public-home' | 'home' | 'public-list' | 'permalink' | 'invite' | 'personvern' | 'tag';
payload?: string;
}
@ -54,6 +58,18 @@
if (path === '/' || path === '') return { view: 'public-home' };
if (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
// 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\/([^/]+)\/?$/);
if (tagMatch) {
try {
return { view: 'tag', payload: decodeURIComponent(tagMatch[1]!) };
} catch {
return { view: 'public-home' };
}
}
const userList = path.match(/^\/([a-z0-9_-]{2,31})\/list\/?$/);
if (userList) return { view: 'public-list', payload: userList[1] };
const perma = path.match(/^\/a\/([A-Za-z0-9-]+)\/?$/);
@ -128,9 +144,18 @@
view = session.user ? 'public-home' : 'signup';
} else if (route.view === 'personvern') {
view = 'personvern';
} else if (route.view === 'tag') {
tagName = route.payload ?? tagName;
view = 'tag';
}
}
function leaveTag() {
// Same logic as leavePersonvern — back to wherever they were.
if (session.user) goHome();
else goPublicHome();
}
function goPersonvern() {
pushUrl('/personvern');
view = 'personvern';
@ -171,7 +196,7 @@
<a href="/" onclick={(e) => { e.preventDefault(); goPublicHome(); }}
style="color: inherit; text-decoration: none;">Vinterliste</a>
</h1>
{#if view !== 'public-list' && view !== 'permalink'}
{#if view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
<div class="row">
{#if session.user}
{#if view !== 'home'}
@ -230,6 +255,8 @@
<Admin onDone={goHome} />
{:else if view === 'personvern'}
<Personvern onBack={leavePersonvern} />
{:else if view === 'tag'}
<TagPage tag={tagName} onBack={leaveTag} />
{:else}
<Home publicOnly={false} />
{/if}

View file

@ -206,7 +206,10 @@
{/if}
{#if decrypted.tags.length}
<div>
{#each decrypted.tags as t}<span class="tag private">{t}</span>{/each}
{#each decrypted.tags as t}
<a href={`/tags/${encodeURIComponent(t)}`} class="tag private"
style="text-decoration: none; color: inherit;">{t}</a>
{/each}
</div>
{/if}
{#if decrypted.loc_label}
@ -236,7 +239,10 @@
{/if}
{#if activity.tags.length}
<div>
{#each activity.tags as t}<span class="tag">{t}</span>{/each}
{#each activity.tags as t}
<a href={`/tags/${encodeURIComponent(t)}`} class="tag"
style="text-decoration: none; color: inherit;">{t}</a>
{/each}
</div>
{/if}
{#if activity.loc_label}

View file

@ -185,8 +185,8 @@
<input id="dn" type="text" maxlength="50" bind:value={displayName}
placeholder="f.eks. Ole" />
<p class="muted" style="margin: 0.25rem 0 0.5rem;">
Vises på offentlige aktiviteter du legger til. La være tomt for å bruke
delen av eposten din før <code>@</code>.
Vises på offentlige aktiviteter du legger til. Er feltet tomt og du har
satt et brukernavn, brukes det i stedet. Ellers vises ingen attribusjon.
</p>
<label for="un">Brukernavn for URL</label>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../lib/api';
import { session } from '../lib/session.svelte';
import {
decryptPayload, base64ToBytes, type PrivatePayload,
} from '../lib/crypto';
import ActivityRow from './ActivityRow.svelte';
import type { Activity } from '../../../shared/types';
interface Props {
tag: string;
onBack?: () => void;
}
let { tag, onBack }: Props = $props();
// Normalise the URL-supplied tag the same way the server does so the
// visible heading matches what was actually filtered. The server lowercases
// and trims; tags in payloads are stored lowercase via TagInput too.
const needle = $derived(tag.trim().toLowerCase());
let activities: Activity[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(load);
async function load() {
loading = true;
error = null;
try {
activities = await api.listActivities();
} catch {
error = 'Kunne ikke laste aktiviteter.';
} finally {
loading = false;
}
}
/**
* Pre-decrypt private payloads once so we can both filter by tag and let
* ActivityRow render without re-doing the work. Same pattern as Home.svelte.
*/
const privateCleartext = $derived.by(() => {
const map = new Map<string, PrivatePayload>();
if (!session.dek) return map;
for (const a of activities) {
if (a.visibility !== 'private') continue;
try {
map.set(a.id, decryptPayload(
{ ciphertext: base64ToBytes(a.ciphertext), nonce: base64ToBytes(a.nonce) },
session.dek,
));
} catch { /* skip undecryptable rows */ }
}
return map;
});
function hasTag(a: Activity): boolean {
if (a.visibility === 'private') {
const p = privateCleartext.get(a.id);
return !!p && p.tags.includes(needle);
}
return a.tags.includes(needle);
}
const matched = $derived(activities.filter(hasTag));
// For removed/changed rows from ActivityRow, mutate the local list. Same
// contracts as Home.svelte.
function onDeleted(id: string) {
activities = activities.filter((a) => a.id !== id);
}
function onChanged(a: Activity) {
activities = activities.map((x) => (x.id === a.id ? a : x));
}
</script>
<section aria-labelledby="tag-h">
{#if onBack}
<div class="row" style="margin-bottom: 1rem;">
<button type="button" onclick={onBack}> Tilbake</button>
</div>
{/if}
<h2 id="tag-h" style="font-size: 1.5rem;">
Etikett: <span class="tag" style="font-size: inherit; padding: 0.2rem 0.6rem;">{needle}</span>
</h2>
<p class="muted">
{#if session.user}
Aktiviteter med denne etiketten du har tilgang til — egne private,
offentlige, anonyme, og fra venner.
{:else}
Offentlige og anonyme aktiviteter med denne etiketten.
{/if}
</p>
{#if loading}
<p class="muted">Laster …</p>
{:else if error}
<p class="error">{error}</p>
{:else if matched.length === 0}
<p class="muted">Ingen aktiviteter med denne etiketten ennå.</p>
{:else}
{#each matched as a (a.id)}
<ActivityRow
activity={a}
privateCleartext={privateCleartext.get(a.id) ?? null}
onDeleted={onDeleted}
onChanged={onChanged}
/>
{/each}
{/if}
</section>