Skip to content

Commit d47b3ab

Browse files
committed
feat: build pds landing page with user grid and experimental graph api
1 parent 40d1964 commit d47b3ab

File tree

5 files changed

+298
-0
lines changed

5 files changed

+298
-0
lines changed

app/pages/pds.vue

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<script setup lang="ts">
2+
import type { AtprotoProfile } from '#server/api/pds-users.get.ts'
3+
4+
const router = useRouter()
5+
const canGoBack = useCanGoBack()
6+
7+
useSeoMeta({
8+
title: 'npmx.social - npmx',
9+
ogTitle: 'npmx.social - npmx',
10+
twitterTitle: 'npmx.social - npmx',
11+
description: 'The official AT Protocol Personal Data Server (PDS) for the npmx community.',
12+
ogDescription: 'The official AT Protocol Personal Data Server (PDS) for the npmx community.',
13+
twitterDescription: 'The official AT Protocol Personal Data Server (PDS) for the npmx community.',
14+
})
15+
16+
defineOgImageComponent('Default', {
17+
primaryColor: '#60a5fa',
18+
title: 'npmx.social',
19+
description: 'The official **PDS** for the npmx community.',
20+
})
21+
22+
const brokenImages = ref(new Set<string>())
23+
24+
const handleImageError = (handle: string) => {
25+
brokenImages.value.add(handle)
26+
}
27+
28+
const { data: pdsUsers, status: pdsStatus } = useLazyFetch<AtprotoProfile[]>('/api/pds-users', {
29+
default: () => [],
30+
})
31+
32+
const usersWithAvatars = computed(() => {
33+
return pdsUsers.value.filter(user => user.avatar && !brokenImages.value.has(user.handle))
34+
})
35+
</script>
36+
37+
<template>
38+
<main class="container flex-1 py-12 sm:py-16 overflow-x-hidden">
39+
<article class="max-w-2xl mx-auto">
40+
<header class="mb-12">
41+
<div class="flex items-baseline justify-between gap-4 mb-4">
42+
<h1 class="font-mono text-3xl sm:text-4xl font-medium">npmx.social</h1>
43+
<button
44+
type="button"
45+
class="cursor-pointer inline-flex items-center gap-2 p-1.5 -mx-1.5 font-mono text-sm text-fg-muted hover:text-fg transition-colors duration-200 rounded focus-visible:outline-accent/70 shrink-0"
46+
@click="router.back()"
47+
v-if="canGoBack"
48+
>
49+
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
50+
<span class="hidden sm:inline">Back</span>
51+
</button>
52+
</div>
53+
<p class="text-fg-muted text-lg">
54+
The official AT Protocol Personal Data Server (PDS) for the npmx community.
55+
</p>
56+
</header>
57+
58+
<section class="prose prose-invert max-w-none space-y-12">
59+
<div>
60+
<h2 class="text-lg text-fg uppercase tracking-wider mb-4">Join the Community</h2>
61+
<p class="text-fg-muted leading-relaxed mb-4">
62+
Whether you are creating your first Bluesky account or migrating an existing one, you
63+
belong here. You can migrate your current account without losing your handle, your
64+
posts, or your followers.
65+
</p>
66+
<div class="mt-6">
67+
<LinkBase
68+
to="https://pdsmoover.com/moover/npmx.social"
69+
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md border border-border hover:border-border-hover bg-bg-muted hover:bg-bg transition-colors"
70+
>
71+
<span class="i-lucide:arrow-right-left w-4 h-4 text-fg-muted" aria-hidden="true" />
72+
Migrate with PDS MOOver
73+
</LinkBase>
74+
</div>
75+
</div>
76+
77+
<div>
78+
<h2 class="text-lg text-fg uppercase tracking-wider mb-4">Server Details</h2>
79+
<ul class="space-y-3 text-fg-muted list-none p-0">
80+
<li class="flex items-start gap-3">
81+
<span
82+
class="i-lucide:map-pin shrink-0 mt-1 w-4 h-4 text-fg-subtle"
83+
aria-hidden="true"
84+
/>
85+
<span>
86+
<strong class="text-fg">Location:</strong>
87+
Nuremberg, Germany
88+
</span>
89+
</li>
90+
<li class="flex items-start gap-3">
91+
<span
92+
class="i-lucide:server shrink-0 mt-1 w-4 h-4 text-fg-subtle"
93+
aria-hidden="true"
94+
/>
95+
<span>
96+
<strong class="text-fg">Infrastructure:</strong>
97+
Hosted on Hetzner
98+
</span>
99+
</li>
100+
<li class="flex items-start gap-3">
101+
<span
102+
class="i-lucide:shield-check shrink-0 mt-1 w-4 h-4 text-fg-subtle"
103+
aria-hidden="true"
104+
/>
105+
<span>
106+
<strong class="text-fg">Privacy:</strong>
107+
Subject to strict EU Data Protection laws
108+
</span>
109+
</li>
110+
</ul>
111+
</div>
112+
<div aria-labelledby="community-heading">
113+
<h2 id="community-heading" class="text-lg text-fg uppercase tracking-wider mb-4">
114+
Who is here
115+
</h2>
116+
<p class="text-fg-muted leading-relaxed mb-6">
117+
They are already calling npmx.social home.
118+
</p>
119+
120+
<div v-if="pdsStatus === 'pending'" class="text-fg-subtle text-sm" role="status">
121+
Loading PDS community...
122+
</div>
123+
<div v-else-if="pdsStatus === 'error'" class="text-fg-subtle text-sm" role="alert">
124+
Failed to load PDS community.
125+
</div>
126+
<ul
127+
v-else-if="usersWithAvatars.length"
128+
class="grid grid-cols-[repeat(auto-fill,48px)] gap-2 list-none p-0"
129+
>
130+
<li v-for="user in usersWithAvatars" :key="user.handle" class="block group relative">
131+
<a
132+
:href="`https://bsky.app/profile/${user.handle}`"
133+
target="_blank"
134+
rel="noopener noreferrer"
135+
:aria-label="`View ${user.handle}'s profile`"
136+
class="block rounded-lg"
137+
>
138+
<img
139+
:src="user.avatar"
140+
:alt="`${user.handle}'s avatar`"
141+
@error="handleImageError(user.handle)"
142+
width="48"
143+
height="48"
144+
class="w-12 h-12 rounded-lg ring-2 ring-transparent group-hover:ring-accent transition-all duration-200 ease-out group-hover:scale-125 will-change-transform"
145+
loading="lazy"
146+
/>
147+
148+
<span
149+
class="pointer-events-none absolute -top-9 inset-is-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 text-xs px-2 py-1 shadow-lg opacity-0 scale-95 transition-all duration-150 group-hover:opacity-100 group-hover:scale-100 z-10"
150+
dir="ltr"
151+
role="tooltip"
152+
>
153+
@{{ user.handle }}
154+
</span>
155+
</a>
156+
</li>
157+
</ul>
158+
</div>
159+
</section>
160+
</article>
161+
</main>
162+
</template>

