Zum Inhalt springen
UI Kit
Accent

Musiker-Pressekit

Ein elektronisches Pressekit für Bands und Musiker. Überblick mit Zahlen und Pressefotos, Veröffentlichungen mit abspielbaren Titeln, Tourdaten mit Ticketstand, Pressestimmen und Downloads und der Rider mit Bühnenplan.

Eine von 3 Components in Kunst und Musik

5 Variants

Variants

Überblick

Die Band groß auf einem Bühnenbild, das wandert, Genre und ein Satz zum Klang. Zahlen, die hochzählen, Pressefotos, die beim Zeigen farbig werden, und die Kontakte für Booking und Presse.

Im Studio öffnen

Veröffentlichungen

Cover zur Auswahl, das gewählte groß. Ein Titel spielt eine Hörprobe von 30 Sekunden, die Platte gleitet aus der Hülle und dreht sich, die Linie unter dem Titel füllt sich.

Im Studio öffnen

Tourdaten

Termine nach Land hinter einem gleitenden Filter. Ticketstand je Konzert, wenige Karten im Akzent, ausverkaufte durchgestrichen mit Erinnerung für die nächste Gelegenheit.

Im Studio öffnen

Presse

Ein Zitat groß, weitere als Karten mit dem Logo des Mediums, darunter alles, was die Presse braucht, als Download. Das Download-Symbol füllt sich beim Zeigen.

Im Studio öffnen

Technischer Rider

Der Bühnenplan aus Positionen gezeichnet, die Instrumente erscheinen nacheinander, Zeigen markiert ihre Kanäle in der Kanalliste. Was die Band braucht und der Rider zum Herunterladen.

Im Studio öffnen

Details

Was Musiker-Pressekit mitbringt, aus Registry und Quellcode gelesen.

Bereich
Industries
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/musician-epk.tsx
Bringt mit
utilskitbuttoniconmotionnumber-tickersection
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 25 eigene Texte

Props

Die Schnittstelle MusicianEpkProps aus components/ui/musician-epk.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    MusicianEpkVariant

    overviewreleasestourpressrider

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • artistoptional
    { name: string; genre: string; from?: string; line?: string; bio?: string; photo?: React.ReactNode }
  • statsoptional
    { value: number; suffix?: string; label: string }[]
  • photosoptional
    { label: string; size?: string; href: string; image?: React.ReactNode }[]
  • contactsoptional
    { role: string; name: string; email: string }[]
  • releasesoptional
    EpkRelease[]
  • showsoptional
    EpkShow[]
  • quotesoptional
    EpkQuote[]
  • downloadsoptional
    { label: string; detail?: string; href: string; icon?: IconName }[]
  • inputsoptional
    EpkInput[]
  • stageoptional
    EpkStageItem[]
  • needsoptional
    string[]
  • riderHrefoptional
    string

Eingebaute Texte

Diese Texte schreibt Musiker-Pressekit selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 25 Texte anzeigen
photosEnglisch Press photosDeutsch Pressefotos
downloadEnglisch DownloadDeutsch Herunterladen
contactEnglisch ContactDeutsch Kontakt
tracksEnglisch TracksDeutsch Titel
playEnglisch Play preview of {t}Deutsch Hörprobe von {t} abspielen
pauseEnglisch Pause {t}Deutsch {t} anhalten
listenEnglisch ListenDeutsch Anhören
releasedEnglisch Released {d}Deutsch Erschienen am {d}
allEnglisch AllDeutsch Alle
countriesEnglisch CountriesDeutsch Länder
ticketsEnglisch TicketsDeutsch Tickets
fewEnglisch Few leftDeutsch Nur noch wenige
soldOutEnglisch Sold outDeutsch Ausverkauft
remindEnglisch Remind meDeutsch Erinnere mich
remindedEnglisch Reminder setDeutsch Erinnerung gesetzt
pressEnglisch Downloads for pressDeutsch Downloads für die Presse
plotEnglisch Stage plotDeutsch Bühnenplan
inputsEnglisch Input listDeutsch Kanalliste
chEnglisch ChDeutsch Kanal
sourceEnglisch SourceDeutsch Quelle
micEnglisch MicDeutsch Mikrofon
needsEnglisch We needDeutsch Wir brauchen
riderEnglisch Download the riderDeutsch Rider herunterladen
frontEnglisch AudienceDeutsch Publikum
minEnglisch {n} minDeutsch {n} Min.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Musiker-Pressekit reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, radio, radiogroup und tab.
  • 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).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • 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, 672 Zeilen
components/ui/musician-epk.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts, type Locale } 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 { Section, SectionHeader } from '@/components/ui/section';

