Zum Inhalt springen
UI Kit
Accent

Prompt-Bibliothek

Eine Prompt-Bibliothek in vier Ansichten. Vorlagen nach Kategorie mit Suche und Favoriten, die Felder einer Vorlage ausfüllen mit Live-Vorschau, geteilte Prompts mit Besitzer, Fassung und Sichtbarkeit und ein Editor, der die Felder beim Tippen findet.

Eine von 3 Components in KI-Schreiben und Erstellen

4 Variants

Variants

Bibliothek

Karten nach Kategorie mit Suche, Favoritenfilter und der Zahl der Felder und Nutzungen. Beim Filtern steigen die Karten neu der Reihe nach auf.

Im Studio öffnen

Vorlage ausfüllen

Links die Felder, rechts der Prompt, in dem jeder Wert beim Ausfüllen aufleuchtet. Kopieren und Nutzen warten, bis alle Felder gesetzt sind.

Im Studio öffnen

Mit dem Team geteilt

Geteilte Prompts mit Fassung, Besitzer und Sichtbarkeit. Ein privater Prompt hat keinen Link zum Kopieren.

Im Studio öffnen

Prompt-Editor

Name, Kategorie und Prompt. Jedes getippte {{feld}} erscheint als Marke, die Vorschau zeigt es und kann Beispielwerte einsetzen. Speichern zählt die Fassung hoch.

Im Studio öffnen

Details

Was Prompt-Bibliothek mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/prompt-library.tsx
Bringt mit
utilskiticonrevealmotion
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 43 eigene Texte

Props

Die Schnittstelle PromptLibraryProps aus components/ui/prompt-library.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PromptLibraryVariant

    libraryfill-templateteam-sharededitor

  • titleoptional
    string
  • descriptionoptional
    string
  • templatesoptional
    PromptTemplate[]
  • templateoptional
    PromptTemplate

    fill-template and editor: the template to work on

  • categoriesoptional
    string[]

    editor: categories to choose from

  • onUseoptional
    (prompt: string) => void
  • newHrefoptional
    string

    library: where "New prompt" leads, e.g. the editor page

  • onNewoptional
    () => void

    library: called by "New prompt" when there is no link

Eingebaute Texte

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

