···
1
1
<script lang="ts">
2
2
import { reveal } from '$lib/actions/reveal';
3
3
import LazyLoadingImg from './LazyLoadingImg.svelte';
4
4
-
import type { Publication } from '$lib/types';
4
4
+
import type { LatestPost, Publication } from '$lib/types';
5
5
6
6
-
let { publications }: { publications: Publication[] } = $props();
6
6
+
let {
7
7
+
publications,
8
8
+
latestPosts
9
9
+
}: { publications: Publication[]; latestPosts: Promise<LatestPost[]> } = $props();
7
10
8
11
const INITIAL_COUNT = 4;
9
12
const LOAD_STEP = 2;
10
13
let visibleCount = $state(INITIAL_COUNT);
11
14
const visiblePublications = $derived(publications.slice(0, visibleCount));
12
15
const hasMore = $derived(visibleCount < publications.length);
16
16
+
17
17
+
function publishedLabel(iso: string | null): string {
18
18
+
if (!iso) return '';
19
19
+
const date = new Date(iso);
20
20
+
if (Number.isNaN(date.getTime())) return '';
21
21
+
return date.toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' });
22
22
+
}
13
23
</script>
14
24
15
25
{#if publications.length > 0}
···
53
63
Load More
54
64
</button>
55
65
{/if}
66
66
+
67
67
+
<h2 class="mt-16 text-center font-urbanist text-xl font-semibold opacity-80 md:mt-24 md:text-3xl">
68
68
+
Latest Posts
69
69
+
</h2>
70
70
+
{#await latestPosts}
71
71
+
<div class="container mt-10 grid gap-10 p-4 md:grid-cols-2 xl:grid-cols-3">
72
72
+
{#each Array(2) as _, i (i)}
73
73
+
<div class="card bg-base-100 shadow-sm">
74
74
+
<div class="h-48 w-full skeleton"></div>
75
75
+
<div class="card-body flex flex-col gap-3">
76
76
+
<div class="h-5 w-28 skeleton"></div>
77
77
+
<div class="h-7 w-3/4 skeleton"></div>
78
78
+
<div class="h-4 w-1/3 skeleton"></div>
79
79
+
<div class="h-4 w-full skeleton"></div>
80
80
+
</div>
81
81
+
</div>
82
82
+
{/each}
83
83
+
</div>
84
84
+
{:then posts}
85
85
+
{#if posts.length > 0}
86
86
+
<div class="container mt-10 grid gap-10 p-4 md:grid-cols-2 xl:grid-cols-3">
87
87
+
{#each posts as post (post.postUrl)}
88
88
+
<div class="card bg-base-100 shadow-sm transition duration-300 hover:-translate-y-1">
89
89
+
{#if post.coverImage}
90
90
+
<figure>
91
91
+
<LazyLoadingImg class="h-48 w-full" src={post.coverImage} alt={post.title} />
92
92
+
</figure>
93
93
+
{/if}
94
94
+
<div class="card-body">
95
95
+
<span class="badge font-urbanist badge-primary">{post.publicationName}</span>
96
96
+
<h3 class="mt-2 card-title font-urbanist text-2xl font-black">{post.title}</h3>
97
97
+
{#if post.publishedAt}
98
98
+
<p class="font-urbanist text-xs opacity-40">{publishedLabel(post.publishedAt)}</p>
99
99
+
{/if}
100
100
+
<p class="text-md font-urbanist font-medium opacity-60">{post.description}</p>
101
101
+
<div class="mt-2 card-actions justify-end">
102
102
+
<a
103
103
+
class="btn font-urbanist btn-primary"
104
104
+
href={post.postUrl}
105
105
+
target="_blank"
106
106
+
rel="noopener noreferrer"
107
107
+
>
108
108
+
Read
109
109
+
</a>
110
110
+
</div>
111
111
+
</div>
112
112
+
</div>
113
113
+
{/each}
114
114
+
</div>
115
115
+
{/if}
116
116
+
{/await}
56
117
</div>
57
118
{/if}
···
1
1
+
import type { LatestPost, Publication } from '$lib/types';
2
2
+
import { cache, HOUR_MS } from '$lib/server/cache';
3
3
+
import { getRepoInfo } from './client';
4
4
+
import { fetchPublications } from './publications';
5
5
+
6
6
+
/** Minimal view of a `site.standard.document` record — metadata only, no content. */
7
7
+
type DocumentRecord = {
8
8
+
uri: string;
9
9
+
value: {
10
10
+
site: string;
11
11
+
title: string;
12
12
+
path: string;
13
13
+
description?: string;
14
14
+
publishedAt?: string;
15
15
+
coverImage?: { ref: { $link: string }; mimeType?: string };
16
16
+
};
17
17
+
};
18
18
+
19
19
+
/** Joins a publication base url with a document path, tolerating leading/trailing slashes. */
20
20
+
function buildPostUrl(publicationUrl: string, path: string): string {
21
21
+
return `${publicationUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
22
22
+
}
23
23
+
24
24
+
/**
25
25
+
* Walks the `site.standard.document` records on the PDS and returns the most recent
26
26
+
* post for each publication. A document maps to its publication via its `site` field
27
27
+
* (the publication record's at-uri). Cached for 1 hour.
28
28
+
*/
29
29
+
export async function fetchLatestPosts(): Promise<LatestPost[]> {
30
30
+
const cached = cache.get('latestPosts');
31
31
+
if (cached) {
32
32
+
return cached as LatestPost[];
33
33
+
}
34
34
+
35
35
+
const repo = getRepoInfo('Latest posts');
36
36
+
if (!repo) return [];
37
37
+
38
38
+
try {
39
39
+
// Reuse the cached, blento-filtered publications and key them by their at-uri.
40
40
+
const publications = await fetchPublications();
41
41
+
const byUri = new Map<string, Publication>(publications.map((p) => [p.uri, p]));
42
42
+
43
43
+
const url =
44
44
+
`${repo.pds}/xrpc/com.atproto.repo.listRecords` +
45
45
+
`?repo=${encodeURIComponent(repo.did)}` +
46
46
+
`&collection=site.standard.document&limit=100`;
47
47
+
const res = await fetch(url);
48
48
+
if (!res.ok) {
49
49
+
console.warn(`Failed to list documents: ${res.status} ${res.statusText}`);
50
50
+
return [];
51
51
+
}
52
52
+
const { records } = (await res.json()) as { records: DocumentRecord[] };
53
53
+
54
54
+
// Most recent document per publication, by publishedAt.
55
55
+
const newestByPublication = new Map<string, DocumentRecord>();
56
56
+
for (const doc of records) {
57
57
+
const pub = byUri.get(doc.value.site);
58
58
+
if (!pub) continue; // not one of the publications we surface
59
59
+
const current = newestByPublication.get(doc.value.site);
60
60
+
if (!current || (doc.value.publishedAt ?? '') > (current.value.publishedAt ?? '')) {
61
61
+
newestByPublication.set(doc.value.site, doc);
62
62
+
}
63
63
+
}
64
64
+
65
65
+
const posts: LatestPost[] = [];
66
66
+
for (const [siteUri, doc] of newestByPublication) {
67
67
+
const pub = byUri.get(siteUri)!;
68
68
+
const cid = doc.value.coverImage?.ref.$link;
69
69
+
posts.push({
70
70
+
title: doc.value.title,
71
71
+
description: doc.value.description ?? '',
72
72
+
publishedAt: doc.value.publishedAt ?? null,
73
73
+
postUrl: buildPostUrl(pub.href, doc.value.path),
74
74
+
coverImage: cid
75
75
+
? `${repo.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(repo.did)}&cid=${cid}`
76
76
+
: null,
77
77
+
publicationName: pub.name
78
78
+
});
79
79
+
}
80
80
+
81
81
+
posts.sort((a, b) => (b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''));
82
82
+
83
83
+
cache.setWithExpiry('latestPosts', posts, HOUR_MS);
84
84
+
return posts;
85
85
+
} catch (error) {
86
86
+
console.warn('Failed to fetch latest posts:', error);
87
87
+
return [];
88
88
+
}
89
89
+
}
···
25
25
const publications: Publication[] = records
26
26
//Sorry flo <3, just wanting to show my writings
27
27
.filter((x) => !x.uri.includes('blento.self'))
28
28
-
.map(({ value }) => ({
29
29
-
name: value.name,
30
30
-
description: value.description ?? '',
31
31
-
href: value.url,
32
32
-
image: value.icon ? blobUrl(repo.pds, repo.did, value.icon) : null
28
28
+
.map((x) => ({
29
29
+
uri: x.uri,
30
30
+
name: x.value.name,
31
31
+
description: x.value.description ?? '',
32
32
+
href: x.value.url,
33
33
+
image: x.value.icon ? blobUrl(repo.pds, repo.did, x.value.icon) : null
33
34
}));
34
35
35
36
cache.setWithExpiry('publications', publications, HOUR_MS);
···
6
6
};
7
7
8
8
export type Publication = {
9
9
+
uri: string;
9
10
name: string;
10
11
description: string;
11
12
href: string;
12
13
image: string | null;
14
14
+
};
15
15
+
16
16
+
export type LatestPost = {
17
17
+
title: string;
18
18
+
description: string;
19
19
+
publishedAt: string | null;
20
20
+
postUrl: string; // publication.url + document.path
21
21
+
coverImage: string | null;
22
22
+
publicationName: string;
13
23
};
14
24
15
25
export type CurrentlyReading = {
···
1
1
import type { PageServerLoad } from './$types';
2
2
import { fetchSponsors } from '$lib/server/github';
3
3
import { fetchPublications } from '$lib/server/atproto/publications';
4
4
+
import { fetchLatestPosts } from '$lib/server/atproto/documents';
4
5
import { fetchCurrentlyReading } from '$lib/server/atproto/books';
5
6
import { fetchNowPlaying } from '$lib/server/atproto/music';
6
7
···
12
13
return {
13
14
sponsors,
14
15
publications,
16
16
+
latestPosts: fetchLatestPosts(),
15
17
currentlyReading: fetchCurrentlyReading(),
16
18
nowPlaying: fetchNowPlaying(fetch)
17
19
};
···
11
11
import type { PageProps } from './$types';
12
12
13
13
let { data }: PageProps = $props();
14
14
-
const { sponsors, publications, currentlyReading, nowPlaying } = data;
14
14
+
const { sponsors, publications, latestPosts, currentlyReading, nowPlaying } = data;
15
15
</script>
16
16
17
17
<svelte:head>
···
26
26
<NavBar />
27
27
<Hero />
28
28
<OpenSourceProjects />
29
29
-
<Writing {publications} />
29
29
+
<Writing {publications} {latestPosts} />
30
30
<FreeTime {currentlyReading} {nowPlaying} />
31
31
<Sponsors {sponsors} />
32
32
</div>