/*
 * An electronic press kit for musicians and bands: who they are, what
 * they released, where they play, what the press says and what a venue
 * needs on stage.
 *
 *   overview    name, genre, a line about the sound, numbers that count, press photos and contacts
 *   releases    covers to pick from, the chosen release with its tracks and a preview that plays
 *   tour        dates by country with ticket state, sold out ones crossed, a reminder for the others
 *   press       quotes with the outlet, the outlets as a row and the downloads for press
 *   rider       the stage plot drawn from positions, the input list and what the band needs
 */

export type MusicianEpkVariant = 'overview' | 'releases' | 'tour' | 'press' | 'rider';

export interface EpkTrack {
  title: string;
  /** m:ss */
  length: string;
}

export interface EpkRelease {
  id: string;
  title: string;
  /** e.g. "Album", "EP", "Single" */
  kind: string;
  /** YYYY-MM-DD */
  date: string;
  label?: string;
  cover?: React.ReactNode;
  tracks?: EpkTrack[];
  href?: string;
}

export interface EpkShow {
  id: string;
  /** YYYY-MM-DD */
  date: string;
  city: string;
  country: string;
  venue: string;
  state: 'tickets' | 'few' | 'sold-out';
  href?: string;
}

export interface EpkQuote {
  quote: string;
  outlet: string;
  logo?: React.ReactNode;
}

export interface EpkInput {
  ch: number;
  source: string;
  mic: string;
  note?: string;
}

export interface EpkStageItem {
  label: string;
  /** Position on the stage, 0 to 100 from left and from the back */
  x: number;
  y: number;
  kind: 'vocal' | 'guitar' | 'bass' | 'drums' | 'keys' | 'monitor' | 'amp';
}

export interface MusicianEpkProps {
  variant?: MusicianEpkVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  artist?: { name: string; genre: string; from?: string; line?: string; bio?: string; photo?: React.ReactNode };
  stats?: { value: number; suffix?: string; label: string }[];
  photos?: { label: string; size?: string; href: string; image?: React.ReactNode }[];
  contacts?: { role: string; name: string; email: string }[];
  releases?: EpkRelease[];
  shows?: EpkShow[];
  quotes?: EpkQuote[];
  downloads?: { label: string; detail?: string; href: string; icon?: IconName }[];
  inputs?: EpkInput[];
  stage?: EpkStageItem[];
  needs?: string[];
  riderHref?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: {
    photos: 'Press photos',
    download: 'Download',
    contact: 'Contact',
    tracks: 'Tracks',
    play: 'Play preview of {t}',
    pause: 'Pause {t}',
    listen: 'Listen',
    released: 'Released {d}',
    all: 'All',
    countries: 'Countries',
    tickets: 'Tickets',
    few: 'Few left',
    soldOut: 'Sold out',
    remind: 'Remind me',
    reminded: 'Reminder set',
    press: 'Downloads for press',
    plot: 'Stage plot',
    inputs: 'Input list',
    ch: 'Ch',
    source: 'Source',
    mic: 'Mic',
    needs: 'We need',
    rider: 'Download the rider',
    front: 'Audience',
    min: '{n} min',
  },
  de: {
    photos: 'Pressefotos',
    download: 'Herunterladen',
    contact: 'Kontakt',
    tracks: 'Titel',
    play: 'Hörprobe von {t} abspielen',
    pause: '{t} anhalten',
    listen: 'Anhören',
    released: 'Erschienen am {d}',
    all: 'Alle',
    countries: 'Länder',
    tickets: 'Tickets',
    few: 'Nur noch wenige',
    soldOut: 'Ausverkauft',
    remind: 'Erinnere mich',
    reminded: 'Erinnerung gesetzt',
    press: 'Downloads für die Presse',
    plot: 'Bühnenplan',
    inputs: 'Kanalliste',
    ch: 'Kanal',
    source: 'Quelle',
    mic: 'Mikrofon',
    needs: 'Wir brauchen',
    rider: 'Rider herunterladen',
    front: 'Publikum',
    min: '{n} Min.',
  },
};
type Texts = typeof TEXTS.en;

/* ------------------------------------------------------------------ */
/* Helpers                                                             */
/* ------------------------------------------------------------------ */

const TAG: Record<Locale, string> = { en: 'en-US', de: 'de-DE' };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
const focusRing = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
const fit = '[&>*]:size-full [&_img]:object-cover';
const dfmt = (iso: string, locale: Locale, o: Intl.DateTimeFormatOptions) => {
  const [y, m, d] = iso.split('-').map(Number);
  return new Intl.DateTimeFormat(TAG[locale], { timeZone: 'UTC', ...o }).format(new Date(Date.UTC(y, m - 1, d)));
};
const quoted = (text: string, locale: Locale) => (locale === 'de' ? `„${text}“` : `“${text}”`);
const secs = (mmss: string) => {
  const [m, s] = mmss.split(':').map(Number);
  return m * 60 + (s || 0);
};


/** Arrow keys, Home and End move the choice inside a radio group or a tab list (APG) */
function arrowKeys(e: React.KeyboardEvent<HTMLElement>) {
  const keys = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
  if (!keys.includes(e.key)) return;
  const items = [...e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled), [role="tab"]:not(:disabled)')];
  const i = items.indexOf(document.activeElement as HTMLButtonElement);
  if (i < 0) return;
  e.preventDefault();
  const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1;
  const n = e.key === 'Home' ? 0 : e.key === 'End' ? items.length - 1 : (i + step + items.length) % items.length;
  items[n].focus();
  items[n].click();
}

const MOTION_CSS = `
@keyframes kitepk-in{from{opacity:0;transform:translateY(8px)}}
@keyframes kitepk-swap{from{opacity:0;filter:blur(4px);transform:translateY(6px)}}
@keyframes kitepk-pop{0%{transform:scale(.5);opacity:0}60%{transform:scale(1.12);opacity:1}100%{transform:scale(1)}}
@keyframes kitepk-bar{0%,100%{transform:scaleY(.3)}50%{transform:scaleY(1)}}
@keyframes kitepk-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){[class*="kitepk-"],.kitepk-anim{animation:none!important;transition:none!important}}`;

function EpkMotion() {
  return (
    <style href="kitepk-motion" precedence="default">
      {MOTION_CSS}
    </style>
  );
}

function Segmented({ options, value, onChange, label, className }: { options: { id: string; label: React.ReactNode }[]; value: string; onChange: (id: string) => void; label: string; className?: string }) {
  const wrap = useRef<HTMLDivElement>(null);
  const [box, setBox] = useState<{ x: number; w: number } | null>(null);
  useEffect(() => {
    const el = wrap.current;
    if (!el) return;
    const measure = () => {
      const b = el.querySelector<HTMLElement>('[aria-checked="true"]');
      if (b) setBox({ x: b.offsetLeft, w: b.offsetWidth });
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [value]);
  const key = (e: React.KeyboardEvent) => {
    const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
    if (!d) return;
    e.preventDefault();
    const i = options.findIndex((o) => o.id === value);
    const n = options[(i + d + options.length) % options.length];
    onChange(n.id);
    wrap.current?.querySelector<HTMLElement>(`[data-v="${n.id}"]`)?.focus();
  };
  return (
    <div ref={wrap} role="radiogroup" aria-label={label} onKeyDown={key} className={cn('relative inline-flex max-w-full rounded-xl border border-border bg-muted p-1', className)}>
      {box && <span aria-hidden className="absolute bottom-1 left-0 top-1 rounded-lg bg-card shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border-strong transition-[transform,width] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ width: box.w, transform: `translateX(${box.x}px)` }} />}
      {options.map((o) => {
        const on = o.id === value;
        return (
          <button key={o.id} type="button" role="radio" data-v={o.id} aria-checked={on} tabIndex={on ? 0 : -1} onClick={() => onChange(o.id)} className={cn('relative z-[1] inline-flex min-h-10 shrink-0 items-center justify-center whitespace-nowrap rounded-lg px-4 text-[14px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !box && 'bg-card shadow-[var(--shadow-sm)]')}>
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* overview                                                            */
/* ------------------------------------------------------------------ */

function Overview({ artist, stats = [], photos = [], contacts = [], t }: MusicianEpkProps & { t: Texts }) {
  if (!artist) return null;
  return (
    <div className="grid gap-6">
      <div className="relative overflow-hidden rounded-2xl border border-border bg-bezel" data-clip-ok>
        <div className="relative sm:aspect-[16/9] lg:aspect-[21/9]">
          {/* On the phone the photo stands above the name, so the stage stays visible */}
          <div className="relative aspect-[4/3] overflow-hidden sm:absolute sm:inset-0 sm:aspect-auto">
            <Parallax speed={70} className={cn('absolute -inset-y-10 inset-x-0', fit)}>
              {artist.photo}
            </Parallax>
            <div className="absolute inset-0 bg-[linear-gradient(to_top,rgb(0_0_0),rgb(0_0_0/0.1)_45%,transparent)] sm:bg-[linear-gradient(to_top,rgb(0_0_0/0.82),rgb(0_0_0/0.15)_60%,transparent)]" />
          </div>
          <div className="relative bg-black px-6 pb-7 pt-1 text-white sm:absolute sm:inset-x-0 sm:bottom-0 sm:bg-transparent sm:p-10">
            <p className="kit-rise text-[14px] font-medium text-white/75">
              {artist.genre}
              {artist.from && ` · ${artist.from}`}
            </p>
            <h2 className="kit-rise mt-2 text-[44px] font-semibold uppercase leading-[0.95] tracking-[-0.03em] [--kit-delay:80ms] sm:text-[72px] lg:text-[96px]">{artist.name}</h2>
            {artist.line && <p className="kit-rise mt-4 max-w-[34rem] text-pretty text-[16px] text-white/85 [--kit-delay:160ms] sm:text-[18px]">{artist.line}</p>}
          </div>
        </div>
      </div>
      {stats.length > 0 && (
        <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border lg:grid-cols-4">
          {stats.slice(0, 4).map((s, i) => (
            <div key={s.label} className="flex flex-col-reverse justify-end bg-card p-5 sm:p-6">
              <dt className="mt-2 text-[13.5px] leading-snug text-muted-foreground">{s.label}</dt>
              <dd className="text-[30px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:text-[36px]">
                <NumberTicker value={s.value} delay={i * 120} />
                {s.suffix}
              </dd>
            </div>
          ))}
        </dl>
      )}
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
        {photos.length > 0 && (
          <div className="kit-card p-5 sm:p-6">
            <h3 className="text-[16px] font-semibold text-foreground">{t.photos}</h3>
            <Stagger as="ul" step={70} className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
              {photos.map((p, i) => (
                <li key={p.label} className={cn(i === 0 && photos.length % 2 === 1 && 'max-sm:col-span-2 max-sm:[&_span.aspect-square]:aspect-[16/9]')}>
                  <a href={p.href} download className={cn('group block rounded-lg', focusRing)} aria-label={`${t.download}: ${p.label}`}>
                    <span className="relative block aspect-square overflow-hidden rounded-lg bg-muted" data-clip-ok>
                      <span className={cn('absolute inset-0 block grayscale transition-[transform,filter] duration-700 ease-[var(--ease-kit)] group-hover:scale-105 group-hover:grayscale-0', fit)}>{p.image}</span>
                      <span className="absolute bottom-2 right-2 flex size-9 items-center justify-center rounded-full bg-background/90 text-foreground backdrop-blur transition-transform duration-300 group-hover:-translate-y-0.5">
                        <Icon name="download" size={16} />
                      </span>
                    </span>
                    <span className="mt-2 block text-[13.5px] font-medium text-foreground">{p.label}</span>
                    {p.size && <span className="block text-[12.5px] text-muted-foreground">{p.size}</span>}
                  </a>
                </li>
              ))}
            </Stagger>
          </div>
        )}
        <div className="kit-card flex flex-col p-5 sm:p-6">
          {artist.bio && <p className="text-pretty text-[15px] leading-relaxed text-foreground/85">{artist.bio}</p>}
          {contacts.length > 0 && (
            <>
              <h3 className={cn('text-[16px] font-semibold text-foreground', artist.bio && 'mt-6 border-t border-border pt-5')}>{t.contact}</h3>
              <ul className="mt-3 grid gap-3">
                {contacts.map((c) => (
                  <li key={c.role} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
                    <span className="min-w-0">
                      <span className="block text-[12.5px] text-muted-foreground">{c.role}</span>
                      <span className="block truncate text-[14.5px] font-medium text-foreground">{c.name}</span>
                    </span>
                    <a href={`mailto:${c.email}`} aria-label={`${c.role}, ${c.email}`} className={cn('flex size-11 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent', focusRing)}>
                      <Icon name="mail" size={17} />
                    </a>
                  </li>
                ))}
              </ul>
            </>
          )}
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* releases                                                            */
/* ------------------------------------------------------------------ */

function Releases({ releases = [], t }: MusicianEpkProps & { t: Texts }) {
  const locale = useLocale();
  const [sel, setSel] = useState(releases[0]?.id ?? '');
  const [playing, setPlaying] = useState<number | null>(null);
  const [pos, setPos] = useState(0);
  const r = releases.find((x) => x.id === sel) ?? releases[0];
  const [row, fade] = useScrollFade<HTMLDivElement>();
  useEffect(() => {
    if (playing === null) return;
    if (prefersReducedMotion()) return;
    const id = window.setInterval(() => setPos((p) => p + 0.25), 250);
    return () => window.clearInterval(id);
  }, [playing]);
  // A preview is 30 seconds long
  useEffect(() => {
    if (pos < 30) return;
    setPlaying(null);
    setPos(0);
  }, [pos]);
  const total = (r?.tracks ?? []).reduce((a, x) => a + secs(x.length), 0);
  return (
    <div className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] lg:gap-14">
      <div>
        {r && (
          <div key={r.id} className="relative mx-auto aspect-square w-full max-w-[460px] [animation:kitepk-swap_.5s_var(--ease-kit)]">
            {/* a record slides out of the sleeve while a track plays */}
            <span aria-hidden className={cn('absolute inset-[4%] rounded-full bg-[repeating-radial-gradient(circle,#18181b_0_3px,#232327_3px_5px)] shadow-[var(--shadow-lg),inset_0_0_0_1px_rgb(255_255_255/0.14)] transition-transform duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', playing !== null ? 'translate-x-[32%]' : 'translate-x-0')}>
              <span className={cn('absolute inset-[38%] rounded-full bg-primary', playing !== null && 'kitepk-anim [animation:kitepk-spin_2.4s_linear_infinite]')}>
                <span className="absolute left-1/2 top-[18%] size-[12%] -translate-x-1/2 rounded-full bg-primary-foreground/70" />
              </span>
            </span>
            <div className={cn('relative size-full overflow-hidden rounded-md shadow-[0_30px_60px_-24px_rgb(0_0_0/0.55)]', fit)} data-clip-ok>
              {r.cover}
            </div>
          </div>
        )}
        <div ref={row} className={cn('kit-no-scrollbar -mx-6 mt-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
          <div role="radiogroup" aria-label={t.tracks} onKeyDown={arrowKeys} className="flex gap-3 sm:justify-center">
            {releases.map((x) => {
              const on = x.id === r?.id;
              return (
                <button key={x.id} type="button" role="radio" aria-checked={on} tabIndex={on ? 0 : -1} aria-label={`${x.title}, ${x.kind}`} onClick={() => { setSel(x.id); setPlaying(null); setPos(0); }} className={cn('size-16 shrink-0 overflow-hidden rounded-md border-2 transition-[border-color,transform,opacity] duration-300 sm:size-[72px]', focusRing, on ? 'border-primary' : 'border-transparent opacity-70 hover:-translate-y-0.5 hover:opacity-100', fit)}>
                  {x.cover}
                </button>
              );
            })}
          </div>
        </div>
      </div>
      {r && (
        <div key={r.id} className="[animation:kitepk-swap_.5s_var(--ease-kit)]">
          <p className="text-[13px] font-medium text-muted-foreground">
            {r.kind}
            {r.label && ` · ${r.label}`}
          </p>
          <h3 className="mt-1.5 text-[32px] font-semibold leading-tight tracking-[-0.02em] text-foreground">{r.title}</h3>
          <p className="mt-1 text-[14px] text-muted-foreground">
            {fill(t.released, { d: dfmt(r.date, locale, { day: 'numeric', month: 'long', year: 'numeric' }) })}
            {total > 0 && ` · ${fill(t.min, { n: Math.round(total / 60) })}`}
          </p>
          <ol className="mt-6 border-t border-border">
            {(r.tracks ?? []).map((tr, i) => {
              const on = playing === i;
              return (
                <li key={tr.title} className={cn('relative border-b border-border transition-colors', on && 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)]')}>
                  <button type="button" aria-pressed={on} aria-label={on ? fill(t.pause, { t: tr.title }) : fill(t.play, { t: tr.title })} onClick={() => { setPlaying(on ? null : i); setPos(0); }} className={cn('group grid min-h-[52px] w-full grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-3 px-2 text-left', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
                    <span className="flex size-8 items-center justify-center text-[13.5px] tabular-nums text-muted-foreground">
                      {on ? (
                        <span className="flex h-3.5 items-end gap-[2px]" aria-hidden>
                          {[0, 1, 2].map((b) => (
                            <span key={b} className="kitepk-anim h-full w-[3px] origin-bottom rounded-full bg-primary [animation:kitepk-bar_.9s_ease-in-out_infinite]" style={{ animationDelay: `${b * 0.15}s` }} />
                          ))}
                        </span>
                      ) : (
                        <>
                          <span className="group-hover:hidden">{i + 1}</span>
                          <Icon name="play" size={15} className="hidden text-foreground group-hover:block" />
                        </>
                      )}
                    </span>
                    <span className={cn('truncate text-[15px]', on ? 'font-semibold text-foreground' : 'text-foreground/90')}>{tr.title}</span>
                    <span className="text-[13.5px] tabular-nums text-muted-foreground">{tr.length}</span>
                  </button>
                  {on && <span aria-hidden className="absolute bottom-[-1px] left-0 h-0.5 bg-primary transition-[width] duration-300 ease-linear" style={{ width: `${round((pos / 30) * 100, 2)}%` }} />}
                </li>
              );
            })}
          </ol>
          <Button href={r.href ?? '#'} variant="outline" iconLeft="headphones" className="mt-6 max-sm:w-full">
            {t.listen}
          </Button>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* tour                                                                */
/* ------------------------------------------------------------------ */

function Tour({ shows = [], t }: MusicianEpkProps & { t: Texts }) {
  const locale = useLocale();
  const countries = [...new Set(shows.map((s) => s.country))];
  const [c, setC] = useState('all');
  const [remind, setRemind] = useState<string[]>([]);
  const shown = shows.filter((s) => c === 'all' || s.country === c).sort((a, b) => (a.date < b.date ? -1 : 1));
  const [row, fade] = useScrollFade<HTMLDivElement>();
  return (
    <div>
      <div ref={row} className={cn('kit-no-scrollbar -mx-6 overflow-x-auto px-6 sm:mx-0 sm:px-0', fade)}>
        <Segmented label={t.countries} value={c} onChange={setC} options={[{ id: 'all', label: t.all }, ...countries.map((x) => ({ id: x, label: x }))]} />
      </div>
      <ol key={c} className="mt-8 border-t border-border">
        {shown.map((s, i) => {
          const sold = s.state === 'sold-out';
          const on = remind.includes(s.id);
          return (
            <li key={s.id} className="group grid grid-cols-[64px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 border-b border-border py-4 [animation:kitepk-in_.45s_var(--ease-kit)_both] sm:grid-cols-[80px_minmax(0,1fr)_minmax(0,1fr)_15rem] sm:gap-x-6" style={{ animationDelay: `${i * 45}ms` }}>
              <span className="flex flex-col leading-none">
                <span className="text-[12px] font-semibold uppercase tracking-[0.06em] text-primary">{dfmt(s.date, locale, { month: 'short' }).replace('.', '')}</span>
                <span className="mt-1 text-[28px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{dfmt(s.date, locale, { day: 'numeric' }).replace('.', '')}</span>
              </span>
              <span className="min-w-0">
                <span className={cn('block text-[17px] font-semibold text-foreground transition-transform duration-300 group-hover:translate-x-1', sold && 'text-muted-foreground line-through decoration-1')}>{s.city}</span>
                <span className="block text-[13.5px] text-muted-foreground">{dfmt(s.date, locale, { weekday: 'long' })}</span>
              </span>
              <span className="col-start-2 text-[14.5px] text-muted-foreground sm:col-start-auto">{s.venue}</span>
              <span className="col-start-2 flex items-center gap-2 sm:col-start-auto sm:justify-end">
                {sold ? (
                  <>
                  <span className="text-[12.5px] font-medium text-muted-foreground">{t.soldOut}</span>
                  <button type="button" aria-pressed={on} onClick={() => setRemind((r) => (on ? r.filter((x) => x !== s.id) : [...r, s.id]))} className={cn('inline-flex min-h-10 items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors max-sm:min-h-11', focusRing, on ? 'border-transparent bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                    <span key={String(on)} className={cn('inline-flex', on && '[animation:kitepk-pop_.4s_var(--ease-kit)]')}>
                      <Icon name={on ? 'check' : 'bell'} size={14} />
                    </span>
                    {on ? t.reminded : t.remind}
                  </button>
                  </>
                ) : (
                  <>
                    {s.state === 'few' && <span className="text-[12.5px] font-medium text-warning-foreground">{t.few}</span>}
                    <Button href={s.href ?? '#'} size="sm" variant={s.state === 'few' ? 'primary' : 'outline'} icon="arrow-up-right">
                      {t.tickets}
                    </Button>
                  </>
                )}
              </span>
            </li>
          );
        })}
      </ol>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* press                                                               */
/* ------------------------------------------------------------------ */

function Press({ quotes = [], downloads = [], t }: MusicianEpkProps & { t: Texts }) {
  const locale = useLocale();
  const [lead, ...rest] = quotes;
  return (
    <div className="grid gap-10">
      {lead && (
        <figure className="mx-auto max-w-[52rem] text-center">
          <blockquote className="kit-rise text-balance text-[26px] font-medium leading-[1.3] tracking-[-0.015em] text-foreground sm:text-[36px]">{quoted(lead.quote, locale)}</blockquote>
          <figcaption className="kit-rise mt-5 flex items-center justify-center gap-3 text-[14px] text-muted-foreground [--kit-delay:120ms]">
            {lead.logo ? <span className="flex h-6 items-center text-foreground [&_svg]:h-5">{lead.logo}</span> : lead.outlet}
            {lead.logo && <span className="sr-only">{lead.outlet}</span>}
          </figcaption>
        </figure>
      )}
      {rest.length > 0 && (
        <Stagger as="ul" step={80} className="grid grid-cols-1 gap-4 md:grid-cols-3">
          {rest.map((q) => (
            <li key={q.outlet} className="flex flex-col justify-between rounded-xl border border-border bg-card p-5 transition-[transform,border-color] duration-300 hover:-translate-y-0.5 hover:border-border-strong">
              <p className="text-pretty text-[15.5px] leading-relaxed text-foreground">{quoted(q.quote, locale)}</p>
              <p className="mt-4 flex h-6 items-center text-[13.5px] font-medium text-muted-foreground [&_svg]:h-[18px]">
                {q.logo ?? q.outlet}
                {q.logo && <span className="sr-only">{q.outlet}</span>}
              </p>
            </li>
          ))}
        </Stagger>
      )}
      {downloads.length > 0 && (
        <div>
          <h3 className="mb-3 text-[13px] font-medium text-muted-foreground">{t.press}</h3>
          <ul className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-border bg-border sm:grid-cols-2 lg:grid-cols-4">
            {downloads.map((d) => (
              <li key={d.label} className="bg-card">
                <a href={d.href} download className={cn('group flex h-full min-h-[72px] items-center gap-3 p-4 transition-colors hover:bg-accent', focusRing, 'focus-visible:ring-inset focus-visible:ring-offset-0')}>
                  <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
                    <Icon name={d.icon ?? 'download'} size={18} />
                  </span>
                  <span className="min-w-0 flex-1">
                    <span className="block text-[14.5px] font-medium text-foreground">{d.label}</span>
                    {d.detail && <span className="block text-[12.5px] text-muted-foreground">{d.detail}</span>}
                  </span>
                  <Icon name="download" size={16} className="text-muted-foreground transition-transform duration-300 group-hover:translate-y-0.5" />
                </a>
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* rider                                                               */
/* ------------------------------------------------------------------ */

const STAGE_ICON: Record<EpkStageItem['kind'], IconName> = { vocal: 'mic', guitar: 'music', bass: 'music', drums: 'circle', keys: 'keyboard', monitor: 'volume', amp: 'server' };

function Rider({ inputs = [], stage = [], needs = [], riderHref, t }: MusicianEpkProps & { t: Texts }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  const [hl, setHl] = useState<string | null>(null);
  return (
    <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-8">
      <div className="kit-card p-5 sm:p-6">
        <div className="flex items-center justify-between gap-3">
          <h3 className="text-[16px] font-semibold text-foreground">{t.plot}</h3>
          {riderHref && (
            <Button href={riderHref} size="sm" variant="outline" iconLeft="download">
              {t.rider}
            </Button>
          )}
        </div>
        <div ref={ref} className="relative mt-5 aspect-square overflow-hidden sm:aspect-[4/3] rounded-xl border border-border bg-[repeating-linear-gradient(90deg,color-mix(in_oklab,var(--foreground)_4%,transparent)_0_1px,transparent_1px_40px),repeating-linear-gradient(0deg,color-mix(in_oklab,var(--foreground)_4%,transparent)_0_1px,transparent_1px_40px)]" role="img" aria-label={`${t.plot}: ${stage.map((s) => s.label).join(', ')}`}>
          {stage.map((s, i) => {
            const on = hl === s.label;
            return (
              <span key={s.label} onPointerEnter={() => setHl(s.label)} onPointerLeave={() => setHl(null)} className={cn('absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on && 'z-[1]')} style={{ left: `${s.x}%`, top: `${s.y}%`, opacity: inView ? 1 : 0, transform: `translate(-50%, -50%) scale(${inView ? (on ? 1.12 : 1) : 0.6})`, transitionDelay: inView && !hl ? `${i * 70}ms` : '0ms' }}>
                <span className={cn('flex items-center justify-center rounded-full border-2 transition-colors', s.kind === 'drums' ? 'size-14' : s.kind === 'monitor' ? 'h-6 w-10 rounded-md' : 'size-10', on ? 'border-primary bg-primary text-primary-foreground' : s.kind === 'monitor' ? 'border-border-strong bg-muted text-muted-foreground' : 'border-primary bg-card text-primary')}>
                  <Icon name={STAGE_ICON[s.kind]} size={s.kind === 'monitor' ? 13 : 17} />
                </span>
                {s.kind !== 'monitor' && <span className="whitespace-nowrap rounded bg-background/85 px-1.5 text-[11.5px] font-medium text-foreground">{s.label}</span>}
              </span>
            );
          })}
          <span className="absolute inset-x-0 bottom-0 flex h-8 items-center justify-center border-t border-dashed border-border-strong bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] text-[11.5px] font-medium uppercase tracking-[0.12em] text-muted-foreground">{t.front}</span>
        </div>
        {needs.length > 0 && (
          <>
            <h3 className="mt-6 text-[16px] font-semibold text-foreground">{t.needs}</h3>
            <ul className="mt-3 grid gap-2 sm:grid-cols-2">
              {needs.map((n) => (
                <li key={n} className="flex gap-2.5 text-[14px] text-foreground">
                  <Icon name="check" size={16} className="mt-0.5 text-primary" />
                  {n}
                </li>
              ))}
            </ul>
          </>
        )}
      </div>
      {inputs.length > 0 && (
        <div className="kit-card overflow-hidden">
          <h3 className="px-5 pt-5 text-[16px] font-semibold text-foreground sm:px-6 sm:pt-6">{t.inputs}</h3>
          <div className="mt-4 overflow-x-auto kit-no-scrollbar">
            <table className="w-full min-w-[340px] border-collapse text-left text-[14px]">
              <thead>
                <tr className="border-y border-border bg-surface text-[12.5px] text-muted-foreground">
                  <th scope="col" className="py-2.5 pl-5 pr-3 font-medium sm:pl-6">
                    {t.ch}
                  </th>
                  <th scope="col" className="px-3 py-2.5 font-medium">
                    {t.source}
                  </th>
                  <th scope="col" className="py-2.5 pl-3 pr-5 font-medium sm:pr-6">
                    {t.mic}
                  </th>
                </tr>
              </thead>
              <tbody>
                {inputs.map((x) => {
                  const on = hl !== null && x.source.toLowerCase().includes(hl.toLowerCase().split(' ')[0]);
                  return (
                    <tr key={x.ch} className={cn('border-b border-border transition-colors last:border-0', on && 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)]')}>
                      <td className="py-2.5 pl-5 pr-3 tabular-nums text-muted-foreground sm:pl-6">{String(x.ch).padStart(2, '0')}</td>
                      <td className="px-3 py-2.5 text-foreground">
                        {x.source}
                        {x.note && <span className="block text-[12.5px] text-muted-foreground">{x.note}</span>}
                      </td>
                      <td className="py-2.5 pl-3 pr-5 text-foreground/85 sm:pr-6">{x.mic}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        </div>
      )}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* MusicianEpk                                                         */
/* ------------------------------------------------------------------ */

/**
 * An electronic press kit for musicians.
 *   overview  name, genre, numbers, press photos and contacts
 *   releases  covers, tracks with a playing preview
 *   tour      dates by country with ticket state
 *   press     quotes, outlets and downloads
 *   rider     stage plot, input list and needs
 */
export function MusicianEpk(props: MusicianEpkProps) {
  const { variant = 'overview', eyebrow, title, description, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const body = (() => {
    switch (variant) {
      case 'releases':
        return <Releases {...props} t={t} />;
      case 'tour':
        return <Tour {...props} t={t} />;
      case 'press':
        return <Press {...props} t={t} />;
      case 'rider':
        return <Rider {...props} t={t} />;
      default:
        return <Overview {...props} t={t} />;
    }
  })();
  return (
    <Section className={className} background={variant === 'press' ? 'surface' : 'default'}>
      <EpkMotion />
      {title && variant !== 'overview' ? <SectionHeader eyebrow={eyebrow} title={title} description={description} /> : null}
      {body}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Musiker-Pressekit?

Mit aiw add @ai-website/musician-epk. Der Befehl legt die Datei components/ui/musician-epk.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, motion, number-ticker und section. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Musiker-Pressekit?

5 Varianten, Überblick, Veröffentlichungen, Tourdaten, Presse und Technischer Rider. Du wählst sie mit der Prop variant, zum Beispiel variant="releases".

Kann ich die Texte von Musiker-Pressekit übersetzen?

Ja. Musiker-Pressekit bringt 25 eigene Texte auf Deutsch und Englisch mit, etwa „Pressefotos“, „Herunterladen“ und „Kontakt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Musiker-Pressekit einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Musiker-Pressekit mit aiw_components und installieren sie mit aiw_add @ai-website/musician-epk. Nenn im Prompt die Variante, etwa variant="overview", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Musiker-Pressekit ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden MusicianEpk-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert