+42
frontend/src/routes/tag/[name]/+page.server.ts
+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
+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
-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
-
}