Autorenseite
Die Menschen, die schreiben. Ein Autorenprofil mit Artikeln, das Verzeichnis aller Autorinnen, das Impressum einer Redaktion, eine Box mit Autor, Prüferin und Prüfungen und eine Zeile für mehrere Autoren.
Eine von 2 Components in Autoren und Interviews
Variants
Details
Was Autorenseite mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Autoren und Interviews
- Bereich
- Content
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/author-page.tsx
- Bringt mit
- utilskitbuttoniconnumber-tickerrevealmotionsection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 15 eigene Texte
- In Sammlungen
- Blog oder Magazin
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AuthorPageProps aus components/ui/author-page.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAuthorPageVariantprofilecontributorsmastheadexpert-boxbyline
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeauthoroptionalAuthorprofile: the author; expert-box: the writer
revieweroptionalAuthorexpert-box: the reviewer
authorsoptionalAuthor[]contributors, byline
articlesoptionalAuthorArticle[]profile
statsoptional{ value: number; label: string; suffix?: string }[]profile: figures, e.g. [{ value: 214, label: 'articles' }]
groupsoptionalMastheadGroup[]masthead
footnoteoptionalstringmasthead: address or legal line under the team
checksoptional{ label: string; value: string }[]expert-box: checks done, e.g. sources, last review
methodoptionalstringexpert-box: how the editorial team works
dateoptionalstringbyline: date line
Eingebaute Texte
Diese Texte schreibt Autorenseite selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 15 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| all | Englisch All | Deutsch Alle |
| articles | Englisch Articles | Deutsch Artikel |
| writesAbout | Englisch Writes about | Deutsch Schreibt über |
| minutes | Englisch {n} min | Deutsch {n} Min. |
| latest | Englisch Latest | Deutsch Zuletzt |
| articlesN | Englisch {n} articles | Deutsch {n} Artikel |
| beat | Englisch Beat | Deutsch Ressort |
| showing | Englisch {n} articles | Deutsch {n} Artikel |
| written | Englisch Written by | Deutsch Geschrieben von |
| reviewed | Englisch Reviewed by | Deutsch Geprüft von |
| how | Englisch How we checked this text | Deutsch So haben wir diesen Text geprüft |
| follow | Englisch Follow | Deutsch Folgen |
| following | Englisch Following | Deutsch Du folgst |
| and | Englisch and | Deutsch und |
| profile | Englisch View profile | Deutsch Profil ansehen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Autorenseite reagiert selbst auf die Tasten Escape, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-controls, aria-expanded, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen dialog, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 577 Zeilen
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Parallax, Stagger, useScrollFade } from '@/components/ui/motion';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';
/*
* The people who write. An author page with their work, the directory of
* all contributors, the masthead of an editorial team, the box that says
* who wrote and who checked a text, and a byline for several authors.
*
* profile portrait, bio, beats, figures and the articles, filtered by topic
* contributors everyone who writes, filtered by beat, the latest piece shows on hover
* masthead the editorial team set like the masthead of a magazine, by role
* expert-box written by, reviewed by, credentials and how the text was checked
* byline several authors in one line, a card per person, follow with one press
*/
export type AuthorPageVariant = 'profile' | 'contributors' | 'masthead' | 'expert-box' | 'byline';
export interface AuthorArticle {
title: string;
/** Topic, used by the filter */
topic?: string;
date?: string;
readingTime?: number;
href?: string;
}
export interface Author {
name: string;
/** Role or beat, e.g. city editor */
role?: string;
bio?: string;
avatar?: React.ReactNode;
/** Topics the author covers */
topics?: string[];
/** contributors: number of articles */
count?: number;
/** contributors: the latest article */
latest?: string;
/** expert-box: degrees, memberships, years of practice */
credentials?: string[];
href?: string;
links?: { icon: IconName; label: string; href: string }[];
}
export interface MastheadGroup {
role: string;
people: { name: string; note?: string; avatar?: React.ReactNode }[];
}
export interface AuthorPageProps {
variant?: AuthorPageVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** profile: the author; expert-box: the writer */
author?: Author;
/** expert-box: the reviewer */
reviewer?: Author;
/** contributors, byline */
authors?: Author[];
/** profile */
articles?: AuthorArticle[];
/** profile: figures, e.g. [{ value: 214, label: 'articles' }] */
stats?: { value: number; label: string; suffix?: string }[];
/** masthead */
groups?: MastheadGroup[];
/** masthead: address or legal line under the team */
footnote?: string;
/** expert-box: checks done, e.g. sources, last review */
checks?: { label: string; value: string }[];
/** expert-box: how the editorial team works */
method?: string;
/** byline: date line */
date?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
all: 'All',
articles: 'Articles',
writesAbout: 'Writes about',
minutes: '{n} min',
latest: 'Latest',
articlesN: '{n} articles',
beat: 'Beat',
showing: '{n} articles',
written: 'Written by',
reviewed: 'Reviewed by',
how: 'How we checked this text',
follow: 'Follow',
following: 'Following',
and: 'and',
profile: 'View profile',
},
de: {
all: 'Alle',
articles: 'Artikel',
writesAbout: 'Schreibt über',
minutes: '{n} Min.',
latest: 'Zuletzt',
articlesN: '{n} Artikel',
beat: 'Ressort',
showing: '{n} Artikel',
written: 'Geschrieben von',
reviewed: 'Geprüft von',
how: 'So haben wir diesen Text geprüft',
follow: 'Folgen',
following: 'Du folgst',
and: 'und',
profile: 'Profil ansehen',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared pieces */
/* ------------------------------------------------------------------ */
function Face({ a, className }: { a: { name: string; avatar?: React.ReactNode }; className?: string }) {
return (
<span data-clip-ok className={cn('relative block shrink-0 overflow-hidden rounded-full border border-border bg-muted [&>*]:size-full', className)}>
{a.avatar ?? (
<span className="grid size-full place-items-center text-[0.4em] font-semibold text-muted-foreground" aria-hidden>
{a.name
.split(' ')
.map((p) => p[0])
.slice(0, 2)
.join('')}
</span>
)}
</span>
);
}
/** A segmented filter with a gliding pill, arrow keys move */
function Segments({ items, value, onChange, label }: { items: string[]; value: number; onChange: (i: number) => void; label: string }) {
const ref = useRef<HTMLDivElement>(null);
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
useLayoutEffect(() => {
const b = ref.current?.querySelector<HTMLElement>('[aria-checked="true"]');
if (b) setPill({ x: b.offsetLeft, w: b.offsetWidth });
}, [value, items.length]);
const key = (e: React.KeyboardEvent) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
const n = (value + (e.key === 'ArrowRight' ? 1 : items.length - 1)) % items.length;
onChange(n);
ref.current?.querySelectorAll<HTMLElement>('[role="radio"]')[n]?.focus();
};
return (
<div ref={rowRef} className={cn('kit-no-scrollbar -m-1 overflow-x-auto p-1', fade)}>
<div ref={ref} role="radiogroup" aria-label={label} onKeyDown={key} className="relative inline-flex w-max rounded-xl border border-border bg-surface p-1">
{pill && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: pill.w, transform: `translateX(${pill.x}px)` }} />}
{items.map((it, i) => (
<button key={it} type="button" role="radio" aria-checked={value === i} tabIndex={value === i ? 0 : -1} onClick={() => onChange(i)} className={cn('relative min-h-10 min-w-11 whitespace-nowrap rounded-lg px-3.5 text-[14px] font-medium transition-colors duration-300 max-sm:min-h-11', value === i ? cn('text-foreground', !pill && 'bg-card') : 'text-muted-foreground hover:text-foreground')}>
{it}
</button>
))}
</div>
</div>
);
}
function Links({ links }: { links?: Author['links'] }) {
if (!links?.length) return null;
return (
<div className="flex flex-wrap gap-2">
{links.map((l, i) => (
<a key={i} href={l.href} className="inline-flex min-h-10 items-center gap-2 rounded-lg border border-border px-3 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent max-sm:min-h-11">
<Icon name={l.icon} size={15} className="text-muted-foreground" />
{l.label}
</a>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* profile */
/* ------------------------------------------------------------------ */
function Profile(p: AuthorPageProps & { t: Texts }) {
const { t } = p;
const a = p.author;
const articles = p.articles ?? [];
const topics = useMemo(() => [t.all, ...Array.from(new Set(articles.map((x) => x.topic).filter(Boolean) as string[]))], [articles, t.all]);
const [ti, setTi] = useState(0);
const shown = ti === 0 ? articles : articles.filter((x) => x.topic === topics[ti]);
if (!a) return null;
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="grid grid-cols-[minmax(0,1fr)] gap-10 md:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] md:gap-12 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-20">
<div className="md:sticky md:top-10 md:self-start">
<Reveal effect="scale" className="w-32 sm:w-40 md:w-full">
<div data-clip-ok className="aspect-[4/5] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-md)]">
<Parallax speed={24} className="size-full scale-[1.08] [&>*]:size-full">
{a.avatar}
</Parallax>
</div>
</Reveal>
{p.stats && p.stats.length > 0 && (
<dl className="mt-6 hidden grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border md:grid">
{p.stats.map((s, i) => (
<div key={i} className="bg-background px-4 py-3.5">
<dt className="text-[12.5px] text-muted-foreground">{s.label}</dt>
<dd className="mt-0.5 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
<NumberTicker value={s.value} />
{s.suffix}
</dd>
</div>
))}
</dl>
)}
</div>
<div className="min-w-0">
{p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
<h1 className="kit-rise text-balance text-h1 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
{a.name}
</h1>
{a.role && <p className="kit-rise mt-3 text-[17px] font-medium text-muted-foreground" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>{a.role}</p>}
{a.bio && <p className="kit-rise mt-6 max-w-[38rem] text-pretty text-[18px] leading-[1.65] text-[color-mix(in_oklab,var(--foreground)_88%,var(--background))]" style={{ '--kit-delay': '150ms' } as React.CSSProperties}>{a.bio}</p>}
{a.topics && a.topics.length > 0 && (
<p className="kit-rise mt-5 text-[14.5px] text-muted-foreground" style={{ '--kit-delay': '190ms' } as React.CSSProperties}>
{t.writesAbout} <span className="text-foreground">{a.topics.join(', ')}</span>
</p>
)}
<div className="kit-rise mt-6" style={{ '--kit-delay': '230ms' } as React.CSSProperties}>
<Links links={a.links} />
</div>
{p.stats && p.stats.length > 0 && (
<dl className="mt-8 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border md:hidden">
{p.stats.map((s, i) => (
<div key={i} className="bg-background px-4 py-3.5">
<dt className="text-[12.5px] text-muted-foreground">{s.label}</dt>
<dd className="mt-0.5 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">
<NumberTicker value={s.value} />
{s.suffix}
</dd>
</div>
))}
</dl>
)}
<div className="mt-14 flex flex-col gap-4 border-t border-border pt-8 lg:flex-row lg:items-center lg:justify-between">
<h2 className="text-[22px] font-semibold tracking-[-0.012em] text-foreground">
{t.articles} <span className="ml-1 text-[15px] font-normal tabular-nums text-muted-foreground" aria-hidden>{shown.length}</span>
<span className="sr-only" aria-live="polite">{t.showing.replace('{n}', String(shown.length))}</span>
</h2>
{topics.length > 2 && <Segments items={topics} value={ti} onChange={setTi} label={t.writesAbout} />}
</div>
<ul key={ti} className="mt-4">
{shown.map((x, i) => (
<li key={x.title} className="kit-rise border-b border-border" style={{ '--kit-delay': `${i * 50}ms` } as React.CSSProperties}>
<a href={x.href ?? '#'} className="group/ar grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-6 gap-y-1 py-5">
<span className="text-pretty text-[17px] font-semibold leading-[1.4] text-foreground underline decoration-transparent underline-offset-4 transition-colors duration-300 group-hover/ar:decoration-current sm:text-[18px]">{x.title}</span>
<Icon name="arrow-right" size={16} className="-translate-x-1 text-muted-foreground opacity-0 transition-[opacity,transform] duration-300 group-hover/ar:translate-x-0 group-hover/ar:opacity-100 max-sm:hidden" />
<span className="col-span-2 text-[13.5px] text-muted-foreground">
{[x.topic, x.date, x.readingTime ? t.minutes.replace('{n}', String(x.readingTime)) : null].filter(Boolean).join(' · ')}
</span>
</a>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* contributors */
/* ------------------------------------------------------------------ */
function Contributors(p: AuthorPageProps & { t: Texts }) {
const { t } = p;
const authors = p.authors ?? [];
const beats = useMemo(() => [t.all, ...Array.from(new Set(authors.map((a) => a.role).filter(Boolean) as string[]))], [authors, t.all]);
const [bi, setBi] = useState(0);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="mb-10 flex flex-col gap-6 sm:mb-12 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[40rem]">
{p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
{p.description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
</div>
{beats.length > 2 && <Segments items={beats} value={bi} onChange={setBi} label={t.beat} />}
</div>
<ul className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-2 lg:grid-cols-3">
{authors.map((a, i) => {
const on = bi === 0 || a.role === beats[bi];
return (
<li key={a.name} className={cn('transition-[opacity,filter,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'opacity-100' : 'pointer-events-none scale-[0.98] opacity-30 grayscale')} aria-hidden={!on || undefined}>
<Reveal effect="rise" delay={(i % 3) * 70} className="h-full">
<a href={a.href ?? '#'} tabIndex={on ? undefined : -1} className="group/au relative flex h-full flex-col rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-6">
<div className="flex items-center gap-4">
<Face a={a} className="size-14 transition-transform duration-500 ease-[var(--ease-kit)] group-hover/au:scale-105" />
<div className="min-w-0">
<p className="truncate text-[17px] font-semibold text-foreground">{a.name}</p>
<p className="mt-0.5 text-[13.5px] text-muted-foreground">
{a.role}
{a.count !== undefined && <span className="tabular-nums"> · {t.articlesN.replace('{n}', String(a.count))}</span>}
</p>
</div>
</div>
{a.bio && <p className="mt-4 line-clamp-3 text-pretty text-[14.5px] leading-[1.6] text-muted-foreground">{a.bio}</p>}
{a.latest && (
<div className="mt-auto pt-5">
<div className="border-t border-border pt-4">
<p className="text-[12px] font-medium uppercase tracking-[0.07em] text-muted-foreground">{t.latest}</p>
<p className="mt-1.5 line-clamp-2 min-h-[2.9em] text-pretty text-[14.5px] font-medium leading-[1.45] text-foreground transition-colors duration-300 group-hover/au:text-primary">{a.latest}</p>
</div>
</div>
)}
</a>
</Reveal>
</li>
);
})}
</ul>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* masthead */
/* ------------------------------------------------------------------ */
function Masthead(p: AuthorPageProps & { t: Texts }) {
const groups = p.groups ?? [];
const [lit, setLit] = useState<string | null>(null);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="mx-auto max-w-[60rem]">
<div className="text-center">
{p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h2>}
{p.description && <p className="kit-rise mx-auto mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
</div>
<Stagger step={60} className="mt-12 grid grid-cols-[minmax(0,1fr)] border-t border-border sm:mt-14 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g, i) => (
<div key={i} className="border-b border-border py-7 sm:pr-10">
<h3 className="text-[12.5px] font-semibold uppercase tracking-[0.08em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{g.role}</h3>
<ul className="mt-3 space-y-1">
{g.people.map((m) => {
const key = `${i}-${m.name}`;
return (
<li key={m.name} className="relative">
<span
tabIndex={m.avatar ? 0 : undefined}
onPointerEnter={(e) => e.pointerType === 'mouse' && m.avatar && setLit(key)}
onPointerLeave={() => setLit(null)}
onFocus={() => m.avatar && setLit(key)}
onBlur={() => setLit(null)}
className={cn('inline-flex min-h-8 items-baseline gap-2 rounded text-[18px] leading-[1.5] text-foreground transition-colors duration-300', m.avatar && 'cursor-default hover:text-primary focus-visible:text-primary')}
>
{m.name}
{m.note && <span className="text-[13.5px] text-muted-foreground">{m.note}</span>}
</span>
{m.avatar && lit === key && (
<span className="kit-rise pointer-events-none absolute bottom-[calc(100%+6px)] left-0 z-20 flex items-center gap-3 rounded-xl border border-border bg-popover p-2 pr-4 shadow-[var(--highlight),var(--shadow-lg)]" style={{ animationDuration: '.35s' }} aria-hidden>
<Face a={m} className="size-11" />
<span className="text-[13.5px] font-medium text-popover-foreground">{m.name}</span>
</span>
)}
</li>
);
})}
</ul>
</div>
))}
</Stagger>
{p.footnote && <Reveal effect="fade"><p className="mt-8 text-center text-[13.5px] leading-[1.6] text-muted-foreground">{p.footnote}</p></Reveal>}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* expert-box */
/* ------------------------------------------------------------------ */
function ExpertBox(p: AuthorPageProps & { t: Texts }) {
const { t } = p;
const uid = useId().replace(/:/g, '');
const [open, setOpen] = useState(false);
const people = [
p.author && { a: p.author, label: t.written },
p.reviewer && { a: p.reviewer, label: t.reviewed },
].filter(Boolean) as { a: Author; label: string }[];
return (
<section className={cn('py-16 sm:py-20', p.className)}>
<div className="kit-container">
<Reveal effect="rise" className="mx-auto max-w-[46rem]">
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
{p.title && (
<div className="flex items-center gap-2.5 border-b border-border px-5 py-4 sm:px-6">
<Icon name="shield" size={17} className="text-primary" />
<h2 className="text-[15px] font-semibold text-foreground">{p.title}</h2>
</div>
)}
<div className={cn('grid grid-cols-[minmax(0,1fr)] gap-px bg-border', people.length > 1 && 'sm:grid-cols-2')}>
{people.map(({ a, label }, i) => (
<div key={i} className="bg-card p-5 sm:p-6">
<p className="text-[12.5px] font-medium text-muted-foreground">{label}</p>
<div className="mt-3 flex items-center gap-3.5">
<Face a={a} className="size-12" />
<div className="min-w-0">
<a href={a.href ?? '#'} className="text-[16px] font-semibold text-foreground underline decoration-transparent underline-offset-4 transition-colors hover:decoration-current">
{a.name}
</a>
{a.role && <p className="text-[13.5px] text-muted-foreground">{a.role}</p>}
</div>
</div>
{a.credentials && a.credentials.length > 0 && (
<ul className="mt-4 space-y-2">
{a.credentials.map((c, j) => (
<li key={j} className="flex gap-2.5 text-[14px] leading-[1.5] text-foreground">
<Icon name="check" size={15} className="mt-[3px] text-success" />
{c}
</li>
))}
</ul>
)}
</div>
))}
</div>
{p.checks && p.checks.length > 0 && (
<dl className="grid grid-cols-2 gap-px border-t border-border bg-border sm:grid-cols-4">
{p.checks.map((c, i) => (
<div key={i} className="bg-surface px-5 py-4 sm:px-6">
<dt className="text-[12px] text-muted-foreground">{c.label}</dt>
<dd className="mt-1 text-[15px] font-semibold tabular-nums text-foreground">{c.value}</dd>
</div>
))}
</dl>
)}
{p.method && (
<div className="border-t border-border">
<button type="button" aria-expanded={open} aria-controls={`${uid}-m`} onClick={() => setOpen((o) => !o)} className="flex min-h-12 w-full items-center justify-between gap-3 px-5 text-left focus-visible:outline-offset-[-2px] text-[14.5px] font-medium text-foreground transition-colors hover:bg-accent sm:px-6">
{t.how}
<Icon name="plus" size={16} className={cn('text-muted-foreground transition-transform duration-300', open && 'rotate-45')} />
</button>
<div id={`${uid}-m`} className={cn('grid transition-[grid-template-rows,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0')}>
<div className="min-h-0 overflow-hidden" inert={!open}>
<p className="px-5 pb-5 text-pretty text-[14.5px] leading-[1.65] text-muted-foreground sm:px-6">{p.method}</p>
</div>
</div>
</div>
)}
</div>
</Reveal>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* byline */
/* ------------------------------------------------------------------ */
function Byline(p: AuthorPageProps & { t: Texts }) {
const { t } = p;
const authors = p.authors ?? [];
const [card, setCard] = useState<number | null>(null);
const [follow, setFollow] = useState<Set<number>>(new Set());
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
if (card === null) return;
const off = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setCard(null);
};
const esc = (e: KeyboardEvent) => e.key === 'Escape' && setCard(null);
document.addEventListener('pointerdown', off);
document.addEventListener('keydown', esc);
return () => {
document.removeEventListener('pointerdown', off);
document.removeEventListener('keydown', esc);
};
}, [card]);
const a = card !== null ? authors[card] : null;
return (
<section className={cn('py-16 sm:py-20', p.className)}>
<div className="kit-container">
<div ref={box} className="relative mx-auto max-w-[40rem]">
{p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
{p.title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '40ms' } as React.CSSProperties}>{p.title}</h2>}
{p.description && <p className="kit-rise mt-5 text-pretty text-[18px] leading-[1.6] text-muted-foreground sm:text-[19px]" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>{p.description}</p>}
<Reveal effect="rise" delay={140} className="mt-8 flex items-center gap-4 border-y border-border py-4">
<div className="flex shrink-0 -space-x-2.5">
{authors.map((x, i) => (
<button
key={i}
type="button"
aria-expanded={card === i}
aria-label={x.name}
onClick={() => setCard(card === i ? null : i)}
className={cn('relative rounded-full ring-2 ring-background transition-transform duration-300 ease-[var(--ease-kit)] hover:z-10 hover:-translate-y-0.5 focus-visible:z-10', card === i && 'z-10 -translate-y-0.5')}
style={{ zIndex: card === i ? 10 : authors.length - i }}
>
<Face a={x} className="size-11" />
</button>
))}
</div>
<div className="min-w-0 text-[14.5px] leading-snug">
<p className="text-foreground">
<span className="text-muted-foreground">{t.written} </span>
{authors.map((x, i) => (
<span key={i}>
<button type="button" onClick={() => setCard(card === i ? null : i)} className="relative font-medium underline decoration-border-strong underline-offset-4 transition-colors before:absolute before:-inset-x-1 before:-inset-y-3 before:content-[''] hover:decoration-current">
{x.name}
</button>
{i < authors.length - 2 ? ', ' : i === authors.length - 2 ? ` ${t.and} ` : ''}
</span>
))}
</p>
{p.date && <p className="mt-0.5 text-[13.5px] text-muted-foreground">{p.date}</p>}
</div>
</Reveal>
{a && card !== null && (
<div role="dialog" aria-label={a.name} className="kit-rise absolute inset-x-0 top-full z-20 mt-3 rounded-2xl border border-border bg-popover p-5 shadow-[var(--highlight),var(--shadow-lg)] sm:inset-x-auto sm:left-0 sm:w-[24rem]" style={{ animationDuration: '.4s' }}>
<div className="flex items-start gap-3.5">
<Face a={a} className="size-14" />
<div className="min-w-0 flex-1">
<p className="text-[16px] font-semibold text-popover-foreground">{a.name}</p>
{a.role && <p className="text-[13.5px] text-muted-foreground">{a.role}</p>}
</div>
</div>
{a.bio && <p className="mt-4 text-pretty text-[14px] leading-[1.6] text-muted-foreground">{a.bio}</p>}
<div className="mt-5 flex flex-wrap gap-2">
<Button size="sm" variant={follow.has(card) ? 'outline' : 'primary'} iconLeft={follow.has(card) ? 'check' : 'plus'} aria-pressed={follow.has(card)} onClick={() => setFollow((s) => { const n = new Set(s); if (n.has(card)) n.delete(card); else n.add(card); return n; })}>
{follow.has(card) ? t.following : t.follow}
<span className="sr-only"> {a.name}</span>
</Button>
{a.href && (
<Button size="sm" variant="ghost" href={a.href} icon="arrow-right">
{t.profile}
</Button>
)}
</div>
</div>
)}
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export function AuthorPage(props: AuthorPageProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'profile' } = props;
if (variant === 'contributors') return <Contributors {...props} t={t} />;
if (variant === 'masthead') return <Masthead {...props} t={t} />;
if (variant === 'expert-box') return <ExpertBox {...props} t={t} />;
if (variant === 'byline') return <Byline {...props} t={t} />;
return <Profile {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Autorenseite?
Mit aiw add @ai-website/author-page. Der Befehl legt die Datei components/ui/author-page.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, number-ticker, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Autorenseite?
5 Varianten, Profil, Autorinnen und Autoren, Impressum der Redaktion, Expertenbox und Autorenzeile. Du wählst sie mit der Prop variant, zum Beispiel variant="contributors".
Kann ich die Texte von Autorenseite übersetzen?
Ja. Autorenseite bringt 15 eigene Texte auf Deutsch und Englisch mit, etwa „Alle“, „Artikel“ und „Schreibt über“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Autorenseite einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Autorenseite mit aiw_components und installieren sie mit aiw_add @ai-website/author-page. Nenn im Prompt die Variante, etwa variant="profile", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Autorenseite ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden AuthorPage-Code zum Kopieren.