Alle 43 Texte anzeigen
searchEnglisch Search promptsDeutsch Prompts durchsuchen
allEnglisch AllDeutsch Alle
favoritesEnglisch FavoritesDeutsch Favoriten
favoriteEnglisch Add {name} to favoritesDeutsch {name} zu Favoriten
unfavoriteEnglisch Remove {name} from favoritesDeutsch {name} aus Favoriten entfernen
usesEnglisch {n} usesDeutsch {n} Mal genutzt
fieldsEnglisch {n} fieldsDeutsch {n} Felder
fieldOneEnglisch 1 fieldDeutsch 1 Feld
useEnglisch UseDeutsch Nutzen
inChatEnglisch In the chatDeutsch Im Chat
noneEnglisch No prompt matches. Try another word or category.Deutsch Kein Prompt passt. Versuch ein anderes Wort oder eine andere Kategorie.
newPromptEnglisch New promptDeutsch Neuer Prompt
fillEnglisch Fill inDeutsch Ausfüllen
previewEnglisch PromptDeutsch Prompt
missingEnglisch {n} fields leftDeutsch Noch {n} Felder offen
missingOneEnglisch 1 field leftDeutsch Noch 1 Feld offen
readyEnglisch Ready to useDeutsch Bereit
copyEnglisch Copy promptDeutsch Prompt kopieren
copiedEnglisch CopiedDeutsch Kopiert
sendEnglisch Use in chatDeutsch Im Chat nutzen
sentEnglisch Sent to the chat.Deutsch An den Chat gesendet.
sharedEnglisch Shared promptsDeutsch Geteilte Prompts
visibilityEnglisch Who can see itDeutsch Wer es sieht
privateEnglisch Only meDeutsch Nur ich
teamEnglisch TeamDeutsch Team
publicEnglisch Anyone with the linkDeutsch Alle mit dem Link
authorEnglisch OwnerDeutsch Erstellt von
updatedEnglisch Updated {when}Deutsch Geändert {when}
versionEnglisch v{n}Deutsch v{n}
shareEnglisch ShareDeutsch Teilen
copyLinkEnglisch Copy linkDeutsch Link kopieren
linkCopiedEnglisch Link copiedDeutsch Link kopiert
nameEnglisch NameDeutsch Name
categoryEnglisch CategoryDeutsch Kategorie
descriptionFieldEnglisch Short descriptionDeutsch Kurze Beschreibung
bodyEnglisch PromptDeutsch Prompt
bodyHintEnglisch Write {{name}} where something changes each time. Fields appear on their own.Deutsch Schreib {{name}}, wo sich jedes Mal etwas ändert. Felder erscheinen von selbst.
detectedEnglisch FieldsDeutsch Felder
noFieldsEnglisch No fields yet. Type {{ to add one.Deutsch Noch keine Felder. Tippe {{, um eines anzulegen.
saveEnglisch Save promptDeutsch Prompt speichern
savedEnglisch Saved as version {n}.Deutsch Als Fassung {n} gespeichert.
testEnglisch Test with sample valuesDeutsch Mit Beispielwerten testen
charsEnglisch {n} charactersDeutsch {n} Zeichen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Prompt-Bibliothek reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, radio und radiogroup.
  • 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.
  • 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. 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, 677 Zeilen
components/ui/prompt-library.tsx
'use client';

import { Fragment, useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';

export type PromptLibraryVariant = 'library' | 'fill-template' | 'team-shared' | 'editor';

export interface PromptVariable {
  /** The name used in the template, {{name}} */
  name: string;
  label: string;
  placeholder?: string;
  /** Offer a choice instead of free text */
  options?: string[];
  value?: string;
}

export interface PromptTemplate {
  id: string;
  title: string;
  description: string;
  category: string;
  icon?: IconName;
  /** The prompt with {{variables}} */
  body?: string;
  variables?: PromptVariable[];
  /** How often the team used it */
  uses?: number;
  author?: string;
  favorite?: boolean;
  visibility?: 'private' | 'team' | 'public';
  /** e.g. "2 days ago" */
  updated?: string;
  version?: number;
}

export interface PromptLibraryProps {
  variant?: PromptLibraryVariant;
  title?: string;
  description?: string;
  templates?: PromptTemplate[];
  /** fill-template and editor: the template to work on */
  template?: PromptTemplate;
  /** editor: categories to choose from */
  categories?: string[];
  onUse?: (prompt: string) => void;
  /** library: where "New prompt" leads, e.g. the editor page */
  newHref?: string;
  /** library: called by "New prompt" when there is no link */
  onNew?: () => void;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    search: 'Search prompts',
    all: 'All',
    favorites: 'Favorites',
    favorite: 'Add {name} to favorites',
    unfavorite: 'Remove {name} from favorites',
    uses: '{n} uses',
    fields: '{n} fields',
    fieldOne: '1 field',
    use: 'Use',
    inChat: 'In the chat',
    none: 'No prompt matches. Try another word or category.',
    newPrompt: 'New prompt',
    fill: 'Fill in',
    preview: 'Prompt',
    missing: '{n} fields left',
    missingOne: '1 field left',
    ready: 'Ready to use',
    copy: 'Copy prompt',
    copied: 'Copied',
    send: 'Use in chat',
    sent: 'Sent to the chat.',
    
    shared: 'Shared prompts',
    visibility: 'Who can see it',
    private: 'Only me',
    team: 'Team',
    public: 'Anyone with the link',
    
    author: 'Owner',
    updated: 'Updated {when}',
    version: 'v{n}',
    share: 'Share',
    copyLink: 'Copy link',
    linkCopied: 'Link copied',
    
    name: 'Name',
    category: 'Category',
    descriptionField: 'Short description',
    body: 'Prompt',
    bodyHint: 'Write {{name}} where something changes each time. Fields appear on their own.',
    detected: 'Fields',
    noFields: 'No fields yet. Type {{ to add one.',
    save: 'Save prompt',
    saved: 'Saved as version {n}.',
    test: 'Test with sample values',
    
    chars: '{n} characters',
  },
  de: {
    search: 'Prompts durchsuchen',
    all: 'Alle',
    favorites: 'Favoriten',
    favorite: '{name} zu Favoriten',
    unfavorite: '{name} aus Favoriten entfernen',
    uses: '{n} Mal genutzt',
    fields: '{n} Felder',
    fieldOne: '1 Feld',
    use: 'Nutzen',
    inChat: 'Im Chat',
    none: 'Kein Prompt passt. Versuch ein anderes Wort oder eine andere Kategorie.',
    newPrompt: 'Neuer Prompt',
    fill: 'Ausfüllen',
    preview: 'Prompt',
    missing: 'Noch {n} Felder offen',
    missingOne: 'Noch 1 Feld offen',
    ready: 'Bereit',
    copy: 'Prompt kopieren',
    copied: 'Kopiert',
    send: 'Im Chat nutzen',
    sent: 'An den Chat gesendet.',
    
    shared: 'Geteilte Prompts',
    visibility: 'Wer es sieht',
    private: 'Nur ich',
    team: 'Team',
    public: 'Alle mit dem Link',
    
    author: 'Erstellt von',
    updated: 'Geändert {when}',
    version: 'v{n}',
    share: 'Teilen',
    copyLink: 'Link kopieren',
    linkCopied: 'Link kopiert',
    
    name: 'Name',
    category: 'Kategorie',
    descriptionField: 'Kurze Beschreibung',
    body: 'Prompt',
    bodyHint: 'Schreib {{name}}, wo sich jedes Mal etwas ändert. Felder erscheinen von selbst.',
    detected: 'Felder',
    noFields: 'Noch keine Felder. Tippe {{, um eines anzulegen.',
    save: 'Prompt speichern',
    saved: 'Als Fassung {n} gespeichert.',
    test: 'Mit Beispielwerten testen',
    
    chars: '{n} Zeichen',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = PromptLibraryProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
  const [state, setState] = useState(value);
  const key = JSON.stringify(value);
  const seen = useRef(key);
  if (seen.current !== key) {
    seen.current = key;
    setState(value);
  }
  return [state, setState] as const;
}

const CSS =
  '@keyframes plb-up{from{opacity:0;transform:translateY(8px)}}@keyframes plb-pop{0%{transform:scale(1)}45%{transform:scale(1.3)}100%{transform:scale(1)}}@keyframes plb-flash{from{background-color:color-mix(in oklab,var(--primary) 30%,transparent)}}' +
  '@media (prefers-reduced-motion:no-preference){.plb-up{animation:plb-up .45s var(--ease-kit) var(--plb-d,0ms) both}.plb-pop{animation:plb-pop .45s var(--ease-kit)}.plb-flash{animation:plb-flash 1.2s ease-out both}}';

function Style() {
  return (
    <style href="kit-prompt-library" precedence="default">
      {CSS}
    </style>
  );
}

/* ------------------------------------------------------------------ */
/* Shared parts                                                        */
/* ------------------------------------------------------------------ */

const VAR = /\{\{\s*([\w-]+)\s*\}\}/g;
const varsOf = (body: string) => Array.from(new Set(Array.from(body.matchAll(VAR), (m) => m[1])));

function Stage({ title, description, children, className, action }: { title?: string; description?: string; children: React.ReactNode; className?: string; action?: React.ReactNode }) {
  return (
    <section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
      <div className="kit-container">
        <div className="mx-auto w-full max-w-[68rem]">
          {(title || description || action) && (
            <Reveal className="mb-6 flex flex-col gap-3 sm:mb-8 sm:flex-row sm:items-end sm:justify-between">
              <div>
                {title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
                {description && <p className="mt-2 max-w-[40rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>}
              </div>
              {action}
            </Reveal>
          )}
          {children}
        </div>
      </div>
    </section>
  );
}

const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-[13.5px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9';
const quietBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9';
const field = 'w-full rounded-lg border border-border bg-background px-3 text-[14px] text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)] focus-visible:outline-none';

function Tile({ icon, className }: { icon?: IconName; className?: string }) {
  return (
    <span aria-hidden className={cn('grid size-10 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]', className)}>
      <Icon name={icon ?? 'sparkle'} size={19} />
    </span>
  );
}

function Initials({ name }: { name: string }) {
  const hue = (name.charCodeAt(0) * 37 + name.length * 11) % 5;
  const tone = ['--primary', '--success', '--warning', '--info', '--destructive'][hue];
  return (
    <span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-full text-[11px] font-semibold" style={{ background: `color-mix(in oklab, var(${tone}) 18%, var(--card))`, color: `color-mix(in oklab, var(${tone}) 70%, var(--foreground))` }}>
      {name
        .split(' ')
        .map((p) => p[0])
        .slice(0, 2)
        .join('')}
    </span>
  );
}

/** The prompt body with each {{field}} as a chip, filled ones show their value */
function Body({ body, values, className }: { body: string; values?: Record<string, string>; className?: string }) {
  const parts = body.split(/(\{\{\s*[\w-]+\s*\}\})/g);
  return (
    <p className={cn('whitespace-pre-wrap text-pretty text-[14.5px] leading-[1.8] text-foreground', className)}>
      {parts.map((p, i) => {
        const m = /^\{\{\s*([\w-]+)\s*\}\}$/.exec(p);
        if (!m) return <Fragment key={i}>{p}</Fragment>;
        const v = values?.[m[1]];
        return v ? (
          <mark key={`${i}-${v}`} className="plb-flash rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-0.5 text-foreground [box-decoration-break:clone]">
            {v}
          </mark>
        ) : (
          <span key={i} className="rounded-[4px] border border-dashed border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] px-0.5 text-[13px] text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]">
            {m[1]}
          </span>
        );
      })}
    </p>
  );
}

/* ------------------------------------------------------------------ */
/* library                                                             */
/* ------------------------------------------------------------------ */

function Library({ t, title, description, templates = [], onUse, newHref, onNew, className }: P) {
  const locale = useLocale();
  const [list, setList] = useSynced(templates);
  const [cat, setCat] = useState('all');
  const [q, setQ] = useState('');
  const [pop, setPop] = useState<string | null>(null);
  const [used, setUsed] = useState<string | null>(null);
  const [row, fade] = useScrollFade<HTMLDivElement>();
  const cats = Array.from(new Set(list.map((x) => x.category)));
  const shown = list.filter((x) => (cat === 'all' || (cat === 'fav' ? x.favorite : x.category === cat)) && (!q || `${x.title} ${x.description}`.toLowerCase().includes(q.toLowerCase())));
  const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
  const chip = (id: string, label: string, icon?: IconName) => (
    <button key={id} type="button" aria-pressed={cat === id} onClick={() => setCat(id)} className={cn('inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors focus-visible:-outline-offset-2 sm:h-9', cat === id ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
      {icon && <Icon name={icon} size={14} />}
      {label}
    </button>
  );
  return (
    <Stage
      title={title}
      description={description}
      className={className}
      action={
        newHref ? (
          <a href={newHref} className={cn(primaryBtn, 'self-start sm:self-auto')}>
            <Icon name="plus" size={15} />
            {t.newPrompt}
          </a>
        ) : onNew ? (
          <button type="button" onClick={onNew} className={cn(primaryBtn, 'self-start sm:self-auto')}>
            <Icon name="plus" size={15} />
            {t.newPrompt}
          </button>
        ) : undefined
      }
    >
      <Reveal delay={60}>
        <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
          <label className="flex h-11 items-center gap-2 rounded-xl border border-border bg-card px-3 shadow-[var(--shadow-sm)] focus-within:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus-within:shadow-[var(--shadow-sm),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] lg:w-72">
            <Icon name="search" size={16} className="shrink-0 text-muted-foreground" />
            <span className="sr-only">{t.search}</span>
            <input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className="w-0 min-w-0 flex-1 bg-transparent text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
          </label>
          <div ref={row} role="group" aria-label={t.all} className={cn('kit-no-scrollbar -mx-6 flex gap-1 overflow-x-auto px-6 lg:mx-0 lg:px-0', fade)}>
            {chip('all', t.all)}
            {chip('fav', t.favorites, 'star')}
            {cats.map((c) => chip(c, c))}
          </div>
        </div>
      </Reveal>
      {shown.length === 0 ? (
        <p className="plb-up mt-4 rounded-2xl border border-dashed border-border-strong px-6 py-12 text-center text-[13.5px] text-muted-foreground">{t.none}</p>
      ) : (
        <Stagger key={`${cat}-${q}`} as="ul" step={60} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
          {shown.map((x) => {
            const n = x.variables?.length ?? varsOf(x.body ?? '').length;
            return (
              <li key={x.id} className={cn(card, 'group relative flex flex-col p-4 transition-[border-color,transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0')}>
                <div className="flex items-start justify-between gap-3">
                  <Tile icon={x.icon} />
                  <button
                    type="button"
                    aria-pressed={!!x.favorite}
                    aria-label={fill(x.favorite ? t.unfavorite : t.favorite, { name: x.title })}
                    onClick={() => (setList((s) => s.map((y) => (y.id === x.id ? { ...y, favorite: !y.favorite } : y))), setPop(x.id))}
                    className="-m-1.5 grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent sm:size-9"
                  >
                    <Icon key={pop === x.id ? `${x.id}-${x.favorite}` : x.id} name="star" size={17} className={cn(x.favorite && 'fill-[var(--warning)] text-warning-foreground', pop === x.id && 'plb-pop')} />
                  </button>
                </div>
                <p className="mt-3 text-[11.5px] font-medium text-subtle-foreground">{x.category}</p>
                <h3 className="mt-0.5 text-[15.5px] font-semibold leading-snug text-foreground">{x.title}</h3>
                <p className="mt-1 line-clamp-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.description}</p>
                <div className="mt-auto flex items-center justify-between gap-3 pt-4">
                  <span className="flex items-center gap-3 text-[12px] tabular-nums text-muted-foreground">
                    {n > 0 && (
                      <span className="inline-flex items-center gap-1">
                        <Icon name="edit" size={12} />
                        {n === 1 ? t.fieldOne : fill(t.fields, { n })}
                      </span>
                    )}
                    {x.uses !== undefined && <span>{fill(t.uses, { n: nf(x.uses) })}</span>}
                  </span>
                  <button type="button" onClick={() => (onUse?.(x.body ?? x.title), setUsed(x.id))} aria-live="polite" className={cn('inline-flex h-11 items-center gap-1 rounded-lg px-2.5 text-[13px] font-medium transition-colors hover:bg-accent sm:h-8', used === x.id ? 'text-success-foreground' : 'text-foreground')}>
                    {used === x.id ? t.inChat : t.use}
                    <Icon name={used === x.id ? 'check' : 'arrow-right'} size={14} className={used === x.id ? 'plb-up' : 'transition-transform duration-300 group-hover:translate-x-0.5'} />
                  </button>
                </div>
              </li>
            );
          })}
        </Stagger>
      )}
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* fill-template                                                       */
/* ------------------------------------------------------------------ */

function FillTemplate({ t, title, description, template, onUse, className }: P) {
  const vars = template?.variables ?? [];
  const [values, setValues] = useState<Record<string, string>>(() => Object.fromEntries(vars.map((v) => [v.name, v.value ?? ''])));
  const [done, setDone] = useState<'copy' | 'send' | null>(null);
  const id = useId();
  const open = vars.filter((v) => !values[v.name]?.trim()).length;
  const prompt = (template?.body ?? '').replace(VAR, (_, k) => values[k] || `{{${k}}}`);
  const set = (k: string, v: string) => (setValues((s) => ({ ...s, [k]: v })), setDone(null));

  return (
    <Stage title={title} description={description} className={className}>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start lg:gap-6">
        <Reveal delay={60}>
          <form className={cn(card, 'p-4 sm:p-5')} onSubmit={(e) => e.preventDefault()}>
            <div className="flex items-center gap-3">
              <Tile icon={template?.icon} />
              <div className="min-w-0">
                <p className="truncate text-[15px] font-semibold text-foreground">{template?.title}</p>
                <p className="text-[12.5px] text-muted-foreground">{template?.category}</p>
              </div>
            </div>
            <p className="mb-3 mt-5 text-[12.5px] font-medium text-subtle-foreground">{t.fill}</p>
            <div className="space-y-3.5">
              {vars.map((v) => (
                <div key={v.name}>
                  <label htmlFor={`${id}-${v.name}`} className="mb-1.5 flex items-center justify-between text-[13px] font-medium text-foreground">
                    {v.label}
                    {values[v.name]?.trim() && <Icon name="check" size={14} className="plb-up text-success-foreground" />}
                  </label>
                  {v.options ? (
                    <div id={`${id}-${v.name}`} role="radiogroup" aria-label={v.label} className="flex flex-wrap gap-1.5">
                      {v.options.map((o, oi, all) => (
                        <button
                          key={o}
                          type="button"
                          role="radio"
                          aria-checked={values[v.name] === o}
                          tabIndex={values[v.name] === o || (!all.includes(values[v.name] ?? '') && oi === 0) ? 0 : -1}
                          onClick={() => set(v.name, o)}
                          onKeyDown={(e) => {
                            const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
                            if (!d) return;
                            e.preventDefault();
                            const n = (oi + d + all.length) % all.length;
                            set(v.name, all[n]);
                            (e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
                          }}
                          className={cn('inline-flex min-h-11 items-center rounded-lg border px-3 text-[13px] font-medium transition-colors sm:min-h-9', values[v.name] === o ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
                          {o}
                        </button>
                      ))}
                    </div>
                  ) : (
                    <input id={`${id}-${v.name}`} value={values[v.name] ?? ''} onChange={(e) => set(v.name, e.target.value)} placeholder={v.placeholder} className={cn(field, 'h-11 sm:h-10')} />
                  )}
                </div>
              ))}
            </div>
          </form>
        </Reveal>
        <Reveal delay={160} className="lg:sticky lg:top-6">
          <div className={card}>
            <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
              <p className="text-[13px] font-medium text-foreground">{t.preview}</p>
              <span className={cn('inline-flex items-center gap-1.5 text-[12.5px] font-medium', open ? 'text-muted-foreground' : 'text-success-foreground')} aria-live="polite">
                {open ? (open === 1 ? t.missingOne : fill(t.missing, { n: open })) : (
                  <>
                    <Icon name="check-circle" size={14} />
                    {t.ready}
                  </>
                )}
              </span>
            </div>
            <div className="p-4 sm:p-5">
              <Body body={template?.body ?? ''} values={values} />
            </div>
            <div className="flex flex-wrap items-center justify-end gap-2 border-t border-border bg-surface/60 px-4 py-3 sm:px-5">
              {done === 'send' && <span className="plb-up mr-auto text-[13px] text-success-foreground">{t.sent}</span>}
              <button
                type="button"
                disabled={open > 0}
                onClick={() => {
                  navigator.clipboard?.writeText(prompt).catch(() => {});
                  setDone('copy');
                }}
                className={cn(quietBtn, 'disabled:opacity-50')}
              >
                <Icon name={done === 'copy' ? 'check' : 'copy'} size={15} className={done === 'copy' ? 'plb-up text-success-foreground' : undefined} />
                {done === 'copy' ? t.copied : t.copy}
              </button>
              <button type="button" disabled={open > 0} onClick={() => (onUse?.(prompt), setDone('send'))} className={primaryBtn}>
                <Icon name="send" size={15} />
                {t.send}
              </button>
            </div>
          </div>
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* team-shared                                                         */
/* ------------------------------------------------------------------ */

const VIS: Record<NonNullable<PromptTemplate['visibility']>, IconName> = { private: 'lock', team: 'users', public: 'globe' };

function TeamShared({ t, title, description, templates = [], className }: P) {
  const [list, setList] = useSynced(templates);
  const [copied, setCopied] = useState<string | null>(null);
  return (
    <Stage title={title} description={description} className={className}>
      <Reveal delay={60}>
        <div className={card}>
          <div className="hidden grid-cols-[minmax(0,1fr)_9rem_11rem_8.5rem] gap-4 border-b border-border px-5 py-2.5 text-[12px] font-medium text-subtle-foreground lg:grid">
            <span>{t.shared}</span>
            <span>{t.author}</span>
            <span>{t.visibility}</span>
            <span className="text-right">{t.share}</span>
          </div>
          <Stagger as="ul" step={60} className="divide-y divide-border">
            {list.map((x) => (
              <li key={x.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-3 px-4 py-4 sm:px-5 lg:grid-cols-[minmax(0,1fr)_9rem_11rem_8.5rem] lg:items-center lg:gap-4">
                <div className="col-span-2 flex min-w-0 items-start gap-3 lg:col-span-1">
                  <Tile icon={x.icon} className="size-9" />
                  <div className="min-w-0">
                    <p className="flex items-center gap-2 text-[14.5px] font-medium text-foreground">
                      <span className="truncate">{x.title}</span>
                      {x.version && <span className="shrink-0 rounded-md bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 py-px text-[11px] tabular-nums text-muted-foreground">{fill(t.version, { n: x.version })}</span>}
                    </p>
                    <p className="truncate text-[12.5px] text-muted-foreground">{x.updated && fill(t.updated, { when: x.updated })}</p>
                  </div>
                </div>
                <span className="col-span-2 flex min-w-0 items-center gap-2 text-[13px] text-muted-foreground lg:col-span-1">
                  {x.author && (
                    <>
                      <Initials name={x.author} />
                      <span className="truncate">{x.author}</span>
                    </>
                  )}
                </span>
                <label className="relative col-span-1 block lg:col-span-1">
                  <span className="sr-only">{`${t.visibility}, ${x.title}`}</span>
                  <Icon name={VIS[x.visibility ?? 'team']} size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
                  <select value={x.visibility ?? 'team'} onChange={(e) => setList((s) => s.map((y) => (y.id === x.id ? { ...y, visibility: e.target.value as PromptTemplate['visibility'] } : y)))} className="h-11 w-full appearance-none rounded-lg border border-border bg-background pl-9 pr-8 text-[13.5px] text-foreground hover:border-border-strong sm:h-9">
                    {(['private', 'team', 'public'] as const).map((v) => (
                      <option key={v} value={v}>
                        {t[v]}
                      </option>
                    ))}
                  </select>
                  <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
                </label>
                <div className="flex justify-end">
                  <button
                    type="button"
                    disabled={x.visibility === 'private'}
                    onClick={() => {
                      navigator.clipboard?.writeText(`https://prompts.example/p/${x.id}`).catch(() => {});
                      setCopied(x.id);
                      window.setTimeout(() => setCopied((c) => (c === x.id ? null : c)), 1800);
                    }}
                    className={cn(quietBtn, 'w-full px-3 disabled:opacity-40 lg:w-auto')}
                  >
                    <Icon name={copied === x.id ? 'check' : 'link'} size={14} className={copied === x.id ? 'plb-up text-success-foreground' : undefined} />
                    {copied === x.id ? t.linkCopied : t.copyLink}
                  </button>
                </div>
              </li>
            ))}
          </Stagger>
        </div>
      </Reveal>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */
/* editor                                                              */
/* ------------------------------------------------------------------ */

function Editor({ t, title, description, template, categories = [], className }: P) {
  const [name, setName] = useState(template?.title ?? '');
  const [desc, setDesc] = useState(template?.description ?? '');
  const [cat, setCat] = useState(template?.category ?? categories[0] ?? '');
  const [body, setBody] = useState(template?.body ?? '');
  const [version, setVersion] = useState(template?.version ?? 1);
  const [saved, setSaved] = useState(false);
  const [test, setTest] = useState(false);
  const id = useId();
  const names = varsOf(body);
  const samples = Object.fromEntries((template?.variables ?? []).map((v) => [v.name, v.value ?? v.placeholder ?? '']));

  return (
    <Stage title={title} description={description} className={className}>
      <div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-start lg:gap-6">
        <Reveal delay={60}>
          <form
            className={cn(card, 'space-y-4 p-4 sm:p-5')}
            onSubmit={(e) => {
              e.preventDefault();
              setVersion((v) => v + 1);
              setSaved(true);
            }}
          >
            <div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
              <label className="block">
                <span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.name}</span>
                <input value={name} onChange={(e) => (setName(e.target.value), setSaved(false))} className={cn(field, 'h-11 sm:h-10')} />
              </label>
              <label className="relative block">
                <span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.category}</span>
                <select value={cat} onChange={(e) => (setCat(e.target.value), setSaved(false))} className={cn(field, 'h-11 appearance-none pr-8 sm:h-10')}>
                  {categories.map((c) => (
                    <option key={c}>{c}</option>
                  ))}
                </select>
                <Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute bottom-3.5 right-2.5 text-muted-foreground sm:bottom-3" />
              </label>
            </div>
            <label className="block">
              <span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.descriptionField}</span>
              <input value={desc} onChange={(e) => (setDesc(e.target.value), setSaved(false))} className={cn(field, 'h-11 sm:h-10')} />
            </label>
            <div>
              <label htmlFor={`${id}-body`} className="mb-1 block text-[13px] font-medium text-foreground">
                {t.body}
              </label>
              <p className="mb-2 text-[12.5px] text-muted-foreground">{t.bodyHint}</p>
              <textarea id={`${id}-body`} rows={7} value={body} onChange={(e) => (setBody(e.target.value), setSaved(false))} className={cn(field, 'resize-y py-3 leading-relaxed')} />
              <p className="mt-1 text-right text-[12px] tabular-nums text-subtle-foreground">{fill(t.chars, { n: body.length })}</p>
            </div>
            <div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
              <span aria-live="polite">{saved && <span className="plb-up inline-flex items-center gap-1.5 text-[13px] text-success-foreground"><Icon name="check-circle" size={15} />{fill(t.saved, { n: version })}</span>}</span>
              <button type="submit" disabled={!name.trim() || !body.trim()} className={primaryBtn}>
                {t.save}
              </button>
            </div>
          </form>
        </Reveal>
        <Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
          <div className={cn(card, 'p-4 sm:p-5')}>
            <p className="text-[13px] font-medium text-foreground">
              {t.detected} <span className="tabular-nums text-muted-foreground">{names.length}</span>
            </p>
            {names.length ? (
              <ul className="mt-3 flex flex-wrap gap-1.5">
                {names.map((n) => (
                  <li key={n} className="plb-up inline-flex h-8 items-center gap-1.5 rounded-lg border border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] px-2.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
                    <Icon name="edit" size={12} />
                    {n}
                  </li>
                ))}
              </ul>
            ) : (
              <p className="mt-2 text-[13px] text-muted-foreground">{t.noFields}</p>
            )}
          </div>
          <div className={card}>
            <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5 sm:px-5">
              <p className="text-[13px] font-medium text-foreground">{t.preview}</p>
              <label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12.5px] text-muted-foreground sm:min-h-8">
                <input type="checkbox" checked={test} onChange={(e) => setTest(e.target.checked)} className="size-4 accent-[var(--primary)]" />
                {t.test}
              </label>
            </div>
            <div className="p-4 sm:p-5">
              <Body body={body} values={test ? samples : undefined} className="text-[14px]" />
            </div>
          </div>
        </Reveal>
      </div>
    </Stage>
  );
}

/* ------------------------------------------------------------------ */

/**
 * A prompt library: templates by category with search and favorites,
 * filling a template's fields with a live preview, shared prompts with
 * owner, version and visibility, and an editor that finds the fields as
 * you type them.
 */
export function PromptLibrary({ variant = 'library', labels, ...props }: PromptLibraryProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'fill-template':
        // A new template from the Studio starts the form over
        return <FillTemplate key={JSON.stringify(props.template)} {...p} />;
      case 'team-shared':
        return <TeamShared {...p} />;
      case 'editor':
        return <Editor key={JSON.stringify(props.template)} {...p} />;
      default:
        return <Library {...p} />;
    }
  })();
  return (
    <>
      <Style />
      {body}
    </>
  );
}

Häufige Fragen

Wie installiere ich Prompt-Bibliothek?

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

Welche Varianten hat Prompt-Bibliothek?

4 Varianten, Bibliothek, Vorlage ausfüllen, Mit dem Team geteilt und Prompt-Editor. Du wählst sie mit der Prop variant, zum Beispiel variant="fill-template".

Kann ich die Texte von Prompt-Bibliothek übersetzen?

Ja. Prompt-Bibliothek bringt 43 eigene Texte auf Deutsch und Englisch mit, etwa „Prompts durchsuchen“, „Alle“ und „Favoriten“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Prompt-Bibliothek einbauen?

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

Kann ich Prompt-Bibliothek ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert