Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions app/pages/pds.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { AtprotoProfile } from '#server/api/pds-users.get.ts'

const router = useRouter()
const canGoBack = useCanGoBack()

useSeoMeta({
title: 'npmx.social - npmx',
ogTitle: 'npmx.social - npmx',
twitterTitle: 'npmx.social - npmx',
description: 'The official AT Protocol Personal Data Server (PDS) for the npmx community.',
ogDescription: 'The official AT Protocol Personal Data Server (PDS) for the npmx community.',
twitterDescription: 'The official AT Protocol Personal Data Server (PDS) for the npmx community.',
})

defineOgImageComponent('Default', {
primaryColor: '#60a5fa',
title: 'npmx.social',
description: 'The official **PDS** for the npmx community.',
})

const brokenImages = ref(new Set<string>())

const handleImageError = (handle: string) => {
brokenImages.value.add(handle)
}

const { data: pdsUsers, status: pdsStatus } = useLazyFetch<AtprotoProfile[]>('/api/pds-users', {
default: () => [],
})

const usersWithAvatars = computed(() => {
return pdsUsers.value.filter(user => user.avatar && !brokenImages.value.has(user.handle))
})
</script>

<template>
<main class="container flex-1 py-12 sm:py-16 overflow-x-hidden">
<article class="max-w-2xl mx-auto">
<header class="mb-12">
<div class="flex items-baseline justify-between gap-4 mb-4">
<h1 class="font-mono text-3xl sm:text-4xl font-medium">npmx.social</h1>
<button
type="button"
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"
@click="router.back()"
v-if="canGoBack"
>
<span class="i-lucide:arrow-left rtl-flip w-4 h-4" aria-hidden="true" />
<span class="hidden sm:inline">Back</span>
</button>
</div>
<p class="text-fg-muted text-lg">
The official AT Protocol Personal Data Server (PDS) for the npmx community.
</p>
</header>

<section class="prose prose-invert max-w-none space-y-12">
<div>
<h2 class="text-lg text-fg uppercase tracking-wider mb-4">Join the Community</h2>
<p class="text-fg-muted leading-relaxed mb-4">
Whether you are creating your first Bluesky account or migrating an existing one, you
belong here. You can migrate your current account without losing your handle, your
posts, or your followers.
</p>
<div class="mt-6">
<LinkBase
to="https://pdsmoover.com/moover/npmx.social"
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"
>
<span class="i-lucide:arrow-right-left w-4 h-4 text-fg-muted" aria-hidden="true" />
Migrate with PDS MOOver
</LinkBase>
</div>
</div>

<div>
<h2 class="text-lg text-fg uppercase tracking-wider mb-4">Server Details</h2>
<ul class="space-y-3 text-fg-muted list-none p-0">
<li class="flex items-start gap-3">
<span
class="i-lucide:map-pin shrink-0 mt-1 w-4 h-4 text-fg-subtle"
aria-hidden="true"
/>
<span>
<strong class="text-fg">Location:</strong>
Nuremberg, Germany
</span>
</li>
<li class="flex items-start gap-3">
<span
class="i-lucide:server shrink-0 mt-1 w-4 h-4 text-fg-subtle"
aria-hidden="true"
/>
<span>
<strong class="text-fg">Infrastructure:</strong>
Hosted on Hetzner
</span>
</li>
<li class="flex items-start gap-3">
<span
class="i-lucide:shield-check shrink-0 mt-1 w-4 h-4 text-fg-subtle"
aria-hidden="true"
/>
<span>
<strong class="text-fg">Privacy:</strong>
Subject to strict EU Data Protection laws
</span>
</li>
</ul>
</div>
<div aria-labelledby="community-heading">
<h2 id="community-heading" class="text-lg text-fg uppercase tracking-wider mb-4">
Who is here
</h2>
<p class="text-fg-muted leading-relaxed mb-6">
They are already calling npmx.social home.
</p>

<div v-if="pdsStatus === 'pending'" class="text-fg-subtle text-sm" role="status">
Loading PDS community...
</div>
<div v-else-if="pdsStatus === 'error'" class="text-fg-subtle text-sm" role="alert">
Failed to load PDS community.
</div>
<ul
v-else-if="usersWithAvatars.length"
class="grid grid-cols-[repeat(auto-fill,48px)] gap-2 list-none p-0"
>
<li v-for="user in usersWithAvatars" :key="user.handle" class="block group relative">
<a
:href="`https://bsky.app/profile/${user.handle}`"
target="_blank"
rel="noopener noreferrer"
:aria-label="`View ${user.handle}'s profile`"
class="block rounded-lg"
>
<img
:src="user.avatar"
:alt="`${user.handle}'s avatar`"
@error="handleImageError(user.handle)"
width="48"
height="48"
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"
loading="lazy"
/>

