Skip to content
Merged
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
83 changes: 83 additions & 0 deletions components/OgImage/TeamMember.satori.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<script setup lang="ts">
defineProps<{
name?: string
description?: string
roleText?: string
rankLabel?: string
avatarUrl?: string
}>()
</script>

<template>
<div
class="relative h-full w-full flex items-center text-white overflow-hidden"
style="background: linear-gradient(135deg, #11162a 0%, #2A388F 55%, #91268F 100%);"
>
<!-- Top-right brand wordmark -->
<div class="absolute top-12 right-16 flex items-center gap-3 text-[26px] font-semibold tracking-tight opacity-90">
<div
class="flex items-center justify-center rounded-full"
style="width: 44px; height: 44px; background: rgba(255,255,255,0.12);"
>
<span class="text-[22px]">OL</span>
</div>
<span>OneLiteFeather</span>
</div>

<!-- Avatar block (left) -->
<div class="flex items-center justify-center" style="margin-left: 80px;">
<div
class="flex items-center justify-center"
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);"
>
<img
v-if="avatarUrl"
:src="avatarUrl"
width="256"
height="256"
style="image-rendering: pixelated; border-radius: 24px;"
/>
</div>
</div>

<!-- Text block (right) -->
<div class="flex flex-col" style="margin-left: 60px; max-width: 660px;">
<div
v-if="rankLabel"
class="text-[22px] font-semibold uppercase tracking-[0.18em]"
style="color: #27A9E1;"
>
{{ rankLabel }}
</div>

<h1
class="text-[88px] font-bold leading-[1.02] tracking-tight mt-3"
style="display: block; line-clamp: 2; text-overflow: ellipsis; text-wrap: balance;"
>
{{ name }}
</h1>

<p
v-if="description"
class="text-[28px] leading-snug mt-6"
style="color: rgba(255,255,255,0.85); display: block; line-clamp: 3; text-overflow: ellipsis;"
>
{{ description }}
</p>

<p
v-if="roleText"
class="text-[22px] mt-5"
style="color: rgba(255,255,255,0.65); display: block; line-clamp: 1; text-overflow: ellipsis;"
>
{{ roleText }}
</p>
</div>

<!-- Accent bar bottom -->
<div
class="absolute bottom-0 left-0 right-0"
style="height: 8px; background: linear-gradient(90deg, #EC008B 0%, #F7931D 50%, #27A9E1 100%);"
/>
</div>
</template>
3 changes: 2 additions & 1 deletion components/features/blog/page/FeaturedTeamMembers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { NuxtLink } from '#components'
import { teamAvatarUrl } from '~/utils/teamAvatar'
import { toRoleString } from '~/utils/teamRoles'

const props = defineProps<{ slugs: string[] }>()

Expand Down Expand Up @@ -46,7 +47,7 @@ const members = computed(() => props.slugs
/>
<span class="min-w-0">
<span class="block text-sm font-semibold text-neutral-900 dark:text-neutral-100">{{ m.name }}</span>
<span v-if="m.role" class="block text-xs text-neutral-600 dark:text-neutral-400">{{ m.role }}</span>
<span v-if="toRoleString(m.role)" class="block text-xs text-neutral-600 dark:text-neutral-400">{{ toRoleString(m.role) }}</span>
</span>
</NuxtLink>
</li>
Expand Down
19 changes: 16 additions & 3 deletions components/features/home/team/TeamMemberCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { useI18n } from 'vue-i18n'
import { NuxtLink } from '#components'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons'
import UiChip from '~/components/base/Chip.vue'
import { teamAvatarUrl } from '~/utils/teamAvatar'
import { toRoleList, toRoleString } from '~/utils/teamRoles'

type Props = {
name: string
role: string
role?: string | string[]
slogan?: string
mcName?: string
slug?: string
Expand All @@ -16,6 +18,7 @@ type Props = {
}

const props = withDefaults(defineProps<Props>(), {
role: undefined,
slogan: undefined,
mcName: undefined,
slug: undefined,
Expand All @@ -35,7 +38,9 @@ const avatarSrc = computed(() => teamAvatarUrl({
avatarUrl: props.avatarUrl
}, 128))

const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: props.role }))
const roleChips = computed(() => toRoleList(props.role))
const roleAriaText = computed(() => toRoleString(props.role))
const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: roleAriaText.value }))
</script>

