Skip to content
Open
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
71 changes: 60 additions & 11 deletions app/pages/about.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Role } from '#server/api/contributors.get'
import type { Role, SocialAccount } from '#server/api/contributors.get'
import { SPONSORS } from '~/assets/logos/sponsors'
import { OSS_PARTNERS } from '~/assets/logos/oss-partners'

Expand Down Expand Up @@ -37,6 +37,30 @@ const communityContributors = computed(
() => contributors.value?.filter(c => c.role === 'contributor') ?? [],
)

const socialIcons: Record<string, string> = {
TWITTER: 'i-simple-icons:x',
MASTODON: 'i-simple-icons:mastodon',
BLUESKY: 'i-simple-icons:bluesky',
LINKEDIN: 'i-simple-icons:linkedin',
YOUTUBE: 'i-simple-icons:youtube',
HOMETOWN: 'i-lucide:globe',
DISCORD: 'i-simple-icons:discord',
}

function getSocialIcon(provider: string): string {
return socialIcons[provider] ?? 'i-lucide:link'
}

function getSocialLinks(person: {
socialAccounts: SocialAccount[]
}): { provider: string; url: string; icon: string }[] {
return person.socialAccounts.map(account => ({
provider: account.provider,
url: account.url,
icon: getSocialIcon(account.provider),
}))
}

