diff --git a/src/app/api/compatibility/route.ts b/src/app/api/compatibility/route.ts index 3d47f6f..6f4b3ae 100644 --- a/src/app/api/compatibility/route.ts +++ b/src/app/api/compatibility/route.ts @@ -60,7 +60,7 @@ export async function GET(request: NextRequest) { const statusFilter = statusFilterRaw && VALID_STATUSES.includes(statusFilterRaw) ? statusFilterRaw : undefined; const seed = await readCompatibilitySeed(); - const mcpBySlug = new Map((mcpServers as any as McpServer[]).map((server) => [server.slug, server])); + const mcpBySlug = new Map((mcpServers as unknown as McpServer[]).map((server) => [server.slug, server])); const rows = seed.servers .map((row) => { diff --git a/src/app/mcp/mcp-client.tsx b/src/app/mcp/mcp-client.tsx index 0943079..3e57ad9 100644 --- a/src/app/mcp/mcp-client.tsx +++ b/src/app/mcp/mcp-client.tsx @@ -196,21 +196,21 @@ export function McpHubClient({ servers }: { servers: McpServer[] }) {

{/* Stats */} - {((server as any).stars || (server as any).installs || (server as any).framework) && ( + {(server.stars || server.installs || server.framework) && (
- {(server as any).stars && ( + {server.stars && ( - ⭐ {(server as any).stars.toLocaleString()} + ⭐ {server.stars.toLocaleString()} )} - {(server as any).installs && ( + {server.installs && ( - 📦 {(server as any).installs.toLocaleString()} + 📦 {server.installs.toLocaleString()} )} - {(server as any).framework && ( + {server.framework && ( - {(server as any).framework} + {server.framework} )}
@@ -311,21 +311,21 @@ export function McpHubClient({ servers }: { servers: McpServer[] }) {

{/* Stats */} - {((server as any).stars || (server as any).installs || (server as any).framework) && ( + {(server.stars || server.installs || server.framework) && (
- {(server as any).stars && ( + {server.stars && ( - ⭐ {(server as any).stars.toLocaleString()} + ⭐ {server.stars.toLocaleString()} )} - {(server as any).installs && ( + {server.installs && ( - 📦 {(server as any).installs.toLocaleString()} + 📦 {server.installs.toLocaleString()} )} - {(server as any).framework && ( + {server.framework && ( - {(server as any).framework} + {server.framework} )}
diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index f87fa17..9bc35e8 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,292 +1,30 @@ -"use client"; +import { Suspense } from "react"; +import type { Metadata } from "next"; +import SearchClient from "./search-client"; -import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; - -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; - -type SearchResultType = "skill" | "mcp" | "agent" | "llms-txt" | "blog" | "guide"; -type FilterType = "all" | SearchResultType; - -type SearchResult = { - title: string; - description: string; - url: string; - type: SearchResultType; - score?: number; +export const metadata: Metadata = { + title: "Search — forAgents.dev", + description: "Search skills, MCP servers, agents, llms.txt, blog posts, and guides.", }; -type SearchResponse = { - query: string; - results?: SearchResult[]; - skills?: SearchResult[]; - mcp_servers?: SearchResult[]; - agents?: SearchResult[]; - llmstxt?: SearchResult[]; - blog?: SearchResult[]; - guides?: SearchResult[]; - total: number; -}; - -const FILTERS: Array<{ key: FilterType; label: string }> = [ - { key: "all", label: "All" }, - { key: "skill", label: "Skills" }, - { key: "mcp", label: "MCP" }, - { key: "agent", label: "Agents" }, - { key: "llms-txt", label: "llms.txt" }, - { key: "blog", label: "Blog" }, - { key: "guide", label: "Guides" }, -]; - -const TYPE_BADGE_CLASS: Record = { - skill: "bg-cyan/15 text-cyan border-cyan/30", - mcp: "bg-purple/15 text-purple border-purple/30", - agent: "bg-yellow/15 text-yellow border-yellow/30", - "llms-txt": "bg-green-500/15 text-green-400 border-green-500/30", - blog: "bg-pink-500/15 text-pink-300 border-pink-500/30", - guide: "bg-orange-500/15 text-orange-300 border-orange-500/30", -}; - -const TYPE_LABEL: Record = { - skill: "Skill", - mcp: "MCP", - agent: "Agent", - "llms-txt": "llms.txt", - blog: "Blog", - guide: "Guide", -}; - -function clampText(input: string, maxLen: number): string { - const s = (input || "").trim(); - if (s.length <= maxLen) return s; - return `${s.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`; -} - -export default function SearchPage() { - const router = useRouter(); - const searchParams = useSearchParams(); - - const urlQuery = searchParams.get("q")?.trim() ?? ""; - - const [query, setQuery] = useState(urlQuery); - const [activeType, setActiveType] = useState("all"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [data, setData] = useState(null); - - useEffect(() => { - setQuery(urlQuery); - }, [urlQuery]); - - useEffect(() => { - if (!urlQuery) { - setData(null); - setError(null); - setLoading(false); - return; - } - - let cancelled = false; - - async function runSearch() { - setLoading(true); - setError(null); - - try { - const res = await fetch(`/api/search?q=${encodeURIComponent(urlQuery)}`, { - cache: "no-store", - }); - - if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string }; - throw new Error(body.message || body.error || "Search failed"); - } - - const payload = (await res.json()) as SearchResponse; - if (!cancelled) { - setData(payload); - } - } catch (err) { - if (!cancelled) { - setError(err instanceof Error ? err.message : "Search failed"); - setData(null); - } - } finally { - if (!cancelled) setLoading(false); - } - } - - void runSearch(); - - return () => { - cancelled = true; - }; - }, [urlQuery]); - - const allResults = useMemo(() => { - if (!data) return [] as SearchResult[]; - - if (Array.isArray(data.results)) return data.results; - - return [ - ...(data.skills || []), - ...(data.mcp_servers || []), - ...(data.agents || []), - ...(data.llmstxt || []), - ...(data.blog || []), - ...(data.guides || []), - ]; - }, [data]); - - const counts = useMemo(() => { - const byType: Record = { - all: allResults.length, - skill: 0, - mcp: 0, - agent: 0, - "llms-txt": 0, - blog: 0, - guide: 0, - }; - - for (const item of allResults) { - byType[item.type] += 1; - } - - return byType; - }, [allResults]); - - const filteredResults = useMemo(() => { - if (activeType === "all") return allResults; - return allResults.filter((r) => r.type === activeType); - }, [activeType, allResults]); - +function SearchFallback() { return (
-
+

Search

-

- Unified search across skills, MCP servers, agents, llms.txt, blog posts, and guides. -

- -
{ - e.preventDefault(); - const q = query.trim(); - if (!q) return; - router.replace(`/search?q=${encodeURIComponent(q)}`); - }} - > - -
- setQuery(e.target.value)} - placeholder="Search skills, MCP servers, agents, llms.txt, blog, guides…" - className="w-full h-12 px-4 pr-12 rounded-lg bg-card border border-white/10 text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-cyan/50 focus:ring-1 focus:ring-cyan/20 font-mono text-sm transition-colors" - aria-label="Search" - autoFocus - /> - -
-
- - {urlQuery && !loading && !error && ( -
- {filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""} - {activeType !== "all" ? ` in ${FILTERS.find((f) => f.key === activeType)?.label}` : ""} -
- )} - - {urlQuery && ( - setActiveType(v as FilterType)} className="mb-6"> - - {FILTERS.map((filter) => ( - - {filter.label} - {counts[filter.key]} - - ))} - - - )} - - {loading && ( -
-

Searching…

-
- )} - - {error && ( -
- {error} -
- )} - - {!urlQuery && ( -
-

⌘K

-

Start searching

-

Try a keyword like “security”, “cursor”, or “memory”.

-
- )} - - {urlQuery && !loading && !error && filteredResults.length === 0 && ( -
-

🔍

-

No results found

-

Try a different query or switch result type.

-
- )} - - {urlQuery && !loading && !error && filteredResults.length > 0 && ( -
- {filteredResults.map((result, index) => { - const isExternal = /^https?:\/\//i.test(result.url); - const badgeClass = TYPE_BADGE_CLASS[result.type]; - - return ( - -
- - {TYPE_LABEL[result.type]} - -
- -

- {result.title} -

- -

- {clampText(result.description, 220)} -

- -

- {result.url} -

- - ); - })} -
- )} +

Loading search…

+
); } + +export default function SearchPage() { + return ( + }> + + + ); +} diff --git a/src/app/search/search-client.tsx b/src/app/search/search-client.tsx new file mode 100644 index 0000000..e23d18f --- /dev/null +++ b/src/app/search/search-client.tsx @@ -0,0 +1,292 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +type SearchResultType = "skill" | "mcp" | "agent" | "llms-txt" | "blog" | "guide"; +type FilterType = "all" | SearchResultType; + +type SearchResult = { + title: string; + description: string; + url: string; + type: SearchResultType; + score?: number; +}; + +type SearchResponse = { + query: string; + results?: SearchResult[]; + skills?: SearchResult[]; + mcp_servers?: SearchResult[]; + agents?: SearchResult[]; + llmstxt?: SearchResult[]; + blog?: SearchResult[]; + guides?: SearchResult[]; + total: number; +}; + +const FILTERS: Array<{ key: FilterType; label: string }> = [ + { key: "all", label: "All" }, + { key: "skill", label: "Skills" }, + { key: "mcp", label: "MCP" }, + { key: "agent", label: "Agents" }, + { key: "llms-txt", label: "llms.txt" }, + { key: "blog", label: "Blog" }, + { key: "guide", label: "Guides" }, +]; + +const TYPE_BADGE_CLASS: Record = { + skill: "bg-cyan/15 text-cyan border-cyan/30", + mcp: "bg-purple/15 text-purple border-purple/30", + agent: "bg-yellow/15 text-yellow border-yellow/30", + "llms-txt": "bg-green-500/15 text-green-400 border-green-500/30", + blog: "bg-pink-500/15 text-pink-300 border-pink-500/30", + guide: "bg-orange-500/15 text-orange-300 border-orange-500/30", +}; + +const TYPE_LABEL: Record = { + skill: "Skill", + mcp: "MCP", + agent: "Agent", + "llms-txt": "llms.txt", + blog: "Blog", + guide: "Guide", +}; + +function clampText(input: string, maxLen: number): string { + const s = (input || "").trim(); + if (s.length <= maxLen) return s; + return `${s.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`; +} + +export default function SearchClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const urlQuery = searchParams.get("q")?.trim() ?? ""; + + const [query, setQuery] = useState(urlQuery); + const [activeType, setActiveType] = useState("all"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + useEffect(() => { + setQuery(urlQuery); + }, [urlQuery]); + + useEffect(() => { + if (!urlQuery) { + setData(null); + setError(null); + setLoading(false); + return; + } + + let cancelled = false; + + async function runSearch() { + setLoading(true); + setError(null); + + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(urlQuery)}`, { + cache: "no-store", + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { message?: string; error?: string }; + throw new Error(body.message || body.error || "Search failed"); + } + + const payload = (await res.json()) as SearchResponse; + if (!cancelled) { + setData(payload); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Search failed"); + setData(null); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void runSearch(); + + return () => { + cancelled = true; + }; + }, [urlQuery]); + + const allResults = useMemo(() => { + if (!data) return [] as SearchResult[]; + + if (Array.isArray(data.results)) return data.results; + + return [ + ...(data.skills || []), + ...(data.mcp_servers || []), + ...(data.agents || []), + ...(data.llmstxt || []), + ...(data.blog || []), + ...(data.guides || []), + ]; + }, [data]); + + const counts = useMemo(() => { + const byType: Record = { + all: allResults.length, + skill: 0, + mcp: 0, + agent: 0, + "llms-txt": 0, + blog: 0, + guide: 0, + }; + + for (const item of allResults) { + byType[item.type] += 1; + } + + return byType; + }, [allResults]); + + const filteredResults = useMemo(() => { + if (activeType === "all") return allResults; + return allResults.filter((r) => r.type === activeType); + }, [activeType, allResults]); + + return ( +
+
+

+ Search +

+

+ Unified search across skills, MCP servers, agents, llms.txt, blog posts, and guides. +

+ +
{ + e.preventDefault(); + const q = query.trim(); + if (!q) return; + router.replace(`/search?q=${encodeURIComponent(q)}`); + }} + > + +
+ setQuery(e.target.value)} + placeholder="Search skills, MCP servers, agents, llms.txt, blog, guides…" + className="w-full h-12 px-4 pr-12 rounded-lg bg-card border border-white/10 text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-cyan/50 focus:ring-1 focus:ring-cyan/20 font-mono text-sm transition-colors" + aria-label="Search" + autoFocus + /> + +
+
+ + {urlQuery && !loading && !error && ( +
+ {filteredResults.length} result{filteredResults.length !== 1 ? "s" : ""} + {activeType !== "all" ? ` in ${FILTERS.find((f) => f.key === activeType)?.label}` : ""} +
+ )} + + {urlQuery && ( + setActiveType(v as FilterType)} className="mb-6"> + + {FILTERS.map((filter) => ( + + {filter.label} + {counts[filter.key]} + + ))} + + + )} + + {loading && ( +
+

Searching…

+
+ )} + + {error && ( +
+ {error} +
+ )} + + {!urlQuery && ( +
+

⌘K

+

Start searching

+

Try a keyword like "security", "cursor", or "memory".

+
+ )} + + {urlQuery && !loading && !error && filteredResults.length === 0 && ( +
+

🔍

+

No results found

+

Try a different query or switch result type.

+
+ )} + + {urlQuery && !loading && !error && filteredResults.length > 0 && ( +
+ {filteredResults.map((result, index) => { + const isExternal = /^https?:\/\//i.test(result.url); + const badgeClass = TYPE_BADGE_CLASS[result.type]; + + return ( + +
+ + {TYPE_LABEL[result.type]} + +
+ +

+ {result.title} +

+ +

+ {clampText(result.description, 220)} +

+ +

+ {result.url} +

+ + ); + })} +
+ )} +
+
+ ); +}