<template>
Expand All @@ -62,9 +67,17 @@ const ariaLabel = computed(() => t('team.card_aria', { name: props.name, role: p
/>
<div class="min-w-0">
<h3 class="truncate text-lg font-semibold text-gray-900 dark:text-gray-100">{{ name }}</h3>
<p class="truncate text-sm text-gray-600 dark:text-gray-400">{{ role }}</p>
</div>
</div>
<div v-if="roleChips.length" class="mt-3 flex flex-wrap gap-2">
<UiChip
v-for="chip in roleChips"
:key="chip"
:label="chip"
variant="outlined"
as="span"
/>
</div>
<p v-if="slogan" class="mt-3 line-clamp-3 text-sm text-gray-700 dark:text-gray-300">“{{ slogan }}”</p>
<p v-if="profileHref" class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-brand-600 dark:text-brand-400">
{{ t('team.view_profile') }}
Expand Down
8 changes: 6 additions & 2 deletions components/features/home/team/TeamMembers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import TeamMemberCard from './TeamMemberCard.vue'
import { useI18n } from 'vue-i18n'
import type { TeamMember } from '~/types/team'
import { toRoleList } from '~/utils/teamRoles'

type Props = {
title?: string
Expand All @@ -23,15 +24,18 @@ const selectedRole = ref<string>('')
const visibleCount = ref<number | null>(props.limit)

const roles = computed(() => {
const set = new Set(props.members.map(m => m.role).filter(Boolean))
const set = new Set<string>()
for (const m of props.members) {
for (const r of toRoleList(m.role)) set.add(r)
}
return Array.from(set)
})

const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
const r = selectedRole.value
let list = props.members
if (r) list = list.filter(m => m.role === r)
if (r) list = list.filter(m => toRoleList(m.role).includes(r))
if (q) list = list.filter(m => m.name.toLowerCase().includes(q))
return list
})
Expand Down
28 changes: 21 additions & 7 deletions components/features/team/OpenPositionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,33 @@
import { useI18n } from 'vue-i18n'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faDiscord } from '@fortawesome/free-brands-svg-icons'
import { faHandHoldingHeart } from '@fortawesome/free-solid-svg-icons'
import { toRoleString } from '~/utils/teamRoles'

type Props = {
role: string
role: string | string[]
slogan?: string
applyUrl?: string
applyVia?: 'discord' | 'opencollective'
}

const props = withDefaults(defineProps<Props>(), {
slogan: undefined,
applyUrl: 'https://1lf.link/discord'
applyUrl: 'https://1lf.link/discord',
applyVia: 'discord'
})

const { t } = useI18n()

const roleText = computed(() => toRoleString(props.role))
const isOpenCollective = computed(() => props.applyVia === 'opencollective')
const icon = computed(() => isOpenCollective.value ? faHandHoldingHeart : faDiscord)
const applyLabel = computed(() => isOpenCollective.value
? t('team.open_position.apply_opencollective')
: t('team.open_position.apply'))
const applyAria = computed(() => isOpenCollective.value
? t('team.open_position.apply_aria_opencollective', { role: roleText.value })
: t('team.open_position.apply_aria', { role: roleText.value }))
</script>

<template>
Expand All @@ -29,19 +43,19 @@ const { t } = useI18n()
<p class="text-xs font-semibold uppercase tracking-wide text-primary dark:text-secondary">
{{ t('team.open_position.badge') }}
</p>
<h3 class="truncate text-lg font-semibold text-gray-900 dark:text-gray-100">{{ props.role }}</h3>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 break-words">{{ roleText }}</h3>
</div>
</div>
<p v-if="props.slogan" class="mt-3 line-clamp-3 text-sm text-gray-700 dark:text-gray-300">{{ props.slogan }}</p>
<p v-if="props.slogan" class="mt-3 text-sm text-gray-700 dark:text-gray-300">{{ props.slogan }}</p>
<a
:href="props.applyUrl"
target="_blank"
rel="noopener noreferrer"
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"
:aria-label="t('team.open_position.apply_aria', { role: props.role })"
:aria-label="applyAria"
>
<FontAwesomeIcon :icon="faDiscord" class="h-4 w-4" aria-hidden="true" />
{{ t('team.open_position.apply') }}
<FontAwesomeIcon :icon="icon" class="h-4 w-4" aria-hidden="true" />
{{ applyLabel }}
</a>
</div>
</li>
Expand Down
85 changes: 85 additions & 0 deletions components/features/team/TeamFaqSection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { extractPlainText } from '~/utils/content'

const { t } = useI18n()
const { items } = useTeamFaqContent()

const detailsClass = [
'group rounded-xl border border-neutral-200 dark:border-neutral-800',
'bg-white dark:bg-neutral-900/60 px-4 py-3 open:shadow-sm',
'transition-shadow'
].join(' ')

const summaryClass = [
'flex cursor-pointer list-none items-center justify-between gap-4',
'text-base font-semibold text-neutral-900 dark:text-neutral-100',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md'
].join(' ')

const toggleClass = [
'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full',
'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300',
'transition-transform group-open:rotate-45'
].join(' ')

const proseClass = [
'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'
].join(' ')

// Extra FAQPage schema scoped to the team page; Google currently restricts
// FAQ rich results to authoritative sources, but other crawlers (Bing,
// DuckDuckGo, AI assistants) still pick it up. Plain-text answers only,
// so we strip the MDC AST down.
useSchemaOrg(() => {
if (!items.value.length) return []
return [
{
'@type': 'FAQPage',
mainEntity: items.value.map((entry) => ({
'@type': 'Question' as const,
name: entry.question,
acceptedAnswer: {
'@type': 'Answer' as const,
text: extractPlainText(entry.body, 500)
}
}))
}
]
})
</script>

<template>
<section
v-if="items.length"
class="mt-12 md:mt-16"
:aria-labelledby="'team-faq-heading'"
>
<header class="mb-6 text-center">
<h2
id="team-faq-heading"
class="text-2xl md:text-3xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100"
>
{{ t('team.faq.section_title') }}
</h2>
<p class="mt-2 text-sm md:text-base text-neutral-600 dark:text-neutral-400">
{{ t('team.faq.section_subtitle') }}
</p>
</header>

<div class="space-y-3">
<details
v-for="entry in items"
:key="entry.key"
:class="detailsClass"
>
<summary :class="summaryClass">
<span>{{ entry.question }}</span>
<span :class="toggleClass" aria-hidden="true">+</span>
</summary>
<div :class="proseClass">
<ContentRenderer :value="entry" />
</div>
</details>
</div>
</section>
</template>
1 change: 1 addition & 0 deletions components/features/team/TeamRankSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const profileHref = (slug?: string) => slug ? `/${locale.value}/team/${slug}` :
:role="p.role || rankLabel"
:slogan="p.slogan"
:apply-url="p.applyUrl"
:apply-via="p.applyVia"
/>
</ul>
</section>
Expand Down
24 changes: 24 additions & 0 deletions composables/useTeamFaqContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useContentRepository } from '~/composables/useContentRepository'
import type { Locale } from '~/utils/content/collections'
import type { TeamFaqEntry } from '~/types/faq'

/**
* Fetches the application/rank-requirement FAQ for the active locale,
* ordered by the optional `order` frontmatter field. Kept separate from
* the home FAQ so the two surfaces can evolve independently.
*/
export function useTeamFaqContent() {
const { locale } = useI18n()
const repo = useContentRepository()
const activeLocale = computed<Locale>(() => (locale?.value || 'en') as Locale)

const { data: entries } = useAsyncData<TeamFaqEntry[]>(
() => `team-faq-${activeLocale.value}`,
() => repo.listTeamFaqEntries(activeLocale.value),
{ watch: [activeLocale] }
)

const items = computed<TeamFaqEntry[]>(() => entries.value || [])

return { items }
}
11 changes: 9 additions & 2 deletions composables/useTeamProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import type { Locale } from '~/utils/content/collections'
import type { TeamDocument, TeamMember } from '~/types/team'
import { teamAvatarUrl } from '~/utils/teamAvatar'

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

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

const { data: teamDoc } = useAsyncData<TeamDocument | null>(
const { data: teamDoc } = await useAsyncData<TeamDocument | null>(
() => `team-profile-${activeLocale.value}`,
() => repo.getTeamDocument(activeLocale.value),
{ watch: [activeLocale] }
Expand Down
Loading
Loading