+191
src/routes/archive/+page.svelte
+191
src/routes/archive/+page.svelte
···
1
+
<script lang="ts">
2
+
import {
3
+
Card,
4
+
SearchBar,
5
+
Pagination,
6
+
Tabs,
7
+
PostsGroupedView,
8
+
Dropdown
9
+
} from '$lib/components/ui';
10
+
import type { BlogPost } from '$lib/services/atproto';
11
+
import { getUserLocale } from '$lib/utils/locale';
12
+
import { filterPosts, getSortedYears, groupPostsByDate } from '$lib/helper/posts';
13
+
14
+
interface Props {
15
+
data: {
16
+
allPosts: BlogPost[];
17
+
};
18
+
}
19
+
20
+
let { data }: Props = $props();
21
+
22
+
// Get user locale once
23
+
const userLocale = getUserLocale();
24
+
25
+
// Filter state
26
+
let searchQuery = $state('');
27
+
let selectedYear = $state('all');
28
+
let selectedPublication = $state('');
29
+
let currentPage = $state(1);
30
+
const postsPerPage = 50;
31
+
32
+
// Get available years from all posts
33
+
const allGrouped = $derived(groupPostsByDate(data.allPosts, userLocale));
34
+
const availableYears = $derived(getSortedYears(allGrouped));
35
+
36
+
// Create year tabs (All + individual years)
37
+
const yearTabs = $derived([
38
+
{ id: 'all', label: 'All Years' },
39
+
...availableYears.map((year) => ({ id: year.toString(), label: year.toString() }))
40
+
]);
41
+
42
+
// Get unique publications
43
+
const publications = $derived.by(() => {
44
+
const pubs = new Map<string, string>();
45
+
data.allPosts.forEach((post) => {
46
+
if (post.platform === 'leaflet' && post.publicationName) {
47
+
const key = `${post.publicationName}-${post.publicationRkey || 'default'}`;
48
+
pubs.set(key, post.publicationName);
49
+
}
50
+
});
51
+
return Array.from(pubs.entries()).map(([key, name]) => ({
52
+
value: key,
53
+
label: name
54
+
}));
55
+
});
56
+
57
+
// Filter posts by search, year, and publication
58
+
const filteredBySearch = $derived(filterPosts(data.allPosts, searchQuery));
59
+
60
+
const filteredByYear = $derived.by(() => {
61
+
if (selectedYear === 'all') return filteredBySearch;
62
+
return filteredBySearch.filter((post) => {
63
+
const postYear = new Date(post.createdAt).getFullYear();
64
+
return postYear === parseInt(selectedYear);
65
+
});
66
+
});
67
+
68
+
const filteredPosts = $derived.by(() => {
69
+
if (!selectedPublication) return filteredByYear;
70
+
return filteredByYear.filter((post: BlogPost) => {
71
+
if (post.platform === 'WhiteWind' && selectedPublication === 'whitewind') return true;
72
+
if (post.platform === 'leaflet') {
73
+
const key = `${post.publicationName}-${post.publicationRkey || 'default'}`;
74
+
return key === selectedPublication;
75
+
}
76
+
return false;
77
+
});
78
+
});
79
+
80
+
// Add WhiteWind to publication options if there are WhiteWind posts
81
+
const hasWhiteWind = $derived(data.allPosts.some((p) => p.platform === 'WhiteWind'));
82
+
const publicationOptions = $derived.by(() => [
83
+
...(hasWhiteWind ? [{ value: 'whitewind', label: 'WhiteWind' }] : []),
84
+
...publications
85
+
]);
86
+
87
+
// Pagination calculations
88
+
const totalPages = $derived(Math.ceil(filteredPosts.length / postsPerPage));
89
+
const paginatedPosts = $derived(
90
+
filteredPosts.slice((currentPage - 1) * postsPerPage, currentPage * postsPerPage)
91
+
);
92
+
93
+
// Reset to page 1 when filters change
94
+
$effect(() => {
95
+
searchQuery;
96
+
selectedYear;
97
+
selectedPublication;
98
+
currentPage = 1;
99
+
});
100
+
101
+
// Handle page changes
102
+
function handlePageChange(page: number) {
103
+
currentPage = page;
104
+
}
105
+
106
+
// Handle year tab changes
107
+
function handleYearChange(yearId: string) {
108
+
selectedYear = yearId;
109
+
}
110
+
</script>
111
+
112
+
<div class="mx-auto max-w-4xl">
113
+
<!-- Page Header -->
114
+
<div class="mb-8">
115
+
<h1 class="mb-4 text-4xl font-bold text-ink-900 md:text-5xl dark:text-ink-50">Archive</h1>
116
+
<p class="text-lg text-ink-700 dark:text-ink-200">
117
+
Browse all {data.allPosts.length} blog posts from WhiteWind and Leaflet, organised by date.
118
+
</p>
119
+
</div>
120
+
121
+
<!-- Search Bar -->
122
+
<div class="mb-6">
123
+
<SearchBar
124
+
bind:value={searchQuery}
125
+
placeholder="Search posts by title, description, platform, or publication..."
126
+
resultCount={searchQuery ? filteredPosts.length : undefined}
127
+
/>
128
+
</div>
129
+
130
+
<!-- Filters Row -->
131
+
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end">
132
+
<!-- Publication Dropdown -->
133
+
{#if publicationOptions.length > 0}
134
+
<div class="flex-1 sm:max-w-xs">
135
+
<Dropdown
136
+
bind:value={selectedPublication}
137
+
options={publicationOptions}
138
+
label="Filter by Publication"
139
+
placeholder="All Publications"
140
+
/>
141
+
</div>
142
+
{/if}
143
+
</div>
144
+
145
+
<!-- Year Tabs (Pills) -->
146
+
<Tabs tabs={yearTabs} activeTab={selectedYear} onTabChange={handleYearChange} />
147
+
148
+
<!-- Archive Content -->
149
+
{#if filteredPosts.length === 0}
150
+
<Card variant="flat" padding="lg">
151
+
{#snippet children()}
152
+
<div class="text-center">
153
+
{#if searchQuery || selectedPublication}
154
+
<p class="text-ink-700 dark:text-ink-300">
155
+
No posts found matching your filters. Try adjusting your search or filters.
156
+
</p>
157
+
{:else}
158
+
<p class="text-ink-700 dark:text-ink-300">
159
+
No blog posts found. Start writing on
160
+
<a
161
+
href="https://whtwnd.com/"
162
+
class="text-primary-600 hover:underline dark:text-primary-400"
163
+
target="_blank"
164
+
rel="noopener noreferrer">WhiteWind</a
165
+
>
166
+
or
167
+
<a
168
+
href="https://leaflet.pub/"
169
+
class="text-primary-600 hover:underline dark:text-primary-400"
170
+
target="_blank"
171
+
rel="noopener noreferrer">Leaflet</a
172
+
>!
173
+
</p>
174
+
{/if}
175
+
</div>
176
+
{/snippet}
177
+
</Card>
178
+
{:else}
179
+
<!-- Posts Grouped View -->
180
+
<PostsGroupedView posts={paginatedPosts} locale={userLocale} />
181
+
182
+
<!-- Pagination -->
183
+
<Pagination
184
+
{currentPage}
185
+
{totalPages}
186
+
totalItems={filteredPosts.length}
187
+
itemsPerPage={postsPerPage}
188
+
onPageChange={handlePageChange}
189
+
/>
190
+
{/if}
191
+
</div>
+153
src/routes/archive/+page.ts
+153
src/routes/archive/+page.ts
···
1
+
import type { PageLoad } from './$types';
2
+
import { createSiteMeta, type SiteMetadata } from '$lib/helper/siteMeta';
3
+
import { withFallback } from '$lib/services/atproto/agents';
4
+
import { PUBLIC_ATPROTO_DID } from '$env/static/public';
5
+
import type { BlogPost } from '$lib/services/atproto';
6
+
7
+
/**
8
+
* Fetches ALL blog posts from WhiteWind and Leaflet (no limit)
9
+
*/
10
+
async function fetchAllBlogPosts(fetchFn?: typeof fetch): Promise<BlogPost[]> {
11
+
const posts: BlogPost[] = [];
12
+
13
+
// Fetch WhiteWind posts
14
+
try {
15
+
const whiteWindRecords = await withFallback(
16
+
PUBLIC_ATPROTO_DID,
17
+
async (agent) => {
18
+
const response = await agent.com.atproto.repo.listRecords({
19
+
repo: PUBLIC_ATPROTO_DID,
20
+
collection: 'com.whtwnd.blog.entry',
21
+
limit: 100
22
+
});
23
+
return response.data.records;
24
+
},
25
+
true,
26
+
fetchFn
27
+
);
28
+
29
+
for (const record of whiteWindRecords) {
30
+
const value = record.value as any;
31
+
// Skip drafts and non-public posts
32
+
if (value.isDraft || (value.visibility && value.visibility !== 'public')) {
33
+
continue;
34
+
}
35
+
36
+
posts.push({
37
+
title: value.title || 'Untitled Post',
38
+
url: `https://whtwnd.com/${PUBLIC_ATPROTO_DID}/${record.uri.split('/').pop()}`,
39
+
createdAt: value.createdAt || record.value.createdAt || new Date().toISOString(),
40
+
platform: 'WhiteWind',
41
+
description: value.subtitle,
42
+
rkey: record.uri.split('/').pop() || ''
43
+
});
44
+
}
45
+
} catch (error) {
46
+
console.warn('Failed to fetch WhiteWind posts:', error);
47
+
}
48
+
49
+
// Fetch Leaflet publications and documents
50
+
try {
51
+
// Get all publications first
52
+
const publicationsRecords = await withFallback(
53
+
PUBLIC_ATPROTO_DID,
54
+
async (agent) => {
55
+
const response = await agent.com.atproto.repo.listRecords({
56
+
repo: PUBLIC_ATPROTO_DID,
57
+
collection: 'pub.leaflet.publication',
58
+
limit: 100
59
+
});
60
+
return response.data.records;
61
+
},
62
+
true,
63
+
fetchFn
64
+
);
65
+
66
+
const publicationsMap = new Map<string, { name: string; basePath?: string }>();
67
+
for (const pubRecord of publicationsRecords) {
68
+
const pubValue = pubRecord.value as any;
69
+
publicationsMap.set(pubRecord.uri, {
70
+
name: pubValue.name || 'Untitled Publication',
71
+
basePath: pubValue.base_path
72
+
});
73
+
}
74
+
75
+
// Fetch all Leaflet documents
76
+
const leafletDocsRecords = await withFallback(
77
+
PUBLIC_ATPROTO_DID,
78
+
async (agent) => {
79
+
const response = await agent.com.atproto.repo.listRecords({
80
+
repo: PUBLIC_ATPROTO_DID,
81
+
collection: 'pub.leaflet.document',
82
+
limit: 100
83
+
});
84
+
return response.data.records;
85
+
},
86
+
true,
87
+
fetchFn
88
+
);
89
+
90
+
for (const record of leafletDocsRecords) {
91
+
const value = record.value as any;
92
+
const rkey = record.uri.split('/').pop() || '';
93
+
const publicationUri = value.publication;
94
+
const publication = publicationsMap.get(publicationUri);
95
+
96
+
// Determine URL based on priority: publication base_path → Leaflet /lish format
97
+
let url: string;
98
+
const publicationRkey = publicationUri ? publicationUri.split('/').pop() : '';
99
+
100
+
if (publication?.basePath) {
101
+
// Ensure basePath is a complete URL
102
+
const basePath = publication.basePath.startsWith('http')
103
+
? publication.basePath
104
+
: `https://${publication.basePath}`;
105
+
url = `${basePath}/${rkey}`;
106
+
} else if (publicationRkey) {
107
+
url = `https://leaflet.pub/lish/${PUBLIC_ATPROTO_DID}/${publicationRkey}/${rkey}`;
108
+
} else {
109
+
url = `https://leaflet.pub/${PUBLIC_ATPROTO_DID}/${rkey}`;
110
+
}
111
+
112
+
posts.push({
113
+
title: value.title || 'Untitled Document',
114
+
url,
115
+
createdAt: value.publishedAt || new Date().toISOString(),
116
+
platform: 'leaflet',
117
+
description: value.description,
118
+
rkey,
119
+
publicationName: publication?.name,
120
+
publicationRkey: publicationRkey || undefined
121
+
});
122
+
}
123
+
} catch (error) {
124
+
console.warn('Failed to fetch Leaflet documents:', error);
125
+
}
126
+
127
+
// Sort by date (newest first)
128
+
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
129
+
130
+
return posts;
131
+
}
132
+
133
+
export const load: PageLoad = async ({ fetch }) => {
134
+
// Fetch all blog posts
135
+
let allPosts: BlogPost[] = [];
136
+
137
+
try {
138
+
allPosts = await fetchAllBlogPosts(fetch);
139
+
} catch (err) {
140
+
console.warn('Archive page: failed to fetch blog posts', err);
141
+
}
142
+
143
+
// Create page metadata
144
+
const meta: Partial<SiteMetadata> = {
145
+
title: 'Archive',
146
+
description: `Browse all ${allPosts.length} blog posts organised by date`
147
+
};
148
+
149
+
return {
150
+
meta,
151
+
allPosts
152
+
};
153
+
};