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
Variants
Details
Was Prompt-Bibliothek mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- KI-Schreiben und Erstellen
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PromptLibraryProps aus components/ui/prompt-library.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalPromptLibraryVariantlibraryfill-templateteam-sharededitor
titleoptionalstringdescriptionoptionalstringtemplatesoptionalPromptTemplate[]templateoptionalPromptTemplatefill-template and editor: the template to work on
categoriesoptionalstring[]editor: categories to choose from
onUseoptional(prompt: string) => voidnewHrefoptionalstringlibrary: where "New prompt" leads, e.g. the editor page
onNewoptional() => voidlibrary: 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| search | Englisch Search prompts | Deutsch Prompts durchsuchen |
| all | Englisch All | Deutsch Alle |
| favorites | Englisch Favorites | Deutsch Favoriten |
| favorite | Englisch Add {name} to favorites | Deutsch {name} zu Favoriten |
| unfavorite | Englisch Remove {name} from favorites | Deutsch {name} aus Favoriten entfernen |
| uses | Englisch {n} uses | Deutsch {n} Mal genutzt |
| fields | Englisch {n} fields | Deutsch {n} Felder |
| fieldOne | Englisch 1 field | Deutsch 1 Feld |
| use | Englisch Use | Deutsch Nutzen |
| inChat | Englisch In the chat | Deutsch Im Chat |
| none | Englisch No prompt matches. Try another word or category. | Deutsch Kein Prompt passt. Versuch ein anderes Wort oder eine andere Kategorie. |
| newPrompt | Englisch New prompt | Deutsch Neuer Prompt |
| fill | Englisch Fill in | Deutsch Ausfüllen |
| preview | Englisch Prompt | Deutsch Prompt |
| missing | Englisch {n} fields left | Deutsch Noch {n} Felder offen |
| missingOne | Englisch 1 field left | Deutsch Noch 1 Feld offen |
| ready | Englisch Ready to use | Deutsch Bereit |
| copy | Englisch Copy prompt | Deutsch Prompt kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| send | Englisch Use in chat | Deutsch Im Chat nutzen |
| sent | Englisch Sent to the chat. | Deutsch An den Chat gesendet. |
| shared | Englisch Shared prompts | Deutsch Geteilte Prompts |
| visibility | Englisch Who can see it | Deutsch Wer es sieht |
| private | Englisch Only me | Deutsch Nur ich |
| team | Englisch Team | Deutsch Team |
| public | Englisch Anyone with the link | Deutsch Alle mit dem Link |
| author | Englisch Owner | Deutsch Erstellt von |
| updated | Englisch Updated {when} | Deutsch Geändert {when} |
| version | Englisch v{n} | Deutsch v{n} |
| share | Englisch Share | Deutsch Teilen |
| copyLink | Englisch Copy link | Deutsch Link kopieren |
| linkCopied | Englisch Link copied | Deutsch Link kopiert |
| name | Englisch Name | Deutsch Name |
| category | Englisch Category | Deutsch Kategorie |
| descriptionField | Englisch Short description | Deutsch Kurze Beschreibung |
| body | Englisch Prompt | Deutsch Prompt |
| bodyHint | Englisch 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. |
| detected | Englisch Fields | Deutsch Felder |
| noFields | Englisch No fields yet. Type {{ to add one. | Deutsch Noch keine Felder. Tippe {{, um eines anzulegen. |
| save | Englisch Save prompt | Deutsch Prompt speichern |
| saved | Englisch Saved as version {n}. | Deutsch Als Fassung {n} gespeichert. |
| test | Englisch Test with sample values | Deutsch Mit Beispielwerten testen |
| chars | Englisch {n} characters | Deutsch {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
'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.