Newsletter-Archiv
Jede Ausgabe, die verschickt wurde, lesbar im Web. Die neueste Ausgabe neben der Anmeldung, eine durchsuchbare Liste, gezeichnete Mailvorschauen, eine Ausgabe als Webseite und ein Kalender des Jahres.
Eine von 2 Components in Newsletter
Variants
Details
Was Newsletter-Archiv mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Newsletter
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/newsletter-archive.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 28 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle NewsletterArchiveProps aus components/ui/newsletter-archive.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalNewsletterArchiveVariantlatestlistcoversreadercalendar
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodenameoptionalstringName of the newsletter
readersoptionalstringNumber of readers, shown next to the sign up
cadenceoptionalstringHow often it comes, e.g. every second Thursday
issuesoptionalNewsletterIssue[]onSubscribeoptional(email: string) => voidCalled with the email address, the sign up shows its success state
backHrefoptionalstringreader: address of the archive, shows the link back
Eingebaute Texte
Diese Texte schreibt Newsletter-Archiv selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 28 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| issue | Englisch Issue {n} | Deutsch Ausgabe {n} |
| latest | Englisch Latest issue | Deutsch Neueste Ausgabe |
| read | Englisch Read the issue | Deutsch Ausgabe lesen |
| before | Englisch Earlier issues | Deutsch Frühere Ausgaben |
| all | Englisch All | Deutsch Alle |
| search | Englisch Search the archive | Deutsch Im Archiv suchen |
| searchPh | Englisch Search issues | Deutsch Ausgaben durchsuchen |
| found | Englisch {n} issues | Deutsch {n} Ausgaben |
| found1 | Englisch 1 issue | Deutsch 1 Ausgabe |
| none | Englisch No issue mentions “{q}” yet. Try a broader word. | Deutsch Noch keine Ausgabe erwähnt „{q}“. Versuch ein allgemeineres Wort. |
| min | Englisch {n} min | Deutsch {n} Min. |
| Englisch Email address | Deutsch E-Mail-Adresse | |
| emailPh | Englisch you@example.com | Deutsch du@beispiel.de |
| subscribe | Englisch Subscribe | Deutsch Abonnieren |
| subscribed | Englisch Almost there. Check your inbox to confirm. | Deutsch Fast geschafft. Bestätige kurz in deinem Postfach. |
| readers | Englisch {n} readers | Deutsch {n} Lesende |
| free | Englisch Free, unsubscribe with one click. | Deutsch Kostenlos, mit einem Klick abbestellbar. |
| contents | Englisch In this issue | Deutsch In dieser Ausgabe |
| back | Englisch All issues | Deutsch Alle Ausgaben |
| previous | Englisch Previous issue | Deutsch Vorherige Ausgabe |
| next | Englisch Next issue | Deutsch Nächste Ausgabe |
| liked | Englisch Liked this issue? | Deutsch Hat dir diese Ausgabe gefallen? |
| likedText | Englisch Get the next one in your inbox. | Deutsch Hol dir die nächste ins Postfach. |
| year | Englisch Year | Deutsch Jahr |
| noIssue | Englisch No issue | Deutsch Keine Ausgabe |
| issuesIn | Englisch Issues in {m} | Deutsch Ausgaben im {m} |
| from | Englisch From | Deutsch Von |
| to | Englisch To | Deutsch An |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-controls, aria-current, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, status, tab, tablist und tabpanel.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- 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).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 633 Zeilen
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input } from '@/components/ui/input';
import { Stagger, useScrollFade } from '@/components/ui/motion';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';
/*
* The archive of a newsletter: every issue that went out, readable on the
* web, so the next reader can see what they sign up for.
*
* latest the newest issue large with the sign up next to it and the
* issues before it in a row
* list every issue with search and year filter, the match marked
* covers issues as drawn mail previews, the preview opens on hover
* reader one issue as a web page: contents, sections, sign up at the end
* calendar a year as twelve months, each issue a dot, the month opens its issues
*/
export type NewsletterArchiveVariant = 'latest' | 'list' | 'covers' | 'reader' | 'calendar';
export interface NewsletterIssue {
number: number;
title: string;
/** One or two sentences, the preview line of the mail */
summary?: string;
/** ISO date, e.g. 2026-10-14 */
date: string;
/** Minutes */
readingTime?: number;
topics?: string[];
href?: string;
/** reader: the sections of the issue */
sections?: { title: string; text: string[]; link?: { label: string; href: string } }[];
}
export interface NewsletterArchiveProps {
variant?: NewsletterArchiveVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** Name of the newsletter */
name?: string;
/** Number of readers, shown next to the sign up */
readers?: string;
/** How often it comes, e.g. every second Thursday */
cadence?: string;
issues?: NewsletterIssue[];
/** Called with the email address, the sign up shows its success state */
onSubscribe?: (email: string) => void;
/** reader: address of the archive, shows the link back */
backHref?: string;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
issue: 'Issue {n}',
latest: 'Latest issue',
read: 'Read the issue',
before: 'Earlier issues',
all: 'All',
search: 'Search the archive',
searchPh: 'Search issues',
found: '{n} issues',
found1: '1 issue',
none: 'No issue mentions “{q}” yet. Try a broader word.',
min: '{n} min',
email: 'Email address',
emailPh: 'you@example.com',
subscribe: 'Subscribe',
subscribed: 'Almost there. Check your inbox to confirm.',
readers: '{n} readers',
free: 'Free, unsubscribe with one click.',
contents: 'In this issue',
back: 'All issues',
previous: 'Previous issue',
next: 'Next issue',
liked: 'Liked this issue?',
likedText: 'Get the next one in your inbox.',
year: 'Year',
noIssue: 'No issue',
issuesIn: 'Issues in {m}',
from: 'From',
to: 'To',
},
de: {
issue: 'Ausgabe {n}',
latest: 'Neueste Ausgabe',
read: 'Ausgabe lesen',
before: 'Frühere Ausgaben',
all: 'Alle',
search: 'Im Archiv suchen',
searchPh: 'Ausgaben durchsuchen',
found: '{n} Ausgaben',
found1: '1 Ausgabe',
none: 'Noch keine Ausgabe erwähnt „{q}“. Versuch ein allgemeineres Wort.',
min: '{n} Min.',
email: 'E-Mail-Adresse',
emailPh: 'du@beispiel.de',
subscribe: 'Abonnieren',
subscribed: 'Fast geschafft. Bestätige kurz in deinem Postfach.',
readers: '{n} Lesende',
free: 'Kostenlos, mit einem Klick abbestellbar.',
contents: 'In dieser Ausgabe',
back: 'Alle Ausgaben',
previous: 'Vorherige Ausgabe',
next: 'Nächste Ausgabe',
liked: 'Hat dir diese Ausgabe gefallen?',
likedText: 'Hol dir die nächste ins Postfach.',
year: 'Jahr',
noIssue: 'Keine Ausgabe',
issuesIn: 'Ausgaben im {m}',
from: 'Von',
to: 'An',
},
};
type Texts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Shared pieces */
/* ------------------------------------------------------------------ */
function useDates() {
const loc = useLocale() === 'de' ? 'de-DE' : 'en-US';
const long = new Intl.DateTimeFormat(loc, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
const short = new Intl.DateTimeFormat(loc, { day: 'numeric', month: 'short', timeZone: 'UTC' });
const month = new Intl.DateTimeFormat(loc, { month: 'long', timeZone: 'UTC' });
const monthShort = new Intl.DateTimeFormat(loc, { month: 'short', timeZone: 'UTC' });
const d = (iso: string) => new Date(`${iso}T12:00:00Z`);
return { long: (s: string) => long.format(d(s)), short: (s: string) => short.format(d(s)), month: (m: number) => month.format(new Date(Date.UTC(2026, m, 15))), monthShort: (m: number) => monthShort.format(new Date(Date.UTC(2026, m, 15))), year: (s: string) => d(s).getUTCFullYear(), monthOf: (s: string) => d(s).getUTCMonth() };
}
function Hit({ text, q }: { text: string; q: string }) {
const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
if (i < 0) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_22%,transparent)] px-px text-foreground">{text.slice(i, i + q.length)}</mark>
{text.slice(i + q.length)}
</>
);
}
/** Email field and button; turns into a confirmation after sending */
function SignUp({ t, onSubscribe, compact, className }: { t: Texts; onSubscribe?: (email: string) => void; compact?: boolean; className?: string }) {
const uid = useId();
const [done, setDone] = useState(false);
const [email, setEmail] = useState('');
if (done)
return (
<p role="status" className={cn('kit-rise flex items-start gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,transparent)] p-4 text-[14.5px] leading-[1.5] text-foreground', className)}>
<span className="grid size-6 shrink-0 place-items-center rounded-full bg-success text-white">
<Icon name="check" size={14} strokeWidth={2.4} />
</span>
{t.subscribed}
</p>
);
return (
<form
className={cn('flex flex-col gap-2', !compact && 'sm:flex-row', className)}
onSubmit={(e) => {
e.preventDefault();
if (!email.includes('@')) return;
onSubscribe?.(email);
setDone(true);
}}
>
<label htmlFor={uid} className="sr-only">
{t.email}
</label>
<Input id={uid} type="email" required autoComplete="email" placeholder={t.emailPh} value={email} onChange={(e) => setEmail(e.target.value)} className={cn('min-w-0', !compact && 'sm:flex-1')} />
<Button type="submit" magnetic className={cn('max-sm:w-full', compact && 'w-full')}>
{t.subscribe}
</Button>
</form>
);
}
/** A drawn mail preview: masthead, headline lines, a picture block and text lines */
function MailPreview({ n, name, title, hot }: { n: number; name?: string; title: string; hot?: boolean }) {
const seed = (n * 37) % 5;
return (
<div data-clip-ok className="relative flex aspect-[3/4] flex-col overflow-hidden rounded-xl border border-border bg-card p-[8%] shadow-[var(--highlight),var(--shadow-sm)] [container-type:inline-size]" aria-hidden>
<div className="flex items-center justify-between">
<span className="text-[6cqw] font-bold tracking-[-0.02em] text-foreground">{name}</span>
<span className="text-[4.5cqw] tabular-nums text-muted-foreground">#{n}</span>
</div>
<span className="mt-[4cqw] h-px bg-border" />
<p className="mt-[6cqw] line-clamp-3 text-[7.4cqw] font-semibold leading-[1.15] tracking-[-0.015em] text-foreground">{title}</p>
<div className={cn('mt-[6cqw] h-[30cqw] rounded-[2cqw] transition-transform duration-700 ease-[var(--ease-kit)]', hot && 'scale-[1.03]')} style={{ background: `linear-gradient(${120 + seed * 30}deg, color-mix(in oklab, var(--primary) ${30 + seed * 8}%, var(--card)), color-mix(in oklab, var(--primary) 8%, var(--muted)))` }} />
<div className="mt-[6cqw] space-y-[2.6cqw]">
{[92, 100, 84, 96, 60].map((w, i) => (
<span key={i} className="block h-[2.4cqw] rounded-full bg-muted" style={{ width: `${w - ((seed + i) % 3) * 6}%` }} />
))}
</div>
<span className="mt-[7cqw] h-[2.6cqw] w-[44%] rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,var(--muted))]" />
<div className="mt-[3cqw] space-y-[2.6cqw]">
{[88, 70].map((w, i) => (
<span key={i} className="block h-[2.4cqw] rounded-full bg-muted" style={{ width: `${w}%` }} />
))}
</div>
<span className="mt-auto h-[9cqw] w-[42%] rounded-[2cqw] bg-primary" />
</div>
);
}
function Head({ eyebrow, title, description, aside }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode; aside?: React.ReactNode }) {
if (!title && !eyebrow) return null;
return (
<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-[42rem]">
{eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
{title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
{description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
</div>
{aside}
</div>
);
}
/* ------------------------------------------------------------------ */
/* latest */
/* ------------------------------------------------------------------ */
function Latest(p: NewsletterArchiveProps & { t: Texts }) {
const { t } = p;
const dates = useDates();
const [first, ...rest] = p.issues ?? [];
const [rowRef, fade] = useScrollFade<HTMLDivElement>();
if (!first) 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 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:gap-16">
<Reveal effect="rise">
<a href={first.href ?? '#'} className="group/l block h-full rounded-[calc(var(--radius)+12px)] border border-border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] sm:p-8">
<p className="flex items-center gap-2 text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<span className="relative flex size-2">
<span className="absolute inset-0 rounded-full bg-primary opacity-60 motion-safe:animate-ping" />
<span className="relative size-2 rounded-full bg-primary" />
</span>
{t.latest}
<span className="text-muted-foreground">· {t.issue.replace('{n}', String(first.number))}</span>
</p>
<h2 className="mt-5 text-balance text-[30px] font-semibold leading-[1.12] tracking-[-0.022em] text-foreground sm:text-[40px]">{first.title}</h2>
{first.summary && <p className="mt-4 max-w-[36rem] text-pretty text-[17px] leading-[1.6] text-muted-foreground">{first.summary}</p>}
<p className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 text-[14px] text-muted-foreground">
<span className="tabular-nums">{dates.long(first.date)}</span>
{first.readingTime && <span className="tabular-nums">· {t.min.replace('{n}', String(first.readingTime))}</span>}
<span className="ml-auto inline-flex items-center gap-1.5 font-medium text-foreground">
{t.read}
<Icon name="arrow-right" size={16} className="transition-transform duration-300 group-hover/l:translate-x-1" />
</span>
</p>
</a>
</Reveal>
<div className="flex flex-col justify-center">
{p.eyebrow && <Eyebrow className="kit-rise">{p.eyebrow}</Eyebrow>}
{p.title && <h3 className="kit-rise text-balance text-[28px] font-semibold leading-[1.15] tracking-[-0.02em] text-foreground sm:text-[32px]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{p.title}</h3>}
{p.description && <p className="kit-rise mt-3 text-pretty text-[16.5px] leading-[1.6] text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{p.description}</p>}
<SignUp t={t} onSubscribe={p.onSubscribe} className="kit-rise mt-6" />
<p className="kit-rise mt-3 text-[13px] text-muted-foreground" style={{ '--kit-delay': '200ms' } as React.CSSProperties}>
{[p.readers ? t.readers.replace('{n}', p.readers) : null, p.cadence, t.free].filter(Boolean).join(' · ')}
</p>
</div>
</div>
{rest.length > 0 && (
<div className="mt-14">
<div className="flex items-center justify-between gap-4 border-b border-border pb-3">
<h3 className="text-[15px] font-semibold text-foreground">{t.before}</h3>
</div>
<div ref={rowRef} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 lg:mx-0 lg:px-0 [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
<Stagger as="ol" step={60} className="flex w-max gap-px lg:grid lg:w-full lg:grid-cols-4">
{rest.slice(0, 4).map((x) => (
<li key={x.number} className="w-[17rem] border-r border-border px-6 first:pl-0 last:border-r-0 lg:w-auto">
<a href={x.href ?? '#'} className="group/i flex h-full flex-col py-5">
<span className="text-[12.5px] tabular-nums text-muted-foreground">
#{x.number} · {dates.short(x.date)}
</span>
<span className="mt-2 text-pretty text-[16px] font-semibold leading-[1.35] text-foreground underline decoration-transparent underline-offset-4 transition-colors group-hover/i:decoration-current">{x.title}</span>
</a>
</li>
))}
</Stagger>
</div>
</div>
)}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* list */
/* ------------------------------------------------------------------ */
function List(p: NewsletterArchiveProps & { t: Texts }) {
const { t } = p;
const dates = useDates();
const issues = p.issues ?? [];
const years = useMemo(() => [...new Set(issues.map((x) => dates.year(x.date)))], [issues]); // eslint-disable-line react-hooks/exhaustive-deps
const [year, setYear] = useState<number | null>(null);
const [q, setQ] = useState('');
const query = q.trim();
const shown = issues.filter((x) => (year === null || dates.year(x.date) === year) && (!query || `${x.title} ${x.summary ?? ''} ${(x.topics ?? []).join(' ')}`.toLowerCase().includes(query.toLowerCase())));
const seg = useRef<HTMLDivElement>(null);
const [pill, setPill] = useState<{ x: number; w: number } | null>(null);
useLayoutEffect(() => {
const b = seg.current?.querySelector<HTMLElement>('[aria-pressed="true"]');
if (b) setPill({ x: b.offsetLeft, w: b.offsetWidth });
}, [year, years.length]);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="mx-auto max-w-[52rem]">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<Input type="search" icon="search" aria-label={t.search} placeholder={t.searchPh} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div ref={seg} role="group" aria-label={t.year} className="relative inline-flex self-start 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)` }} />}
{[null, ...years].map((y) => (
<button key={String(y)} type="button" aria-pressed={year === y} onClick={() => setYear(y)} className={cn('relative min-h-10 rounded-lg px-3.5 text-[14px] font-medium tabular-nums transition-colors duration-300 max-sm:min-h-11', year === y ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
{y ?? t.all}
</button>
))}
</div>
</div>
<p className="mt-4 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
{shown.length ? (shown.length === 1 ? t.found1 : t.found.replace('{n}', String(shown.length))) : t.none.replace('{q}', query)}
</p>
<ol key={`${year}`} className="mt-3 border-t border-border">
{shown.map((x, i) => (
<li key={x.number} className="kit-rise border-b border-border" style={{ '--kit-delay': `${Math.min(i, 8) * 40}ms`, animationDuration: '.6s' } as React.CSSProperties}>
<a href={x.href ?? '#'} className="group/i -mx-3 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-x-4 rounded-xl px-3 py-5 transition-colors duration-300 hover:bg-surface sm:grid-cols-[4rem_minmax(0,1fr)_7.5rem]">
<span className="pt-0.5 text-[15px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">#{x.number}</span>
<span className="min-w-0">
<span className="block text-pretty text-[17px] font-semibold leading-[1.35] text-foreground">
<Hit text={x.title} q={query} />
</span>
{x.summary && (
<span className="mt-1 block text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">
<Hit text={x.summary} q={query} />
</span>
)}
<span className="mt-1.5 block text-[13px] tabular-nums text-muted-foreground sm:hidden">{dates.long(x.date)}</span>
</span>
<span className="hidden text-right text-[13.5px] tabular-nums text-muted-foreground sm:block">
{dates.long(x.date)}
{x.readingTime && <span className="mt-0.5 block">{t.min.replace('{n}', String(x.readingTime))}</span>}
</span>
</a>
</li>
))}
</ol>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* covers */
/* ------------------------------------------------------------------ */
function Covers(p: NewsletterArchiveProps & { t: Texts }) {
const { t } = p;
const dates = useDates();
const issues = p.issues ?? [];
const [hot, setHot] = useState<number | null>(null);
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head eyebrow={p.eyebrow} title={p.title} description={p.description} aside={<SignUp t={t} onSubscribe={p.onSubscribe} className="w-full lg:w-[26rem]" />} />
<Stagger as="ul" step={60} className="grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-4 lg:gap-x-6">
{issues.slice(0, 8).map((x) => (
<li key={x.number}>
<a href={x.href ?? '#'} onPointerEnter={() => setHot(x.number)} onPointerLeave={() => setHot(null)} onFocus={() => setHot(x.number)} onBlur={() => setHot(null)} className="group/c block">
<div className="transition-transform duration-500 ease-[var(--ease-kit)] group-hover/c:-translate-y-1.5 group-hover/c:rotate-[-0.6deg]">
<MailPreview n={x.number} name={p.name} title={x.title} hot={hot === x.number} />
</div>
<p className="mt-3 text-[12.5px] tabular-nums text-muted-foreground">
#{x.number} · {dates.short(x.date)}
</p>
<p className="mt-1 line-clamp-2 text-pretty text-[15px] font-semibold leading-[1.35] text-foreground">{x.title}</p>
</a>
</li>
))}
</Stagger>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
/* reader */
/* ------------------------------------------------------------------ */
function Reader(p: NewsletterArchiveProps & { t: Texts }) {
const { t } = p;
const uid = useId().replace(/:/g, '');
const dates = useDates();
const issues = p.issues ?? [];
const [i, setI] = useState(0);
const x = issues[i];
const [active, setActive] = useState(0);
const body = useRef<HTMLDivElement>(null);
useEffect(() => {
let frame = 0;
const update = () => {
frame = 0;
const els = [...(body.current?.querySelectorAll<HTMLElement>('[data-sec]') ?? [])];
let cur = 0;
els.forEach((el, k) => {
if (el.getBoundingClientRect().top <= window.innerHeight * 0.35) cur = k;
});
// The last section can never reach the line at the end of the page
const last = els[els.length - 1];
if (last && window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 && last.getBoundingClientRect().top < window.innerHeight) cur = els.length - 1;
setActive(cur);
};
const on = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', on, { passive: true });
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', on);
};
}, [i]);
if (!x) return null;
const sections = x.sections ?? [];
return (
<article className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<div className="lg:grid lg:grid-cols-[13rem_minmax(0,40rem)] lg:justify-center lg:gap-16">
<aside className="hidden lg:block">
<div className="sticky top-10">
{p.backHref && (
<a href={p.backHref} className="inline-flex min-h-11 items-center gap-2 text-[14px] font-medium text-muted-foreground transition-colors hover:text-foreground">
<Icon name="arrow-left" size={15} />
{t.back}
</a>
)}
<p className="mt-8 text-[13px] font-semibold text-foreground">{t.contents}</p>
<ol className="mt-3 space-y-0.5 border-l border-border">
{sections.map((s, k) => (
<li key={k}>
<a href={`#${uid}-${k}`} aria-current={active === k ? 'location' : undefined} className={cn('-ml-px flex min-h-9 items-center border-l py-1.5 pl-4 text-[14px] leading-snug transition-colors duration-300', active === k ? 'border-primary font-medium text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground')}>
{s.title}
</a>
</li>
))}
</ol>
</div>
</aside>
<div key={x.number} className="min-w-0">
{p.backHref && (
<a href={p.backHref} className="mb-4 inline-flex min-h-11 items-center gap-2 text-[14px] font-medium text-muted-foreground transition-colors hover:text-foreground lg:hidden">
<Icon name="arrow-left" size={15} />
{t.back}
</a>
)}
{/* The mail header, as it arrived */}
<div className="kit-rise rounded-xl border border-border bg-surface px-4 py-3 text-[13.5px] text-muted-foreground">
<p>
<span className="inline-block w-12 text-subtle-foreground">{t.from}</span>
<span className="text-foreground">{p.name}</span>
</p>
<p className="mt-1">
<span className="inline-block w-12 text-subtle-foreground">{t.to}</span>
{t.readers.replace('{n}', p.readers ?? '')}
</p>
</div>
<p className="kit-rise mt-8 text-[13px] font-medium tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>
{t.issue.replace('{n}', String(x.number))} · {dates.long(x.date)}
</p>
<h2 className="kit-rise mt-3 text-balance text-h1 font-semibold text-foreground" style={{ '--kit-delay': '100ms' } as React.CSSProperties}>
{x.title}
</h2>
{x.summary && <p className="kit-rise mt-5 text-pretty text-[19px] leading-[1.55] text-muted-foreground" style={{ '--kit-delay': '140ms' } as React.CSSProperties}>{x.summary}</p>}
<div ref={body} className="mt-10 border-t border-border">
{sections.map((s, k) => (
<section key={k} id={`${uid}-${k}`} data-sec className="scroll-mt-10 border-b border-border py-10">
<Reveal effect="fade">
<p className="text-[12.5px] font-semibold tabular-nums text-muted-foreground">{String(k + 1).padStart(2, '0')}</p>
<h3 className="mt-2 text-balance text-[24px] font-semibold leading-[1.25] tracking-[-0.014em] text-foreground">{s.title}</h3>
{s.text.map((para, j) => (
<p key={j} className="mt-4 text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_88%,var(--background))]">
{para}
</p>
))}
{s.link && (
<a href={s.link.href} className="group/a mt-4 inline-flex min-h-11 items-center gap-1.5 text-[15px] font-medium text-primary">
{s.link.label}
<Icon name="arrow-right" size={15} className="transition-transform duration-300 group-hover/a:translate-x-0.5" />
</a>
)}
</Reveal>
</section>
))}
</div>
<Reveal effect="rise" className="kit-card relative mt-12 overflow-hidden p-6 sm:p-8">
<span className="kit-beam pointer-events-none absolute inset-0 rounded-[inherit]" aria-hidden />
<p className="text-[20px] font-semibold text-foreground">{t.liked}</p>
<p className="mt-1 text-[15px] text-muted-foreground">{t.likedText}</p>
<SignUp t={t} onSubscribe={p.onSubscribe} className="mt-5" />
</Reveal>
<nav aria-label={t.back} className="mt-8 grid grid-cols-2 gap-3">
{[issues[i + 1], issues[i - 1]].map((n, k) => (
<button key={k} type="button" disabled={!n} onClick={() => n && setI(i + (k ? -1 : 1))} className={cn('flex min-h-20 flex-col justify-center gap-1 rounded-xl border border-border px-4 py-3 text-left transition-[background-color,border-color] hover:border-border-strong hover:bg-surface disabled:pointer-events-none disabled:opacity-0', k && 'items-end text-right')}>
<span className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
{!k && <Icon name="arrow-left" size={13} />}
{k ? t.next : t.previous}
{k ? <Icon name="arrow-right" size={13} /> : null}
</span>
<span className="line-clamp-2 text-[14.5px] font-semibold text-foreground">{n?.title}</span>
</button>
))}
</nav>
</div>
</div>
</div>
</article>
);
}
/* ------------------------------------------------------------------ */
/* calendar */
/* ------------------------------------------------------------------ */
function Calendar(p: NewsletterArchiveProps & { t: Texts }) {
const { t } = p;
const uid = useId().replace(/:/g, '');
const dates = useDates();
const issues = p.issues ?? [];
const years = useMemo(() => [...new Set(issues.map((x) => dates.year(x.date)))].sort((a, b) => b - a), [issues]); // eslint-disable-line react-hooks/exhaustive-deps
const [year, setYear] = useState(years[0]);
const inYear = issues.filter((x) => dates.year(x.date) === year);
const byMonth = Array.from({ length: 12 }, (_, m) => inYear.filter((x) => dates.monthOf(x.date) === m));
const latestMonth = byMonth.reduce((a, l, m) => (l.length ? m : a), 0);
const [open, setOpen] = useState<number>(latestMonth);
useEffect(() => setOpen(latestMonth), [year]); // eslint-disable-line react-hooks/exhaustive-deps
const max = Math.max(1, ...byMonth.map((l) => l.length));
const sel = byMonth[open] ?? [];
return (
<section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
<div className="kit-container">
<Head
eyebrow={p.eyebrow}
title={p.title}
description={p.description}
aside={
<div role="group" aria-label={t.year} className="inline-flex self-start rounded-xl border border-border bg-surface p-1 lg:self-end">
{years.map((y) => (
<button key={y} type="button" aria-pressed={year === y} onClick={() => setYear(y)} className={cn('min-h-10 rounded-lg px-3.5 text-[14px] font-medium tabular-nums transition-[background-color,color] duration-300 max-sm:min-h-11', year === y ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{y}
</button>
))}
</div>
}
/>
<div className="grid grid-cols-[minmax(0,1fr)] gap-8 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] lg:gap-14">
<div role="tablist" aria-label={String(year)} className="grid grid-cols-4 content-start gap-2 self-start sm:grid-cols-6">
{byMonth.map((list, m) => {
const has = list.length > 0;
return (
<button
key={m}
type="button"
role="tab"
id={`${uid}-m${m}`}
aria-selected={open === m}
aria-controls={`${uid}-panel`}
disabled={!has}
onClick={() => setOpen(m)}
className={cn('group/m relative flex aspect-square flex-col justify-between rounded-xl border p-2.5 text-left transition-[background-color,border-color,transform] duration-300 sm:p-3', open === m ? 'border-transparent bg-primary text-primary-foreground shadow-[var(--shadow-md)]' : has ? 'border-border bg-card hover:-translate-y-0.5 hover:border-border-strong' : 'border-dashed border-border text-subtle-foreground')}
>
<span className={cn('text-[13px] font-medium', open === m ? '' : has ? 'text-foreground' : '')}>{dates.monthShort(m)}</span>
<span className="flex flex-wrap gap-1" aria-hidden>
{list.map((x) => (
<span key={x.number} className={cn('size-2 rounded-full', open === m ? 'bg-primary-foreground' : 'bg-primary')} />
))}
</span>
<span className="sr-only">{has ? (list.length === 1 ? t.found1 : t.found.replace('{n}', String(list.length))) : t.noIssue}</span>
{has && <span aria-hidden className={cn('absolute bottom-0 left-2.5 right-2.5 h-0.5 origin-left rounded-full', open === m ? 'bg-primary-foreground/40' : 'bg-[color-mix(in_oklab,var(--primary)_40%,transparent)]')} style={{ transform: `scaleX(${round(list.length / max, 3)})` }} />}
</button>
);
})}
</div>
<div id={`${uid}-panel`} role="tabpanel" aria-labelledby={`${uid}-m${open}`}>
<p className="text-[13px] font-semibold text-foreground">{t.issuesIn.replace('{m}', `${dates.month(open)} ${year}`)}</p>
<ol key={`${year}-${open}`} className="mt-3 divide-y divide-border border-y border-border">
{sel.map((x, k) => (
<li key={x.number} className="kit-rise" style={{ '--kit-delay': `${k * 60}ms`, animationDuration: '.6s' } as React.CSSProperties}>
<a href={x.href ?? '#'} className="group/i block py-4">
<span className="text-[12.5px] tabular-nums text-muted-foreground">
#{x.number} · {dates.long(x.date)}
</span>
<span className="mt-1 block text-pretty text-[17px] font-semibold leading-[1.35] text-foreground underline decoration-transparent underline-offset-4 transition-colors group-hover/i:decoration-current">{x.title}</span>
{x.summary && <span className="mt-1 block line-clamp-2 text-pretty text-[14.5px] leading-[1.55] text-muted-foreground">{x.summary}</span>}
</a>
</li>
))}
</ol>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export function NewsletterArchive(props: NewsletterArchiveProps) {
const t = useTexts(TEXTS, props.labels);
const { variant = 'latest' } = props;
if (variant === 'list') return <List {...props} t={t} />;
if (variant === 'covers') return <Covers {...props} t={t} />;
if (variant === 'reader') return <Reader {...props} t={t} />;
if (variant === 'calendar') return <Calendar {...props} t={t} />;
return <Latest {...props} t={t} />;
}Häufige Fragen
Wie installiere ich Newsletter-Archiv?
Mit aiw add @ai-website/newsletter-archive. Der Befehl legt die Datei components/ui/newsletter-archive.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, input, reveal, motion und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Newsletter-Archiv?
5 Varianten, Neueste Ausgabe, Durchsuchbare Liste, Mailvorschauen, Webversion und Kalender. Du wählst sie mit der Prop variant, zum Beispiel variant="list".
Kann ich die Texte von Newsletter-Archiv übersetzen?
Ja. Newsletter-Archiv bringt 28 eigene Texte auf Deutsch und Englisch mit, etwa „Neueste Ausgabe“, „Ausgabe lesen“ und „Frühere Ausgaben“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Newsletter-Archiv einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Newsletter-Archiv mit aiw_components und installieren sie mit aiw_add @ai-website/newsletter-archive. Nenn im Prompt die Variante, etwa variant="latest", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Newsletter-Archiv ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden NewsletterArchive-Code zum Kopieren.