feat: add Open Graph tags to tag detail page for link previews (#605)

- add +page.server.ts for SSR tag metadata (name, track count)
- add og:title, og:description, twitter:card meta tags
- keep tracks fetched client-side to preserve auth state
- link previews now show "X tracks tagged #tagname"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub f0632f49 b14db000

Changed files
+123 -56
frontend
src
+42
frontend/src/routes/tag/[name]/+page.server.ts
··· 1 + import { API_URL } from '$lib/config'; 2 + import { error } from '@sveltejs/kit'; 3 + import type { PageServerLoad } from './$types'; 4 + 5 + interface TagDetail { 6 + name: string; 7 + track_count: number; 8 + created_by_handle: string | null; 9 + } 10 + 11 + export const load: PageServerLoad = async ({ params, fetch }) => { 12 + try { 13 + const response = await fetch(`${API_URL}/tracks/tags/${encodeURIComponent(params.name)}`); 14 + 15 + if (!response.ok) { 16 + if (response.status === 404) { 17 + return { 18 + tag: null, 19 + trackCount: 0, 20 + error: `tag "${params.name}" not found` 21 + }; 22 + } 23 + throw error(500, 'failed to load tag'); 24 + } 25 + 26 + const data = await response.json(); 27 + const tag = data.tag as TagDetail; 28 + 29 + return { 30 + tag, 31 + trackCount: tag.track_count, 32 + error: null 33 + }; 34 + } catch (e) { 35 + console.error('failed to load tag:', e); 36 + return { 37 + tag: null, 38 + trackCount: 0, 39 + error: 'failed to load tag' 40 + }; 41 + } 42 + };
+81 -10
frontend/src/routes/tag/[name]/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { browser } from '$app/environment'; 3 + import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 4 + import { API_URL } from '$lib/config'; 2 5 import Header from '$lib/components/Header.svelte'; 3 6 import TrackItem from '$lib/components/TrackItem.svelte'; 4 7 import { player } from '$lib/player.svelte'; ··· 10 13 11 14 let { data }: { data: PageData } = $props(); 12 15 16 + // server provides tag metadata for OG tags, client fetches full track list 17 + let tracks = $state<Track[]>([]); 18 + let loading = $state(true); 19 + let error = $state<string | null>(data.error); 20 + 21 + async function loadTracks() { 22 + if (!browser || !data.tag) return; 23 + 24 + loading = true; 25 + try { 26 + const response = await fetch(`${API_URL}/tracks/tags/${encodeURIComponent(data.tag.name)}`, { 27 + credentials: 'include' 28 + }); 29 + 30 + if (!response.ok) { 31 + error = 'failed to load tracks'; 32 + return; 33 + } 34 + 35 + const result = await response.json(); 36 + tracks = result.tracks; 37 + } catch (e) { 38 + console.error('failed to load tracks:', e); 39 + error = 'failed to load tracks'; 40 + } finally { 41 + loading = false; 42 + } 43 + } 44 + 45 + $effect(() => { 46 + if (browser && data.tag) { 47 + loadTracks(); 48 + } 49 + }); 50 + 13 51 async function handleLogout() { 14 52 await auth.logout(); 15 53 window.location.href = '/'; ··· 20 58 } 21 59 22 60 function queueAll() { 23 - if (data.tracks.length === 0) return; 24 - queue.addTracks(data.tracks); 25 - toast.success(`queued ${data.tracks.length} ${data.tracks.length === 1 ? 'track' : 'tracks'}`); 61 + if (tracks.length === 0) return; 62 + queue.addTracks(tracks); 63 + toast.success(`queued ${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`); 26 64 } 27 65 </script> 28 66 29 67 <svelte:head> 30 - <title>{data.tag?.name ?? 'tag'} • plyr</title> 68 + <title>#{data.tag?.name ?? 'tag'} • {APP_NAME}</title> 69 + <meta 70 + name="description" 71 + content="{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} tagged #{data.tag?.name ?? 'tag'} on {APP_NAME}" 72 + /> 73 + 74 + <!-- Open Graph --> 75 + <meta property="og:type" content="website" /> 76 + <meta property="og:title" content="#{data.tag?.name ?? 'tag'} • {APP_NAME}" /> 77 + <meta 78 + property="og:description" 79 + content="{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} tagged #{data.tag?.name ?? 'tag'}" 80 + /> 81 + <meta property="og:url" content="{APP_CANONICAL_URL}/tag/{data.tag?.name ?? ''}" /> 82 + <meta property="og:site_name" content={APP_NAME} /> 83 + 84 + <!-- Twitter --> 85 + <meta name="twitter:card" content="summary" /> 86 + <meta name="twitter:title" content="#{data.tag?.name ?? 'tag'} • {APP_NAME}" /> 87 + <meta 88 + name="twitter:description" 89 + content="{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} tagged #{data.tag?.name ?? 'tag'}" 90 + /> 31 91 </svelte:head> 32 92 33 93 <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} /> 34 94 35 95 <div class="page"> 36 - {#if data.error} 96 + {#if error} 37 97 <div class="empty-state"> 38 98 <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 39 99 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path> 40 100 <line x1="7" y1="7" x2="7.01" y2="7"></line> 41 101 </svg> 42 - <h2>{data.error}</h2> 102 + <h2>{error}</h2> 43 103 <p><a href="/">back to home</a></p> 44 104 </div> 45 105 {:else if data.tag} ··· 54 114 {data.tag.name} 55 115 </h1> 56 116 <p class="subtitle"> 57 - {data.tag.track_count} {data.tag.track_count === 1 ? 'track' : 'tracks'} 117 + {data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} 58 118 </p> 59 119 </div> 60 - {#if data.tracks.length > 0} 120 + {#if tracks.length > 0} 61 121 <button class="btn-queue-all" onclick={queueAll} title="queue all tracks"> 62 122 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 63 123 <line x1="8" y1="6" x2="21" y2="6"></line> ··· 73 133 </div> 74 134 </header> 75 135 76 - {#if data.tracks.length === 0} 136 + {#if loading} 137 + <div class="loading-state"> 138 + <p>loading tracks...</p> 139 + </div> 140 + {:else if tracks.length === 0} 77 141 <div class="empty-state"> 78 142 <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 79 143 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path> ··· 84 148 </div> 85 149 {:else} 86 150 <div class="tracks-list"> 87 - {#each data.tracks as track, i (track.id)} 151 + {#each tracks as track, i (track.id)} 88 152 <TrackItem 89 153 {track} 90 154 index={i} ··· 194 258 195 259 .empty-state a:hover { 196 260 text-decoration: underline; 261 + } 262 + 263 + .loading-state { 264 + text-align: center; 265 + padding: 4rem 1rem; 266 + color: var(--text-tertiary); 267 + font-size: 0.95rem; 197 268 } 198 269 199 270 .tracks-list {
-46
frontend/src/routes/tag/[name]/+page.ts
··· 1 - import { browser } from '$app/environment'; 2 - import { API_URL } from '$lib/config'; 3 - import type { Track } from '$lib/types'; 4 - 5 - interface TagDetail { 6 - name: string; 7 - track_count: number; 8 - created_by_handle: string | null; 9 - } 10 - 11 - export interface PageData { 12 - tag: TagDetail | null; 13 - tracks: Track[]; 14 - error: string | null; 15 - } 16 - 17 - export const ssr = false; 18 - 19 - export async function load({ params }: { params: { name: string } }): Promise<PageData> { 20 - if (!browser) { 21 - return { tag: null, tracks: [], error: null }; 22 - } 23 - 24 - try { 25 - const response = await fetch(`${API_URL}/tracks/tags/${encodeURIComponent(params.name)}`, { 26 - credentials: 'include' 27 - }); 28 - 29 - if (!response.ok) { 30 - if (response.status === 404) { 31 - return { tag: null, tracks: [], error: `tag "${params.name}" not found` }; 32 - } 33 - throw new Error(`failed to load tag: ${response.statusText}`); 34 - } 35 - 36 - const data = await response.json(); 37 - return { 38 - tag: data.tag, 39 - tracks: data.tracks, 40 - error: null 41 - }; 42 - } catch (e) { 43 - console.error('failed to load tag:', e); 44 - return { tag: null, tracks: [], error: 'failed to load tag' }; 45 - } 46 - }