Skip to content

Commit e87055d

Browse files
howwohmmclaude
andcommitted
feat(ui): add description and identity claims to governance cards
Expand governance member cards on the about page to show: - Bio/description line from GitHub profile - Social identity links (X/Twitter, Bluesky, Mastodon, LinkedIn, etc.) The existing `fetchSponsorable` GraphQL query is replaced with a broader `fetchGovernanceProfiles` that fetches bio, twitterUsername, and socialAccounts in the same request. Social icons use the existing UnoCSS icon sets (simple-icons + lucide). Data gracefully degrades when the GitHub token is unavailable. Closes #1564 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f2fc1a commit e87055d

File tree

2 files changed

+137
-33
lines changed

2 files changed

+137
-33
lines changed

app/pages/about.vue

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import type { Role } from '#server/api/contributors.get'
2+
import type { Role, SocialAccount } from '#server/api/contributors.get'
33
import { SPONSORS } from '~/assets/logos/sponsors'
44
import { OSS_PARTNERS } from '~/assets/logos/oss-partners'
55
@@ -37,6 +37,44 @@ const communityContributors = computed(
3737
() => contributors.value?.filter(c => c.role === 'contributor') ?? [],
3838
)
3939
40+
const socialIcons: Record<string, string> = {
41+
TWITTER: 'i-simple-icons:x',
42+
MASTODON: 'i-simple-icons:mastodon',
43+
BLUESKY: 'i-simple-icons:bluesky',
44+
LINKEDIN: 'i-simple-icons:linkedin',
45+
YOUTUBE: 'i-simple-icons:youtube',
46+
HOMETOWN: 'i-lucide:globe',
47+
DISCORD: 'i-simple-icons:discord',
48+
}
49+
50+
function getSocialIcon(provider: string): string {
51+
return socialIcons[provider] ?? 'i-lucide:link'
52+
}
53+
54+
function getSocialLinks(person: { twitterUsername: string | null; socialAccounts: SocialAccount[] }): { provider: string; url: string; icon: string }[] {
55+
const links: { provider: string; url: string; icon: string }[] = []
56+
57+
if (person.twitterUsername) {
58+
links.push({
59+
provider: 'TWITTER',
60+
url: `https://x.com/${person.twitterUsername}`,
61+
icon: socialIcons.TWITTER!,
62+
})
63+
}
64+
65+
for (const account of person.socialAccounts) {
66+
// Skip twitter if already added via twitterUsername
67+
if (account.provider === 'TWITTER') continue
68+
links.push({
69+
provider: account.provider,
70+
url: account.url,
71+
icon: getSocialIcon(account.provider),
72+
})
73+
}
74+
75+
return links
76+
}
77+
4078
const roleLabels = computed(
4179
() =>
4280
({
@@ -209,17 +247,42 @@ const roleLabels = computed(
209247
<div class="text-xs text-fg-muted tracking-tight">
210248
{{ roleLabels[person.role] ?? person.role }}
211249
</div>
212-
<LinkBase
213-
v-if="person.sponsors_url"
214-
:to="person.sponsors_url"
215-
no-underline
216-
no-external-icon
217-
classicon="i-lucide:heart"
218-
class="relative z-10 text-xs text-fg-muted hover:text-pink-400 mt-0.5"
219-
:aria-label="$t('about.team.sponsor_aria', { name: person.login })"
250+
<div
251+
v-if="person.bio"
252+
class="text-xs text-fg-subtle truncate mt-0.5"
253+
:title="person.bio"
220254
>
221-
{{ $t('about.team.sponsor') }}
222-
</LinkBase>
255+
{{ person.bio }}
256+
</div>
257+
<div class="flex items-center gap-1.5 mt-1">
258+
<LinkBase
259+
v-if="person.sponsors_url"
260+
:to="person.sponsors_url"
261+
no-underline
262+
no-external-icon
263+
classicon="i-lucide:heart"
264+
class="relative z-10 text-xs text-fg-muted hover:text-pink-400"
265+
:aria-label="$t('about.team.sponsor_aria', { name: person.login })"
266+
>
267+
{{ $t('about.team.sponsor') }}
268+
</LinkBase>
269+
<div
270+
v-if="getSocialLinks(person).length"
271+
class="relative z-10 flex items-center gap-1"
272+
>
273+
<a
274+
v-for="link in getSocialLinks(person)"
275+
:key="link.provider"
276+
:href="link.url"
277+
target="_blank"
278+
rel="noopener noreferrer"
279+
class="text-fg-muted hover:text-fg transition-colors"
280+
:aria-label="`${person.login} on ${link.provider.toLowerCase()}`"
281+
>
282+
<span :class="[link.icon, 'w-3 h-3']" aria-hidden="true" />
283+
</a>
284+
</div>
285+
</div>
223286
</div>
224287
<span
225288
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"

server/api/contributors.get.ts

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export type Role = 'steward' | 'maintainer' | 'contributor'
22

3+
export interface SocialAccount {
4+
provider: string
5+
url: string
6+
}
7+
38
export interface GitHubContributor {
49
login: string
510
id: number
@@ -8,9 +13,12 @@ export interface GitHubContributor {
813
contributions: number
914
role: Role
1015
sponsors_url: string | null
16+
bio: string | null
17+
twitterUsername: string | null
18+
socialAccounts: SocialAccount[]
1119
}
1220

13-
type GitHubAPIContributor = Omit<GitHubContributor, 'role' | 'sponsors_url'>
21+
type GitHubAPIContributor = Omit<GitHubContributor, 'role' | 'sponsors_url' | 'bio' | 'twitterUsername' | 'socialAccounts'>
1422

1523
// Fallback when no GitHub token is available (e.g. preview environments).
1624
// Only stewards are shown as maintainers; everyone else is a contributor.
@@ -60,16 +68,31 @@ async function fetchTeamMembers(token: string): Promise<TeamMembers | null> {
6068
}
6169
}
6270

71+
interface GovernanceProfile {
72+
hasSponsorsListing: boolean
73+
bio: string | null
74+
twitterUsername: string | null
75+
socialAccounts: SocialAccount[]
76+
}
77+
6378
/**
64-
* Batch-query GitHub GraphQL API to check which users have sponsors enabled.
65-
* Returns a Set of logins that have a sponsors listing.
79+
* Batch-query GitHub GraphQL API to fetch profile data for governance members.
80+
* Returns bio, social accounts, and sponsors listing status.
6681
*/
67-
async function fetchSponsorable(token: string, logins: string[]): Promise<Set<string>> {
68-
if (logins.length === 0) return new Set()
82+
async function fetchGovernanceProfiles(
83+
token: string,
84+
logins: string[],
85+
): Promise<Map<string, GovernanceProfile>> {
86+
if (logins.length === 0) return new Map()
6987

70-
// Build aliased GraphQL query: user0: user(login: "x") { hasSponsorsListing login }
7188
const fragments = logins.map(
72-
(login, i) => `user${i}: user(login: "${login}") { hasSponsorsListing login }`,
89+
(login, i) => `user${i}: user(login: "${login}") {
90+
login
91+
hasSponsorsListing
92+
bio
93+
twitterUsername
94+
socialAccounts(first: 10) { nodes { provider url } }
95+
}`,
7396
)
7497
const query = `{ ${fragments.join('\n')} }`
7598

@@ -85,26 +108,40 @@ async function fetchSponsorable(token: string, logins: string[]): Promise<Set<st
85108
})
86109

87110
if (!response.ok) {
88-
console.warn(`Failed to fetch sponsors info: ${response.status}`)
89-
return new Set()
111+
console.warn(`Failed to fetch governance profiles: ${response.status}`)
112+
return new Map()
90113
}
91114

92115
const json = (await response.json()) as {
93-
data?: Record<string, { login: string; hasSponsorsListing: boolean } | null>
116+
data?: Record<string, {
117+
login: string
118+
hasSponsorsListing: boolean
119+
bio: string | null
120+
twitterUsername: string | null
121+
socialAccounts: { nodes: { provider: string; url: string }[] }
122+
} | null>
94123
}
95124

96-
const sponsorable = new Set<string>()
125+
const profiles = new Map<string, GovernanceProfile>()
97126
if (json.data) {
98127
for (const user of Object.values(json.data)) {
99-
if (user?.hasSponsorsListing) {
100-
sponsorable.add(user.login)
128+
if (user) {
129+
profiles.set(user.login, {
130+
hasSponsorsListing: user.hasSponsorsListing,
131+
bio: user.bio,
132+
twitterUsername: user.twitterUsername,
133+
socialAccounts: user.socialAccounts.nodes.map(n => ({
134+
provider: n.provider,
135+
url: n.url,
136+
})),
137+
})
101138
}
102139
}
103140
}
104-
return sponsorable
141+
return profiles
105142
} catch (error) {
106-
console.warn('Failed to fetch sponsors info:', error)
107-
return new Set()
143+
console.warn('Failed to fetch governance profiles:', error)
144+
return new Map()
108145
}
109146
}
110147

@@ -172,18 +209,22 @@ export default defineCachedEventHandler(
172209
.filter(c => teams.steward.has(c.login) || teams.maintainer.has(c.login))
173210
.map(c => c.login)
174211

175-
const sponsorable = githubToken
176-
? await fetchSponsorable(githubToken, maintainerLogins)
177-
: new Set<string>()
212+
const governanceProfiles = githubToken
213+
? await fetchGovernanceProfiles(githubToken, maintainerLogins)
214+
: new Map<string, GovernanceProfile>()
178215

179216
return filtered
180217
.map(c => {
181218
const { role, order } = getRoleInfo(c.login, teams)
182-
const sponsors_url = sponsorable.has(c.login)
219+
const profile = governanceProfiles.get(c.login)
220+
const sponsors_url = profile?.hasSponsorsListing
183221
? `https://github.com/sponsors/${c.login}`
184222
: null
185-
Object.assign(c, { role, order, sponsors_url })
186-
return c as GitHubContributor & { order: number; sponsors_url: string | null; role: Role }
223+
const bio = profile?.bio ?? null
224+
const twitterUsername = profile?.twitterUsername ?? null
225+
const socialAccounts = profile?.socialAccounts ?? []
226+
Object.assign(c, { role, order, sponsors_url, bio, twitterUsername, socialAccounts })
227+
return c as GitHubContributor & { order: number }
187228
})
188229
.sort((a, b) => a.order - b.order || b.contributions - a.contributions)
189230
.map(({ order: _, ...rest }) => rest)

0 commit comments

Comments
 (0)