Skip to content

Commit 736981b

Browse files
TheMeinerLPclaude
andauthored
feat(team): roster refresh, application FAQ, role chips (#157)
Roster - Tag developers (joltra, theShadowsDust, mcmdev, Random) as Entwicklung, Pega as Bau-Team, weltspielt as Content & Konzepte; promote Alex M. to Teamassistenz and move BavarianKingdom into Moderation with their real MC name (morelia0815) so the head render resolves correctly - Add permanent open positions for Social Media (Media rank) and the donation-funded Lite rank; the Lite card links to OpenCollective and uses a heart icon via a new `applyVia` field on the team schema - Extend the rank enum to cover teamassist/media/lite FAQ - New `team_faq` content collection (DE/EN markdown) with five entries covering how to apply, the process, per-rank requirements, the Lite rank, and speculative applications - Repository contract gains `listTeamFaqEntries`; `useTeamFaqContent` composable + `TeamFaqSection` accordion mounted below the roster, with its own FAQPage schema.org block UI - Sub-roles render as outlined chips under each team member name (TeamMemberCard), reusing the shared Chip component Copy - Strip em-dashes from team titles/subtitles and the profile page; lean on the existing "| OneLiteFeather.net" title template instead Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6872b5d commit 736981b

32 files changed

Lines changed: 728 additions & 104 deletions
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
name?: string
4+
description?: string
5+
roleText?: string
6+
rankLabel?: string
7+
avatarUrl?: string
8+
}>()
9+
</script>
10+
11+
<template>
12+
<div
13+
class="relative h-full w-full flex items-center text-white overflow-hidden"
14+
style="background: linear-gradient(135deg, #11162a 0%, #2A388F 55%, #91268F 100%);"
15+
>
16+
<!-- Top-right brand wordmark -->
17+
<div class="absolute top-12 right-16 flex items-center gap-3 text-[26px] font-semibold tracking-tight opacity-90">
18+
<div
19+
class="flex items-center justify-center rounded-full"
20+
style="width: 44px; height: 44px; background: rgba(255,255,255,0.12);"
21+
>
22+
<span class="text-[22px]">OL</span>
23+
</div>
24+
<span>OneLiteFeather</span>
25+
</div>
26+
27+
<!-- Avatar block (left) -->
28+
<div class="flex items-center justify-center" style="margin-left: 80px;">
29+
<div
30+
class="flex items-center justify-center"
31+
style="width: 320px; height: 320px; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.18); border-radius: 32px; box-shadow: 0 24px 60px -16px rgba(0,0,0,0.45);"
32+
>
33+
<img
34+
v-if="avatarUrl"
35+
:src="avatarUrl"
36+
width="256"
37+
height="256"
38+
style="image-rendering: pixelated; border-radius: 24px;"
39+
/>
40+
</div>
41+
</div>
42+
43+
<!-- Text block (right) -->
44+
<div class="flex flex-col" style="margin-left: 60px; max-width: 660px;">
45+
<div
46+
v-if="rankLabel"
47+
class="text-[22px] font-semibold uppercase tracking-[0.18em]"
48+
style="color: #27A9E1;"
49+
>
50+
{{ rankLabel }}
51+
</div>
52+
53+
<h1
54+
class="text-[88px] font-bold leading-[1.02] tracking-tight mt-3"
55+
style="display: block; line-clamp: 2; text-overflow: ellipsis; text-wrap: balance;"
56+
>
57+
{{ name }}
58+
</h1>
59+
60+
<p
61+
v-if="description"
62+
class="text-[28px] leading-snug mt-6"
63+
style="color: rgba(255,255,255,0.85); display: block; line-clamp: 3; text-overflow: ellipsis;"
64+
>
65+
{{ description }}
66+
</p>
67+
68+
<p
69+
v-if="roleText"
70+
class="text-[22px] mt-5"
71+
style="color: rgba(255,255,255,0.65); display: block; line-clamp: 1; text-overflow: ellipsis;"
72+
>
73+
{{ roleText }}
74+
</p>
75+
</div>
76+
77+
<!-- Accent bar bottom -->
78+
<div
79+
class="absolute bottom-0 left-0 right-0"
80+
style="height: 8px; background: linear-gradient(90deg, #EC008B 0%, #F7931D 50%, #27A9E1 100%);"
81+
/>
82+
</div>
83+
</template>

