TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { useRouter, useSearchParams } from "next/navigation";4import { Folder, Plus, Search, Star, WandSparkles, X } from "lucide-react";5import { api, useApi } from "@/lib/client/api";6import { errorMessage } from "@/lib/client/humanize";7import { useDebounced } from "@/lib/client/hooks";8import { Button } from "@/components/ui/button";9import { Input } from "@/components/ui/input";10import { Segmented, ChipRow } from "@/components/ui/segmented";11import { EmptyState, Skeleton } from "@/components/ui/misc";12import { toast } from "@/components/ui/toast";13import { ConfirmDialog } from "@/components/common/confirm-dialog";14import type { PublicPrompt, PromptKind } from "@/lib/client/types";15import { variablesToAsk } from "@/lib/prompts/variables";16import { WorkspacePageHeader, WorkspacePageBody } from "@/components/projects/page-header";17import { PromptCard } from "./prompt-card";18import { PromptEditorSheet } from "./prompt-editor-sheet";19import { PromptUseSheet, insertPromptIntoChat } from "./prompt-use-sheet";20import { LegacyPresets } from "./legacy-presets";21import { PROMPT_KIND_META, PROMPT_KIND_ORDER } from "./prompt-kind";22import { cn } from "@/lib/utils";2324interface ListResponse {25 prompts: PublicPrompt[];26 folders: string[];27 tags: string[];28 total: number;29}3031type KindFilter = PromptKind | "all";3233export function PromptLibrary() {34 const router = useRouter();35 const params = useSearchParams();36 const [q, setQ] = React.useState("");37 const dq = useDebounced(q, 150);38 const [kind, setKind] = React.useState<KindFilter>("all");39 const [folder, setFolder] = React.useState<string | null>(null);40 const [tag, setTag] = React.useState<string | null>(null);41 const [favOnly, setFavOnly] = React.useState(false);42 const [editor, setEditor] = React.useState<{ open: boolean; prompt: PublicPrompt | null; initial?: { kind?: PromptKind; content?: string; name?: string } }>({ open: false, prompt: null });43 const [using, setUsing] = React.useState<PublicPrompt | null>(null);44 const [deleting, setDeleting] = React.useState<PublicPrompt | null>(null);4546 const key = React.useMemo(() => {47 const p = new URLSearchParams();48 if (dq.trim()) p.set("q", dq.trim());49 if (kind !== "all") p.set("kind", kind);50 if (folder) p.set("folder", folder);51 if (tag) p.set("tag", tag);52 if (favOnly) p.set("favorite", "1");53 const s = p.toString();54 return `/api/prompts${s ? `?${s}` : ""}`;55 }, [dq, kind, folder, tag, favOnly]);56 const { data, isLoading, error, mutate } = useApi<ListResponse>(key, { keepPreviousData: true });57 const prompts = React.useMemo(() => data?.prompts ?? [], [data]);58 const total = data?.total ?? 0;59 const filtered = kind !== "all" || Boolean(folder) || Boolean(tag) || favOnly || Boolean(dq.trim());6061 // `?new=1` / `?edit=new` open a blank editor; `?edit=<id>` edits; `?content=` prefills (e.g. "Save as prompt" from chat).62 const editParam = params.get("edit") ?? (params.get("new") === "1" ? "new" : null);63 const contentParam = params.get("content");64 const kindParam = params.get("kind");65 const handled = React.useRef(false);66 React.useEffect(() => {67 if (handled.current || (!editParam && !contentParam)) return;68 if (editParam && editParam !== "new" && !data) return; // wait for the list69 handled.current = true;70 const target = editParam && editParam !== "new" ? prompts.find((p) => p.id === editParam) ?? null : null;71 // eslint-disable-next-line react-hooks/set-state-in-effect72 setEditor({ open: true, prompt: target, initial: target ? undefined : { content: contentParam ?? undefined, kind: (PROMPT_KIND_ORDER as string[]).includes(kindParam ?? "") ? (kindParam as PromptKind) : undefined } });73 router.replace("/app/prompts");74 }, [editParam, contentParam, kindParam, data, prompts, router]);7576 const refresh = () => Promise.all([mutate()]);7778 const use = (p: PublicPrompt) => {79 if (variablesToAsk(p.variables).length) setUsing(p);80 else void insertPromptIntoChat(p, {}, router).catch((e) => toast.error("Could not use prompt", errorMessage(e)));81 };82 const toggleFavorite = async (p: PublicPrompt) => {83 try {84 await api(`/api/prompts/${p.id}`, { method: "PATCH", json: { favorite: !p.favorite } });85 await refresh();86 } catch (e) {87 toast.error("Could not update", errorMessage(e));88 }89 };90 const duplicate = async (p: PublicPrompt) => {91 try {92 await api("/api/prompts", { method: "POST", json: { kind: p.kind, name: `${p.name} (copy)`, description: p.description, content: p.content, variables: p.variables, schema: p.schema, folder: p.folder, tags: p.tags, defaultModelKey: p.defaultModelKey, projectId: p.projectId } });93 await refresh();94 toast.success("Prompt duplicated");95 } catch (e) {96 toast.error("Could not duplicate", errorMessage(e));97 }98 };99 const remove = async (p: PublicPrompt) => {100 try {101 await api(`/api/prompts/${p.id}`, { method: "DELETE" });102 await refresh();103 toast.success("Prompt deleted");104 } catch (e) {105 toast.error("Could not delete", errorMessage(e));106 throw e;107 }108 };109 const actions = { onUse: use, onEdit: (p: PublicPrompt) => setEditor({ open: true, prompt: p }), onDuplicate: duplicate, onToggleFavorite: toggleFavorite, onDelete: setDeleting };110111 const clearFilters = () => {112 setKind("all");113 setFolder(null);114 setTag(null);115 setFavOnly(false);116 setQ("");117 };118119 return (120 <div className="flex h-full min-h-0 flex-col">121 <WorkspacePageHeader122 title="Prompts"123 subtitle={total ? `${total} in your library` : undefined}124 actions={125 <Button size="sm" onClick={() => setEditor({ open: true, prompt: null })}>126 <Plus /> <span className="hidden sm:inline">New prompt</span>127 <span className="sm:hidden">New</span>128 </Button>129 }130 />131 <WorkspacePageBody>132 {total > 0 || filtered ? (133 <div className="space-y-3">134 <div className="flex flex-col gap-2 sm:flex-row sm:items-center">135 <div className="relative flex-1 sm:max-w-sm">136 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />137 <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, content, tags…" className="h-10 pl-8 sm:h-9" aria-label="Search prompts" />138 </div>139 <Segmented ariaLabel="Prompt kind" value={kind} onChange={setKind} size="sm" className="max-w-full" options={[{ value: "all" as KindFilter, label: "All" }, ...PROMPT_KIND_ORDER.map((k) => ({ value: k as KindFilter, label: PROMPT_KIND_META[k].short, icon: PROMPT_KIND_META[k].icon }))]} />140 </div>141 {(data?.folders.length ?? 0) + (data?.tags.length ?? 0) > 0 || favOnly ? (142 <div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 scrollbar-none sm:mx-0 sm:flex-wrap sm:px-0">143 <button type="button" role="checkbox" aria-checked={favOnly} onClick={() => setFavOnly((v) => !v)} className={cn("inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition-colors [&_svg]:size-3.5", favOnly ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg")}>144 <Star className={cn(favOnly && "fill-current")} /> Favourites145 </button>146 {data?.folders.length ? <ChipRow value={folder} onChange={(f) => setFolder(folder === f ? null : f)} options={data.folders.map((f) => ({ value: f, label: f, icon: <Folder /> }))} className="shrink-0 overflow-visible" /> : null}147 {data?.tags.length ? <ChipRow value={tag} onChange={(t) => setTag(tag === t ? null : t)} options={data.tags.slice(0, 12).map((t) => ({ value: t, label: `#${t}` }))} className="shrink-0 overflow-visible" /> : null}148 {filtered ? (149 <button type="button" onClick={clearFilters} className="inline-flex h-8 shrink-0 items-center gap-1 rounded-full px-2 text-[13px] text-fg-subtle hover:text-fg">150 <X className="size-3.5" /> Clear151 </button>152 ) : null}153 </div>154 ) : null}155 </div>156 ) : null}157158 <div className={cn((total > 0 || filtered) && "mt-4")}>159 {isLoading && !data ? (160 <ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">161 {Array.from({ length: 6 }).map((_, i) => (162 <li key={i}>163 <Skeleton className="h-44 rounded-xl" />164 </li>165 ))}166 </ul>167 ) : error ? (168 <EmptyState title="Could not load prompts" description={errorMessage(error)} action={<Button variant="outline" onClick={() => mutate()}>Retry</Button>} />169 ) : total === 0 && !filtered ? (170 <EmptyState171 icon={<WandSparkles />}172 title="No prompts yet"173 description="Save the prompts you keep retyping — a code reviewer, a translator, a strict JSON extractor. Add {{variables}} to fill in before sending."174 action={175 <Button onClick={() => setEditor({ open: true, prompt: null })}>176 <Plus /> Create your first prompt177 </Button>178 }179 />180 ) : prompts.length === 0 ? (181 <EmptyState title="No matches" description={dq ? `Nothing matches “${dq}”.` : "No prompts match these filters."} action={<Button variant="outline" onClick={clearFilters}>Clear filters</Button>} />182 ) : (183 <ul className={cn("grid gap-3 sm:grid-cols-2 xl:grid-cols-3", isLoading && "opacity-70 transition-opacity")}>184 {prompts.map((p) => (185 <PromptCard key={p.id} prompt={p} actions={actions} />186 ))}187 </ul>188 )}189 </div>190191 <LegacyPresets onImported={() => refresh()} />192 </WorkspacePageBody>193194 <PromptEditorSheet open={editor.open} onOpenChange={(o) => setEditor((s) => ({ ...s, open: o }))} prompt={editor.prompt} initial={editor.initial} folders={data?.folders} onSaved={() => refresh()} />195 <PromptUseSheet prompt={using} open={using !== null} onOpenChange={(o) => !o && setUsing(null)} />196 <ConfirmDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)} destructive title={`Delete “${deleting?.name ?? ""}”?`} description="Conversations that used this prompt are not affected." confirmLabel="Delete" onConfirm={() => (deleting ? remove(deleting) : Promise.resolve())} />197 </div>198 );199}200