SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
10.5 KB · 200 lines tsx
Raw Blame History
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