mirror of
https://github.com/openclaw/clawhub.git
synced 2026-07-22 03:35:29 -04:00
fix search
This commit is contained in:
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"packages/clawdhub": {
|
||||
"name": "clawdhub",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"bin": {
|
||||
"clawdhub": "bin/clawdhub.js",
|
||||
},
|
||||
@@ -76,6 +76,7 @@
|
||||
"ora": "^9.0.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"semver": "^7.7.3",
|
||||
"undici": "^7.16.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
|
||||
+23
-9
@@ -9,6 +9,7 @@ type HydratedEntry = {
|
||||
embeddingId: Id<'skillEmbeddings'>
|
||||
skill: Doc<'skills'> | null
|
||||
version: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
type SearchResult = HydratedEntry & { score: number }
|
||||
@@ -86,18 +87,31 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
export const hydrateResults = internalQuery({
|
||||
args: { embeddingIds: v.array(v.id('skillEmbeddings')) },
|
||||
handler: async (ctx, args): Promise<HydratedEntry[]> => {
|
||||
const entries: HydratedEntry[] = []
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
for (const embeddingId of args.embeddingIds) {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) continue
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (skill?.softDeletedAt) continue
|
||||
const version = await ctx.db.get(embedding.versionId)
|
||||
entries.push({ embeddingId, skill, version })
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = ctx.db.get(ownerUserId).then((owner) => owner?.handle ?? owner?._id ?? null)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
return entries
|
||||
const entries = await Promise.all(
|
||||
args.embeddingIds.map(async (embeddingId) => {
|
||||
const embedding = await ctx.db.get(embeddingId)
|
||||
if (!embedding) return null
|
||||
const skill = await ctx.db.get(embedding.skillId)
|
||||
if (skill?.softDeletedAt) return null
|
||||
const [version, ownerHandle] = await Promise.all([
|
||||
ctx.db.get(embedding.versionId),
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
return { embeddingId, skill, version, ownerHandle }
|
||||
}),
|
||||
)
|
||||
|
||||
return entries.filter((entry): entry is HydratedEntry => entry !== null)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+40
-27
@@ -25,6 +25,39 @@ const MAX_PUBLIC_LIST_LIMIT = 200
|
||||
const MAX_LIST_BULK_LIMIT = 200
|
||||
const MAX_LIST_TAKE = 1000
|
||||
|
||||
async function resolveOwnerHandle(ctx: QueryCtx, ownerUserId: Id<'users'>) {
|
||||
const owner = await ctx.db.get(ownerUserId)
|
||||
return owner?.handle ?? owner?._id ?? null
|
||||
}
|
||||
|
||||
type PublicSkillEntry = {
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle: string | null
|
||||
}
|
||||
|
||||
async function buildPublicSkillEntries(ctx: QueryCtx, skills: Doc<'skills'>[]) {
|
||||
const ownerHandleCache = new Map<Id<'users'>, Promise<string | null>>()
|
||||
|
||||
const getOwnerHandle = (ownerUserId: Id<'users'>) => {
|
||||
const cached = ownerHandleCache.get(ownerUserId)
|
||||
if (cached) return cached
|
||||
const handlePromise = resolveOwnerHandle(ctx, ownerUserId)
|
||||
ownerHandleCache.set(ownerUserId, handlePromise)
|
||||
return handlePromise
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
skills.map(async (skill) => {
|
||||
const [latestVersion, ownerHandle] = await Promise.all([
|
||||
skill.latestVersionId ? ctx.db.get(skill.latestVersionId) : null,
|
||||
getOwnerHandle(skill.ownerUserId),
|
||||
])
|
||||
return { skill, latestVersion, ownerHandle }
|
||||
}),
|
||||
) satisfies Promise<PublicSkillEntry[]>
|
||||
}
|
||||
|
||||
export const getBySlug = query({
|
||||
args: { slug: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -178,35 +211,24 @@ export const listPublicPage = query({
|
||||
.order('desc')
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: limit })
|
||||
|
||||
const items: Array<{
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
}> = []
|
||||
|
||||
for (const skill of page) {
|
||||
if (skill.softDeletedAt) continue
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
items.push({ skill, latestVersion })
|
||||
}
|
||||
const skills = page.filter((skill) => !skill.softDeletedAt)
|
||||
const items = await buildPublicSkillEntries(ctx, skills)
|
||||
|
||||
return { items, nextCursor: isDone ? null : continueCursor }
|
||||
}
|
||||
|
||||
if (sort === 'trending') {
|
||||
const entries = await getTrendingEntries(ctx, limit)
|
||||
const items: Array<{
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
}> = []
|
||||
const skills: Doc<'skills'>[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const skill = await ctx.db.get(entry.skillId)
|
||||
if (!skill || skill.softDeletedAt) continue
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
items.push({ skill, latestVersion })
|
||||
if (items.length >= limit) break
|
||||
skills.push(skill)
|
||||
if (skills.length >= limit) break
|
||||
}
|
||||
|
||||
const items = await buildPublicSkillEntries(ctx, skills)
|
||||
return { items, nextCursor: null }
|
||||
}
|
||||
|
||||
@@ -218,16 +240,7 @@ export const listPublicPage = query({
|
||||
.take(Math.min(limit * 5, MAX_LIST_TAKE))
|
||||
|
||||
const filtered = page.filter((skill) => !skill.softDeletedAt).slice(0, limit)
|
||||
const items: Array<{
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
}> = []
|
||||
|
||||
for (const skill of filtered) {
|
||||
const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null
|
||||
items.push({ skill, latestVersion })
|
||||
}
|
||||
|
||||
const items = await buildPublicSkillEntries(ctx, filtered)
|
||||
return { items, nextCursor: null }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"mime": "^4.1.0",
|
||||
"ora": "^9.0.0",
|
||||
"p-retry": "^7.1.1",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.7.3",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('@tanstack/react-router', () => ({
|
||||
import { Route } from '../routes/search'
|
||||
|
||||
describe('search route', () => {
|
||||
it('redirects to home with search mode enabled', () => {
|
||||
it('redirects to the skills index', () => {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
@@ -22,18 +22,17 @@ describe('search route', () => {
|
||||
|
||||
expect(thrown).toEqual({
|
||||
redirect: {
|
||||
to: '/',
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: 'crab',
|
||||
highlighted: true,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to home with search flag even without query', () => {
|
||||
it('redirects to the skills index without query', () => {
|
||||
const beforeLoad = Route.__config.beforeLoad as (args: {
|
||||
search: { q?: string; highlighted?: boolean }
|
||||
}) => void
|
||||
@@ -47,11 +46,10 @@ describe('search route', () => {
|
||||
|
||||
expect(thrown).toEqual({
|
||||
redirect: {
|
||||
to: '/',
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: undefined,
|
||||
highlighted: undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
},
|
||||
|
||||
@@ -8,11 +8,14 @@ type SkillCardProps = {
|
||||
chip?: string
|
||||
summaryFallback: string
|
||||
meta: ReactNode
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function SkillCard({ skill, badge, chip, summaryFallback, meta }: SkillCardProps) {
|
||||
export function SkillCard({ skill, badge, chip, summaryFallback, meta, href }: SkillCardProps) {
|
||||
const link = href ?? `/skills/${skill.slug}`
|
||||
|
||||
return (
|
||||
<Link to="/skills/$slug" params={{ slug: skill.slug }} className="card skill-card">
|
||||
<Link to={link} className="card skill-card">
|
||||
{badge || chip ? (
|
||||
<div className="skill-card-tags">
|
||||
{badge ? <div className="tag">{badge}</div> : null}
|
||||
|
||||
+59
-221
@@ -29,86 +29,13 @@ function Home() {
|
||||
}
|
||||
|
||||
function SkillsHome() {
|
||||
const navigate = Route.useNavigate()
|
||||
const search = Route.useSearch()
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const highlighted =
|
||||
(useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) as Doc<'skills'>[]) ?? []
|
||||
const latest = (useQuery(api.skills.list, { limit: 12 }) as Doc<'skills'>[]) ?? []
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const [highlightedOnly, setHighlightedOnly] = useState(search.highlighted ?? false)
|
||||
const [results, setResults] = useState<
|
||||
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchMode, setSearchMode] = useState(
|
||||
Boolean(search.q || search.highlighted || search.search),
|
||||
)
|
||||
const searchRequest = useRef(0)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const trimmedQuery = useMemo(() => query.trim(), [query])
|
||||
const hasQuery = trimmedQuery.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? '')
|
||||
setHighlightedOnly(search.highlighted ?? false)
|
||||
if (search.q || search.highlighted || search.search) {
|
||||
setSearchMode(true)
|
||||
} else {
|
||||
setSearchMode(false)
|
||||
}
|
||||
}, [search.highlighted, search.q, search.search])
|
||||
|
||||
useEffect(() => {
|
||||
void navigate({
|
||||
search: () => ({
|
||||
q: trimmedQuery || undefined,
|
||||
highlighted: highlightedOnly ? true : undefined,
|
||||
search: searchMode && !trimmedQuery && !highlightedOnly ? true : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}, [highlightedOnly, navigate, searchMode, trimmedQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchMode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [searchMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!trimmedQuery) {
|
||||
setResults([])
|
||||
setIsSearching(false)
|
||||
return
|
||||
}
|
||||
searchRequest.current += 1
|
||||
const requestId = searchRequest.current
|
||||
setIsSearching(true)
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const data = (await searchSkills({ query: trimmedQuery, highlightedOnly })) as Array<{
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
}>
|
||||
if (requestId === searchRequest.current) {
|
||||
setResults(data)
|
||||
}
|
||||
} finally {
|
||||
if (requestId === searchRequest.current) {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
}, 220)
|
||||
return () => window.clearTimeout(handle)
|
||||
}, [highlightedOnly, searchSkills, trimmedQuery])
|
||||
|
||||
return (
|
||||
<main>
|
||||
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
|
||||
<section className="hero">
|
||||
<div className="hero-inner">
|
||||
<div className="hero-copy fade-up" data-delay="1">
|
||||
<span className="hero-badge">Lobster-light. Agent-right.</span>
|
||||
@@ -121,162 +48,73 @@ function SkillsHome() {
|
||||
<Link to="/upload" search={{ updateSlug: undefined }} className="btn btn-primary">
|
||||
Publish a skill
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
search={{ q: undefined, highlighted: undefined, search: true }}
|
||||
className="btn"
|
||||
>
|
||||
Explore search
|
||||
<Link to="/skills" className="btn">
|
||||
Browse skills
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-card hero-search-card fade-up" data-delay="2">
|
||||
<form
|
||||
className="search-bar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!searchMode) setSearchMode(true)
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<span className="mono">/</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
placeholder="Search skills, tags, or capabilities"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onFocus={() => setSearchMode(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !trimmedQuery) {
|
||||
setSearchMode(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="search-filter-button"
|
||||
type="button"
|
||||
aria-pressed={highlightedOnly}
|
||||
onClick={() => {
|
||||
setHighlightedOnly((value) => !value)
|
||||
setSearchMode(true)
|
||||
}}
|
||||
>
|
||||
Highlighted
|
||||
</button>
|
||||
</form>
|
||||
{!searchMode ? (
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="stat">Search skills. Versioned, rollback-ready.</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="hero-install" style={{ marginTop: 18 }}>
|
||||
<div className="stat">Search skills. Versioned, rollback-ready.</div>
|
||||
<InstallSwitcher exampleSlug="sonoscli" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{searchMode ? (
|
||||
<section className="section">
|
||||
<h2 className="section-title">Search results</h2>
|
||||
<p className="section-subtitle">
|
||||
{isSearching ? 'Searching now.' : 'Instant results as you type.'}
|
||||
</p>
|
||||
<div className="grid">
|
||||
{!hasQuery ? (
|
||||
<div className="card">Start typing to search.</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="card">No results yet. Try a different prompt.</div>
|
||||
) : (
|
||||
results.map((result) => (
|
||||
<Link
|
||||
key={result.skill._id}
|
||||
to="/skills/$slug"
|
||||
params={{ slug: result.skill.slug }}
|
||||
className="card"
|
||||
>
|
||||
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
|
||||
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
|
||||
{result.skill.displayName}
|
||||
</h3>
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{result.skill.summary ?? 'Skill pack'}
|
||||
</p>
|
||||
{result.skill.batch === 'highlighted' ? (
|
||||
<div className="tag">Highlighted</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge="Highlighted"
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="section">
|
||||
<h2 className="section-title">Highlighted batch</h2>
|
||||
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
|
||||
<div className="grid">
|
||||
{highlighted.length === 0 ? (
|
||||
<div className="card">No highlighted skills yet.</div>
|
||||
) : (
|
||||
highlighted.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
badge="Highlighted"
|
||||
summaryFallback="A fresh skill bundle."
|
||||
meta={
|
||||
<div className="stat">
|
||||
⭐ {skill.stats.stars} · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="section-cta">
|
||||
<Link
|
||||
to="/skills"
|
||||
search={{
|
||||
q: undefined,
|
||||
sort: undefined,
|
||||
dir: undefined,
|
||||
highlighted: undefined,
|
||||
view: undefined,
|
||||
}}
|
||||
className="btn"
|
||||
>
|
||||
See all skills
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
<section className="section">
|
||||
<h2 className="section-title">Latest drops</h2>
|
||||
<p className="section-subtitle">Newest uploads across the registry.</p>
|
||||
<div className="grid">
|
||||
{latest.length === 0 ? (
|
||||
<div className="card">No skills yet. Be the first.</div>
|
||||
) : (
|
||||
latest.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
meta={
|
||||
<div className="stat">
|
||||
{skill.stats.versions} versions · ⤓ {skill.stats.downloads} · ⤒{' '}
|
||||
{skill.stats.installsAllTime ?? 0}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="section-cta">
|
||||
<Link to="/skills" className="btn">
|
||||
See all skills
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,10 @@ export const Route = createFileRoute('/search')({
|
||||
}),
|
||||
beforeLoad: ({ search }) => {
|
||||
throw redirect({
|
||||
to: '/',
|
||||
to: '/skills',
|
||||
search: {
|
||||
q: search.q || undefined,
|
||||
highlighted: search.highlighted || undefined,
|
||||
search: true,
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
|
||||
+31
-19
@@ -21,13 +21,34 @@ function parseDir(value: unknown, sort: SortKey): SortDir {
|
||||
return sort === 'name' ? 'asc' : 'desc'
|
||||
}
|
||||
|
||||
type SkillListEntry = {
|
||||
skill: Doc<'skills'>
|
||||
latestVersion: Doc<'skillVersions'> | null
|
||||
ownerHandle?: string | null
|
||||
}
|
||||
|
||||
type SkillSearchEntry = {
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
ownerHandle?: string | null
|
||||
}
|
||||
|
||||
function buildSkillHref(skill: Doc<'skills'>, ownerHandle?: string | null) {
|
||||
const owner = ownerHandle ?? 'unknown'
|
||||
return `/${encodeURIComponent(owner)}/${encodeURIComponent(skill.slug)}`
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/skills/')({
|
||||
validateSearch: (search) => {
|
||||
return {
|
||||
q: typeof search.q === 'string' && search.q.trim() ? search.q : undefined,
|
||||
sort: typeof search.sort === 'string' ? parseSort(search.sort) : undefined,
|
||||
dir: search.dir === 'asc' || search.dir === 'desc' ? search.dir : undefined,
|
||||
highlighted: search.highlighted === '1' || search.highlighted === 'true' ? true : undefined,
|
||||
highlighted:
|
||||
search.highlighted === '1' || search.highlighted === 'true' || search.highlighted === true
|
||||
? true
|
||||
: undefined,
|
||||
view: search.view === 'cards' || search.view === 'list' ? search.view : undefined,
|
||||
}
|
||||
},
|
||||
@@ -43,14 +64,10 @@ export function SkillsIndex() {
|
||||
const highlightedOnly = search.highlighted ?? false
|
||||
const [query, setQuery] = useState(search.q ?? '')
|
||||
const searchSkills = useAction(api.search.searchSkills)
|
||||
const [pages, setPages] = useState<
|
||||
Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
|
||||
>([])
|
||||
const [pages, setPages] = useState<Array<SkillListEntry>>([])
|
||||
const [cursor, setCursor] = useState<string | null>(null)
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
|
||||
>([])
|
||||
const [searchResults, setSearchResults] = useState<Array<SkillSearchEntry>>([])
|
||||
const [searchLimit, setSearchLimit] = useState(pageSize)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const searchRequest = useRef(0)
|
||||
@@ -65,7 +82,7 @@ export function SkillsIndex() {
|
||||
hasQuery ? 'skip' : { cursor: cursor ?? undefined, limit: pageSize },
|
||||
) as
|
||||
| {
|
||||
items: Array<{ skill: Doc<'skills'>; latestVersion: Doc<'skillVersions'> | null }>
|
||||
items: Array<SkillListEntry>
|
||||
nextCursor: string | null
|
||||
}
|
||||
| undefined
|
||||
@@ -110,11 +127,7 @@ export function SkillsIndex() {
|
||||
query: trimmedQuery,
|
||||
highlightedOnly,
|
||||
limit: searchLimit,
|
||||
})) as Array<{
|
||||
skill: Doc<'skills'>
|
||||
version: Doc<'skillVersions'> | null
|
||||
score: number
|
||||
}>
|
||||
})) as Array<SkillSearchEntry>
|
||||
if (requestId === searchRequest.current) {
|
||||
setSearchResults(data)
|
||||
}
|
||||
@@ -133,6 +146,7 @@ export function SkillsIndex() {
|
||||
return searchResults.map((entry) => ({
|
||||
skill: entry.skill,
|
||||
latestVersion: entry.version,
|
||||
ownerHandle: entry.ownerHandle ?? null,
|
||||
}))
|
||||
}
|
||||
return pages
|
||||
@@ -322,10 +336,12 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
return (
|
||||
<SkillCard
|
||||
key={skill._id}
|
||||
skill={skill}
|
||||
href={skillHref}
|
||||
badge={skill.batch === 'highlighted' ? 'Highlighted' : undefined}
|
||||
chip={isPlugin ? 'Plugin bundle (nix)' : undefined}
|
||||
summaryFallback="Agent-ready skill pack."
|
||||
@@ -344,13 +360,9 @@ export function SkillsIndex() {
|
||||
{sorted.map((entry) => {
|
||||
const skill = entry.skill
|
||||
const isPlugin = Boolean(entry.latestVersion?.parsed?.clawdis?.nix?.plugin)
|
||||
const skillHref = buildSkillHref(skill, entry.ownerHandle)
|
||||
return (
|
||||
<Link
|
||||
key={skill._id}
|
||||
className="skills-row"
|
||||
to="/skills/$slug"
|
||||
params={{ slug: skill.slug }}
|
||||
>
|
||||
<Link key={skill._id} className="skills-row" to={skillHref}>
|
||||
<div className="skills-row-main">
|
||||
<div className="skills-row-title">
|
||||
<span>{skill.displayName}</span>
|
||||
|
||||
Reference in New Issue
Block a user