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:
parent
7d9b4a3599
commit
b9a312668e
4 changed files with 154 additions and 7 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
114
frontend/src/components/TagPage.svelte
Normal file
114
frontend/src/components/TagPage.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue