|
| 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