components/features/blog/page/FeaturedTeamMembers.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useI18n } from 'vue-i18n'
33
import { NuxtLink } from '#components'
44
import { teamAvatarUrl } from '~/utils/teamAvatar'
5+
import { toRoleString } from '~/utils/teamRoles'
56
67
const props = defineProps<{ slugs: string[] }>()
78
@@ -46,7 +47,7 @@ const members = computed(() => props.slugs
4647
/>
4748
<span class="min-w-0">
4849
<span class="block text-sm font-semibold text-neutral-900 dark:text-neutral-100">{{ m.name }}</span>
49-
<span v-if="m.role" class="block text-xs text-neutral-600 dark:text-neutral-400">{{ m.role }}</span>
50+
<span v-if="toRoleString(m.role)" class="block text-xs text-neutral-600 dark:text-neutral-400">{{ toRoleString(m.role) }}</span>
5051
</span>
5152
</NuxtLink>
5253
</li>

components/features/home/team/TeamMemberCard.vue

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { useI18n } from 'vue-i18n'
33
import { NuxtLink } from '#components'
44
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
55
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
6+
import UiChip from '~/components/base/Chip.vue'
67
import { teamAvatarUrl } from '~/utils/teamAvatar'
8+
import { toRoleList, toRoleString } from '~/utils/teamRoles'
79
810
type Props = {
911
name: string
10-
role: string
12+
role?: string | string[]
1113
slogan?: string
1214
mcName?: string
1315
slug?: string
@@ -16,6 +18,7 @@ type Props = {
1618
}
1719
1820
const props = withDefaults(defineProps<Props>(), {
21+
role: undefined,
1922
slogan: undefined,
2023
mcName: undefined,
2124
slug: undefined,
@@ -35,7 +38,9 @@ const avatarSrc = computed(() => teamAvatarUrl({
3538
avatarUrl: props.avatarUrl
3639
}, 128))
3740
38-
const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: props.role }))
41+
const roleChips = computed(() => toRoleList(props.role))
42+
const roleAriaText = computed(() => toRoleString(props.role))
43+
const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: roleAriaText.value }))
3944
</script>
4045

4146
<template>
@@ -62,9 +67,17 @@ const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: p
6267
/>
6368
<div class="min-w-0">
6469
<h3 class="truncate text-lg font-semibold text-gray-900 dark:text-gray-100">{{ name }}</h3>
65-
<p class="truncate text-sm text-gray-600 dark:text-gray-400">{{ role }}</p>
6670
</div>
6771
</div>
72+
<div v-if="roleChips.length" class="mt-3 flex flex-wrap gap-2">
73+
<UiChip
74+
v-for="chip in roleChips"
75+
:key="chip"
76+
:label="chip"
77+
variant="outlined"
78+
as="span"
79+
/>
80+
</div>
6881
<p v-if="slogan" class="mt-3 line-clamp-3 text-sm text-gray-700 dark:text-gray-300">“{{ slogan }}”</p>
6982
<p v-if="profileHref" class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-600 dark:text-brand-400">
7083
{{ t('team.view_profile') }}

