"use client"; import { ArrowRightIcon, BugIcon, CheckIcon, type LucideIcon, MailIcon, NotebookPenIcon, RotateCcwIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export interface PromptTemplate { id: string; title: string; icon?: LucideIcon; template: string; } export interface FillInPromptsProps { templates?: PromptTemplate[]; onUse?: (prompt: string, values: Record) => void; className?: string; } const defaultTemplates: PromptTemplate[] = [ { id: "email", title: "Cold email", icon: MailIcon, template: "Write a {tone:warm|direct|playful} email to {name} at {company} about {topic}, under {count:80|120|200} words.", }, { id: "bug", title: "Bug report", icon: BugIcon, template: "Turn this into a bug report for {team:web|mobile|platform}: when I {action}, I expected {expected} but got {actual}.", }, { id: "recap", title: "Meeting recap", icon: NotebookPenIcon, template: "Summarise my notes from the {meeting} into {count:3|5|7} decisions and owners, in a {tone:crisp|friendly} tone.", }, ]; type Part = { kind: "text"; text: string } | { kind: "var"; name: string; options: string[]; slot: number }; function parse(template: string): Part[] { const parts: Part[] = []; const re = /\{([\w ]+?)(?::([^}]+))?\}/g; let last = 0; let slot = 0; for (const m of template.matchAll(re)) { const at = m.index ?? 0; if (at > last) parts.push({ kind: "text", text: template.slice(last, at) }); parts.push({ kind: "var", name: m[1] ?? "", options: m[2] ? m[2].split("|") : [], slot: slot++ }); last = at + m[0].length; } if (last < template.length) parts.push({ kind: "text", text: template.slice(last) }); return parts; } const tones = [ { chip: "bg-violet-500/15", dot: "bg-violet-500" }, { chip: "bg-sky-500/15", dot: "bg-sky-500" }, { chip: "bg-amber-500/20", dot: "bg-amber-500" }, { chip: "bg-emerald-500/15", dot: "bg-emerald-500" }, { chip: "bg-rose-500/15", dot: "bg-rose-500" }, ] as const; const toneOf = (slot: number) => tones[slot % tones.length] ?? tones[0]; const spring = { type: "spring", stiffness: 460, damping: 36 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[background-color,color,border-color,scale] duration-150 ease-out active:scale-[0.97]"; const blank = "rounded-md border px-1.5 font-medium leading-7"; type Piece = { kind: "text"; text: string } | { kind: "var"; part: Extract; trail: string }; function pieces(parts: Part[]): Piece[] { const out: Piece[] = []; for (const p of parts) { const prev = out.at(-1); if (p.kind === "text" && prev?.kind === "var") { const m = /^[.,;:!?]+/.exec(p.text); if (m) { prev.trail = m[0]; const rest = p.text.slice(m[0].length); if (rest) out.push({ kind: "text", text: rest }); continue; } } out.push(p.kind === "text" ? p : { kind: "var", part: p, trail: "" }); } return out; } export function FillInPrompts({ templates = defaultTemplates, onUse, className }: FillInPromptsProps) { const reduce = useReducedMotion(); const uid = useId(); const [activeId, setActiveId] = useState(templates[0]?.id ?? ""); const [values, setValues] = useState>>({}); const [focused, setFocused] = useState(null); const [used, setUsed] = useState(false); const inputs = useRef<(HTMLInputElement | null)[]>([]); const usedTimer = useRef | undefined>(undefined); useEffect(() => () => clearTimeout(usedTimer.current), []); const active = templates.find((t) => t.id === activeId) ?? templates[0]; const parts = useMemo(() => parse(active?.template ?? ""), [active]); const vars = parts.filter((p): p is Extract => p.kind === "var"); const current = values[activeId] ?? {}; const filled = vars.filter((v) => current[v.name]?.trim()).length; const ready = vars.length > 0 && filled === vars.length; const focusedVar = vars.find((v) => v.name === focused); inputs.current.length = vars.length; const set = (name: string, value: string) => setValues((all) => ({ ...all, [activeId]: { ...(all[activeId] ?? {}), [name]: value } })); const assembled = parts .map((p) => (p.kind === "text" ? p.text : current[p.name]?.trim() || `{${p.name}}`)) .join(""); const onKey = (e: KeyboardEvent, slot: number) => { if (e.key === "Enter") { e.preventDefault(); const next = inputs.current[slot + 1]; if (next) next.focus(); else if (ready) use(); } }; const use = () => { if (!ready) return; onUse?.(assembled, current); setUsed(true); clearTimeout(usedTimer.current); usedTimer.current = setTimeout(() => setUsed(false), 1600); }; return (
{templates.map((t) => { const on = t.id === activeId; const Icon = t.icon; return ( ); })}
{templates.map((t) => (

{pieces(parse(t.template)).map((p, i) => p.kind === "text" ? ( {p.text} ) : ( {p.part.name} {p.trail} ), )}

))}

{pieces(parts).map((piece, i) => piece.kind === "text" ? ( {piece.text} ) : ( { inputs.current[piece.part.slot] = el; }} name={piece.part.name} onBlur={() => setFocused((f) => (f === piece.part.name ? null : f))} onChange={(v) => set(piece.part.name, v)} onFocus={() => setFocused(piece.part.name)} onKeyDown={(e) => onKey(e, piece.part.slot)} slot={piece.part.slot} value={current[piece.part.name] ?? ""} /> {piece.trail} ), )}

{focusedVar && focusedVar.options.length > 0 ? ( Try {focusedVar.options.map((o) => ( ))} ) : ( {ready ? "All blanks filled. Ready to use." : "Type into the blanks. Enter jumps to the next one."} )}
{vars.map((v) => ( ))}
{filled}/{vars.length} {filled > 0 && ( setValues((all) => ({ ...all, [activeId]: {} }))} type="button" > Clear )}
); } function InlineVar({ name, value, slot, onChange, onFocus, onBlur, onKeyDown, inputRef, }: { name: string; value: string; slot: number; onChange: (v: string) => void; onFocus: () => void; onBlur: () => void; onKeyDown: (e: KeyboardEvent) => void; inputRef: (el: HTMLInputElement | null) => void; }) { const filled = value.trim().length > 0; const tone = toneOf(slot); return ( {value || name} onChange(e.target.value)} onFocus={onFocus} onKeyDown={onKeyDown} placeholder={name} ref={inputRef} size={1} spellCheck={false} value={value} /> ); }