Skip to content

Commit 554d5d4

Browse files
committed
feat: unify homepage search
1 parent 236c670 commit 554d5d4

File tree

3 files changed

+284
-139
lines changed

3 files changed

+284
-139
lines changed

src/routes/index.tsx

Lines changed: 191 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,91 @@
1-
import { createFileRoute, Link } from '@tanstack/react-router'
2-
import { useQuery } from 'convex/react'
1+
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
2+
import { useAction, useQuery } from 'convex/react'
3+
import { useEffect, useMemo, useRef, useState } from 'react'
34
import { api } from '../../convex/_generated/api'
45
import type { Doc } from '../../convex/_generated/dataModel'
56
import { InstallSwitcher } from '../components/InstallSwitcher'
67
import { SkillCard } from '../components/SkillCard'
78

89
export const Route = createFileRoute('/')({
10+
validateSearch: (search) => ({
11+
q: typeof search.q === 'string' ? search.q : '',
12+
highlighted: search.highlighted === '1' || search.highlighted === 'true',
13+
}),
914
component: Home,
1015
})
1116

1217
function Home() {
18+
const navigate = useNavigate()
19+
const search = Route.useSearch()
20+
const searchSkills = useAction(api.search.searchSkills)
1321
const highlighted =
1422
(useQuery(api.skills.list, { batch: 'highlighted', limit: 6 }) as Doc<'skills'>[]) ?? []
1523
const latest = (useQuery(api.skills.list, { limit: 12 }) as Doc<'skills'>[]) ?? []
24+
const [query, setQuery] = useState(search.q ?? '')
25+
const [highlightedOnly, setHighlightedOnly] = useState(search.highlighted ?? false)
26+
const [results, setResults] = useState<
27+
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
28+
>([])
29+
const [isSearching, setIsSearching] = useState(false)
30+
const [searchMode, setSearchMode] = useState(Boolean(search.q || search.highlighted))
31+
const searchRequest = useRef(0)
32+
const inputRef = useRef<HTMLInputElement | null>(null)
33+
const trimmedQuery = useMemo(() => query.trim(), [query])
34+
const hasQuery = trimmedQuery.length > 0
35+
36+
useEffect(() => {
37+
setQuery(search.q ?? '')
38+
setHighlightedOnly(search.highlighted ?? false)
39+
if (search.q || search.highlighted) {
40+
setSearchMode(true)
41+
}
42+
}, [search.highlighted, search.q])
43+
44+
useEffect(() => {
45+
void navigate({
46+
search: (prev) => ({
47+
...prev,
48+
q: trimmedQuery || undefined,
49+
highlighted: highlightedOnly ? '1' : undefined,
50+
}),
51+
replace: true,
52+
})
53+
}, [highlightedOnly, navigate, trimmedQuery])
54+
55+
useEffect(() => {
56+
if (!trimmedQuery) {
57+
setResults([])
58+
setIsSearching(false)
59+
return
60+
}
61+
const requestId = (searchRequest.current += 1)
62+
setIsSearching(true)
63+
const handle = window.setTimeout(() => {
64+
void (async () => {
65+
try {
66+
const data = (await searchSkills({ query: trimmedQuery, highlightedOnly })) as Array<{
67+
skill: Doc<'skills'>
68+
version: Doc<'skillVersions'> | null
69+
score: number
70+
}>
71+
if (requestId === searchRequest.current) {
72+
setResults(data)
73+
}
74+
} finally {
75+
if (requestId === searchRequest.current) {
76+
setIsSearching(false)
77+
}
78+
}
79+
})()
80+
}, 220)
81+
return () => window.clearTimeout(handle)
82+
}, [highlightedOnly, searchSkills, trimmedQuery])
1683

1784
return (
1885
<main className="app-shell">
19-
<section className="hero">
86+
<section className={`hero${searchMode ? ' search-mode' : ''}`}>
2087
<div className="hero-inner">
21-
<div className="fade-up" data-delay="1">
88+
<div className="hero-copy fade-up" data-delay="1">
2289
<span className="hero-badge">Lobster-light. Agent-right.</span>
2390
<h1 className="hero-title">ClawdHub, the skill dock for sharp agents.</h1>
2491
<p className="hero-subtitle">
@@ -34,72 +101,136 @@ function Home() {
34101
</Link>
35102
</div>
36103
</div>
37-
<div className="hero-card fade-up" data-delay="2">
38-
<div className="search-bar">
104+
<div className="hero-card hero-search-card fade-up" data-delay="2">
105+
<form
106+
className="search-bar"
107+
onSubmit={(event) => {
108+
event.preventDefault()
109+
if (!searchMode) setSearchMode(true)
110+
inputRef.current?.focus()
111+
}}
112+
>
39113
<span className="mono">/</span>
40114
<input
115+
ref={inputRef}
41116
className="search-input"
42117
placeholder="Search skills, tags, or capabilities"
43-
disabled
118+
value={query}
119+
onChange={(event) => setQuery(event.target.value)}
120+
onFocus={() => setSearchMode(true)}
121+
onKeyDown={(event) => {
122+
if (event.key === 'Escape' && !trimmedQuery) {
123+
setSearchMode(false)
124+
inputRef.current?.blur()
125+
}
126+
}}
44127
/>
45-
<Link to="/search" className="btn">
46-
Search
47-
</Link>
48-
</div>
49-
<div className="hero-install" style={{ marginTop: 18 }}>
50-
<div className="stat">Search skills. Versioned, rollback-ready.</div>
51-
<InstallSwitcher exampleSlug="sonoscli" />
52-
</div>
128+
<button
129+
className="search-filter-button"
130+
type="button"
131+
aria-pressed={highlightedOnly}
132+
onClick={() => {
133+
setHighlightedOnly((value) => !value)
134+
setSearchMode(true)
135+
}}
136+
>
137+
Highlighted
138+
</button>
139+
</form>
140+
{!searchMode ? (
141+
<div className="hero-install" style={{ marginTop: 18 }}>
142+
<div className="stat">Search skills. Versioned, rollback-ready.</div>
143+
<InstallSwitcher exampleSlug="sonoscli" />
144+
</div>
145+
) : null}
53146
</div>
54147
</div>
55148
</section>
56149

57-
<section className="section">
58-
<h2 className="section-title">Highlighted batch</h2>
59-
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
60-
<div className="grid">
61-
{highlighted.length === 0 ? (
62-
<div className="card">No highlighted skills yet.</div>
63-
) : (
64-
highlighted.map((skill) => (
65-
<SkillCard
66-
key={skill._id}
67-
skill={skill}
68-
badge="Highlighted"
69-
summaryFallback="A fresh skill bundle."
70-
meta={
71-
<div className="stat">
72-
{skill.stats.stars} · ⤓ {skill.stats.downloads}
73-
</div>
74-
}
75-
/>
76-
))
77-
)}
78-
</div>
79-
</section>
150+
{searchMode ? (
151+
<section className="section">
152+
<h2 className="section-title">Search results</h2>
153+
<p className="section-subtitle">
154+
{isSearching ? 'Searching now.' : 'Instant results as you type.'}
155+
</p>
156+
<div className="grid">
157+
{!hasQuery ? (
158+
<div className="card">Start typing to search.</div>
159+
) : results.length === 0 ? (
160+
<div className="card">No results yet. Try a different prompt.</div>
161+
) : (
162+
results.map((result) => (
163+
<Link
164+
key={result.skill._id}
165+
to="/skills/$slug"
166+
params={{ slug: result.skill.slug }}
167+
className="card"
168+
>
169+
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
170+
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
171+
{result.skill.displayName}
172+
</h3>
173+
<p className="section-subtitle" style={{ margin: 0 }}>
174+
{result.skill.summary ?? 'Skill pack'}
175+
</p>
176+
{result.skill.batch === 'highlighted' ? (
177+
<div className="tag">Highlighted</div>
178+
) : null}
179+
</Link>
180+
))
181+
)}
182+
</div>
183+
</section>
184+
) : (
185+
<>
186+
<section className="section">
187+
<h2 className="section-title">Highlighted batch</h2>
188+
<p className="section-subtitle">Curated signal — highlighted for quick trust.</p>
189+
<div className="grid">
190+
{highlighted.length === 0 ? (
191+
<div className="card">No highlighted skills yet.</div>
192+
) : (
193+
highlighted.map((skill) => (
194+
<SkillCard
195+
key={skill._id}
196+
skill={skill}
197+
badge="Highlighted"
198+
summaryFallback="A fresh skill bundle."
199+
meta={
200+
<div className="stat">
201+
{skill.stats.stars} · ⤓ {skill.stats.downloads}
202+
</div>
203+
}
204+
/>
205+
))
206+
)}
207+
</div>
208+
</section>
80209

81-
<section className="section">
82-
<h2 className="section-title">Latest drops</h2>
83-
<p className="section-subtitle">Newest uploads across the registry.</p>
84-
<div className="grid">
85-
{latest.length === 0 ? (
86-
<div className="card">No skills yet. Be the first.</div>
87-
) : (
88-
latest.map((skill) => (
89-
<SkillCard
90-
key={skill._id}
91-
skill={skill}
92-
summaryFallback="Agent-ready skill pack."
93-
meta={
94-
<div className="stat">
95-
{skill.stats.versions} versions · {skill.stats.downloads} downloads
96-
</div>
97-
}
98-
/>
99-
))
100-
)}
101-
</div>
102-
</section>
210+
<section className="section">
211+
<h2 className="section-title">Latest drops</h2>
212+
<p className="section-subtitle">Newest uploads across the registry.</p>
213+
<div className="grid">
214+
{latest.length === 0 ? (
215+
<div className="card">No skills yet. Be the first.</div>
216+
) : (
217+
latest.map((skill) => (
218+
<SkillCard
219+
key={skill._id}
220+
skill={skill}
221+
summaryFallback="Agent-ready skill pack."
222+
meta={
223+
<div className="stat">
224+
{skill.stats.versions} versions · {skill.stats.downloads} downloads
225+
</div>
226+
}
227+
/>
228+
))
229+
)}
230+
</div>
231+
</section>
232+
</>
233+
)}
103234
</main>
104235
)
105236
}

src/routes/search.tsx

Lines changed: 22 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,29 @@
1-
import { createFileRoute, Link } from '@tanstack/react-router'
2-
import { useAction } from 'convex/react'
3-
import { useState } from 'react'
4-
import { api } from '../../convex/_generated/api'
5-
import type { Doc } from '../../convex/_generated/dataModel'
1+
import { createFileRoute, useNavigate } from '@tanstack/react-router'
2+
import { useEffect } from 'react'
63

74
export const Route = createFileRoute('/search')({
8-
component: Search,
5+
validateSearch: (search) => ({
6+
q: typeof search.q === 'string' ? search.q : '',
7+
highlighted: search.highlighted === '1' || search.highlighted === 'true',
8+
}),
9+
component: SearchRedirect,
910
})
1011

11-
function Search() {
12-
const searchSkills = useAction(api.search.searchSkills)
13-
const [query, setQuery] = useState('')
14-
const [highlightedOnly, setHighlightedOnly] = useState(false)
15-
const [results, setResults] = useState<
16-
Array<{ skill: Doc<'skills'>; version: Doc<'skillVersions'> | null; score: number }>
17-
>([])
18-
const [isSearching, setIsSearching] = useState(false)
12+
function SearchRedirect() {
13+
const navigate = useNavigate()
14+
const search = Route.useSearch()
1915

20-
async function onSubmit(event: React.FormEvent) {
21-
event.preventDefault()
22-
if (!query.trim()) return
23-
setIsSearching(true)
24-
try {
25-
const data = (await searchSkills({ query, highlightedOnly })) as Array<{
26-
skill: Doc<'skills'>
27-
version: Doc<'skillVersions'> | null
28-
score: number
29-
}>
30-
setResults(data)
31-
} finally {
32-
setIsSearching(false)
33-
}
34-
}
16+
useEffect(() => {
17+
void navigate({
18+
to: '/',
19+
search: (prev) => ({
20+
...prev,
21+
q: search.q || undefined,
22+
highlighted: search.highlighted ? '1' : undefined,
23+
}),
24+
replace: true,
25+
})
26+
}, [navigate, search.highlighted, search.q])
3527

36-
return (
37-
<main className="section">
38-
<h1 className="section-title">Search</h1>
39-
<p className="section-subtitle">Ask for capabilities, get skill packs.</p>
40-
<form onSubmit={onSubmit} className="search-bar" style={{ marginBottom: 20 }}>
41-
<input
42-
className="search-input"
43-
value={query}
44-
onChange={(event) => setQuery(event.target.value)}
45-
placeholder="e.g. summarize PDFs, book travel, scrape web"
46-
/>
47-
<button className="btn btn-primary" type="submit" disabled={isSearching}>
48-
{isSearching ? 'Searching…' : 'Search'}
49-
</button>
50-
</form>
51-
<label className="search-filter">
52-
<input
53-
type="checkbox"
54-
className="search-filter-input"
55-
checked={highlightedOnly}
56-
onChange={(event) => setHighlightedOnly(event.target.checked)}
57-
/>
58-
Highlighted only
59-
</label>
60-
61-
<div className="grid" style={{ marginTop: 24 }}>
62-
{results.length === 0 ? (
63-
<div className="card">No results yet. Try a different prompt.</div>
64-
) : (
65-
results.map((result) => (
66-
<Link
67-
key={result.skill._id}
68-
to="/skills/$slug"
69-
params={{ slug: result.skill.slug }}
70-
className="card"
71-
>
72-
<div className="tag">Score {(result.score ?? 0).toFixed(2)}</div>
73-
<h3 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
74-
{result.skill.displayName}
75-
</h3>
76-
<p className="section-subtitle" style={{ margin: 0 }}>
77-
{result.skill.summary ?? 'Skill pack'}
78-
</p>
79-
{result.skill.batch === 'highlighted' ? <div className="tag">Highlighted</div> : null}
80-
</Link>
81-
))
82-
)}
83-
</div>
84-
</main>
85-
)
28+
return null
8629
}

0 commit comments

Comments
 (0)