components/features/home/team/TeamMembers.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import TeamMemberCard from './TeamMemberCard.vue'
33
import { useI18n } from 'vue-i18n'
44
import type { TeamMember } from '~/types/team'
5+
import { toRoleList } from '~/utils/teamRoles'
56
67
type Props = {
78
title?: string
@@ -23,15 +24,18 @@ const selectedRole = ref<string>('')
2324
const visibleCount = ref<number | null>(props.limit)
2425
2526
const roles = computed(() => {
26-
const set = new Set(props.members.map(m => m.role).filter(Boolean))
27+
const set = new Set<string>()
28+
for (const m of props.members) {
29+
for (const r of toRoleList(m.role)) set.add(r)
30+
}
2731
return Array.from(set)
2832
})
2933
3034
const filtered = computed(() => {
3135
const q = query.value.trim().toLowerCase()
3236
const r = selectedRole.value
3337
let list = props.members
34-
if (r) list = list.filter(m => m.role === r)
38+
if (r) list = list.filter(m => toRoleList(m.role).includes(r))
3539
if (q) list = list.filter(m => m.name.toLowerCase().includes(q))
3640
return list
3741
})

components/features/team/OpenPositionCard.vue

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,33 @@
22
import { useI18n } from 'vue-i18n'
33
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
44
import { faDiscord } from '@fortawesome/free-brands-svg-icons'
5+
import { faHandHoldingHeart } from '@fortawesome/free-solid-svg-icons'
6+
import { toRoleString } from '~/utils/teamRoles'
57
68
type Props = {
7-
role: string
9+
role: string | string[]
810
slogan?: string
911
applyUrl?: string
12+
applyVia?: 'discord' | 'opencollective'
1013
}
1114
1215
const props = withDefaults(defineProps<Props>(), {
1316
slogan: undefined,
14-
applyUrl: 'https://1lf.link/discord'
17+
applyUrl: 'https://1lf.link/discord',
18+
applyVia: 'discord'
1519
})
1620
1721
const { t } = useI18n()
22+
23+
const roleText = computed(() => toRoleString(props.role))
24+
const isOpenCollective = computed(() => props.applyVia === 'opencollective')
25+
const icon = computed(() => isOpenCollective.value ? faHandHoldingHeart : faDiscord)
26+
const applyLabel = computed(() => isOpenCollective.value
27+
? t('team.open_position.apply_opencollective')
28+
: t('team.open_position.apply'))
29+
const applyAria = computed(() => isOpenCollective.value
30+
? t('team.open_position.apply_aria_opencollective', { role: roleText.value })
31+
: t('team.open_position.apply_aria', { role: roleText.value }))
1832
</script>
1933

2034
<template>
@@ -29,19 +43,19 @@ const { t } = useI18n()
2943
<p class="text-xs font-semibold uppercase tracking-wide text-primary dark:text-secondary">
3044
{{ t('team.open_position.badge') }}
3145
</p>
32-
<h3 class="truncate text-lg font-semibold text-gray-900 dark:text-gray-100">{{ props.role }}</h3>
46+
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 break-words">{{ roleText }}</h3>
3347
</div>
3448
</div>
35-
<p v-if="props.slogan" class="mt-3 line-clamp-3 text-sm text-gray-700 dark:text-gray-300">{{ props.slogan }}</p>
49+
<p v-if="props.slogan" class="mt-3 text-sm text-gray-700 dark:text-gray-300">{{ props.slogan }}</p>
3650
<a
3751
:href="props.applyUrl"
3852
target="_blank"
3953
rel="noopener noreferrer"
4054
class="mt-4 inline-flex items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-white hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
41-
:aria-label="t('team.open_position.apply_aria', { role: props.role })"
55+
:aria-label="applyAria"
4256
>
43-
<FontAwesomeIcon :icon="faDiscord" class="h-4 w-4" aria-hidden="true" />
44-
{{ t('team.open_position.apply') }}
57+
<FontAwesomeIcon :icon="icon" class="h-4 w-4" aria-hidden="true" />
58+
{{ applyLabel }}
4559
</a>
4660
</div>
4761
</li>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<script setup lang="ts">
2+
import { extractPlainText } from '~/utils/content'
3+
4+
const { t } = useI18n()
5+
const { items } = useTeamFaqContent()
6+
7+
const detailsClass = [
8+
'group rounded-xl border border-neutral-200 dark:border-neutral-800',
9+
'bg-white dark:bg-neutral-900/60 px-4 py-3 open:shadow-sm',
10+
'transition-shadow'
11+
].join(' ')
12+
13+
const summaryClass = [
14+
'flex cursor-pointer list-none items-center justify-between gap-4',
15+
'text-base font-semibold text-neutral-900 dark:text-neutral-100',
16+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md'
17+
].join(' ')
18+
19+
const toggleClass = [
20+
'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
21+
'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300',
22+
'transition-transform group-open:rotate-45'
23+
].join(' ')
24+
25+
const proseClass = [
26+
'prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none mt-3', 'prose-a:text-primary prose-a:underline-offset-2 prose-a:hover:underline'
27+
].join(' ')
28+
29+
// Extra FAQPage schema scoped to the team page; Google currently restricts
30+
// FAQ rich results to authoritative sources, but other crawlers (Bing,
31+
// DuckDuckGo, AI assistants) still pick it up. Plain-text answers only,
32+
// so we strip the MDC AST down.
33+
useSchemaOrg(() => {
34+
if (!items.value.length) return []
35+
return [
36+
{
37+
'@type': 'FAQPage',
38+
mainEntity: items.value.map((entry) => ({
39+
'@type': 'Question' as const,
40+
name: entry.question,
41+
acceptedAnswer: {
42+
'@type': 'Answer' as const,
43+
text: extractPlainText(entry.body, 500)
44+
}
45+
}))
46+
}
47+
]
48+
})
49+
</script>
50+
51+
<template>
52+
<section
53+
v-if="items.length"
54+
class="mt-12 md:mt-16"
55+
:aria-labelledby="'team-faq-heading'"
56+
>
57+
<header class="mb-6 text-center">
58+
<h2
59+
id="team-faq-heading"
60+
class="text-2xl md:text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100"
61+
>
62+
{{ t('team.faq.section_title') }}
63+
</h2>
64+
<p class="mt-2 text-sm md:text-base text-neutral-600 dark:text-neutral-400">
65+
{{ t('team.faq.section_subtitle') }}
66+
</p>
67+
</header>
68+
69+
<div class="space-y-3">
70+
<details
71+
v-for="entry in items"
72+
:key="entry.key"
73+
:class="detailsClass"
74+
>
75+
<summary :class="summaryClass">
76+
<span>{{ entry.question }}</span>
77+
<span :class="toggleClass" aria-hidden="true">+</span>
78+
</summary>
79+
<div :class="proseClass">
80+
<ContentRenderer :value="entry" />
81+
</div>
82+
</details>
83+
</div>
84+
</section>
85+
</template>

components/features/team/TeamRankSection.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const profileHref = (slug?: string) => slug ? `/${locale.value}/team/${slug}` :
3939
:role="p.role || rankLabel"
4040
:slogan="p.slogan"
4141
:apply-url="p.applyUrl"
42+
:apply-via="p.applyVia"
4243
/>
4344
</ul>
4445
</section>

composables/useTeamFaqContent.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useContentRepository } from '~/composables/useContentRepository'
2+
import type { Locale } from '~/utils/content/collections'
3+
import type { TeamFaqEntry } from '~/types/faq'
4+
5+
/**
6+
* Fetches the application/rank-requirement FAQ for the active locale,
7+
* ordered by the optional `order` frontmatter field. Kept separate from
8+
* the home FAQ so the two surfaces can evolve independently.
9+
*/
10+
export function useTeamFaqContent() {
11+
const { locale } = useI18n()
12+
const repo = useContentRepository()
13+
const activeLocale = computed<Locale>(() => (locale?.value || 'en') as Locale)
14+
15+
const { data: entries } = useAsyncData<TeamFaqEntry[]>(
16+
() => `team-faq-${activeLocale.value}`,
17+
() => repo.listTeamFaqEntries(activeLocale.value),
18+
{ watch: [activeLocale] }
19+
)
20+
21+
const items = computed<TeamFaqEntry[]>(() => entries.value || [])
22+
23+
return { items }
24+
}

