mirror of
https://github.com/f/awesome-chatgpt-prompts.git
synced 2026-02-12 15:52:47 +00:00
feat(components): add McpConfigTabs component for MCP configuration tabs
This commit is contained in:
6
public/mcp-dark.svg
Normal file
6
public/mcp-dark.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="none">
|
||||
<path d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822" stroke="white" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 869 B |
@@ -9,6 +9,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { McpConfigTabs } from "@/components/mcp/mcp-config-tabs";
|
||||
|
||||
export const metadata = {
|
||||
title: "API Documentation - prompts.chat",
|
||||
@@ -61,19 +62,11 @@ export default async function ApiDocsPage() {
|
||||
Using with MCP Clients
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Add prompts.chat to your MCP client configuration:
|
||||
Add prompts.chat to your MCP client configuration. Choose your client and connection type below:
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg p-4 font-mono text-sm overflow-x-auto">
|
||||
<pre>{`{
|
||||
"mcpServers": {
|
||||
"prompts-chat": {
|
||||
"url": "${baseUrl}/api/mcp"
|
||||
}
|
||||
}
|
||||
}`}</pre>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Compatible with Claude Desktop, Cursor, Windsurf, and other MCP-enabled tools.
|
||||
<McpConfigTabs baseUrl={baseUrl} className="[&_button]:text-sm [&_button]:px-3 [&_button]:py-1.5 [&_pre]:text-sm [&_pre]:p-4" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<strong>Remote</strong> connects directly to prompts.chat API. <strong>Local</strong> runs the MCP server locally via npx.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InfinitePromptList } from "@/components/prompts/infinite-prompt-list";
|
||||
import { PromptFilters } from "@/components/prompts/prompt-filters";
|
||||
import { FilterProvider } from "@/components/prompts/filter-context";
|
||||
import { HFDataStudioDropdown } from "@/components/prompts/hf-data-studio-dropdown";
|
||||
import { McpServerPopup, McpIcon } from "@/components/mcp/mcp-server-popup";
|
||||
import { db } from "@/lib/db";
|
||||
import { isAISearchEnabled, semanticSearch } from "@/lib/ai/embeddings";
|
||||
import { isAIGenerationEnabled } from "@/lib/ai/generation";
|
||||
@@ -180,7 +181,10 @@ export default async function PromptsPage({ searchParams }: PromptsPageProps) {
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
{!config.homepage?.useCloneBranding && (
|
||||
<HFDataStudioDropdown aiGenerationEnabled={aiGenerationAvailable} />
|
||||
<div className="flex items-center gap-2">
|
||||
<HFDataStudioDropdown aiGenerationEnabled={aiGenerationAvailable} />
|
||||
<McpServerPopup />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="h-8 text-xs w-full sm:w-auto" asChild>
|
||||
<Link href="/prompts/new">
|
||||
|
||||
228
src/components/mcp/mcp-config-tabs.tsx
Normal file
228
src/components/mcp/mcp-config-tabs.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Client = "cursor" | "claude-code" | "vscode" | "codex" | "windsurf" | "gemini";
|
||||
type Mode = "remote" | "local";
|
||||
|
||||
interface McpConfigTabsProps {
|
||||
baseUrl?: string;
|
||||
/** URL parameters to append (e.g., users, categories, tags) */
|
||||
queryParams?: string;
|
||||
className?: string;
|
||||
/** External mode control */
|
||||
mode?: "remote" | "local";
|
||||
onModeChange?: (mode: "remote" | "local") => void;
|
||||
/** Hide the mode toggle (when controlled externally) */
|
||||
hideModeToggle?: boolean;
|
||||
}
|
||||
|
||||
const CLIENT_LABELS: Record<Client, string> = {
|
||||
cursor: "Cursor",
|
||||
"claude-code": "Claude",
|
||||
vscode: "VS Code",
|
||||
codex: "Codex",
|
||||
windsurf: "Windsurf",
|
||||
gemini: "Gemini",
|
||||
};
|
||||
|
||||
const NPM_PACKAGE = "@fkadev/prompts.chat-mcp";
|
||||
|
||||
function getConfig(client: Client, mode: Mode, mcpUrl: string): string {
|
||||
const packageName = NPM_PACKAGE;
|
||||
|
||||
switch (client) {
|
||||
case "cursor":
|
||||
if (mode === "remote") {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"prompts.chat": {
|
||||
url: mcpUrl,
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"prompts.chat": {
|
||||
command: "npx",
|
||||
args: ["-y", packageName],
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
case "claude-code":
|
||||
if (mode === "remote") {
|
||||
return `claude mcp add --transport http prompts.chat ${mcpUrl}`;
|
||||
} else {
|
||||
return `claude mcp add prompts.chat -- npx -y ${packageName}`;
|
||||
}
|
||||
|
||||
case "vscode":
|
||||
if (mode === "remote") {
|
||||
return JSON.stringify({
|
||||
mcp: {
|
||||
servers: {
|
||||
"prompts.chat": {
|
||||
type: "http",
|
||||
url: mcpUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
mcp: {
|
||||
servers: {
|
||||
"prompts.chat": {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", packageName],
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
case "codex":
|
||||
if (mode === "remote") {
|
||||
return `[mcp_servers.prompts_chat]
|
||||
url = "${mcpUrl}"`;
|
||||
} else {
|
||||
return `[mcp_servers.prompts_chat]
|
||||
command = "npx"
|
||||
args = ["-y", "${packageName}"]`;
|
||||
}
|
||||
|
||||
case "windsurf":
|
||||
if (mode === "remote") {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"prompts.chat": {
|
||||
serverUrl: mcpUrl,
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
} else {
|
||||
return JSON.stringify({
|
||||
mcpServers: {
|
||||
"prompts.chat": {
|
||||
command: "npx",
|
||||
args: ["-y", packageName],
|
||||
},
|
||||
},
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
case "gemini":
|
||||
if (mode === "remote") {
|
||||
return `gemini mcp add prompts.chat --transport sse ${mcpUrl}`;
|
||||
} else {
|
||||
return `gemini mcp add prompts.chat -- npx -y ${packageName}`;
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function McpConfigTabs({ baseUrl, queryParams, className, mode, onModeChange, hideModeToggle }: McpConfigTabsProps) {
|
||||
const [selectedClient, setSelectedClient] = useState<Client>("vscode");
|
||||
const [internalMode, setInternalMode] = useState<Mode>("remote");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const selectedMode = mode ?? internalMode;
|
||||
const handleModeChange = (newMode: Mode) => {
|
||||
if (onModeChange) {
|
||||
onModeChange(newMode);
|
||||
} else {
|
||||
setInternalMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
const base = baseUrl || (typeof window !== "undefined" ? window.location.origin : "https://prompts.chat");
|
||||
const mcpUrl = queryParams ? `${base}/api/mcp?${queryParams}` : `${base}/api/mcp`;
|
||||
|
||||
const config = getConfig(selectedClient, selectedMode, mcpUrl);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(config);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const clients: Client[] = ["vscode", "windsurf", "cursor", "claude-code", "codex", "gemini"];
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* Client Tabs */}
|
||||
<div className="flex gap-0.5 overflow-x-auto">
|
||||
{clients.map((client) => (
|
||||
<button
|
||||
key={client}
|
||||
onClick={() => setSelectedClient(client)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] font-medium rounded transition-colors whitespace-nowrap",
|
||||
selectedClient === client
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{CLIENT_LABELS[client]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mode Toggle - only show if not hidden */}
|
||||
{!hideModeToggle && (
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
onClick={() => handleModeChange("remote")}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
|
||||
selectedMode === "remote"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Remote
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange("local")}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
|
||||
selectedMode === "local"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Local
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Display */}
|
||||
<div className="relative">
|
||||
<div dir="ltr" className="bg-muted rounded-md p-2 font-mono text-[11px] overflow-x-auto text-left">
|
||||
<pre className="whitespace-pre">{config}</pre>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-1 right-1 h-6 w-6 p-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,30 +2,36 @@
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Copy, Plus, X } from "lucide-react";
|
||||
import { Plus, X, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { analyticsMcp } from "@/lib/analytics";
|
||||
import { McpConfigTabs } from "./mcp-config-tabs";
|
||||
|
||||
// MCP Logo component
|
||||
function McpIcon({ className }: { className?: string }) {
|
||||
// MCP Logo component - shows dark version in dark mode
|
||||
export function McpIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<img
|
||||
src="/mcp.svg"
|
||||
alt="MCP"
|
||||
className={className}
|
||||
/>
|
||||
<>
|
||||
<img
|
||||
src="/mcp.svg"
|
||||
alt="MCP"
|
||||
className={cn(className, "dark:hidden")}
|
||||
/>
|
||||
<img
|
||||
src="/mcp-dark.svg"
|
||||
alt="MCP"
|
||||
className={cn(className, "hidden dark:block")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { McpIcon };
|
||||
|
||||
interface McpServerPopupProps {
|
||||
/** Pre-filled users (usernames) */
|
||||
initialUsers?: string[];
|
||||
@@ -44,7 +50,8 @@ export function McpServerPopup({
|
||||
baseUrl,
|
||||
}: McpServerPopupProps) {
|
||||
const t = useTranslations("mcp");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [mcpMode, setMcpMode] = useState<"remote" | "local">("remote");
|
||||
const [users, setUsers] = useState<string[]>(initialUsers);
|
||||
const [categories, setCategories] = useState<string[]>(initialCategories);
|
||||
const [tags, setTags] = useState<string[]>(initialTags);
|
||||
@@ -52,9 +59,8 @@ export function McpServerPopup({
|
||||
const [categoryInput, setCategoryInput] = useState("");
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
// Build the MCP URL
|
||||
const mcpUrl = useMemo(() => {
|
||||
const base = baseUrl || (typeof window !== "undefined" ? window.location.origin : "https://prompts.chat");
|
||||
// Build query params for MCP URL
|
||||
const queryParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (users.length > 0) {
|
||||
@@ -67,31 +73,8 @@ export function McpServerPopup({
|
||||
params.set("tags", tags.join(","));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return `${base}/api/mcp${queryString ? `?${queryString}` : ""}`;
|
||||
}, [baseUrl, users, categories, tags]);
|
||||
|
||||
// Generate the JSON config
|
||||
const configJson = useMemo(() => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
"prompts-chat": {
|
||||
url: mcpUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}, [mcpUrl]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(configJson);
|
||||
analyticsMcp.copyCommand("config_json");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
return params.toString();
|
||||
}, [users, categories, tags]);
|
||||
|
||||
const addUser = () => {
|
||||
const value = userInput.trim().replace(/^@/, "");
|
||||
@@ -129,134 +112,148 @@ export function McpServerPopup({
|
||||
<span className="hidden sm:inline">{t("button")}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[calc(100vw-2rem)] sm:w-[420px] p-4" sideOffset={8} collisionPadding={16}>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<PopoverContent align="end" className="w-[calc(100vw-2rem)] sm:w-[480px] p-3" sideOffset={8} collisionPadding={16}>
|
||||
<div className="space-y-2">
|
||||
{/* Header with Mode Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<McpIcon className="h-4 w-4" />
|
||||
{t("title")}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
{t("copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<h3 className="font-semibold text-sm">{t("title")}</h3>
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
<button
|
||||
onClick={() => setMcpMode("remote")}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
|
||||
mcpMode === "remote"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Remote
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMcpMode("local")}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[11px] font-medium rounded transition-colors",
|
||||
mcpMode === "local"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Local
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{/* Config JSON */}
|
||||
<div dir="ltr" className="bg-muted rounded-md p-3 font-mono text-xs overflow-x-auto text-left">
|
||||
<pre className="whitespace-pre">{configJson}</pre>
|
||||
</div>
|
||||
{/* Config Tabs */}
|
||||
<McpConfigTabs baseUrl={baseUrl} queryParams={queryParams || undefined} mode={mcpMode} hideModeToggle />
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<p className="text-xs text-muted-foreground">{t("customizeFilters")}</p>
|
||||
{/* Collapsible Filters */}
|
||||
<div className="border-t pt-2">
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className="flex items-center justify-between w-full text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>{t("customizeFilters")}</span>
|
||||
<ChevronDown className={cn("h-3 w-3 transition-transform", filtersOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{/* Users */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t("users")}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addUser()}
|
||||
placeholder={t("userPlaceholder")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" onClick={addUser}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{users.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{users.map((user) => (
|
||||
<Badge key={user} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
@{user}
|
||||
<button onClick={() => removeUser(user)} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{filtersOpen && (
|
||||
<div className="space-y-2 mt-2">
|
||||
{/* Users */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium">{t("users")}</label>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addUser()}
|
||||
placeholder={t("userPlaceholder")}
|
||||
className="h-6 text-[11px] flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-6 px-1.5" onClick={addUser}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{users.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{users.map((user) => (
|
||||
<Badge key={user} variant="secondary" className="text-[10px] gap-0.5 pr-0.5 h-5">
|
||||
@{user}
|
||||
<button onClick={() => removeUser(user)} className="hover:text-destructive">
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t("categories")}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addCategory()}
|
||||
placeholder={t("categoryPlaceholder")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" onClick={addCategory}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{cat}
|
||||
<button onClick={() => removeCategory(cat)} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{/* Categories */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium">{t("categories")}</label>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addCategory()}
|
||||
placeholder={t("categoryPlaceholder")}
|
||||
className="h-6 text-[11px] flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-6 px-1.5" onClick={addCategory}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" className="text-[10px] gap-0.5 pr-0.5 h-5">
|
||||
{cat}
|
||||
<button onClick={() => removeCategory(cat)} className="hover:text-destructive">
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium">{t("tags")}</label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addTag()}
|
||||
placeholder={t("tagPlaceholder")}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-7 px-2" onClick={addTag}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs gap-1 pr-1">
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{/* Tags */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-medium">{t("tags")}</label>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addTag()}
|
||||
placeholder={t("tagPlaceholder")}
|
||||
className="h-6 text-[11px] flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" className="h-6 px-1.5" onClick={addTag}>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] gap-0.5 pr-0.5 h-5">
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="hover:text-destructive">
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { X, Sparkles, Search } from "lucide-react";
|
||||
import { X, Sparkles, Search, SlidersHorizontal } from "lucide-react";
|
||||
import { analyticsSearch } from "@/lib/analytics";
|
||||
|
||||
interface PromptFiltersProps {
|
||||
@@ -49,6 +49,7 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations();
|
||||
const [tagSearch, setTagSearch] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const { setFilterPending } = useFilterContext();
|
||||
const debounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -76,64 +77,140 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
|
||||
|
||||
const hasFilters = currentFilters.q || currentFilters.type || currentFilters.category || currentFilters.tag || currentFilters.sort;
|
||||
|
||||
const activeFilterCount = [currentFilters.type, currentFilters.category, currentFilters.tag, currentFilters.sort && currentFilters.sort !== "newest"].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 border rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between h-6">
|
||||
<span className="font-medium text-xs uppercase text-muted-foreground">{t("search.filters")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-xs px-2 ${hasFilters ? "visible" : "invisible"}`}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />{t("search.clear")}
|
||||
</Button>
|
||||
<div className="space-y-4 p-0 pt-4 border-t lg:pt-4 lg:p-4 lg:border lg:rounded-lg text-sm">
|
||||
{/* Mobile: Compact search with filter toggle */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{/* Search bar with AI toggle and filter toggle */}
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("search.placeholder")}
|
||||
className="h-8 text-xs pl-8"
|
||||
defaultValue={currentFilters.q}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFilterPending(true);
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (value) {
|
||||
analyticsSearch.search(value, currentFilters.ai === "1");
|
||||
}
|
||||
updateFilter("q", value || null);
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{aiSearchEnabled && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Sparkles className="h-3 w-3 text-primary" />
|
||||
<Switch
|
||||
id="ai-search-mobile"
|
||||
checked={currentFilters.ai === "1"}
|
||||
onCheckedChange={(checked) => {
|
||||
analyticsSearch.aiSearchToggle(checked);
|
||||
updateFilter("ai", checked ? "1" : null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 relative shrink-0"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-primary text-[10px] text-primary-foreground flex items-center justify-center">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Collapsible filters */}
|
||||
{showFilters && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs uppercase text-muted-foreground">{t("search.filters")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-xs px-2 ${hasFilters ? "visible" : "invisible"}`}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />{t("search.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile filters content rendered below */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("search.search")}</Label>
|
||||
<Input
|
||||
placeholder={t("search.placeholder")}
|
||||
className="h-8 text-sm"
|
||||
defaultValue={currentFilters.q}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Show loading immediately
|
||||
setFilterPending(true);
|
||||
// Clear previous debounce
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
// Debounce the actual navigation
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (value) {
|
||||
analyticsSearch.search(value, currentFilters.ai === "1");
|
||||
{/* Desktop: Full filters */}
|
||||
<div className="hidden lg:block space-y-4">
|
||||
<div className="flex items-center justify-between h-6">
|
||||
<span className="font-medium text-xs uppercase text-muted-foreground">{t("search.filters")}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 text-xs px-2 ${hasFilters ? "visible" : "invisible"}`}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />{t("search.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("search.search")}</Label>
|
||||
<Input
|
||||
placeholder={t("search.placeholder")}
|
||||
className="h-8 text-sm"
|
||||
defaultValue={currentFilters.q}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setFilterPending(true);
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
updateFilter("q", value || null);
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* AI Search Toggle */}
|
||||
{aiSearchEnabled && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label className="text-xs flex items-center gap-1.5 cursor-pointer" htmlFor="ai-search">
|
||||
<Sparkles className="h-3 w-3 text-primary" />
|
||||
{t("search.aiSearch")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="ai-search"
|
||||
checked={currentFilters.ai === "1"}
|
||||
onCheckedChange={(checked) => {
|
||||
analyticsSearch.aiSearchToggle(checked);
|
||||
updateFilter("ai", checked ? "1" : null);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (value) {
|
||||
analyticsSearch.search(value, currentFilters.ai === "1");
|
||||
}
|
||||
updateFilter("q", value || null);
|
||||
}, 300);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Search Toggle */}
|
||||
{aiSearchEnabled && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label className="text-xs flex items-center gap-1.5 cursor-pointer" htmlFor="ai-search">
|
||||
<Sparkles className="h-3 w-3 text-primary" />
|
||||
{t("search.aiSearch")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="ai-search"
|
||||
checked={currentFilters.ai === "1"}
|
||||
onCheckedChange={(checked) => {
|
||||
analyticsSearch.aiSearchToggle(checked);
|
||||
updateFilter("ai", checked ? "1" : null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shared filters - shown on desktop always, on mobile when showFilters is true */}
|
||||
<div className={`space-y-4 ${showFilters ? "block" : "hidden"} lg:block`}>
|
||||
{/* Type filter */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("prompts.promptType")}</Label>
|
||||
@@ -259,6 +336,7 @@ export function PromptFilters({ categories, tags, currentFilters, aiSearchEnable
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -351,7 +351,9 @@ function createServer(options: ServerOptions = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await extra.sendRequest(
|
||||
// Add timeout to prevent hanging if client doesn't support elicitation
|
||||
const timeoutMs = 10000; // 10 seconds
|
||||
const elicitationPromise = extra.sendRequest(
|
||||
{
|
||||
method: "elicitation/create",
|
||||
params: {
|
||||
@@ -367,6 +369,12 @@ function createServer(options: ServerOptions = {}) {
|
||||
ElicitResultSchema
|
||||
);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Elicitation timeout")), timeoutMs)
|
||||
);
|
||||
|
||||
const result = await Promise.race([elicitationPromise, timeoutPromise]);
|
||||
|
||||
if (result.action === "accept" && result.content) {
|
||||
let filledContent = prompt.content;
|
||||
for (const [key, value] of Object.entries(result.content)) {
|
||||
|
||||
Reference in New Issue
Block a user