my website at ewancroft.uk

feat(route): add /archive page and loader

Add archive UI with search/filters/pagination and loader fetching external posts with URL mapping and fallbacks

ewancroft.uk 8b139491 0f6080f5

verified
Changed files
+344
src
routes
+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
··· 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 + };