Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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

gists.tsxBlame1571 lines · 1 contributor
1e162a8Claude1/**
2 * Block E4 — Gists: user-owned tiny multi-file repos.
3 *
4 * DB-backed v1 (no git bare repo). Each gist owns a collection of gist_files,
5 * and every edit appends a gist_revisions row with a JSON snapshot of the
6 * full file set at that revision.
7 *
8 * Never throws — all DB paths wrapped in try/catch; any failure redirects.
23f453aClaude9 *
10 * 2026 polish: scoped `.gists-*` class system mirrors `collaborators.tsx` —
11 * eyebrow + display headline on /gists, snippet cards with language pill +
12 * line count + relative time, mono slug IDs, and orb-lit dashed empty state.
1e162a8Claude13 */
14
15import { Hono } from "hono";
16import { and, eq, desc, sql } from "drizzle-orm";
17import { randomBytes } from "crypto";
18import { db } from "../db";
19import {
20 gists,
21 gistFiles,
22 gistRevisions,
23 gistStars,
24 users,
25} from "../db/schema";
26import { Layout } from "../views/layout";
27import { highlightCode } from "../lib/highlight";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import { html } from "hono/html";
31
32export function generateSlug(): string {
33 return randomBytes(4).toString("hex");
34}
35
36export function snapshotOf(
37 files: { filename: string; content: string }[]
38): string {
39 const map: Record<string, string> = {};
40 for (const f of files) map[f.filename] = f.content;
41 return JSON.stringify(map);
42}
43
44const gistRoutes = new Hono<AuthEnv>();
45
23f453aClaude46// ─── Scoped CSS (.gists-*) ──────────────────────────────────────────────────
47const gistsStyles = `
eed4684Claude48 .gists-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
23f453aClaude49
50 .gists-head {
51 display: flex;
52 align-items: flex-end;
53 justify-content: space-between;
54 gap: var(--space-4);
55 flex-wrap: wrap;
56 margin-bottom: var(--space-5);
57 }
58 .gists-head-text { flex: 1; min-width: 280px; }
59 .gists-eyebrow {
60 display: inline-flex;
61 align-items: center;
62 gap: 8px;
63 text-transform: uppercase;
64 font-family: var(--font-mono);
65 font-size: 11px;
66 letter-spacing: 0.16em;
67 color: var(--text-muted);
68 font-weight: 600;
69 margin-bottom: 10px;
70 }
71 .gists-eyebrow-dot {
72 width: 8px; height: 8px;
73 border-radius: 9999px;
6fd5915Claude74 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
75 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
23f453aClaude76 }
77 .gists-title {
78 font-family: var(--font-display);
79 font-size: clamp(24px, 3.4vw, 36px);
80 font-weight: 800;
81 letter-spacing: -0.028em;
82 line-height: 1.1;
83 margin: 0 0 6px;
84 color: var(--text-strong);
85 }
86 .gists-title-grad {
6fd5915Claude87 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
23f453aClaude88 -webkit-background-clip: text;
89 background-clip: text;
90 -webkit-text-fill-color: transparent;
91 color: transparent;
92 }
93 .gists-sub {
94 margin: 0;
95 font-size: 14px;
96 color: var(--text-muted);
97 line-height: 1.5;
98 max-width: 640px;
99 }
100
101 /* Buttons */
102 .gists-btn {
103 display: inline-flex;
104 align-items: center;
105 justify-content: center;
106 gap: 6px;
107 padding: 9px 16px;
108 border-radius: 10px;
109 font-size: 13px;
110 font-weight: 600;
111 text-decoration: none;
112 border: 1px solid transparent;
113 cursor: pointer;
114 font: inherit;
115 line-height: 1;
116 white-space: nowrap;
117 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
118 }
119 .gists-btn-primary {
6fd5915Claude120 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
23f453aClaude121 color: #ffffff;
6fd5915Claude122 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
23f453aClaude123 }
124 .gists-btn-primary:hover {
125 transform: translateY(-1px);
6fd5915Claude126 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
23f453aClaude127 text-decoration: none;
128 color: #ffffff;
129 }
130 .gists-btn-ghost {
131 background: transparent;
132 color: var(--text);
133 border-color: var(--border-strong);
134 }
135 .gists-btn-ghost:hover {
6fd5915Claude136 background: rgba(91,110,232,0.06);
137 border-color: rgba(91,110,232,0.45);
23f453aClaude138 color: var(--text-strong);
139 text-decoration: none;
140 }
141 .gists-btn-danger {
142 background: transparent;
143 color: #fca5a5;
144 border-color: rgba(248,113,113,0.35);
145 }
146 .gists-btn-danger:hover {
147 border-style: dashed;
148 border-color: rgba(248,113,113,0.70);
149 background: rgba(248,113,113,0.06);
150 color: #fecaca;
151 text-decoration: none;
152 }
153
154 /* Crumbs */
155 .gists-crumbs {
156 display: flex;
157 align-items: center;
158 gap: 12px;
159 flex-wrap: wrap;
160 margin-bottom: var(--space-4);
161 font-size: 12.5px;
162 }
163 .gists-crumbs a {
164 display: inline-flex;
165 align-items: center;
166 gap: 5px;
167 padding: 6px 11px;
168 background: rgba(255,255,255,0.025);
169 border: 1px solid var(--border);
170 border-radius: 8px;
171 color: var(--text-muted);
172 text-decoration: none;
173 font-weight: 500;
174 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
175 }
176 .gists-crumbs a:hover {
177 border-color: var(--border-strong);
178 color: var(--text-strong);
179 background: rgba(255,255,255,0.04);
180 text-decoration: none;
181 }
182
183 /* Card grid */
184 .gists-list { display: flex; flex-direction: column; gap: 10px; }
185 .gists-card {
186 display: flex;
187 align-items: center;
188 gap: 16px;
189 padding: 14px 18px;
190 background: var(--bg-elevated);
191 border: 1px solid var(--border);
192 border-radius: 12px;
193 transition: border-color 120ms ease, background 120ms ease;
194 }
195 .gists-card:hover {
196 border-color: var(--border-strong);
197 background: rgba(255,255,255,0.03);
198 }
199 .gists-card-body { flex: 1; min-width: 0; }
200 .gists-card-row {
201 display: flex;
202 align-items: center;
203 gap: 8px;
204 flex-wrap: wrap;
205 }
206 .gists-card-title {
207 font-family: var(--font-display);
208 font-weight: 700;
209 font-size: 15.5px;
210 color: var(--text-strong);
211 text-decoration: none;
212 letter-spacing: -0.005em;
213 }
214 .gists-card-title:hover { text-decoration: underline; }
215 .gists-card-desc {
216 margin-top: 4px;
217 font-size: 13px;
218 color: var(--text-muted);
219 line-height: 1.45;
220 }
221 .gists-meta-row {
222 display: flex;
223 align-items: center;
224 gap: 10px;
225 flex-wrap: wrap;
226 font-size: 12px;
227 color: var(--text-muted);
228 font-variant-numeric: tabular-nums;
229 margin-top: 6px;
230 }
231 .gists-meta-row .sep { opacity: 0.4; }
232 .gists-meta-row a { color: var(--text-muted); }
233 .gists-meta-row a:hover { color: var(--text-strong); }
234
235 /* Slug ID */
236 .gists-slug {
237 display: inline-flex;
238 align-items: center;
239 padding: 3px 9px;
240 border-radius: 9999px;
241 font-family: var(--font-mono);
242 font-size: 11.5px;
243 font-weight: 600;
244 background: rgba(255,255,255,0.04);
245 color: var(--text);
246 border: 1px solid var(--border);
247 text-decoration: none;
248 flex-shrink: 0;
249 font-variant-numeric: tabular-nums;
250 }
251 .gists-slug:hover {
6fd5915Claude252 border-color: rgba(91,110,232,0.45);
23f453aClaude253 color: #c4b5fd;
254 text-decoration: none;
255 }
256
257 /* Pills */
258 .gists-pill {
259 display: inline-flex;
260 align-items: center;
261 gap: 5px;
262 padding: 2px 9px;
263 border-radius: 9999px;
264 font-size: 11px;
265 font-weight: 600;
266 letter-spacing: 0.02em;
267 text-transform: capitalize;
268 }
269 .gists-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
270 .gists-pill.is-lang {
6fd5915Claude271 background: rgba(91,110,232,0.12);
23f453aClaude272 color: #c4b5fd;
6fd5915Claude273 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
23f453aClaude274 font-family: var(--font-mono);
275 }
276 .gists-pill.is-public {
277 background: rgba(52,211,153,0.14);
278 color: #6ee7b7;
279 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
280 }
281 .gists-pill.is-secret {
282 background: rgba(251,191,36,0.12);
283 color: #fde68a;
284 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
285 }
286 .gists-pill.is-count {
6fd5915Claude287 background: rgba(95,143,160,0.14);
23f453aClaude288 color: #67e8f9;
6fd5915Claude289 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.32);
23f453aClaude290 font-family: var(--font-mono);
291 font-variant-numeric: tabular-nums;
292 }
293
294 /* Pagination */
295 .gists-pager {
296 display: flex;
297 gap: 10px;
298 align-items: center;
299 margin-top: var(--space-5);
300 justify-content: center;
301 }
302 .gists-pager a {
303 display: inline-flex;
304 align-items: center;
305 gap: 4px;
306 padding: 6px 12px;
307 border: 1px solid var(--border-strong);
308 border-radius: 8px;
309 color: var(--text);
310 text-decoration: none;
311 font-size: 12.5px;
312 font-weight: 500;
313 }
314 .gists-pager a:hover {
6fd5915Claude315 border-color: rgba(91,110,232,0.45);
23f453aClaude316 color: var(--text-strong);
6fd5915Claude317 background: rgba(91,110,232,0.06);
23f453aClaude318 text-decoration: none;
319 }
320
321 /* Form card */
322 .gists-form-card {
323 background: var(--bg-elevated);
324 border: 1px solid var(--border);
325 border-radius: 14px;
326 padding: var(--space-5);
327 max-width: 860px;
328 position: relative;
329 overflow: hidden;
330 }
331 .gists-form-card::before {
332 content: '';
333 position: absolute;
334 top: 0; left: 0; right: 0;
335 height: 2px;
6fd5915Claude336 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
23f453aClaude337 opacity: 0.55;
338 }
339 .gists-field { margin-bottom: 14px; }
340 .gists-field-label {
341 display: block;
342 font-size: 11.5px;
343 color: var(--text-muted);
344 font-weight: 600;
345 text-transform: uppercase;
346 letter-spacing: 0.06em;
347 margin-bottom: 6px;
348 }
349 .gists-input, .gists-textarea {
350 width: 100%;
351 box-sizing: border-box;
352 padding: 9px 12px;
353 font: inherit;
354 font-size: 13.5px;
355 color: var(--text);
356 background: rgba(255,255,255,0.03);
357 border: 1px solid var(--border-strong);
358 border-radius: 10px;
359 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
360 }
361 .gists-textarea { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.5; resize: vertical; }
362 .gists-input:focus, .gists-textarea:focus {
363 outline: none;
6fd5915Claude364 border-color: rgba(91,110,232,0.55);
23f453aClaude365 background: rgba(255,255,255,0.05);
6fd5915Claude366 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
23f453aClaude367 }
368 .gists-visibility { display: flex; gap: 18px; flex-wrap: wrap; margin-bottom: 14px; font-size: 13px; }
369 .gists-visibility label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
370 .gist-file {
371 background: rgba(255,255,255,0.02);
372 border: 1px solid var(--border);
373 border-radius: 12px;
374 padding: 14px;
375 margin-bottom: 10px;
376 }
377
378 /* File viewer */
379 .gists-file-block {
380 background: var(--bg-elevated);
381 border: 1px solid var(--border);
382 border-radius: 12px;
383 overflow: hidden;
384 margin-bottom: 14px;
385 }
386 .gists-file-head {
387 display: flex;
388 align-items: center;
389 justify-content: space-between;
390 gap: 10px;
391 padding: 10px 14px;
392 border-bottom: 1px solid var(--border);
393 font-family: var(--font-mono);
394 font-size: 12.5px;
395 color: var(--text-strong);
396 background: rgba(255,255,255,0.02);
397 }
398 .gists-file-body pre {
399 margin: 0;
400 padding: 14px 16px;
401 font-size: 12.5px;
402 line-height: 1.6;
403 overflow-x: auto;
404 }
405
406 /* Detail header */
407 .gists-detail-head {
408 display: flex;
409 align-items: flex-start;
410 justify-content: space-between;
411 gap: 14px;
412 flex-wrap: wrap;
413 margin-bottom: var(--space-4);
414 }
415 .gists-detail-title {
416 font-family: var(--font-display);
417 font-size: clamp(20px, 2.8vw, 30px);
418 font-weight: 800;
419 letter-spacing: -0.025em;
420 line-height: 1.15;
421 margin: 0;
422 color: var(--text-strong);
423 display: inline-flex;
424 align-items: center;
425 gap: 10px;
426 flex-wrap: wrap;
427 }
428 .gists-detail-title a { color: #c4b5fd; text-decoration: none; }
6fd5915Claude429 .gists-detail-title a:hover { color: #5b6ee8; text-decoration: underline; }
23f453aClaude430 .gists-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
431
432 /* Star button */
433 .gists-star {
434 display: inline-flex;
435 align-items: center;
436 gap: 6px;
437 padding: 7px 12px;
438 border-radius: 9999px;
439 background: rgba(255,255,255,0.02);
440 border: 1px solid var(--border-strong);
441 color: var(--text);
442 font-size: 12.5px;
443 font-weight: 600;
444 cursor: pointer;
445 font: inherit;
446 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
447 font-variant-numeric: tabular-nums;
448 }
449 .gists-star:hover {
450 border-color: rgba(251,191,36,0.55);
451 color: #fde68a;
452 background: rgba(251,191,36,0.08);
453 }
454 .gists-star.is-on { color: #fde68a; border-color: rgba(251,191,36,0.55); background: rgba(251,191,36,0.08); }
455
456 /* Empty */
457 .gists-empty {
458 position: relative;
459 overflow: hidden;
460 padding: clamp(32px, 6vw, 60px) clamp(20px, 4vw, 36px);
461 text-align: center;
462 background: var(--bg-elevated);
463 border: 1px dashed var(--border-strong);
464 border-radius: 16px;
465 }
466 .gists-empty-orb {
467 position: absolute;
468 inset: -40% 30% auto 30%;
469 height: 280px;
6fd5915Claude470 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
23f453aClaude471 filter: blur(70px);
472 opacity: 0.7;
473 pointer-events: none;
474 z-index: 0;
475 }
476 .gists-empty-inner { position: relative; z-index: 1; }
477 .gists-empty-icon {
478 width: 56px; height: 56px;
479 border-radius: 9999px;
6fd5915Claude480 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
481 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
23f453aClaude482 display: inline-flex;
483 align-items: center;
484 justify-content: center;
485 color: #c4b5fd;
486 margin-bottom: 14px;
487 }
488 .gists-empty-title {
489 font-family: var(--font-display);
490 font-size: 18px;
491 font-weight: 700;
492 margin: 0 0 6px;
493 color: var(--text-strong);
494 }
495 .gists-empty-sub {
496 margin: 0 auto 16px;
497 font-size: 13.5px;
498 color: var(--text-muted);
499 max-width: 420px;
500 line-height: 1.5;
501 }
502`;
503
504function IconCode() {
505 return (
506 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
507 <polyline points="16 18 22 12 16 6" />
508 <polyline points="8 6 2 12 8 18" />
509 </svg>
510 );
511}
512function IconPlus() {
513 return (
514 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
515 <line x1="12" y1="5" x2="12" y2="19" />
516 <line x1="5" y1="12" x2="19" y2="12" />
517 </svg>
518 );
519}
520function IconArrowLeft() {
521 return (
522 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
523 <line x1="19" y1="12" x2="5" y2="12" />
524 <polyline points="12 19 5 12 12 5" />
525 </svg>
526 );
527}
528
1e162a8Claude529function notFound(user: any, label = "Gist not found") {
530 return (
531 <Layout title={label} user={user}>
23f453aClaude532 <div class="gists-wrap">
533 <div class="gists-empty">
534 <div class="gists-empty-orb" aria-hidden="true" />
535 <div class="gists-empty-inner">
536 <h2 class="gists-empty-title">{label}</h2>
537 </div>
538 </div>
1e162a8Claude539 </div>
23f453aClaude540 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude541 </Layout>
542 );
543}
544
23f453aClaude545/** Guess a short language label from a filename extension. */
546function langOf(filename: string | null | undefined): string | null {
547 if (!filename) return null;
548 const i = filename.lastIndexOf(".");
549 if (i < 0 || i === filename.length - 1) return null;
550 return filename.slice(i + 1).toLowerCase();
551}
552
553function relTime(d: Date | string | null | undefined): string {
554 if (!d) return "";
555 const t = typeof d === "string" ? new Date(d) : d;
556 const diff = Date.now() - t.getTime();
557 const mins = Math.floor(diff / 60000);
558 if (mins < 1) return "just now";
559 if (mins < 60) return `${mins}m ago`;
560 const hrs = Math.floor(mins / 60);
561 if (hrs < 24) return `${hrs}h ago`;
562 const days = Math.floor(hrs / 24);
563 if (days < 30) return `${days}d ago`;
564 return t.toLocaleDateString();
565}
566
1e162a8Claude567// Discover / list public gists
568gistRoutes.get("/gists", softAuth, async (c) => {
569 const user = c.get("user");
570 const page = Math.max(1, Number(c.req.query("page")) || 1);
571 const limit = 30;
572 const offset = (page - 1) * limit;
573
574 let rows: any[] = [];
575 try {
576 rows = await db
577 .select({
578 g: gists,
579 owner: { username: users.username },
580 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
581 starCount: sql<number>`(SELECT count(*) FROM gist_stars WHERE gist_id = ${gists.id})`,
582 })
583 .from(gists)
584 .innerJoin(users, eq(gists.ownerId, users.id))
585 .where(eq(gists.isPublic, true))
586 .orderBy(desc(gists.updatedAt))
587 .limit(limit)
588 .offset(offset);
589 } catch {
590 rows = [];
591 }
592
593 return c.html(
594 <Layout title="Discover gists" user={user}>
23f453aClaude595 <div class="gists-wrap">
596 <header class="gists-head">
597 <div class="gists-head-text">
598 <div class="gists-eyebrow">
599 <span class="gists-eyebrow-dot" aria-hidden="true" />
600 Snippets · Discover
601 </div>
602 <h1 class="gists-title">
603 <span class="gists-title-grad">Public gists.</span>
604 </h1>
605 <p class="gists-sub">
606 Tiny multi-file snippets the community has shared — perfect for
607 scripts, configs, and one-off code samples.
608 </p>
609 </div>
610 {user && (
611 <a href="/gists/new" class="gists-btn gists-btn-primary">
612 <IconPlus />
613 New gist
614 </a>
615 )}
616 </header>
617
618 {rows.length === 0 ? (
619 <div class="gists-empty">
620 <div class="gists-empty-orb" aria-hidden="true" />
621 <div class="gists-empty-inner">
622 <div class="gists-empty-icon" aria-hidden="true">
623 <IconCode />
624 </div>
625 <h3 class="gists-empty-title">Share your first snippet</h3>
626 <p class="gists-empty-sub">
627 Gists are great for sharing scripts, config samples, and quick
628 multi-file demos. Public gists show up here.
629 </p>
630 {user ? (
631 <a href="/gists/new" class="gists-btn gists-btn-primary">
632 <IconPlus />
633 New gist
634 </a>
635 ) : (
636 <a href="/login" class="gists-btn gists-btn-primary">
637 Sign in to create
638 </a>
639 )}
640 </div>
641 </div>
642 ) : (
643 <div class="gists-list">
644 {rows.map((r) => {
645 const lang = langOf(r.g.title);
646 return (
647 <div class="gists-card">
648 <div class="gists-card-body">
649 <div class="gists-card-row">
650 <a href={`/gists/${r.g.slug}`} class="gists-card-title">
651 {r.g.title || r.g.slug}
652 </a>
653 {lang && (
654 <span class="gists-pill is-lang">{lang}</span>
655 )}
656 {r.g.isPublic ? (
657 <span class="gists-pill is-public">
658 <span class="dot" aria-hidden="true" />
659 Public
660 </span>
661 ) : (
662 <span class="gists-pill is-secret">
663 <span class="dot" aria-hidden="true" />
664 Secret
665 </span>
666 )}
667 </div>
668 {r.g.description && (
669 <div class="gists-card-desc">{r.g.description}</div>
670 )}
671 <div class="gists-meta-row">
672 <span>
673 by <a href={`/${r.owner.username}`}>@{r.owner.username}</a>
674 </span>
675 <span class="sep">·</span>
676 <span class="gists-pill is-count">
677 {r.fileCount} file{r.fileCount !== 1 ? "s" : ""}
678 </span>
679 <span class="sep">·</span>
680 <span>★ {r.starCount}</span>
681 <span class="sep">·</span>
682 <span>updated {relTime(r.g.updatedAt)}</span>
683 </div>
684 </div>
685 <a href={`/gists/${r.g.slug}`} class="gists-slug">
686 {r.g.slug}
1e162a8Claude687 </a>
688 </div>
23f453aClaude689 );
690 })}
691 </div>
692 )}
693 {(rows.length === limit || page > 1) && (
694 <div class="gists-pager">
695 {page > 1 && (
696 <a href={`/gists?page=${page - 1}`}>
697 <IconArrowLeft />
698 prev
1e162a8Claude699 </a>
23f453aClaude700 )}
701 {rows.length === limit && (
702 <a href={`/gists?page=${page + 1}`}>next →</a>
703 )}
704 </div>
705 )}
706 </div>
707 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude708 </Layout>
709 );
710});
711
712// New gist form
713gistRoutes.get("/gists/new", requireAuth, async (c) => {
714 const user = c.get("user");
715 return c.html(
716 <Layout title="New gist" user={user}>
23f453aClaude717 <div class="gists-wrap">
718 <div class="gists-crumbs">
719 <a href="/gists">
720 <IconArrowLeft />
721 Discover gists
722 </a>
1e162a8Claude723 </div>
23f453aClaude724 <header class="gists-head">
725 <div class="gists-head-text">
726 <div class="gists-eyebrow">
727 <span class="gists-eyebrow-dot" aria-hidden="true" />
728 Snippets · New
729 </div>
730 <h1 class="gists-title">
731 <span class="gists-title-grad">Create a gist.</span>
732 </h1>
733 <p class="gists-sub">
734 Add one or more files. The first filename becomes the gist title;
735 every save adds a revision.
736 </p>
737 </div>
738 </header>
739 <form
740 method="post"
741 action="/gists"
742 class="gists-form-card"
743 >
744 <div class="gists-field">
745 <label class="gists-field-label" for="gist-desc">Description</label>
1e162a8Claude746 <input
23f453aClaude747 class="gists-input"
1e162a8Claude748 type="text"
23f453aClaude749 id="gist-desc"
750 name="description"
751 placeholder="Gist description..."
752 aria-label="Gist description"
1e162a8Claude753 />
754 </div>
23f453aClaude755 <div class="gists-visibility">
756 <label>
757 <input type="radio" name="is_public" value="true" checked />
758 Public
759 </label>
760 <label>
761 <input type="radio" name="is_public" value="false" />
762 Secret
763 </label>
764 </div>
765 <div id="files">
766 <div class="gist-file">
767 <input
768 class="gists-input"
769 type="text"
770 name="filename[]"
771 placeholder="filename.ext"
772 required
773 aria-label="Filename"
774 />
775 <textarea
776 class="gists-textarea"
777 name="content[]"
778 rows={12}
779 placeholder="File contents..."
780 required
781 style="margin-top:8px"
782 ></textarea>
783 </div>
784 </div>
785 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
786 <button
787 type="button"
788 class="gists-btn gists-btn-ghost"
789 id="add-file"
790 >
791 <IconPlus />
792 Add file
793 </button>
794 <button type="submit" class="gists-btn gists-btn-primary">
795 Create gist
796 </button>
797 </div>
798 </form>
799 </div>
1e162a8Claude800 {html`
801 <script>
802 document.getElementById("add-file").addEventListener("click", () => {
803 const div = document.createElement("div");
804 div.className = "gist-file";
805 div.innerHTML =
23f453aClaude806 '<input class="gists-input" type="text" name="filename[]" placeholder="filename.ext" required />' +
807 '<textarea class="gists-textarea" name="content[]" rows="12" placeholder="File contents..." required style="margin-top:8px"></textarea>';
1e162a8Claude808 document.getElementById("files").appendChild(div);
809 });
810 </script>
811 `}
23f453aClaude812 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude813 </Layout>
814 );
815});
816
817// Create gist
818gistRoutes.post("/gists", requireAuth, async (c) => {
819 const user = c.get("user")!;
820 const form = await c.req.formData();
821 const description = (form.get("description") as string || "").trim();
822 const isPublic = (form.get("is_public") as string) !== "false";
823 const filenames = form.getAll("filename[]") as string[];
824 const contents = form.getAll("content[]") as string[];
825
826 const files = filenames
827 .map((fn, i) => ({
828 filename: (fn || "").trim(),
829 content: contents[i] || "",
830 }))
831 .filter((f) => f.filename && f.content);
832
833 if (files.length === 0) {
834 return c.text("At least one file is required", 400);
835 }
836
837 // Retry on unique slug collision
838 for (let attempt = 0; attempt < 5; attempt++) {
839 const slug = generateSlug();
840 try {
841 const [gist] = await db
842 .insert(gists)
843 .values({
844 ownerId: user.id,
845 slug,
846 title: files[0].filename,
847 description,
848 isPublic,
849 })
850 .returning({ id: gists.id });
851 await db.insert(gistFiles).values(
852 files.map((f) => ({
853 gistId: gist.id,
854 filename: f.filename,
855 content: f.content,
856 sizeBytes: new TextEncoder().encode(f.content).length,
857 }))
858 );
859 await db.insert(gistRevisions).values({
860 gistId: gist.id,
861 revision: 1,
862 snapshot: snapshotOf(files),
863 authorId: user.id,
864 message: "Initial",
865 });
866 return c.redirect(`/gists/${slug}`);
867 } catch (err: any) {
868 if (attempt === 4) {
869 return c.text("Could not create gist", 500);
870 }
871 // Otherwise assume slug collision, retry with fresh slug.
872 }
873 }
874 return c.redirect("/gists");
875});
876
877// View gist
878gistRoutes.get("/gists/:slug", softAuth, async (c) => {
879 const user = c.get("user");
880 const slug = c.req.param("slug");
881
882 let gist: any = null;
883 let files: any[] = [];
884 let starCount = 0;
885 let isStarred = false;
886 try {
887 const [row] = await db
888 .select({ g: gists, owner: { username: users.username } })
889 .from(gists)
890 .innerJoin(users, eq(gists.ownerId, users.id))
891 .where(eq(gists.slug, slug))
892 .limit(1);
893 if (row) {
894 gist = row;
895 files = await db
896 .select()
897 .from(gistFiles)
898 .where(eq(gistFiles.gistId, gist.g.id))
899 .orderBy(gistFiles.filename);
900 const [cnt] = await db
901 .select({ n: sql<number>`count(*)` })
902 .from(gistStars)
903 .where(eq(gistStars.gistId, gist.g.id));
904 starCount = Number(cnt?.n || 0);
905 if (user) {
906 const [has] = await db
907 .select()
908 .from(gistStars)
909 .where(
910 and(
911 eq(gistStars.gistId, gist.g.id),
912 eq(gistStars.userId, user.id)
913 )
914 )
915 .limit(1);
916 isStarred = !!has;
917 }
918 }
919 } catch {
920 // leave null
921 }
922
923 if (!gist) return c.html(notFound(user), 404);
924
925 const isOwner = user && user.id === gist.g.ownerId;
926 if (!gist.g.isPublic && !isOwner) {
927 return c.html(notFound(user), 404);
928 }
929
930 return c.html(
931 <Layout title={gist.g.title || slug} user={user}>
23f453aClaude932 <div class="gists-wrap">
933 <div class="gists-crumbs">
934 <a href="/gists">
935 <IconArrowLeft />
936 Discover
1e162a8Claude937 </a>
23f453aClaude938 </div>
939 <div class="gists-detail-head">
940 <div>
941 <h1 class="gists-detail-title">
942 <a href={`/${gist.owner.username}`}>@{gist.owner.username}</a>
943 <span style="color:var(--text-muted)">/</span>
944 <span>{gist.g.title || slug}</span>
945 {!gist.g.isPublic && (
946 <span class="gists-pill is-secret">
947 <span class="dot" aria-hidden="true" />
948 Secret
949 </span>
950 )}
951 </h1>
952 {gist.g.description && (
953 <p class="gists-sub" style="margin-top:6px">{gist.g.description}</p>
954 )}
955 <div class="gists-meta-row">
956 <span class="gists-slug">{slug}</span>
957 <span class="sep">·</span>
958 <span class="gists-pill is-count">
959 {files.length} file{files.length !== 1 ? "s" : ""}
960 </span>
961 <span class="sep">·</span>
962 <span>updated {relTime(gist.g.updatedAt)}</span>
963 </div>
964 </div>
965 <div class="gists-detail-actions">
966 {user && !isOwner && (
1e162a8Claude967 <form
cce7944Claude968 method="post"
23f453aClaude969 action={`/gists/${slug}/star`}
1e162a8Claude970 >
23f453aClaude971 <button
972 type="submit"
973 class={`gists-star${isStarred ? " is-on" : ""}`}
974 >
975 {isStarred ? "★" : "☆"} {starCount}
1e162a8Claude976 </button>
977 </form>
23f453aClaude978 )}
979 {!user && (
980 <span class="gists-star">☆ {starCount}</span>
981 )}
982 <a href={`/gists/${slug}/revisions`} class="gists-btn gists-btn-ghost">
983 Revisions
984 </a>
985 {isOwner && (
986 <>
987 <a href={`/gists/${slug}/edit`} class="gists-btn gists-btn-ghost">
988 Edit
989 </a>
990 <form
991 method="post"
992 action={`/gists/${slug}/delete`}
993 onsubmit="return confirm('Delete this gist?')"
994 >
995 <button type="submit" class="gists-btn gists-btn-danger">
996 Delete
997 </button>
998 </form>
999 </>
1000 )}
1001 </div>
1e162a8Claude1002 </div>
23f453aClaude1003 {files.map((f) => {
1004 const { html: highlighted } = highlightCode(f.content, f.filename);
1005 const lang = langOf(f.filename);
1006 const lineCount = (f.content || "").split("\n").length;
1007 return (
1008 <div class="gists-file-block">
1009 <div class="gists-file-head">
1010 <span>{f.filename}</span>
1011 <div style="display:flex;gap:6px;align-items:center">
1012 {lang && <span class="gists-pill is-lang">{lang}</span>}
1013 <span class="gists-pill is-count">{lineCount} lines</span>
1014 </div>
1015 </div>
1016 <div class="gists-file-body blob-code">
1017 <pre>
1018 {html([highlighted] as unknown as TemplateStringsArray)}
1019 </pre>
1020 </div>
1e162a8Claude1021 </div>
23f453aClaude1022 );
1023 })}
1024 </div>
1025 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude1026 </Layout>
1027 );
1028});
1029
1030// Edit form
1031gistRoutes.get("/gists/:slug/edit", requireAuth, async (c) => {
1032 const user = c.get("user")!;
1033 const slug = c.req.param("slug");
1034
1035 let gist: any = null;
1036 let files: any[] = [];
1037 try {
1038 const [row] = await db
1039 .select()
1040 .from(gists)
1041 .where(eq(gists.slug, slug))
1042 .limit(1);
1043 if (row && row.ownerId === user.id) {
1044 gist = row;
1045 files = await db
1046 .select()
1047 .from(gistFiles)
1048 .where(eq(gistFiles.gistId, gist.id))
1049 .orderBy(gistFiles.filename);
1050 }
1051 } catch {
1052 // leave null
1053 }
1054
1055 if (!gist) return c.html(notFound(user, "Not found or not yours"), 404);
1056
1057 return c.html(
1058 <Layout title={`Edit ${gist.slug}`} user={user}>
23f453aClaude1059 <div class="gists-wrap">
1060 <div class="gists-crumbs">
1061 <a href={`/gists/${slug}`}>
1062 <IconArrowLeft />
1063 Back to gist
1064 </a>
1e162a8Claude1065 </div>
23f453aClaude1066 <header class="gists-head">
1067 <div class="gists-head-text">
1068 <div class="gists-eyebrow">
1069 <span class="gists-eyebrow-dot" aria-hidden="true" />
1070 Snippets · Edit
1071 </div>
1072 <h1 class="gists-title">
1073 <span class="gists-title-grad">Edit gist.</span>
1074 </h1>
1075 <p class="gists-sub">
1076 Saving creates a new revision — old versions stay accessible via
1077 the revisions list.
1078 </p>
1079 </div>
1080 </header>
1081 <form
1082 method="post"
1083 action={`/gists/${slug}/edit`}
1084 class="gists-form-card"
1e162a8Claude1085 >
23f453aClaude1086 <div class="gists-field">
1087 <label class="gists-field-label" for="gist-edit-desc">Description</label>
1088 <input
1089 class="gists-input"
1090 type="text"
1091 id="gist-edit-desc"
1092 name="description"
1093 value={gist.description}
1094 placeholder="Description"
1095 aria-label="Gist description"
1096 />
1097 </div>
1098 <div class="gists-field">
1099 <label class="gists-field-label" for="gist-edit-msg">Revision message</label>
1100 <input
1101 class="gists-input"
1102 type="text"
1103 id="gist-edit-msg"
1104 name="message"
1105 placeholder="(optional)"
1106 aria-label="Revision message"
1107 />
1108 </div>
1109 <div id="files">
1110 {files.map((f) => (
1111 <div class="gist-file">
1112 <input
1113 class="gists-input"
1114 type="text"
1115 name="filename[]"
1116 value={f.filename}
1117 required
1118 aria-label="Filename"
1119 />
1120 <textarea
1121 class="gists-textarea"
1122 name="content[]"
1123 rows={12}
1124 required
1125 style="margin-top:8px"
1126 >
1127 {f.content}
1128 </textarea>
1129 </div>
1130 ))}
1131 </div>
1132 <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
1133 <button
1134 type="button"
1135 class="gists-btn gists-btn-ghost"
1136 id="add-file"
1137 >
1138 <IconPlus />
1139 Add file
1140 </button>
1141 <button type="submit" class="gists-btn gists-btn-primary">
1142 Save revision
1143 </button>
1144 </div>
1145 </form>
1146 </div>
1e162a8Claude1147 {html`
1148 <script>
1149 document.getElementById("add-file").addEventListener("click", () => {
1150 const div = document.createElement("div");
1151 div.className = "gist-file";
1152 div.innerHTML =
23f453aClaude1153 '<input class="gists-input" type="text" name="filename[]" placeholder="filename.ext" required />' +
1154 '<textarea class="gists-textarea" name="content[]" rows="12" required style="margin-top:8px"></textarea>';
1e162a8Claude1155 document.getElementById("files").appendChild(div);
1156 });
1157 </script>
1158 `}
23f453aClaude1159 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude1160 </Layout>
1161 );
1162});
1163
1164// Save edit
1165gistRoutes.post("/gists/:slug/edit", requireAuth, async (c) => {
1166 const user = c.get("user")!;
1167 const slug = c.req.param("slug");
1168 const form = await c.req.formData();
1169 const description = (form.get("description") as string || "").trim();
1170 const message = (form.get("message") as string || "").trim();
1171 const filenames = form.getAll("filename[]") as string[];
1172 const contents = form.getAll("content[]") as string[];
1173
1174 const files = filenames
1175 .map((fn, i) => ({
1176 filename: (fn || "").trim(),
1177 content: contents[i] || "",
1178 }))
1179 .filter((f) => f.filename && f.content);
1180
1181 if (files.length === 0) {
1182 return c.text("At least one file is required", 400);
1183 }
1184
1185 try {
1186 const [row] = await db
1187 .select()
1188 .from(gists)
1189 .where(eq(gists.slug, slug))
1190 .limit(1);
1191 if (!row || row.ownerId !== user.id) {
1192 return c.redirect("/gists");
1193 }
1194 // Replace file set: delete all, re-insert.
1195 await db.delete(gistFiles).where(eq(gistFiles.gistId, row.id));
1196 await db.insert(gistFiles).values(
1197 files.map((f) => ({
1198 gistId: row.id,
1199 filename: f.filename,
1200 content: f.content,
1201 sizeBytes: new TextEncoder().encode(f.content).length,
1202 }))
1203 );
1204 // Bump revision.
1205 const [last] = await db
1206 .select({ r: sql<number>`max(${gistRevisions.revision})` })
1207 .from(gistRevisions)
1208 .where(eq(gistRevisions.gistId, row.id));
1209 const nextRev = Number(last?.r || 0) + 1;
1210 await db.insert(gistRevisions).values({
1211 gistId: row.id,
1212 revision: nextRev,
1213 snapshot: snapshotOf(files),
1214 authorId: user.id,
1215 message: message || null,
1216 });
1217 await db
1218 .update(gists)
1219 .set({ description, updatedAt: new Date() })
1220 .where(eq(gists.id, row.id));
1221 } catch {
1222 // swallow
1223 }
1224 return c.redirect(`/gists/${slug}`);
1225});
1226
1227// Delete
1228gistRoutes.post("/gists/:slug/delete", requireAuth, async (c) => {
1229 const user = c.get("user")!;
1230 const slug = c.req.param("slug");
1231 try {
1232 const [row] = await db
1233 .select()
1234 .from(gists)
1235 .where(eq(gists.slug, slug))
1236 .limit(1);
1237 if (row && row.ownerId === user.id) {
1238 await db.delete(gists).where(eq(gists.id, row.id));
1239 }
1240 } catch {
1241 // swallow
1242 }
1243 return c.redirect("/gists");
1244});
1245
1246// Toggle star
1247gistRoutes.post("/gists/:slug/star", requireAuth, async (c) => {
1248 const user = c.get("user")!;
1249 const slug = c.req.param("slug");
1250 try {
1251 const [row] = await db
1252 .select()
1253 .from(gists)
1254 .where(eq(gists.slug, slug))
1255 .limit(1);
1256 if (row && row.ownerId !== user.id) {
1257 const [existing] = await db
1258 .select()
1259 .from(gistStars)
1260 .where(
1261 and(
1262 eq(gistStars.gistId, row.id),
1263 eq(gistStars.userId, user.id)
1264 )
1265 )
1266 .limit(1);
1267 if (existing) {
1268 await db.delete(gistStars).where(eq(gistStars.id, existing.id));
1269 } else {
1270 await db.insert(gistStars).values({
1271 gistId: row.id,
1272 userId: user.id,
1273 });
1274 }
1275 }
1276 } catch {
1277 // swallow
1278 }
1279 return c.redirect(`/gists/${slug}`);
1280});
1281
1282// Revisions list
1283gistRoutes.get("/gists/:slug/revisions", softAuth, async (c) => {
1284 const user = c.get("user");
1285 const slug = c.req.param("slug");
1286
1287 let gist: any = null;
1288 let revs: any[] = [];
1289 try {
1290 const [row] = await db
1291 .select()
1292 .from(gists)
1293 .where(eq(gists.slug, slug))
1294 .limit(1);
1295 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
1296 gist = row;
1297 revs = await db
1298 .select({
1299 r: gistRevisions,
1300 author: { username: users.username },
1301 })
1302 .from(gistRevisions)
1303 .innerJoin(users, eq(gistRevisions.authorId, users.id))
1304 .where(eq(gistRevisions.gistId, gist.id))
1305 .orderBy(desc(gistRevisions.revision));
1306 }
1307 } catch {
1308 // leave null
1309 }
1310
1311 if (!gist) return c.html(notFound(user), 404);
1312
1313 return c.html(
1314 <Layout title={`${gist.slug} — revisions`} user={user}>
23f453aClaude1315 <div class="gists-wrap">
1316 <div class="gists-crumbs">
1317 <a href={`/gists/${slug}`}>
1318 <IconArrowLeft />
1319 Back to gist
1320 </a>
1321 </div>
1322 <header class="gists-head">
1323 <div class="gists-head-text">
1324 <div class="gists-eyebrow">
1325 <span class="gists-eyebrow-dot" aria-hidden="true" />
1326 Snippets · Revisions
1e162a8Claude1327 </div>
23f453aClaude1328 <h1 class="gists-title">
1329 <span class="gists-title-grad">{gist.title || slug}</span>
1330 </h1>
1331 <p class="gists-sub">Every save is preserved as a numbered revision.</p>
1e162a8Claude1332 </div>
23f453aClaude1333 </header>
1334 <div class="gists-list">
1335 {revs.map((rv) => (
1336 <div class="gists-card">
1337 <div class="gists-card-body">
1338 <div class="gists-card-row">
1339 <a
1340 href={`/gists/${slug}/revisions/${rv.r.revision}`}
1341 class="gists-card-title"
1342 >
1343 Revision {rv.r.revision}
1344 </a>
1345 {rv.r.message && (
1346 <span style="color:var(--text-muted);font-size:13px">— {rv.r.message}</span>
1347 )}
1348 </div>
1349 <div class="gists-meta-row">
1350 <span>by @{rv.author.username}</span>
1351 </div>
1352 </div>
1353 <a
1354 href={`/gists/${slug}/revisions/${rv.r.revision}`}
1355 class="gists-slug"
1356 >
1357 r{rv.r.revision}
1358 </a>
1359 </div>
1360 ))}
1361 </div>
1e162a8Claude1362 </div>
23f453aClaude1363 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude1364 </Layout>
1365 );
1366});
1367
1368// Revision detail
1369gistRoutes.get(
1370 "/gists/:slug/revisions/:rev",
1371 softAuth,
1372 async (c) => {
1373 const user = c.get("user");
1374 const slug = c.req.param("slug");
1375 const rev = Number(c.req.param("rev"));
1376
1377 let gist: any = null;
1378 let snapshot: Record<string, string> | null = null;
1379 try {
1380 const [row] = await db
1381 .select()
1382 .from(gists)
1383 .where(eq(gists.slug, slug))
1384 .limit(1);
1385 if (row && (row.isPublic || (user && user.id === row.ownerId))) {
1386 gist = row;
1387 const [rv] = await db
1388 .select()
1389 .from(gistRevisions)
1390 .where(
1391 and(
1392 eq(gistRevisions.gistId, gist.id),
1393 eq(gistRevisions.revision, rev)
1394 )
1395 )
1396 .limit(1);
1397 if (rv) {
1398 try {
1399 snapshot = JSON.parse(rv.snapshot);
1400 } catch {
1401 snapshot = {};
1402 }
1403 }
1404 }
1405 } catch {
1406 // leave null
1407 }
1408
1409 if (!gist || !snapshot)
1410 return c.html(notFound(user, "Revision not found"), 404);
1411
1412 return c.html(
1413 <Layout title={`${slug} @ r${rev}`} user={user}>
23f453aClaude1414 <div class="gists-wrap">
1415 <div class="gists-crumbs">
1416 <a href={`/gists/${slug}/revisions`}>
1417 <IconArrowLeft />
1418 All revisions
1419 </a>
1420 </div>
1421 <header class="gists-head">
1422 <div class="gists-head-text">
1423 <div class="gists-eyebrow">
1424 <span class="gists-eyebrow-dot" aria-hidden="true" />
1425 Snippets · Revision {rev}
1e162a8Claude1426 </div>
23f453aClaude1427 <h1 class="gists-title">
1428 <span class="gists-title-grad">{gist.title || slug}</span>{" "}
1429 <span style="color:var(--text-muted);font-size:0.6em;font-weight:600">@ r{rev}</span>
1430 </h1>
1e162a8Claude1431 </div>
23f453aClaude1432 </header>
1433 {Object.entries(snapshot).map(([filename, content]) => {
1434 const { html: highlighted } = highlightCode(content, filename);
1435 const lang = langOf(filename);
1436 const lineCount = (content || "").split("\n").length;
1437 return (
1438 <div class="gists-file-block">
1439 <div class="gists-file-head">
1440 <span>{filename}</span>
1441 <div style="display:flex;gap:6px;align-items:center">
1442 {lang && <span class="gists-pill is-lang">{lang}</span>}
1443 <span class="gists-pill is-count">{lineCount} lines</span>
1444 </div>
1445 </div>
1446 <div class="gists-file-body blob-code">
1447 <pre>
1448 {html([highlighted] as unknown as TemplateStringsArray)}
1449 </pre>
1450 </div>
1451 </div>
1452 );
1453 })}
1454 </div>
1455 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude1456 </Layout>
1457 );
1458 }
1459);
1460
1461// User's public gists
1462gistRoutes.get("/:username/gists", softAuth, async (c) => {
1463 const user = c.get("user");
1464 const username = c.req.param("username");
1465
1466 let ownerUser: any = null;
1467 let rows: any[] = [];
1468 try {
1469 const [u] = await db
1470 .select()
1471 .from(users)
1472 .where(eq(users.username, username))
1473 .limit(1);
1474 if (u) {
1475 ownerUser = u;
1476 const showPrivate = user && user.id === u.id;
1477 rows = await db
1478 .select({
1479 g: gists,
1480 fileCount: sql<number>`(SELECT count(*) FROM gist_files WHERE gist_id = ${gists.id})`,
1481 })
1482 .from(gists)
1483 .where(
1484 showPrivate
1485 ? eq(gists.ownerId, u.id)
1486 : and(eq(gists.ownerId, u.id), eq(gists.isPublic, true))
1487 )
1488 .orderBy(desc(gists.updatedAt));
1489 }
1490 } catch {
1491 rows = [];
1492 }
1493
1494 if (!ownerUser) return c.html(notFound(user, "User not found"), 404);
1495
1496 return c.html(
1497 <Layout title={`@${username}'s gists`} user={user}>
23f453aClaude1498 <div class="gists-wrap">
1499 <header class="gists-head">
1500 <div class="gists-head-text">
1501 <div class="gists-eyebrow">
1502 <span class="gists-eyebrow-dot" aria-hidden="true" />
1503 Snippets · @{username}
1504 </div>
1505 <h1 class="gists-title">
1506 <span class="gists-title-grad">@{username}'s gists</span>
1507 </h1>
1508 <p class="gists-sub">
1509 Every snippet authored by{" "}
1510 <a href={`/${username}`}>@{username}</a>.
1511 </p>
1512 </div>
1513 </header>
1514 {rows.length === 0 ? (
1515 <div class="gists-empty">
1516 <div class="gists-empty-orb" aria-hidden="true" />
1517 <div class="gists-empty-inner">
1518 <div class="gists-empty-icon" aria-hidden="true">
1519 <IconCode />
1e162a8Claude1520 </div>
23f453aClaude1521 <h3 class="gists-empty-title">No gists yet</h3>
1522 <p class="gists-empty-sub">
1523 @{username} hasn't published any public snippets.
1524 </p>
1e162a8Claude1525 </div>
23f453aClaude1526 </div>
1527 ) : (
1528 <div class="gists-list">
1529 {rows.map((r) => {
1530 const lang = langOf(r.g.title);
1531 return (
1532 <div class="gists-card">
1533 <div class="gists-card-body">
1534 <div class="gists-card-row">
1535 <a href={`/gists/${r.g.slug}`} class="gists-card-title">
1536 {r.g.title || r.g.slug}
1537 </a>
1538 {lang && <span class="gists-pill is-lang">{lang}</span>}
1539 {!r.g.isPublic && (
1540 <span class="gists-pill is-secret">
1541 <span class="dot" aria-hidden="true" />
1542 Secret
1543 </span>
1544 )}
1545 </div>
1546 {r.g.description && (
1547 <div class="gists-card-desc">{r.g.description}</div>
1548 )}
1549 <div class="gists-meta-row">
1550 <span class="gists-pill is-count">
1551 {r.fileCount} file{r.fileCount !== 1 ? "s" : ""}
1552 </span>
1553 <span class="sep">·</span>
1554 <span>updated {relTime(r.g.updatedAt)}</span>
1555 </div>
1556 </div>
1557 <a href={`/gists/${r.g.slug}`} class="gists-slug">
1558 {r.g.slug}
1559 </a>
1560 </div>
1561 );
1562 })}
1563 </div>
1564 )}
1565 </div>
1566 <style dangerouslySetInnerHTML={{ __html: gistsStyles }} />
1e162a8Claude1567 </Layout>
1568 );
1569});
1570
1571export default gistRoutes;