From b9a312668ea340aebebeccb5cc30945f4cefef18 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 15:42:54 +0200 Subject: [PATCH] Clickable tags + /tags/:tag pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag pills in ActivityRow now navigate to /tags/ 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 to , 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. --- frontend/src/App.svelte | 33 +++++- frontend/src/components/ActivityRow.svelte | 10 +- frontend/src/components/Profile.svelte | 4 +- frontend/src/components/TagPage.svelte | 114 +++++++++++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/TagPage.svelte diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2baa443..f37ecbb 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -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) * //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' // "//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/. 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 @@ { e.preventDefault(); goPublicHome(); }} style="color: inherit; text-decoration: none;">Vinterliste - {#if view !== 'public-list' && view !== 'permalink'} + {#if view !== 'public-list' && view !== 'permalink' && view !== 'tag'}
{#if session.user} {#if view !== 'home'} @@ -230,6 +255,8 @@ {:else if view === 'personvern'} + {:else if view === 'tag'} + {:else} {/if} diff --git a/frontend/src/components/ActivityRow.svelte b/frontend/src/components/ActivityRow.svelte index c0f9276..00cf323 100644 --- a/frontend/src/components/ActivityRow.svelte +++ b/frontend/src/components/ActivityRow.svelte @@ -206,7 +206,10 @@ {/if} {#if decrypted.tags.length}
- {#each decrypted.tags as t}{t}{/each} + {#each decrypted.tags as t} + {t} + {/each}
{/if} {#if decrypted.loc_label} @@ -236,7 +239,10 @@ {/if} {#if activity.tags.length}
- {#each activity.tags as t}{t}{/each} + {#each activity.tags as t} + {t} + {/each}
{/if} {#if activity.loc_label} diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index b2f87da..99eac75 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -185,8 +185,8 @@

- Vises på offentlige aktiviteter du legger til. La være tomt for å bruke - delen av eposten din før @. + Vises på offentlige aktiviteter du legger til. Er feltet tomt og du har + satt et brukernavn, brukes det i stedet. Ellers vises ingen attribusjon.

diff --git a/frontend/src/components/TagPage.svelte b/frontend/src/components/TagPage.svelte new file mode 100644 index 0000000..17757ce --- /dev/null +++ b/frontend/src/components/TagPage.svelte @@ -0,0 +1,114 @@ + + +
+ {#if onBack} +
+ +
+ {/if} + +

+ Etikett: {needle} +

+

+ {#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} +

+ + {#if loading} +

Laster …

+ {:else if error} +

{error}

+ {:else if matched.length === 0} +

Ingen aktiviteter med denne etiketten ennå.

+ {:else} + {#each matched as a (a.id)} + + {/each} + {/if} +