TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Copy, Folder, Pencil, Play, Star, Trash2 } from "lucide-react";4import { Badge } from "@/components/ui/badge";5import { Button } from "@/components/ui/button";6import { ProviderIcon } from "@/components/brand/provider-icon";7import { RowMenu } from "@/components/projects/row-menu";8import { useApp } from "@/components/app/store";9import { useIsMobile, useLongPress } from "@/lib/client/hooks";10import { formatRelative, truncate, cn } from "@/lib/utils";11import type { PublicPrompt } from "@/lib/client/types";12import type { ActionSheetItem } from "@/components/ui/sheet";13import { PROMPT_KIND_META } from "./prompt-kind";1415export interface PromptCardActions {16 onUse: (p: PublicPrompt) => void;17 onEdit: (p: PublicPrompt) => void;18 onDuplicate?: (p: PublicPrompt) => void;19 onToggleFavorite: (p: PublicPrompt) => void;20 onDelete: (p: PublicPrompt) => void;21}2223/** Prompt card (grid) or compact row (`compact`, used on project pages). Long-press on phones opens the actions. */24export function PromptCard({ prompt: p, actions, compact, className }: { prompt: PublicPrompt; actions: PromptCardActions; compact?: boolean; className?: string }) {25 const { modelsByKey } = useApp();26 const isMobile = useIsMobile();27 const [menu, setMenu] = React.useState(false);28 const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile });29 const meta = PROMPT_KIND_META[p.kind];30 const model = p.defaultModelKey ? modelsByKey.get(p.defaultModelKey) : null;3132 const items: (ActionSheetItem | "separator")[] = [33 { key: "use", label: "Use in chat", icon: <Play />, onSelect: () => actions.onUse(p) },34 { key: "edit", label: "Edit", icon: <Pencil />, onSelect: () => actions.onEdit(p) },35 ...(actions.onDuplicate ? [{ key: "dup", label: "Duplicate", icon: <Copy />, onSelect: () => actions.onDuplicate!(p) } as ActionSheetItem] : []),36 { key: "fav", label: p.favorite ? "Remove from favourites" : "Add to favourites", icon: <Star />, onSelect: () => actions.onToggleFavorite(p) },37 "separator",38 { key: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => actions.onDelete(p) },39 ];4041 if (compact) {42 return (43 <li className={cn("group flex min-h-[56px] items-center gap-3 px-3 py-2", className)} {...press}>44 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted [&_svg]:size-4" aria-hidden>45 {meta.icon}46 </span>47 <button type="button" onClick={() => actions.onUse(p)} className="min-w-0 flex-1 text-left">48 <span className="flex items-center gap-1.5">49 <span className="truncate text-[14px] font-medium leading-5">{p.name}</span>50 {p.favorite ? <Star className="size-3 shrink-0 fill-warning text-warning" aria-label="Favourite" /> : null}51 </span>52 <span className="block truncate text-[12px] leading-4 text-fg-subtle">53 {meta.short}54 {p.variables.length ? ` · ${p.variables.length} variable${p.variables.length === 1 ? "" : "s"}` : ""}55 {p.uses ? ` · used ${p.uses}×` : ""} · {formatRelative(p.updatedAt)}56 </span>57 </button>58 <Button size="sm" variant="secondary" className="hidden sm:inline-flex" onClick={() => actions.onUse(p)}>59 <Play /> Use60 </Button>61 <RowMenu items={items} title={p.name} open={menu} onOpenChange={setMenu} />62 </li>63 );64 }6566 return (67 <li className={cn("group flex h-full flex-col rounded-xl border border-border bg-bg-elevated p-4 transition-colors hover:border-border-strong", className)} {...press}>68 <div className="flex items-start gap-3">69 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted [&_svg]:size-4" aria-hidden>70 {meta.icon}71 </span>72 <div className="min-w-0 flex-1">73 <h3 className="flex items-center gap-1.5 text-[15px] font-semibold leading-6">74 <span className="truncate">{p.name}</span>75 <button type="button" onClick={() => actions.onToggleFavorite(p)} className={cn("tap shrink-0 rounded p-0.5", p.favorite ? "text-warning" : "text-fg-subtle/60 hover:text-fg-muted")} aria-label={p.favorite ? "Remove from favourites" : "Add to favourites"} aria-pressed={p.favorite}>76 <Star className={cn("size-3.5", p.favorite && "fill-current")} />77 </button>78 </h3>79 {p.description ? <p className="mt-0.5 line-clamp-2 text-[13px] text-fg-muted">{p.description}</p> : null}80 </div>81 <RowMenu items={items} title={p.name} open={menu} onOpenChange={setMenu} />82 </div>83 <button type="button" onClick={() => actions.onEdit(p)} className="mt-3 block w-full text-left" aria-label={`Edit ${p.name}`}>84 <pre className="max-h-24 overflow-hidden whitespace-pre-wrap break-words rounded-md border border-border bg-bg-subtle/70 px-3 py-2 font-mono text-[12px] leading-5 text-fg-muted">{truncate(p.content, 280)}</pre>85 </button>86 <div className="mt-3 flex flex-wrap items-center gap-1.5">87 <Badge variant={meta.badge}>{meta.short}</Badge>88 {p.variables.slice(0, 3).map((v) => (89 <Badge key={v.name} variant="outline" className="font-mono">90 {`{{${v.name}}}`}91 </Badge>92 ))}93 {p.variables.length > 3 ? <Badge variant="outline">+{p.variables.length - 3}</Badge> : null}94 {model ? (95 <Badge variant="outline" className="gap-1.5">96 <ProviderIcon provider={model.provider} size={11} /> {model.displayName}97 </Badge>98 ) : null}99 {p.folder ? (100 <Badge className="gap-1">101 <Folder className="size-3" /> {p.folder}102 </Badge>103 ) : null}104 {p.tags.slice(0, 3).map((t) => (105 <Badge key={t}>#{t}</Badge>106 ))}107 </div>108 <div className="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3">109 <span className="text-[11px] text-fg-subtle tabular-nums">{p.uses ? `Used ${p.uses}× · ` : ""}Updated {formatRelative(p.updatedAt)}</span>110 <Button size="sm" variant="secondary" onClick={() => actions.onUse(p)}>111 <Play /> Use112 </Button>113 </div>114 </li>115 );116}117