<span
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"
dir="ltr"
role="tooltip"
>
@{{ user.handle }}
</span>
</a>
</li>
</ul>
</div>
</section>
</article>
</main>
</template>
1 change: 1 addition & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export default defineNuxtConfig({
'/search': { isr: false, cache: false }, // never cache
'/settings': { prerender: true },
'/recharging': { prerender: true },
'/pds': { prerender: true },
// proxy for insights
'/_v/script.js': { proxy: 'https://npmx.dev/_vercel/insights/script.js' },
'/_v/view': { proxy: 'https://npmx.dev/_vercel/insights/view' },
Expand Down
76 changes: 76 additions & 0 deletions server/api/pds-graphs.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { AtprotoProfile } from '#server/api/pds-users.get.ts'

interface GraphLink {
source: string
target: string
}

export default defineCachedEventHandler(
async (): Promise<{ nodes: AtprotoProfile[]; links: GraphLink[] }> => {
const response = await fetch('https://npmx.social/xrpc/com.atproto.sync.listRepos?limit=1000')

if (!response.ok) {
throw createError({
statusCode: response.status,
message: 'Failed to fetch PDS repos',
})
}

const listRepos = (await response.json()) as { repos: { did: string }[] }
const dids = listRepos.repos.map(repo => repo.did)
const localDids = new Set(dids)

const getProfilesUrl = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles'
const nodes: AtprotoProfile[] = []
const links: GraphLink[] = []

for (let i = 0; i < dids.length; i += 25) {
const batch = dids.slice(i, i + 25)

const params = new URLSearchParams()
for (const did of batch) {
params.append('actors', did)
}

try {
const profilesResponse = await fetch(`${getProfilesUrl}?${params.toString()}`)

if (!profilesResponse.ok) {
console.warn(`Failed to fetch atproto profiles: ${profilesResponse.status}`)
continue
}

const profilesData = (await profilesResponse.json()) as { profiles: AtprotoProfile[] }

if (profilesData.profiles) {
nodes.push(...profilesData.profiles)
}
} catch (error) {
console.warn('Failed to fetch atproto profiles:', error)
}
}

for (const did of dids) {
const followResponse = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=${did}`,
)

if (!followResponse.ok) {
console.warn(`Failed to fetch atproto profiles: ${followResponse.status}`)
continue
}

const followData = await followResponse.json()

for (const followedUser of followData.follows) {
if (localDids.has(followedUser.did)) {
links.push({ source: did, target: followedUser.did })
}
}
}
return {
nodes,
links,
}
},
)
58 changes: 58 additions & 0 deletions server/api/pds-users.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export interface AtprotoProfile {
did: string
handle: string
displayName?: string
avatar?: string
}

export default defineCachedEventHandler(
async (): Promise<AtprotoProfile[]> => {
const response = await fetch('https://npmx.social/xrpc/com.atproto.sync.listRepos?limit=1000')

if (!response.ok) {
throw createError({
statusCode: response.status,
message: 'Failed to fetch PDS repos',
})
}

const listRepos = (await response.json()) as { repos: { did: string }[] }
const dids = listRepos.repos.map(repo => repo.did)

const getProfilesUrl = 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles'
const allProfiles: AtprotoProfile[] = []

for (let i = 0; i < dids.length; i += 25) {
const batch = dids.slice(i, i + 25)

const params = new URLSearchParams()
for (const did of batch) {
params.append('actors', did)
}

try {
const profilesResponse = await fetch(`${getProfilesUrl}?${params.toString()}`)

if (!profilesResponse.ok) {
console.warn(`Failed to fetch atproto profiles: ${profilesResponse.status}`)
continue
}

const profilesData = (await profilesResponse.json()) as { profiles: AtprotoProfile[] }

if (profilesData.profiles) {
allProfiles.push(...profilesData.profiles)
}
} catch (error) {
console.warn('Failed to fetch atproto profiles:', error)
}
}

return allProfiles
},
{
maxAge: 3600,
name: 'pds-users',
getKey: () => 'pds-users',
},
)
1 change: 1 addition & 0 deletions server/middleware/canonical-redirects.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const pages = [
'/package',
'/package-code',
'/package-docs',
'/pds',
'/privacy',
'/search',
'/settings',
Expand Down
Loading