nuxt.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export default defineNuxtConfig({
152152
'/search': { isr: false, cache: false }, // never cache
153153
'/settings': { prerender: true },
154154
'/recharging': { prerender: true },
155+
'/pds': { prerender: true },
155156
// proxy for insights
156157
'/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
157158
'/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },

server/api/pds-graphs.get.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { AtprotoProfile } from '#server/api/pds-users.get.ts'
2+
3+
interface GraphLink {
4+
source: string
5+
target: string
6+
}
7+
8+
export default defineCachedEventHandler(
9+
async (): Promise<{ nodes: AtprotoProfile[]; links: GraphLink[] }> => {
10+
const response = await fetch('https://npmx.social/xrpc/com.atproto.sync.listRepos?limit=1000')
11+
12+
if (!response.ok) {
13+
throw createError({
14+
statusCode: response.status,
15+
message: 'Failed to fetch PDS repos',
16+
})
17+
}
18+
19+
const listRepos = (await response.json()) as { repos: { did: string }[] }
20+
const dids = listRepos.repos.map(repo => repo.did)
21+
const localDids = new Set(dids)
22+
23+
const getProfilesUrl = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles'
24+
const nodes: AtprotoProfile[] = []
25+
const links: GraphLink[] = []
26+
27+
for (let i = 0; i < dids.length; i += 25) {
28+
const batch = dids.slice(i, i + 25)
29+
30+
const params = new URLSearchParams()
31+
for (const did of batch) {
32+
params.append('actors', did)
33+
}
34+
35+
try {
36+
const profilesResponse = await fetch(`${getProfilesUrl}?${params.toString()}`)
37+
38+
if (!profilesResponse.ok) {
39+
console.warn(`Failed to fetch atproto profiles: ${profilesResponse.status}`)
40+
continue
41+
}
42+
43+
const profilesData = (await profilesResponse.json()) as { profiles: AtprotoProfile[] }
44+
45+
if (profilesData.profiles) {
46+
nodes.push(...profilesData.profiles)
47+
}
48+
} catch (error) {
49+
console.warn('Failed to fetch atproto profiles:', error)
50+
}
51+
}
52+
53+
for (const did of dids) {
54+
const followResponse = await fetch(
55+
`https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${did}`,
56+
)
57+
58+
if (!followResponse.ok) {
59+
console.warn(`Failed to fetch atproto profiles: ${followResponse.status}`)
60+
continue
61+
}
62+
63+
const followData = await followResponse.json()
64+
65+
for (const followedUser of followData.follows) {
66+
if (localDids.has(followedUser.did)) {
67+
links.push({ source: did, target: followedUser.did })
68+
}
69+
}
70+
}
71+
return {
72+
nodes,
73+
links,
74+
}
75+
},
76+
)

server/api/pds-users.get.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
export interface AtprotoProfile {
2+
did: string
3+
handle: string
4+
displayName?: string
5+
avatar?: string
6+
}
7+
8+
export default defineCachedEventHandler(
9+
async (): Promise<AtprotoProfile[]> => {
10+
const response = await fetch('https://npmx.social/xrpc/com.atproto.sync.listRepos?limit=1000')
11+
12+
if (!response.ok) {
13+
throw createError({
14+
statusCode: response.status,
15+
message: 'Failed to fetch PDS repos',
16+
})
17+
}
18+
19+
const listRepos = (await response.json()) as { repos: { did: string }[] }
20+
const dids = listRepos.repos.map(repo => repo.did)
21+
22+
const getProfilesUrl = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles'
23+
const allProfiles: AtprotoProfile[] = []
24+
25+
for (let i = 0; i < dids.length; i += 25) {
26+
const batch = dids.slice(i, i + 25)
27+
28+
const params = new URLSearchParams()
29+
for (const did of batch) {
30+
params.append('actors', did)
31+
}
32+
33+
try {
34+
const profilesResponse = await fetch(`${getProfilesUrl}?${params.toString()}`)
35+
36+
if (!profilesResponse.ok) {
37+
console.warn(`Failed to fetch atproto profiles: ${profilesResponse.status}`)
38+
continue
39+
}
40+
41+
const profilesData = (await profilesResponse.json()) as { profiles: AtprotoProfile[] }
42+
43+
if (profilesData.profiles) {
44+
allProfiles.push(...profilesData.profiles)
45+
}
46+
} catch (error) {
47+
console.warn('Failed to fetch atproto profiles:', error)
48+
}
49+
}
50+
51+
return allProfiles
52+
},
53+
{
54+
maxAge: 3600,
55+
name: 'pds-users',
56+
getKey: () => 'pds-users',
57+
},
58+
)

server/middleware/canonical-redirects.global.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const pages = [
2323
'/package',
2424
'/package-code',
2525
'/package-docs',
26+
'/pds',
2627
'/privacy',
2728
'/search',
2829
'/settings',

0 commit comments

Comments
 (0)