Skip to content

Commit 4ecd497

Browse files
barckcodeclaude
andcommitted
Add search and tag filter to community projects page
Interactive Preact island (client:load) with: - Text search filtering by name and description with amber highlight - Tag filter chips for all 10 project tags - Flat grid when filtering, featured top-5 + rest layout when browsing all - Color-coded tag badges matching cloud-ui Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e8544c5 commit 4ecd497

2 files changed

Lines changed: 283 additions & 236 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { useState } from 'preact/hooks';
2+
3+
export interface Project {
4+
id: number;
5+
handle: string;
6+
name: string;
7+
description: string;
8+
imageUrl?: string;
9+
appUrl?: string;
10+
repoUrl?: string;
11+
tags: string[];
12+
stars: number;
13+
}
14+
15+
const ALL_TAGS = ['agents', 'websites', 'ai', 'api', 'tools', 'automation', 'data', 'mcp', 'voice', 'vision'];
16+
17+
const TAG_COLORS: Record<string, string> = {
18+
agents: 'bg-violet-500/15 text-violet-300 border-violet-800/60',
19+
websites: 'bg-sky-500/15 text-sky-300 border-sky-800/60',
20+
ai: 'bg-emerald-500/15 text-emerald-300 border-emerald-800/60',
21+
api: 'bg-amber-500/15 text-amber-300 border-amber-800/60',
22+
tools: 'bg-rose-500/15 text-rose-300 border-rose-800/60',
23+
automation: 'bg-indigo-500/15 text-indigo-300 border-indigo-800/60',
24+
data: 'bg-cyan-500/15 text-cyan-300 border-cyan-800/60',
25+
mcp: 'bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-800/60',
26+
voice: 'bg-orange-500/15 text-orange-300 border-orange-800/60',
27+
vision: 'bg-teal-500/15 text-teal-300 border-teal-800/60',
28+
};
29+
30+
const medalStyle: Record<number, { border: string; glow: string; badge: string; label: string }> = {
31+
0: { border: 'border-yellow-500/50', glow: 'shadow-[0_0_24px_rgba(234,179,8,0.12)]', badge: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/40', label: '★ #1' },
32+
1: { border: 'border-slate-400/40', glow: 'shadow-[0_0_16px_rgba(148,163,184,0.08)]', badge: 'bg-slate-500/20 text-slate-300 border-slate-500/40', label: '★ #2' },
33+
2: { border: 'border-amber-700/50', glow: 'shadow-[0_0_16px_rgba(180,83,9,0.10)]', badge: 'bg-amber-700/20 text-amber-400 border-amber-700/40', label: '★ #3' },
34+
3: { border: 'border-violet-500/30', glow: '', badge: 'bg-violet-500/10 text-violet-400 border-violet-500/30', label: '★ #4' },
35+
4: { border: 'border-violet-500/20', glow: '', badge: 'bg-violet-500/10 text-violet-400 border-violet-500/20', label: '★ #5' },
36+
};
37+
38+
const safeUrl = (u?: string): string | null => (u && /^https?:\/\//i.test(u) ? u : null);
39+
40+
function escapeRegex(s: string) {
41+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42+
}
43+
44+
function Highlight({ text, query }: { text: string; query: string }) {
45+
if (!query.trim()) return <>{text}</>;
46+
const parts = text.split(new RegExp(`(${escapeRegex(query)})`, 'gi'));
47+
return (
48+
<>
49+
{parts.map((part, i) =>
50+
i % 2 === 1 ? (
51+
<mark key={i} class="rounded-sm bg-amber-400/25 px-0.5 text-amber-200 not-italic">
52+
{part}
53+
</mark>
54+
) : (
55+
<span key={i}>{part}</span>
56+
),
57+
)}
58+
</>
59+
);
60+
}
61+
62+
function ProjectCard({
63+
project,
64+
searchQuery,
65+
rank,
66+
}: {
67+
project: Project;
68+
searchQuery: string;
69+
rank?: number;
70+
}) {
71+
const app = safeUrl(project.appUrl);
72+
const repo = safeUrl(project.repoUrl);
73+
const medal = rank !== undefined ? (medalStyle[rank] ?? medalStyle[4]) : null;
74+
75+
return (
76+
<article
77+
class={`flex flex-col overflow-hidden rounded-xl border bg-neutral-900/40 transition-all ${
78+
medal
79+
? `${medal.border} ${medal.glow}`
80+
: 'border-neutral-800/60 hover:border-neutral-700/60'
81+
}`}
82+
>
83+
{/* Image */}
84+
<div class="relative aspect-video w-full overflow-hidden bg-neutral-900">
85+
{safeUrl(project.imageUrl) ? (
86+
<img
87+
src={safeUrl(project.imageUrl)!}
88+
alt={project.name}
89+
loading="lazy"
90+
decoding="async"
91+
class="h-full w-full object-cover"
92+
/>
93+
) : (
94+
<div class="flex h-full w-full items-center justify-center text-neutral-700">
95+
<svg width="36" height="36" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
96+
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7a2 2 0 012-2h3l2 2h9a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
97+
</svg>
98+
</div>
99+
)}
100+
{medal && (
101+
<span class={`absolute top-2 left-2 rounded border px-2 py-0.5 font-mono text-[10px] backdrop-blur-sm ${medal.badge}`}>
102+
{medal.label}
103+
</span>
104+
)}
105+
<span class="absolute top-2 right-2 flex items-center gap-1 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-amber-300 backdrop-blur-sm">
106+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
107+
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
108+
</svg>
109+
{project.stars}
110+
</span>
111+
</div>
112+
113+
{/* Body */}
114+
<div class="flex flex-1 flex-col gap-2 p-4">
115+
<h3 class="font-mono text-sm font-semibold text-white leading-snug">
116+
<Highlight text={project.name} query={searchQuery} />
117+
</h3>
118+
<p class="font-mono text-[10px] text-neutral-500">by {project.handle}</p>
119+
<p class="font-mono text-xs leading-relaxed text-neutral-400 line-clamp-3">
120+
<Highlight text={project.description} query={searchQuery} />
121+
</p>
122+
123+
{project.tags.length > 0 && (
124+
<div class="mt-1 flex flex-wrap gap-1">
125+
{project.tags.map((tag) => (
126+
<span
127+
key={tag}
128+
class={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${
129+
TAG_COLORS[tag] ?? 'bg-neutral-800/50 text-neutral-400 border-neutral-700'
130+
}`}
131+
>
132+
{tag}
133+
</span>
134+
))}
135+
</div>
136+
)}
137+
138+
<div class="mt-auto flex items-center gap-4 pt-3">
139+
{app && (
140+
<a href={app} target="_blank" rel="noopener noreferrer" class="font-mono text-xs text-violet-400 transition-colors hover:text-violet-300">
141+
↗ App
142+
</a>
143+
)}
144+
{repo && (
145+
<a href={repo} target="_blank" rel="noopener noreferrer" class="font-mono text-xs text-neutral-500 transition-colors hover:text-white">
146+
↗ Repo
147+
</a>
148+
)}
149+
</div>
150+
</div>
151+
</article>
152+
);
153+
}
154+
155+
interface Props {
156+
projects: Project[];
157+
}
158+
159+
export function ProjectsGallery({ projects }: Props) {
160+
const [search, setSearch] = useState('');
161+
const [activeTag, setActiveTag] = useState<string | null>(null);
162+
163+
const isFiltering = search.trim() !== '' || activeTag !== null;
164+
165+
const filtered = isFiltering
166+
? projects.filter((p) => {
167+
const q = search.trim().toLowerCase();
168+
const matchesSearch =
169+
!q ||
170+
p.name.toLowerCase().includes(q) ||
171+
p.description.toLowerCase().includes(q);
172+
const matchesTag = !activeTag || p.tags.includes(activeTag);
173+
return matchesSearch && matchesTag;
174+
})
175+
: projects;
176+
177+
const featured = isFiltering ? [] : filtered.slice(0, 5);
178+
const rest = isFiltering ? filtered : filtered.slice(5);
179+
180+
return (
181+
<div>
182+
{/* Search + Tag filter */}
183+
<div class="mb-10 space-y-4">
184+
<input
185+
type="text"
186+
value={search}
187+
onInput={(e) => setSearch((e.target as HTMLInputElement).value)}
188+
placeholder="Search by name or description…"
189+
class="w-full rounded-lg border border-neutral-800 bg-neutral-900/50 px-4 py-2.5 font-mono text-sm text-white placeholder-neutral-600 outline-none transition-colors focus:border-violet-500/50 sm:max-w-sm"
190+
/>
191+
192+
<div class="flex flex-wrap items-center gap-1.5">
193+
<span class="font-mono text-[10px] uppercase tracking-wider text-neutral-600">tags:</span>
194+
<button
195+
type="button"
196+
onClick={() => setActiveTag(null)}
197+
class={`rounded border px-2 py-0.5 font-mono text-[10px] transition-colors ${
198+
activeTag === null
199+
? 'border-violet-500/40 text-violet-300'
200+
: 'border-neutral-800 text-neutral-500 hover:text-white'
201+
}`}
202+
>
203+
all
204+
</button>
205+
{ALL_TAGS.map((tag) => (
206+
<button
207+
key={tag}
208+
type="button"
209+
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
210+
class={`rounded border px-2 py-0.5 font-mono text-[10px] transition-colors ${
211+
activeTag === tag
212+
? 'border-violet-500/40 text-violet-300'
213+
: 'border-neutral-800 text-neutral-500 hover:text-white'
214+
}`}
215+
>
216+
{tag}
217+
</button>
218+
))}
219+
</div>
220+
</div>
221+
222+
{/* Filtering: flat grid */}
223+
{isFiltering && (
224+
<>
225+
{filtered.length === 0 ? (
226+
<div class="rounded-xl border border-neutral-800/60 bg-neutral-900/20 p-16 text-center">
227+
<p class="font-mono text-sm text-neutral-500">No projects match the current filter.</p>
228+
</div>
229+
) : (
230+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
231+
{filtered.map((p) => (
232+
<ProjectCard key={p.id} project={p} searchQuery={search.trim()} />
233+
))}
234+
</div>
235+
)}
236+
</>
237+
)}
238+
239+
{/* Default: top 5 + rest */}
240+
{!isFiltering && (
241+
<>
242+
{featured.length > 0 && (
243+
<section class="mb-16">
244+
<h2 class="mb-6 font-mono text-xs uppercase tracking-widest text-neutral-500">★ Top Projects</h2>
245+
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
246+
{featured.map((p, i) => (
247+
<ProjectCard key={p.id} project={p} searchQuery="" rank={i} />
248+
))}
249+
</div>
250+
</section>
251+
)}
252+
253+
{rest.length > 0 && (
254+
<section>
255+
<h2 class="mb-6 font-mono text-xs uppercase tracking-widest text-neutral-500">All Projects</h2>
256+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
257+
{rest.map((p) => (
258+
<ProjectCard key={p.id} project={p} searchQuery="" />
259+
))}
260+
</div>
261+
</section>
262+
)}
263+
264+
{projects.length === 0 && (
265+
<div class="rounded-xl border border-neutral-800/60 bg-neutral-900/20 p-16 text-center">
266+
<p class="font-mono text-sm text-neutral-500">No projects yet.</p>
267+
<p class="mt-2 font-mono text-xs text-neutral-600">
268+
Be the first to submit one on{' '}
269+
<a href="https://cloud.nan.builders/projects" class="text-violet-400 hover:text-violet-300 transition-colors">
270+
cloud.nan.builders
271+
</a>.
272+
</p>
273+
</div>
274+
)}
275+
</>
276+
)}
277+
</div>
278+
);
279+
}

0 commit comments

Comments
 (0)