const roleLabels = computed(
() =>
({
Expand Down Expand Up @@ -209,17 +233,42 @@ const roleLabels = computed(
<div class="text-xs text-fg-muted tracking-tight">
{{ roleLabels[person.role] ?? person.role }}
</div>
<LinkBase
v-if="person.sponsors_url"
:to="person.sponsors_url"
no-underline
no-external-icon
classicon="i-lucide:heart"
class="relative z-10 text-xs text-fg-muted hover:text-pink-400 mt-0.5"
:aria-label="$t('about.team.sponsor_aria', { name: person.login })"
<div
v-if="person.bio"
class="text-xs text-fg-subtle truncate mt-0.5"
:title="person.bio"
>
{{ $t('about.team.sponsor') }}
</LinkBase>
{{ person.bio }}
</div>
<div class="flex items-center gap-1.5 mt-1">
<LinkBase
v-if="person.sponsors_url"
:to="person.sponsors_url"
no-underline
no-external-icon
classicon="i-lucide:heart"
class="relative z-10 text-xs text-fg-muted hover:text-pink-400"
:aria-label="$t('about.team.sponsor_aria', { name: person.login })"
>
{{ $t('about.team.sponsor') }}
</LinkBase>
<div
v-if="getSocialLinks(person).length"
class="relative z-10 flex items-center gap-1"
>
<a
v-for="link in getSocialLinks(person)"
:key="link.provider"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="text-fg-muted hover:text-fg transition-colors"
:aria-label="`${person.login} on ${link.provider.toLowerCase()}`"
>
<span :class="[link.icon, 'w-3 h-3']" aria-hidden="true" />
</a>
</div>
</div>
</div>
<span
class="i-lucide:external-link rtl-flip w-3.5 h-3.5 text-fg-muted opacity-50 shrink-0 self-start mt-0.5 pointer-events-none"
Expand Down
98 changes: 76 additions & 22 deletions server/api/contributors.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export type Role = 'steward' | 'maintainer' | 'contributor'

export interface SocialAccount {
provider: string
url: string
}

export interface GitHubContributor {
login: string
id: number
Expand All @@ -8,9 +13,14 @@ export interface GitHubContributor {
contributions: number
role: Role
sponsors_url: string | null
bio: string | null
socialAccounts: SocialAccount[]
}

type GitHubAPIContributor = Omit<GitHubContributor, 'role' | 'sponsors_url'>
type GitHubAPIContributor = Omit<
GitHubContributor,
'role' | 'sponsors_url' | 'bio' | 'socialAccounts'
>

// Fallback when no GitHub token is available (e.g. preview environments).
// Only stewards are shown as maintainers; everyone else is a contributor.
Expand Down Expand Up @@ -60,17 +70,32 @@ async function fetchTeamMembers(token: string): Promise<TeamMembers | null> {
}
}

interface GovernanceProfile {
hasSponsorsListing: boolean
bio: string | null
socialAccounts: SocialAccount[]
}

/**
* Batch-query GitHub GraphQL API to check which users have sponsors enabled.
* Returns a Set of logins that have a sponsors listing.
* Batch-query GitHub GraphQL API to fetch profile data for governance members.
* Returns bio, social accounts, and sponsors listing status.
*/
async function fetchSponsorable(token: string, logins: string[]): Promise<Set<string>> {
if (logins.length === 0) return new Set()
async function fetchGovernanceProfiles(
token: string,
logins: string[],
): Promise<Map<string, GovernanceProfile>> {
if (logins.length === 0) return new Map()

// Build aliased GraphQL query: user0: user(login: "x") { hasSponsorsListing login }
const fragments = logins.map(
(login, i) => `user${i}: user(login: "${login}") { hasSponsorsListing login }`,
(login, i) => `user${i}: user(login: "${login}") {
login
hasSponsorsListing
bio
twitterUsername
socialAccounts(first: 10) { nodes { provider url } }
}`,
)
// twitterUsername is fetched to normalise it into socialAccounts below
const query = `{ ${fragments.join('\n')} }`

try {
Expand All @@ -85,26 +110,52 @@ async function fetchSponsorable(token: string, logins: string[]): Promise<Set<st
})

if (!response.ok) {
console.warn(`Failed to fetch sponsors info: ${response.status}`)
return new Set()
console.warn(`Failed to fetch governance profiles: ${response.status}`)
return new Map()
}

const json = (await response.json()) as {
data?: Record<string, { login: string; hasSponsorsListing: boolean } | null>
data?: Record<
string,
{
login: string
hasSponsorsListing: boolean
bio: string | null
twitterUsername: string | null
socialAccounts: { nodes: { provider: string; url: string }[] }
} | null
>
}

const sponsorable = new Set<string>()
const profiles = new Map<string, GovernanceProfile>()
if (json.data) {
for (const user of Object.values(json.data)) {
if (user?.hasSponsorsListing) {
sponsorable.add(user.login)
if (user) {
const socialAccounts: SocialAccount[] = user.socialAccounts.nodes.map(n => ({
provider: n.provider,
url: n.url,
}))
// Normalise twitterUsername into socialAccounts so callers have a
// single unified array. GitHub returns it separately because it
// predates the socialAccounts field.
if (user.twitterUsername && !socialAccounts.some(a => a.provider === 'TWITTER')) {
socialAccounts.unshift({
provider: 'TWITTER',
url: `https://x.com/${user.twitterUsername}`,
})
}
profiles.set(user.login, {
hasSponsorsListing: user.hasSponsorsListing,
bio: user.bio,
socialAccounts,
})
}
}
}
return sponsorable
return profiles
} catch (error) {
console.warn('Failed to fetch sponsors info:', error)
return new Set()
console.warn('Failed to fetch governance profiles:', error)
return new Map()
}
}

Expand Down Expand Up @@ -172,18 +223,21 @@ export default defineCachedEventHandler(
.filter(c => teams.steward.has(c.login) || teams.maintainer.has(c.login))
.map(c => c.login)

const sponsorable = githubToken
? await fetchSponsorable(githubToken, maintainerLogins)
: new Set<string>()
const governanceProfiles = githubToken
? await fetchGovernanceProfiles(githubToken, maintainerLogins)
: new Map<string, GovernanceProfile>()

return filtered
.map(c => {
const { role, order } = getRoleInfo(c.login, teams)
const sponsors_url = sponsorable.has(c.login)
const profile = governanceProfiles.get(c.login)
const sponsors_url = profile?.hasSponsorsListing
? `https://github.com/sponsors/${c.login}`
: null
Object.assign(c, { role, order, sponsors_url })
return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role }
const bio = profile?.bio ?? null
const socialAccounts = profile?.socialAccounts ?? []
Object.assign(c, { role, order, sponsors_url, bio, socialAccounts })
return c as GitHubContributor & { order: number }
})
.sort((a, b) => a.order - b.order || b.contributions - a.contributions)
.map(({ order: _, ...rest }) => rest)
Expand Down
Loading