composables/useTeamProfile.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,22 @@ import type { Locale } from '~/utils/content/collections'
33
import type { TeamDocument, TeamMember } from '~/types/team'
44
import { teamAvatarUrl } from '~/utils/teamAvatar'
55

6-
export function useTeamProfile(slugOverride?: string) {
6+
/**
7+
* Resolves the active team member synchronously on SSR by awaiting the
8+
* underlying `useAsyncData` call. This is what lets `usePageSeo` see the
9+
* real member name/bio when it runs in the page's setup — without the
10+
* await, meta tags ship with the "Team" fallback because the member ref
11+
* is still null at SSR render time.
12+
*/
13+
export async function useTeamProfile(slugOverride?: string) {
714
const route = useRoute()
815
const { locale } = useI18n()
916
const repo = useContentRepository()
1017
const activeLocale = computed<Locale>(() => (locale?.value || 'de') as Locale)
1118

1219
const slug = computed(() => slugOverride ?? (route.params.slug as string))
1320

14-
const { data: teamDoc } = useAsyncData<TeamDocument | null>(
21+
const { data: teamDoc } = await useAsyncData<TeamDocument | null>(
1522
() => `team-profile-${activeLocale.value}`,
1623
() => repo.getTeamDocument(activeLocale.value),
1724
{ watch: [activeLocale] }

0 commit comments

Comments
 (0)