Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

search.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

search.tsxBlame959 lines · 1 contributor
3ef4c9dClaude1/**
2 * Global search — across repos, users, issues, PRs.
3 *
4 * GET /search?q=...&type=repos|users|issues|prs
5 *
6 * Text search uses Postgres ILIKE — good enough for the scale GlueCron is at
7 * today. If traffic grows, swap in pgvector + AI embeddings.
8 */
9
10import { Hono } from "hono";
283fbc2Claude11import { and, desc, eq, sql } from "drizzle-orm";
3ef4c9dClaude12import { db } from "../db";
13import {
14 issues,
15 pullRequests,
16 repositories,
17 users,
18} from "../db/schema";
19import { Layout } from "../views/layout";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { getUnreadCount } from "../lib/unread";
23
24const search = new Hono<AuthEnv>();
25search.use("*", softAuth);
26
283fbc2Claude27const SearchPageStyle = () => (
28 <style
29 dangerouslySetInnerHTML={{
30 __html: `
31 /* ─── Hero ─── */
32 .search-page-hero {
33 position: relative;
34 margin: 4px 0 22px;
35 padding: 32px 32px 28px;
36 background: var(--bg-elevated);
37 border: 1px solid var(--border);
38 border-radius: 16px;
39 overflow: hidden;
40 }
41 .search-page-hero::before {
42 content: '';
43 position: absolute;
44 top: 0; left: 0; right: 0;
45 height: 2px;
46 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
47 opacity: 0.7;
48 pointer-events: none;
49 }
50 .search-page-hero-bg {
51 position: absolute;
52 inset: -30% -10% auto auto;
53 width: 360px;
54 height: 360px;
55 pointer-events: none;
56 z-index: 0;
57 }
58 .search-page-hero-orb {
59 position: absolute;
60 inset: 0;
61 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
62 filter: blur(80px);
63 opacity: 0.7;
64 animation: searchPageHeroOrb 14s ease-in-out infinite;
65 }
66 @keyframes searchPageHeroOrb {
67 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
68 50% { transform: scale(1.1) translate(-12px, 8px); opacity: 0.85; }
69 }
70 @media (prefers-reduced-motion: reduce) {
71 .search-page-hero-orb { animation: none; }
72 }
73 .search-page-hero-inner {
74 position: relative;
75 z-index: 1;
76 display: flex;
77 flex-direction: column;
78 gap: 16px;
79 }
80 .search-page-hero-eyebrow {
81 font-size: 12.5px;
82 color: var(--text-muted);
83 letter-spacing: 0.06em;
84 text-transform: uppercase;
85 font-weight: 600;
86 }
87 .search-page-hero-title {
88 font-family: var(--font-display);
89 font-size: clamp(28px, 4vw, 40px);
90 font-weight: 800;
91 letter-spacing: -0.028em;
92 line-height: 1.05;
93 margin: 0;
94 color: var(--text-strong);
95 }
96 .search-page-hero-sub {
97 font-size: 15px;
98 color: var(--text-muted);
99 margin: 0;
100 line-height: 1.5;
101 max-width: 620px;
102 }
103 .search-page-form {
104 margin-top: 6px;
105 position: relative;
106 display: flex;
107 align-items: stretch;
108 gap: 0;
109 }
110 .search-page-input-wrap {
111 position: relative;
112 flex: 1;
113 }
114 .search-page-input-icon {
115 position: absolute;
116 top: 50%;
117 left: 18px;
118 transform: translateY(-50%);
119 color: var(--text-muted);
120 pointer-events: none;
121 width: 18px;
122 height: 18px;
123 }
124 .search-page-input {
125 width: 100%;
126 padding: 14px 18px 14px 48px;
127 background: var(--bg);
128 border: 1px solid var(--border);
129 border-radius: 14px;
130 color: var(--text);
131 font-size: 15px;
132 font-family: inherit;
133 transition: border-color 140ms ease, box-shadow 140ms ease;
134 }
135 .search-page-input::placeholder { color: var(--text-muted); }
136 .search-page-input:focus {
137 outline: none;
138 border-color: rgba(140,109,255,0.55);
139 box-shadow: 0 0 0 4px rgba(140,109,255,0.12);
140 }
141 .search-page-kbd {
142 position: absolute;
143 right: 14px;
144 top: 50%;
145 transform: translateY(-50%);
146 font-family: var(--font-mono, monospace);
147 font-size: 11px;
148 color: var(--text-muted);
149 background: rgba(255,255,255,0.04);
150 border: 1px solid var(--border);
151 padding: 2px 8px;
152 border-radius: 6px;
153 pointer-events: none;
154 }
155 @media (max-width: 720px) {
156 .search-page-hero { padding: 24px 20px; }
157 .search-page-kbd { display: none; }
158 }
159
160 /* ─── Tab pills ─── */
161 .search-page-tabs {
162 display: inline-flex;
163 background: var(--bg-elevated);
164 border: 1px solid var(--border);
165 border-radius: 9999px;
166 padding: 4px;
167 gap: 2px;
168 margin: 0 0 18px;
169 flex-wrap: wrap;
170 }
171 .search-page-tab {
172 display: inline-flex;
173 align-items: center;
174 gap: 7px;
175 padding: 7px 16px;
176 border-radius: 9999px;
177 font-size: 13px;
178 font-weight: 500;
179 color: var(--text-muted);
180 text-decoration: none;
181 transition: color 120ms ease, background 120ms ease;
182 line-height: 1.4;
183 }
184 .search-page-tab:hover {
185 color: var(--text-strong);
186 text-decoration: none;
187 }
188 .search-page-tab.is-active {
189 background: rgba(140,109,255,0.14);
190 color: var(--text-strong);
191 }
192 .search-page-tab-count {
193 font-variant-numeric: tabular-nums;
194 font-size: 11.5px;
195 color: var(--text-muted);
196 background: rgba(255,255,255,0.04);
197 padding: 1px 8px;
198 border-radius: 9999px;
199 }
200 .search-page-tab.is-active .search-page-tab-count {
201 background: rgba(140,109,255,0.20);
202 color: var(--text);
203 }
204
205 /* ─── Result list ─── */
206 .search-page-results {
207 display: flex;
208 flex-direction: column;
209 gap: 10px;
210 }
211 .search-page-result {
212 position: relative;
213 display: flex;
214 gap: 14px;
215 padding: 16px 20px;
216 background: var(--bg-elevated);
217 border: 1px solid var(--border);
218 border-radius: 14px;
219 text-decoration: none;
220 color: inherit;
221 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
222 }
223 .search-page-result:hover {
224 transform: translateY(-1px);
225 border-color: rgba(140,109,255,0.45);
226 box-shadow: 0 10px 22px -16px rgba(0,0,0,0.5), 0 0 18px -8px rgba(140,109,255,0.18);
227 text-decoration: none;
228 }
229 .search-page-result-avatar {
230 width: 40px;
231 height: 40px;
232 border-radius: 10px;
233 display: inline-flex;
234 align-items: center;
235 justify-content: center;
236 font-family: var(--font-display);
237 font-weight: 700;
238 font-size: 15px;
239 color: #fff;
240 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
241 flex-shrink: 0;
242 letter-spacing: -0.01em;
243 }
244 .search-page-result-avatar.is-user {
245 border-radius: 9999px;
246 }
247 .search-page-result-body {
248 flex: 1;
249 min-width: 0;
250 display: flex;
251 flex-direction: column;
252 gap: 4px;
253 }
254 .search-page-result-title {
255 font-family: var(--font-display);
256 font-size: 15.5px;
257 font-weight: 600;
258 color: var(--text-strong);
259 letter-spacing: -0.014em;
260 line-height: 1.35;
261 margin: 0;
262 display: flex;
263 flex-wrap: wrap;
264 align-items: center;
265 gap: 8px;
266 }
267 .search-page-result:hover .search-page-result-title { color: var(--accent); }
268 .search-page-result-meta {
269 font-size: 12.5px;
270 color: var(--text-muted);
271 line-height: 1.5;
272 }
273 .search-page-result-meta strong {
274 color: var(--text);
275 font-weight: 600;
276 }
277 .search-page-result-desc {
278 font-size: 13.5px;
279 color: var(--text-muted);
280 margin: 4px 0 0;
281 line-height: 1.5;
282 display: -webkit-box;
283 -webkit-line-clamp: 2;
284 -webkit-box-orient: vertical;
285 overflow: hidden;
286 }
287
288 /* ─── State pill (issues/PRs) ─── */
289 .search-page-state {
290 display: inline-flex;
291 align-items: center;
292 gap: 5px;
293 padding: 2px 10px;
294 border-radius: 9999px;
295 font-size: 11.5px;
296 font-weight: 600;
297 line-height: 1.5;
298 letter-spacing: 0.005em;
299 }
300 .search-page-state-open {
301 background: rgba(52,211,153,0.10);
302 color: #34d399;
303 border: 1px solid rgba(52,211,153,0.30);
304 }
305 .search-page-state-closed {
306 background: rgba(140,109,255,0.12);
307 color: #b69dff;
308 border: 1px solid rgba(140,109,255,0.30);
309 }
310 .search-page-state-merged {
311 background: rgba(54,197,214,0.10);
312 color: #36c5d6;
313 border: 1px solid rgba(54,197,214,0.28);
314 }
315 .search-page-state-draft {
316 background: rgba(255,255,255,0.04);
317 color: var(--text-muted);
318 border: 1px solid var(--border);
319 }
320
321 /* ─── Code result card ─── */
322 .search-page-code {
323 display: flex;
324 flex-direction: column;
325 gap: 10px;
326 padding: 14px 18px;
327 background: var(--bg-elevated);
328 border: 1px solid var(--border);
329 border-radius: 14px;
330 }
331 .search-page-code-path {
332 font-family: var(--font-mono, monospace);
333 font-size: 12.5px;
334 color: var(--text);
335 display: flex;
336 align-items: center;
337 gap: 8px;
338 }
339 .search-page-code-snippet {
340 display: flex;
341 background: var(--bg);
342 border: 1px solid var(--border);
343 border-radius: 10px;
344 padding: 8px 12px;
345 font-family: var(--font-mono, monospace);
346 font-size: 12.5px;
347 overflow-x: auto;
348 }
349 .search-page-code-lineno {
350 color: var(--text-muted);
351 flex-shrink: 0;
352 padding-right: 14px;
353 border-right: 1px solid var(--border);
354 margin-right: 12px;
355 text-align: right;
356 min-width: 32px;
357 font-variant-numeric: tabular-nums;
358 }
359 .search-page-code-line {
360 color: var(--text);
361 white-space: pre;
362 }
363 .search-page-code-mark {
364 background: rgba(140,109,255,0.22);
365 color: var(--text-strong);
366 border-radius: 3px;
367 padding: 0 2px;
368 }
369
370 /* ─── Empty / hint states ─── */
371 .search-page-empty {
372 margin: 0;
373 padding: 56px 32px;
374 background: var(--bg-elevated);
375 border: 1px solid var(--border);
376 border-radius: 16px;
377 text-align: center;
378 position: relative;
379 overflow: hidden;
380 }
381 .search-page-empty::before {
382 content: '';
383 position: absolute;
384 top: 0; left: 0; right: 0;
385 height: 2px;
386 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
387 opacity: 0.55;
388 pointer-events: none;
389 }
390 .search-page-empty-art {
391 width: 88px;
392 height: 88px;
393 margin: 0 auto 18px;
394 display: block;
395 opacity: 0.85;
396 }
397 .search-page-empty-title {
398 font-family: var(--font-display);
399 font-size: 22px;
400 font-weight: 700;
401 letter-spacing: -0.018em;
402 color: var(--text-strong);
403 margin: 0 0 8px;
404 }
405 .search-page-empty-sub {
406 font-size: 14.5px;
407 color: var(--text-muted);
408 line-height: 1.55;
409 margin: 0 auto 22px;
410 max-width: 480px;
411 }
412 .search-page-empty-cta {
413 display: inline-flex;
414 gap: 10px;
415 flex-wrap: wrap;
416 justify-content: center;
417 }
418 .search-page-empty-tip {
419 display: inline-flex;
420 flex-direction: column;
421 gap: 6px;
422 margin-top: 12px;
423 font-size: 13px;
424 color: var(--text-muted);
425 text-align: left;
426 }
427 .search-page-empty-tip-row { color: var(--text-muted); }
428 .search-page-empty-tip-row strong { color: var(--text); font-weight: 600; }
429
430 /* ─── Shortcuts page (kept simple — reuses panel) ─── */
431 .search-page-shortcut-row {
432 display: flex;
433 justify-content: space-between;
434 align-items: center;
435 padding: 11px 16px;
436 border-bottom: 1px solid var(--border);
437 }
438 .search-page-shortcut-row:last-child { border-bottom: none; }
439 `,
440 }}
441 />
442);
443
444function userInitials(u: typeof users.$inferSelect): string {
445 const src = u.displayName || u.username || "·";
446 const clean = src.replace(/[^a-zA-Z0-9]+/g, " ").trim();
447 if (!clean) return src.charAt(0).toUpperCase();
448 const parts = clean.split(/\s+/);
449 if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
450 return (parts[0][0] + parts[1][0]).toUpperCase();
451}
452
453function repoInitials(ownerName: string, name: string): string {
454 const src = name || ownerName || "·";
455 const clean = src.replace(/[^a-zA-Z0-9]+/g, " ").trim();
456 if (!clean) return src.charAt(0).toUpperCase();
457 const parts = clean.split(/\s+/);
458 if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
459 return (parts[0][0] + parts[1][0]).toUpperCase();
460}
461
3ef4c9dClaude462search.get("/search", async (c) => {
463 const user = c.get("user");
464 const q = (c.req.query("q") || "").trim();
465 const type = c.req.query("type") || "repos";
466 const unread = user ? await getUnreadCount(user.id) : 0;
467
468 let repoHits: Array<{
469 repo: typeof repositories.$inferSelect;
470 ownerName: string;
471 }> = [];
472 let userHits: Array<typeof users.$inferSelect> = [];
473 let issueHits: Array<{
474 id: string;
475 number: number;
476 title: string;
477 state: string;
478 repoName: string;
479 repoOwner: string;
480 }> = [];
481 let prHits: Array<{
482 id: string;
483 number: number;
484 title: string;
485 state: string;
486 repoName: string;
487 repoOwner: string;
488 }> = [];
489
490 if (q) {
491 const pat = `%${q}%`;
492 if (type === "repos") {
493 const results = await db
494 .select({ repo: repositories, ownerName: users.username })
495 .from(repositories)
496 .innerJoin(users, eq(repositories.ownerId, users.id))
497 .where(
498 and(
499 eq(repositories.isPrivate, false),
500 sql`(${repositories.name} ILIKE ${pat} OR ${repositories.description} ILIKE ${pat})`
501 )
502 )
503 .orderBy(desc(repositories.starCount))
504 .limit(30);
505 repoHits = results;
506 } else if (type === "users") {
507 userHits = await db
508 .select()
509 .from(users)
510 .where(
511 sql`(${users.username} ILIKE ${pat} OR ${users.displayName} ILIKE ${pat})`
512 )
513 .limit(30);
514 } else if (type === "issues") {
515 issueHits = await db
516 .select({
517 id: issues.id,
518 number: issues.number,
519 title: issues.title,
520 state: issues.state,
521 repoName: repositories.name,
522 repoOwner: users.username,
523 })
524 .from(issues)
525 .innerJoin(repositories, eq(issues.repositoryId, repositories.id))
526 .innerJoin(users, eq(repositories.ownerId, users.id))
527 .where(
528 and(
529 eq(repositories.isPrivate, false),
530 sql`(${issues.title} ILIKE ${pat} OR ${issues.body} ILIKE ${pat})`
531 )
532 )
533 .orderBy(desc(issues.updatedAt))
534 .limit(30);
535 } else if (type === "prs") {
536 prHits = await db
537 .select({
538 id: pullRequests.id,
539 number: pullRequests.number,
540 title: pullRequests.title,
541 state: pullRequests.state,
542 repoName: repositories.name,
543 repoOwner: users.username,
544 })
545 .from(pullRequests)
546 .innerJoin(repositories, eq(pullRequests.repositoryId, repositories.id))
547 .innerJoin(users, eq(repositories.ownerId, users.id))
548 .where(
549 and(
550 eq(repositories.isPrivate, false),
551 sql`(${pullRequests.title} ILIKE ${pat} OR ${pullRequests.body} ILIKE ${pat})`
552 )
553 )
554 .orderBy(desc(pullRequests.updatedAt))
555 .limit(30);
556 }
557 }
558
283fbc2Claude559 // Active-tab count for the count chip (only the active tab knows its count).
560 const activeCount =
561 type === "repos"
562 ? repoHits.length
563 : type === "users"
564 ? userHits.length
565 : type === "issues"
566 ? issueHits.length
567 : type === "prs"
568 ? prHits.length
569 : 0;
570
571 const tabPill = (id: string, label: string) => {
572 const isActive = type === id;
573 const showCount = isActive && q;
574 return (
575 <a
576 href={`/search?q=${encodeURIComponent(q)}&type=${id}`}
577 class={`search-page-tab${isActive ? " is-active" : ""}`}
578 role="tab"
579 aria-selected={isActive ? "true" : "false"}
580 >
581 <span>{label}</span>
582 {showCount && (
583 <span class="search-page-tab-count">{activeCount}</span>
584 )}
585 </a>
586 );
587 };
588
589 const stateClass = (state: string) =>
590 state === "open"
591 ? "search-page-state search-page-state-open"
592 : state === "merged"
593 ? "search-page-state search-page-state-merged"
594 : state === "draft"
595 ? "search-page-state search-page-state-draft"
596 : "search-page-state search-page-state-closed";
597
598 const noResultsFor = (label: string) => (
599 <div class="search-page-empty">
600 <svg
601 class="search-page-empty-art"
602 viewBox="0 0 96 96"
603 fill="none"
604 xmlns="http://www.w3.org/2000/svg"
605 aria-hidden="true"
606 >
607 <defs>
608 <linearGradient id="searchEmptyG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
609 <stop stop-color="#8c6dff" />
610 <stop offset="1" stop-color="#36c5d6" />
611 </linearGradient>
612 </defs>
613 <circle cx="40" cy="40" r="22" stroke="url(#searchEmptyG)" stroke-width="4" />
614 <path d="M56 56L78 78" stroke="url(#searchEmptyG)" stroke-width="4" stroke-linecap="round" />
615 <path d="M32 40h16M40 32v16" stroke="url(#searchEmptyG)" stroke-width="3" stroke-linecap="round" opacity="0.55" />
616 </svg>
617 <h2 class="search-page-empty-title">No {label} for "{q}"</h2>
618 <p class="search-page-empty-sub">
619 Try a different keyword, switch categories, or browse{" "}
620 <a href="/explore">Explore</a> to discover what's shipping on Gluecron.
621 </p>
622 <div class="search-page-empty-cta">
623 <a href="/explore" class="btn btn-primary">
624 Go to Explore
625 </a>
626 <a href={`/search?q=&type=${type}`} class="btn">
627 Clear search
628 </a>
629 </div>
630 </div>
3ef4c9dClaude631 );
632
633 return c.html(
634 <Layout
635 title={q ? `Search — ${q}` : "Search"}
636 user={user}
637 notificationCount={unread}
638 >
283fbc2Claude639 <SearchPageStyle />
640
641 <section class="search-page-hero">
642 <div class="search-page-hero-bg" aria-hidden="true">
643 <div class="search-page-hero-orb" />
644 </div>
645 <div class="search-page-hero-inner">
646 <div class="search-page-hero-eyebrow">Global search</div>
647 <h1 class="search-page-hero-title">
648 <span class="gradient-text">Search</span> Gluecron.
649 </h1>
650 <p class="search-page-hero-sub">
651 Find repos, people, issues, pull requests, and code across the
652 platform. Everything public is fair game.
653 </p>
654 <form
655 method="get"
656 action="/search"
657 class="search-page-form"
658 role="search"
659 >
660 <input type="hidden" name="type" value={type} />
661 <div class="search-page-input-wrap">
662 <svg
663 class="search-page-input-icon"
664 viewBox="0 0 24 24"
665 fill="none"
666 xmlns="http://www.w3.org/2000/svg"
667 aria-hidden="true"
668 >
669 <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
670 <path d="M20 20L17 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
671 </svg>
672 <input
673 type="search"
674 name="q"
675 value={q}
676 placeholder="Search repos, users, issues, code…"
677 aria-label="Search Gluecron"
678 class="search-page-input"
679 autofocus
680 autocomplete="off"
681 />
682 <span class="search-page-kbd" aria-hidden="true">
683 Enter
684 </span>
685 </div>
686 </form>
687 </div>
688 </section>
3ef4c9dClaude689
283fbc2Claude690 <div
691 class="search-page-tabs"
692 role="tablist"
693 aria-label="Result categories"
694 >
695 {tabPill("repos", "Repositories")}
696 {tabPill("users", "Users")}
697 {tabPill("issues", "Issues")}
698 {tabPill("prs", "Pull requests")}
699 {tabPill("code", "Code")}
3ef4c9dClaude700 </div>
701
702 {!q && (
283fbc2Claude703 <div class="search-page-empty">
704 <svg
705 class="search-page-empty-art"
706 viewBox="0 0 96 96"
707 fill="none"
708 xmlns="http://www.w3.org/2000/svg"
709 aria-hidden="true"
710 >
711 <defs>
712 <linearGradient id="searchIdleG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
713 <stop stop-color="#8c6dff" />
714 <stop offset="1" stop-color="#36c5d6" />
715 </linearGradient>
716 </defs>
717 <circle cx="42" cy="42" r="22" stroke="url(#searchIdleG)" stroke-width="4" />
718 <path d="M58 58L78 78" stroke="url(#searchIdleG)" stroke-width="4" stroke-linecap="round" />
719 <circle cx="42" cy="42" r="8" stroke="url(#searchIdleG)" stroke-width="3" opacity="0.55" />
720 </svg>
721 <h2 class="search-page-empty-title">Search Gluecron</h2>
722 <p class="search-page-empty-sub">
723 Type to search across repos, users, issues, and pull requests.
724 Switch categories with the tabs above.
725 </p>
726 <div class="search-page-empty-tip">
727 <span class="search-page-empty-tip-row">
728 <strong>Tip:</strong> Press <code>/</code> from any page to focus
729 the global search.
730 </span>
731 <span class="search-page-empty-tip-row">
732 Looking for something to browse?{" "}
733 <a href="/explore">Head to Explore</a>.
734 </span>
735 </div>
3ef4c9dClaude736 </div>
737 )}
738
739 {q && type === "repos" && (
740 repoHits.length === 0 ? (
283fbc2Claude741 noResultsFor("repositories")
3ef4c9dClaude742 ) : (
283fbc2Claude743 <div class="search-page-results">
3ef4c9dClaude744 {repoHits.map(({ repo, ownerName }) => (
283fbc2Claude745 <a
746 class="search-page-result"
747 href={`/${ownerName}/${repo.name}`}
748 aria-label={`${ownerName}/${repo.name}`}
749 >
750 <div class="search-page-result-avatar" aria-hidden="true">
751 {repoInitials(ownerName, repo.name)}
752 </div>
753 <div class="search-page-result-body">
754 <h3 class="search-page-result-title">
755 <span>
756 {ownerName}/{repo.name}
757 </span>
758 {repo.forkedFromId && (
759 <span class="search-page-state search-page-state-merged">
760 Fork
761 </span>
762 )}
763 </h3>
764 {repo.description && (
765 <p class="search-page-result-desc">{repo.description}</p>
766 )}
767 <div class="search-page-result-meta">
768 <span>{"★"} {repo.starCount}</span>
769 {" · "}
770 <span>{"⑂"} {repo.forkCount}</span>
771 {repo.pushedAt && (
772 <>
773 {" · "}
774 <span>
775 Updated{" "}
776 {new Date(repo.pushedAt).toLocaleDateString("en-US", {
777 month: "short",
778 day: "numeric",
779 year: "numeric",
780 })}
781 </span>
782 </>
783 )}
784 </div>
785 </div>
786 </a>
3ef4c9dClaude787 ))}
788 </div>
789 )
790 )}
791
792 {q && type === "users" && (
793 userHits.length === 0 ? (
283fbc2Claude794 noResultsFor("users")
3ef4c9dClaude795 ) : (
283fbc2Claude796 <div class="search-page-results">
3ef4c9dClaude797 {userHits.map((u) => (
283fbc2Claude798 <a
799 class="search-page-result"
800 href={`/${u.username}`}
801 aria-label={u.username}
802 >
803 <div
804 class="search-page-result-avatar is-user"
805 aria-hidden="true"
806 >
807 {userInitials(u)}
808 </div>
809 <div class="search-page-result-body">
810 <h3 class="search-page-result-title">
811 <span>{u.displayName || u.username}</span>
812 </h3>
813 <div class="search-page-result-meta">@{u.username}</div>
814 {u.bio && (
815 <p class="search-page-result-desc">{u.bio}</p>
816 )}
3ef4c9dClaude817 </div>
283fbc2Claude818 </a>
3ef4c9dClaude819 ))}
820 </div>
821 )
822 )}
823
824 {q && type === "issues" && (
825 issueHits.length === 0 ? (
283fbc2Claude826 noResultsFor("issues")
3ef4c9dClaude827 ) : (
283fbc2Claude828 <div class="search-page-results">
3ef4c9dClaude829 {issueHits.map((i) => (
283fbc2Claude830 <a
831 class="search-page-result"
832 href={`/${i.repoOwner}/${i.repoName}/issues/${i.number}`}
833 aria-label={`Issue ${i.title}`}
834 >
835 <div class="search-page-result-avatar" aria-hidden="true">
836 {repoInitials(i.repoOwner, i.repoName)}
837 </div>
838 <div class="search-page-result-body">
839 <h3 class="search-page-result-title">
840 <span>{i.title}</span>
841 <span class={stateClass(i.state)}>{i.state}</span>
842 </h3>
843 <div class="search-page-result-meta">
844 <strong>
845 {i.repoOwner}/{i.repoName}
846 </strong>{" "}
847 · #{i.number}
3ef4c9dClaude848 </div>
849 </div>
283fbc2Claude850 </a>
3ef4c9dClaude851 ))}
852 </div>
853 )
854 )}
855
856 {q && type === "prs" && (
857 prHits.length === 0 ? (
283fbc2Claude858 noResultsFor("pull requests")
3ef4c9dClaude859 ) : (
283fbc2Claude860 <div class="search-page-results">
3ef4c9dClaude861 {prHits.map((pr) => (
283fbc2Claude862 <a
863 class="search-page-result"
864 href={`/${pr.repoOwner}/${pr.repoName}/pull/${pr.number}`}
865 aria-label={`Pull request ${pr.title}`}
866 >
867 <div class="search-page-result-avatar" aria-hidden="true">
868 {repoInitials(pr.repoOwner, pr.repoName)}
869 </div>
870 <div class="search-page-result-body">
871 <h3 class="search-page-result-title">
872 <span>{pr.title}</span>
873 <span class={stateClass(pr.state)}>{pr.state}</span>
874 </h3>
875 <div class="search-page-result-meta">
876 <strong>
877 {pr.repoOwner}/{pr.repoName}
878 </strong>{" "}
879 · #{pr.number}
3ef4c9dClaude880 </div>
881 </div>
283fbc2Claude882 </a>
3ef4c9dClaude883 ))}
884 </div>
885 )
886 )}
283fbc2Claude887
888 {q && type === "code" && (
889 <div class="search-page-empty">
890 <svg
891 class="search-page-empty-art"
892 viewBox="0 0 96 96"
893 fill="none"
894 xmlns="http://www.w3.org/2000/svg"
895 aria-hidden="true"
896 >
897 <defs>
898 <linearGradient id="searchCodeG" x1="0" y1="0" x2="96" y2="96" gradientUnits="userSpaceOnUse">
899 <stop stop-color="#8c6dff" />
900 <stop offset="1" stop-color="#36c5d6" />
901 </linearGradient>
902 </defs>
903 <path d="M34 30L18 48L34 66" stroke="url(#searchCodeG)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
904 <path d="M62 30L78 48L62 66" stroke="url(#searchCodeG)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
905 <path d="M54 24L42 72" stroke="url(#searchCodeG)" stroke-width="4" stroke-linecap="round" opacity="0.6" />
906 </svg>
907 <h2 class="search-page-empty-title">Code search is per-repo</h2>
908 <p class="search-page-empty-sub">
909 Cross-repo code search is on the roadmap. For now, open a repo and
910 use its search tab to find symbols and matching lines.
911 </p>
912 <div class="search-page-empty-cta">
913 <a href="/explore" class="btn btn-primary">
914 Browse repos
915 </a>
916 <a href={`/search?q=${encodeURIComponent(q)}&type=repos`} class="btn">
917 Search repos instead
918 </a>
919 </div>
920 </div>
921 )}
3ef4c9dClaude922 </Layout>
923 );
924});
925
926// Keyboard shortcuts help page — linked from Cmd+? in nav
927search.get("/shortcuts", async (c) => {
928 const user = c.get("user");
929 const unread = user ? await getUnreadCount(user.id) : 0;
930 const shortcuts: Array<{ keys: string; desc: string }> = [
931 { keys: "/", desc: "Focus global search" },
932 { keys: "Cmd/Ctrl + K", desc: "Open AI assistant" },
933 { keys: "g d", desc: "Go to dashboard" },
934 { keys: "g n", desc: "Go to notifications" },
935 { keys: "g e", desc: "Go to explore" },
936 { keys: "n", desc: "New repository" },
937 { keys: "?", desc: "Show this help" },
938 ];
939
940 return c.html(
941 <Layout title="Keyboard shortcuts" user={user} notificationCount={unread}>
942 <h2 style="margin-bottom: 16px">Keyboard shortcuts</h2>
943 <div class="panel">
944 {shortcuts.map((s) => (
945 <div class="panel-item" style="justify-content: space-between">
946 <span>{s.desc}</span>
947 <kbd
948 style="font-family: var(--font-mono); background: var(--bg-tertiary); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px"
949 >
950 {s.keys}
951 </kbd>
952 </div>
953 ))}
954 </div>
955 </Layout>
956 );
957});
958
959export default search;