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

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

packages.tsxBlame907 lines · 1 contributor
25a91a6Claude1/**
2 * Packages UI — lists packages for a repo, per-package detail (Block C2).
3 *
4 * GET /:owner/:repo/packages — list of published packages
5 * GET /:owner/:repo/packages/:pkg{.+} — detail + version list + install help
6 *
7 * Packages doesn't yet have a tab in RepoNav — we render with active="code"
8 * so the page still lays out correctly.
f0b5874Claude9 *
10 * 2026 polish: scoped `.pkg-*` class system mirrors `admin-ops.tsx` and
11 * `collaborators.tsx` — eyebrow + display headline, mono version pills,
12 * tabular-nums for sizes/dates, dashed orb-lit empty state with publish
13 * instructions, and a sidebar info card on detail.
25a91a6Claude14 */
15
16import { Hono } from "hono";
17import { and, desc, eq } from "drizzle-orm";
18import { db } from "../db";
19import {
20 packages,
21 packageVersions,
22 packageTags,
23 repositories,
24 users,
25} from "../db/schema";
26import type { Package, PackageVersion, PackageTag } from "../db/schema";
27import { Layout } from "../views/layout";
28import { RepoHeader, RepoNav } from "../views/components";
29import { softAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
31import { getUnreadCount } from "../lib/unread";
32import { parsePackageName } from "../lib/packages";
33
34const ui = new Hono<AuthEnv>();
35ui.use("*", softAuth);
36
37async function loadRepo(owner: string, repo: string) {
38 const [row] = await db
39 .select({
40 id: repositories.id,
41 name: repositories.name,
42 ownerId: repositories.ownerId,
43 defaultBranch: repositories.defaultBranch,
44 starCount: repositories.starCount,
45 forkCount: repositories.forkCount,
46 })
47 .from(repositories)
48 .innerJoin(users, eq(repositories.ownerId, users.id))
49 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
50 .limit(1);
51 return row || null;
52}
53
54function relTime(d: Date | string | null): string {
55 if (!d) return "";
56 const t = typeof d === "string" ? new Date(d) : d;
57 const diff = Date.now() - t.getTime();
58 const mins = Math.floor(diff / 60000);
59 if (mins < 1) return "just now";
60 if (mins < 60) return `${mins}m ago`;
61 const hrs = Math.floor(mins / 60);
62 if (hrs < 24) return `${hrs}h ago`;
63 const days = Math.floor(hrs / 24);
64 if (days < 30) return `${days}d ago`;
65 return t.toLocaleDateString();
66}
67
68function fullPkgName(pkg: { scope: string | null; name: string }): string {
69 return pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
70}
71
72function humanSize(bytes: number): string {
73 if (bytes < 1024) return `${bytes} B`;
74 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
75 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
76}
77
f0b5874Claude78// ─── Scoped CSS (.pkg-*) ────────────────────────────────────────────────────
79const pkgStyles = `
eed4684Claude80 .pkg-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
f0b5874Claude81
82 .pkg-head {
83 display: flex;
84 align-items: flex-end;
85 justify-content: space-between;
86 gap: var(--space-4);
87 flex-wrap: wrap;
88 margin-bottom: var(--space-5);
89 }
90 .pkg-head-text { flex: 1; min-width: 280px; }
91 .pkg-eyebrow {
92 display: inline-flex;
93 align-items: center;
94 gap: 8px;
95 text-transform: uppercase;
96 font-family: var(--font-mono);
97 font-size: 11px;
98 letter-spacing: 0.16em;
99 color: var(--text-muted);
100 font-weight: 600;
101 margin-bottom: 10px;
102 }
103 .pkg-eyebrow-dot {
104 width: 8px; height: 8px;
105 border-radius: 9999px;
106 background: linear-gradient(135deg, #8c6dff, #36c5d6);
107 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
108 }
109 .pkg-title {
110 font-family: var(--font-display);
111 font-size: clamp(24px, 3.4vw, 36px);
112 font-weight: 800;
113 letter-spacing: -0.028em;
114 line-height: 1.1;
115 margin: 0 0 6px;
116 color: var(--text-strong);
117 }
118 .pkg-title-grad {
119 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
120 -webkit-background-clip: text;
121 background-clip: text;
122 -webkit-text-fill-color: transparent;
123 color: transparent;
124 }
125 .pkg-sub {
126 margin: 0;
127 font-size: 14px;
128 color: var(--text-muted);
129 line-height: 1.5;
130 max-width: 640px;
131 }
132
133 /* Buttons */
134 .pkg-btn {
135 display: inline-flex;
136 align-items: center;
137 justify-content: center;
138 gap: 6px;
139 padding: 9px 16px;
140 border-radius: 10px;
141 font-size: 13px;
142 font-weight: 600;
143 text-decoration: none;
144 border: 1px solid transparent;
145 cursor: pointer;
146 font: inherit;
147 line-height: 1;
148 white-space: nowrap;
149 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
150 }
151 .pkg-btn-primary {
152 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
153 color: #ffffff;
154 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
155 }
156 .pkg-btn-primary:hover {
157 transform: translateY(-1px);
158 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
159 text-decoration: none;
160 color: #ffffff;
161 }
162 .pkg-btn-ghost {
163 background: transparent;
164 color: var(--text);
165 border-color: var(--border-strong);
166 padding: 6px 12px;
167 font-size: 12px;
168 }
169 .pkg-btn-ghost:hover {
170 background: rgba(140,109,255,0.06);
171 border-color: rgba(140,109,255,0.45);
172 color: var(--text-strong);
173 text-decoration: none;
174 }
175 .pkg-btn-danger {
176 background: transparent;
177 color: #fca5a5;
178 border-color: rgba(248,113,113,0.35);
179 padding: 6px 12px;
180 font-size: 12px;
181 }
182 .pkg-btn-danger:hover {
183 border-style: dashed;
184 border-color: rgba(248,113,113,0.70);
185 background: rgba(248,113,113,0.06);
186 color: #fecaca;
187 text-decoration: none;
188 }
189
190 /* Crumbs */
191 .pkg-crumbs {
192 display: flex;
193 align-items: center;
194 gap: 12px;
195 flex-wrap: wrap;
196 margin-bottom: var(--space-4);
197 font-size: 12.5px;
198 }
199 .pkg-crumbs a {
200 display: inline-flex;
201 align-items: center;
202 gap: 5px;
203 padding: 6px 11px;
204 background: rgba(255,255,255,0.025);
205 border: 1px solid var(--border);
206 border-radius: 8px;
207 color: var(--text-muted);
208 text-decoration: none;
209 font-weight: 500;
210 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
211 }
212 .pkg-crumbs a:hover {
213 border-color: var(--border-strong);
214 color: var(--text-strong);
215 background: rgba(255,255,255,0.04);
216 text-decoration: none;
217 }
218 .pkg-crumbs span.cur {
219 font-family: var(--font-mono);
220 font-size: 12px;
221 color: var(--text);
222 }
223
224 /* Package list */
225 .pkg-list { display: flex; flex-direction: column; gap: 10px; }
226 .pkg-card {
227 display: block;
228 text-decoration: none;
229 color: inherit;
230 background: var(--bg-elevated);
231 border: 1px solid var(--border);
232 border-radius: 12px;
233 padding: 14px 16px;
234 transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
235 }
236 .pkg-card:hover {
237 border-color: var(--border-strong);
238 background: rgba(255,255,255,0.03);
239 text-decoration: none;
240 color: inherit;
241 }
242 .pkg-card-row {
243 display: flex;
244 align-items: baseline;
245 justify-content: space-between;
246 gap: 14px;
247 flex-wrap: wrap;
248 }
249 .pkg-card-name {
250 font-family: var(--font-display);
251 font-weight: 700;
252 font-size: 15.5px;
253 color: var(--text-strong);
254 letter-spacing: -0.005em;
255 }
256 .pkg-card-desc {
257 margin-top: 4px;
258 font-size: 13px;
259 color: var(--text-muted);
260 line-height: 1.45;
261 }
262 .pkg-card-meta {
263 display: flex;
264 align-items: center;
265 gap: 10px;
266 flex-wrap: wrap;
267 font-size: 12px;
268 color: var(--text-muted);
269 font-variant-numeric: tabular-nums;
270 white-space: nowrap;
271 }
272 .pkg-version {
273 display: inline-flex;
274 align-items: center;
275 padding: 3px 9px;
276 border-radius: 9999px;
277 font-family: var(--font-mono);
278 font-size: 11.5px;
279 font-weight: 600;
280 background: rgba(140,109,255,0.12);
281 color: #c4b5fd;
282 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
283 font-variant-numeric: tabular-nums;
284 }
285 .pkg-version.is-empty {
286 background: rgba(148,163,184,0.12);
287 color: #cbd5e1;
288 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
289 }
290 .pkg-card-meta .sep { opacity: 0.4; }
291
292 /* Detail grid */
293 .pkg-detail-grid {
294 display: grid;
295 grid-template-columns: minmax(0, 1fr) 300px;
296 gap: 24px;
297 align-items: start;
298 }
299 @media (max-width: 880px) {
300 .pkg-detail-grid { grid-template-columns: 1fr; }
301 }
302 .pkg-section { margin-bottom: 20px; }
303 .pkg-section-label {
304 font-family: var(--font-mono);
305 font-size: 11px;
306 text-transform: uppercase;
307 letter-spacing: 0.16em;
308 font-weight: 600;
309 color: var(--text-muted);
310 margin: 0 0 8px;
311 }
312 .pkg-code {
313 display: block;
314 background: rgba(255,255,255,0.03);
315 border: 1px solid var(--border);
316 border-radius: 10px;
317 padding: 12px 14px;
318 font-family: var(--font-mono);
319 font-size: 13px;
320 color: var(--text);
321 overflow-x: auto;
322 white-space: pre;
323 }
324 .pkg-readme {
325 background: rgba(255,255,255,0.02);
326 border: 1px solid var(--border);
327 border-radius: 10px;
328 padding: 14px 16px;
329 font-family: var(--font-mono);
330 font-size: 12.5px;
331 line-height: 1.55;
332 white-space: pre-wrap;
333 color: var(--text);
334 max-height: 480px;
335 overflow: auto;
336 }
337
338 /* Version list */
339 .pkg-versions {
340 background: var(--bg-elevated);
341 border: 1px solid var(--border);
342 border-radius: 12px;
343 overflow: hidden;
344 }
345 .pkg-version-row {
346 display: flex;
347 align-items: center;
348 justify-content: space-between;
349 gap: 12px;
350 padding: 12px 14px;
351 border-bottom: 1px solid var(--border);
352 }
353 .pkg-version-row:last-child { border-bottom: 0; }
354 .pkg-version-row:hover { background: rgba(255,255,255,0.02); }
355 .pkg-version-main { flex: 1; min-width: 0; }
356 .pkg-version-line {
357 display: flex;
358 align-items: center;
359 gap: 8px;
360 flex-wrap: wrap;
361 }
362 .pkg-version-meta {
363 margin-top: 4px;
364 font-size: 12px;
365 color: var(--text-muted);
366 font-variant-numeric: tabular-nums;
367 }
368 .pkg-version-meta code {
369 font-family: var(--font-mono);
370 font-size: 11px;
371 padding: 1px 6px;
372 border-radius: 6px;
373 background: rgba(255,255,255,0.04);
374 border: 1px solid var(--border);
375 }
376 .pkg-version-actions {
377 display: flex;
378 gap: 6px;
379 align-items: center;
380 flex-wrap: wrap;
381 }
382 .pkg-version-actions form { margin: 0; }
383 .pkg-yanked {
384 display: inline-flex;
385 align-items: center;
386 gap: 4px;
387 padding: 2px 8px;
388 border-radius: 9999px;
389 font-size: 11px;
390 font-weight: 600;
391 text-transform: uppercase;
392 letter-spacing: 0.04em;
393 background: rgba(248,113,113,0.12);
394 color: #fca5a5;
395 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
396 }
397
398 /* Sidebar */
399 .pkg-side {
400 background: var(--bg-elevated);
401 border: 1px solid var(--border);
402 border-radius: 12px;
403 padding: 14px 16px;
404 position: relative;
405 overflow: hidden;
406 }
407 .pkg-side::before {
408 content: '';
409 position: absolute;
410 top: 0; left: 0; right: 0;
411 height: 2px;
412 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
413 opacity: 0.55;
414 }
415 .pkg-side-label {
416 font-family: var(--font-mono);
417 font-size: 10.5px;
418 text-transform: uppercase;
419 letter-spacing: 0.14em;
420 color: var(--text-muted);
421 margin: 0 0 4px;
422 font-weight: 600;
423 }
424 .pkg-side-value {
425 font-size: 13px;
426 color: var(--text);
427 margin: 0 0 14px;
428 word-break: break-all;
429 }
430 .pkg-side-value:last-child { margin-bottom: 0; }
431 .pkg-side-value code {
432 font-family: var(--font-mono);
433 font-size: 12px;
434 }
435
436 /* Empty */
437 .pkg-empty {
438 position: relative;
439 overflow: hidden;
440 padding: clamp(32px, 6vw, 56px) clamp(20px, 4vw, 36px);
441 text-align: center;
442 background: var(--bg-elevated);
443 border: 1px dashed var(--border-strong);
444 border-radius: 16px;
445 }
446 .pkg-empty-orb {
447 position: absolute;
448 inset: -40% 30% auto 30%;
449 height: 280px;
450 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
451 filter: blur(70px);
452 opacity: 0.7;
453 pointer-events: none;
454 z-index: 0;
455 }
456 .pkg-empty-inner { position: relative; z-index: 1; }
457 .pkg-empty-icon {
458 width: 56px; height: 56px;
459 border-radius: 9999px;
460 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
461 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
462 display: inline-flex;
463 align-items: center;
464 justify-content: center;
465 color: #c4b5fd;
466 margin-bottom: 14px;
467 }
468 .pkg-empty-title {
469 font-family: var(--font-display);
470 font-size: 18px;
471 font-weight: 700;
472 margin: 0 0 6px;
473 color: var(--text-strong);
474 }
475 .pkg-empty-sub {
476 margin: 0 auto 16px;
477 font-size: 13.5px;
478 color: var(--text-muted);
479 max-width: 480px;
480 line-height: 1.5;
481 }
482 .pkg-empty-howto {
483 text-align: left;
484 background: rgba(255,255,255,0.02);
485 border: 1px solid var(--border);
486 border-radius: 12px;
487 padding: 16px 18px;
488 font-size: 13px;
489 max-width: 640px;
490 margin: 16px auto 0;
491 line-height: 1.55;
492 }
493 .pkg-empty-howto ol { padding-left: 20px; margin: 8px 0 0; }
494 .pkg-empty-howto li { margin-bottom: 6px; }
495 .pkg-empty-howto code {
496 font-family: var(--font-mono);
497 font-size: 11.5px;
498 padding: 1px 6px;
499 border-radius: 6px;
500 background: rgba(255,255,255,0.04);
501 border: 1px solid var(--border);
502 }
503 .pkg-empty-howto pre {
504 background: rgba(0,0,0,0.25);
505 border: 1px solid var(--border);
506 border-radius: 8px;
507 padding: 8px 12px;
508 font-family: var(--font-mono);
509 font-size: 11.5px;
510 margin: 6px 0;
511 white-space: pre-wrap;
512 color: var(--text);
513 }
514`;
515
516function IconBox() {
517 return (
518 <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">
519 <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
520 <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
521 <line x1="12" y1="22.08" x2="12" y2="12" />
522 </svg>
523 );
524}
525
25a91a6Claude526// ---------------------------------------------------------------------------
527// List page
528// ---------------------------------------------------------------------------
529
530ui.get("/:owner/:repo/packages", async (c) => {
531 const user = c.get("user");
532 const { owner, repo } = c.req.param();
533 const repoRow = await loadRepo(owner, repo);
534 if (!repoRow) return c.notFound();
535
536 let rows: (Package & { latestVersion: string | null })[] = [];
537 try {
538 const pkgs = await db
539 .select()
540 .from(packages)
541 .where(
542 and(
543 eq(packages.repositoryId, repoRow.id),
544 eq(packages.ecosystem, "npm")
545 )
546 )
547 .orderBy(desc(packages.updatedAt));
548
549 // Fetch the "latest" tag for each package.
550 const latest = await Promise.all(
551 pkgs.map(async (p) => {
552 try {
553 const [tag] = await db
554 .select({
555 version: packageVersions.version,
556 })
557 .from(packageTags)
558 .innerJoin(
559 packageVersions,
560 eq(packageTags.versionId, packageVersions.id)
561 )
562 .where(
563 and(
564 eq(packageTags.packageId, p.id),
565 eq(packageTags.tag, "latest")
566 )
567 )
568 .limit(1);
569 return tag?.version || null;
570 } catch {
571 return null;
572 }
573 })
574 );
575 rows = pkgs.map((p, i) => ({ ...p, latestVersion: latest[i] }));
576 } catch (err) {
577 console.error("[packages] ui list:", err);
578 return c.text("Service unavailable", 503);
579 }
580
581 const unread = user ? await getUnreadCount(user.id) : 0;
582 const host = new URL(c.req.url).host;
583 const registryUrl = `${new URL(c.req.url).protocol}//${host}/npm/`;
584
585 return c.html(
586 <Layout
587 title={`Packages — ${owner}/${repo}`}
588 user={user}
589 notificationCount={unread}
590 >
591 <RepoHeader
592 owner={owner}
593 repo={repo}
594 starCount={repoRow.starCount}
595 forkCount={repoRow.forkCount}
596 currentUser={user?.username || null}
597 />
598 <RepoNav owner={owner} repo={repo} active="code" />
599
f0b5874Claude600 <div class="pkg-wrap">
601 <header class="pkg-head">
602 <div class="pkg-head-text">
603 <div class="pkg-eyebrow">
604 <span class="pkg-eyebrow-dot" aria-hidden="true" />
605 Repository · Packages
606 </div>
607 <h1 class="pkg-title">
608 <span class="pkg-title-grad">Published artifacts.</span>
609 </h1>
610 <p class="pkg-sub">
611 Every npm package shipped from {owner}/{repo}, scoped to the
612 repo's own registry namespace.
613 </p>
614 </div>
615 </header>
25a91a6Claude616
617 {rows.length === 0 ? (
f0b5874Claude618 <div class="pkg-empty">
619 <div class="pkg-empty-orb" aria-hidden="true" />
620 <div class="pkg-empty-inner">
621 <div class="pkg-empty-icon" aria-hidden="true">
622 <IconBox />
623 </div>
624 <h3 class="pkg-empty-title">Publish your first package</h3>
625 <p class="pkg-empty-sub">
626 Wire your repo to the Gluecron npm registry and run{" "}
627 <code>npm publish</code> — your tarball will appear here.
628 </p>
629 <div class="pkg-empty-howto">
630 <strong>To publish:</strong>
631 <ol>
632 <li>
633 Create a personal access token at{" "}
634 <a href="/settings/tokens">/settings/tokens</a>.
635 </li>
636 <li>
637 Add to your <code>.npmrc</code>:
638 <pre>
639 registry={registryUrl}
640 {"\n"}
641 //{host}/npm/:_authToken=YOUR_PAT
642 </pre>
643 </li>
644 <li>
645 In <code>package.json</code>, point{" "}
646 <code>repository.url</code> at this repo
647 (<code>{`http://${host}/${owner}/${repo}.git`}</code>).
648 </li>
649 <li>
650 Run <code>npm publish</code>.
651 </li>
652 </ol>
653 </div>
25a91a6Claude654 </div>
655 </div>
656 ) : (
f0b5874Claude657 <div class="pkg-list">
25a91a6Claude658 {rows.map((p) => {
659 const fullName = fullPkgName(p);
660 return (
661 <a
662 href={`/${owner}/${repo}/packages/${encodeURIComponent(fullName)}`}
f0b5874Claude663 class="pkg-card"
25a91a6Claude664 >
f0b5874Claude665 <div class="pkg-card-row">
25a91a6Claude666 <div style="flex: 1; min-width: 0">
f0b5874Claude667 <div class="pkg-card-name">{fullName}</div>
25a91a6Claude668 {p.description && (
f0b5874Claude669 <div class="pkg-card-desc">{p.description}</div>
25a91a6Claude670 )}
671 </div>
f0b5874Claude672 <div class="pkg-card-meta">
25a91a6Claude673 {p.latestVersion ? (
f0b5874Claude674 <span class="pkg-version">{p.latestVersion}</span>
25a91a6Claude675 ) : (
f0b5874Claude676 <span class="pkg-version is-empty">no versions</span>
25a91a6Claude677 )}
f0b5874Claude678 <span class="sep">·</span>
25a91a6Claude679 <span>{relTime(p.updatedAt)}</span>
680 </div>
681 </div>
682 </a>
683 );
684 })}
685 </div>
686 )}
687 </div>
f0b5874Claude688 <style dangerouslySetInnerHTML={{ __html: pkgStyles }} />
25a91a6Claude689 </Layout>
690 );
691});
692
693// ---------------------------------------------------------------------------
694// Detail page
695// ---------------------------------------------------------------------------
696
697ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
698 const user = c.get("user");
699 const { owner, repo, pkgName } = c.req.param();
700 const parsed = parsePackageName(pkgName);
701 if (!parsed) {
702 return c.text("Invalid package name", 400);
703 }
704
705 const repoRow = await loadRepo(owner, repo);
706 if (!repoRow) return c.notFound();
707
708 let pkg: Package | null = null;
709 let versions: PackageVersion[] = [];
710 let tags: PackageTag[] = [];
711 try {
712 const candidates = await db
713 .select()
714 .from(packages)
715 .where(
716 and(
717 eq(packages.repositoryId, repoRow.id),
718 eq(packages.ecosystem, "npm"),
719 eq(packages.name, parsed.name)
720 )
721 )
722 .limit(10);
723 pkg =
724 candidates.find((p) => (p.scope ?? null) === (parsed.scope ?? null)) ||
725 null;
726 if (pkg) {
727 versions = await db
728 .select()
729 .from(packageVersions)
730 .where(eq(packageVersions.packageId, pkg.id))
731 .orderBy(desc(packageVersions.publishedAt));
732 tags = await db
733 .select()
734 .from(packageTags)
735 .where(eq(packageTags.packageId, pkg.id));
736 }
737 } catch (err) {
738 console.error("[packages] ui detail:", err);
739 return c.text("Service unavailable", 503);
740 }
741
742 if (!pkg) return c.notFound();
743
744 const unread = user ? await getUnreadCount(user.id) : 0;
745 const fullName = fullPkgName(pkg);
746 const latestTag = tags.find((t) => t.tag === "latest");
747 const latestVersion =
748 latestTag && versions.find((v) => v.id === latestTag.versionId);
749 const isOwner = !!user && user.id === repoRow.ownerId;
750 const host = new URL(c.req.url).host;
751
752 return c.html(
753 <Layout
754 title={`${fullName} — ${owner}/${repo}`}
755 user={user}
756 notificationCount={unread}
757 >
758 <RepoHeader
759 owner={owner}
760 repo={repo}
761 starCount={repoRow.starCount}
762 forkCount={repoRow.forkCount}
763 currentUser={user?.username || null}
764 />
765 <RepoNav owner={owner} repo={repo} active="code" />
766
f0b5874Claude767 <div class="pkg-wrap">
768 <div class="pkg-crumbs">
769 <a href={`/${owner}/${repo}/packages`}>
770 Packages
771 </a>
772 <span class="cur">{fullName}</span>
25a91a6Claude773 </div>
774
f0b5874Claude775 <header class="pkg-head">
776 <div class="pkg-head-text">
777 <div class="pkg-eyebrow">
778 <span class="pkg-eyebrow-dot" aria-hidden="true" />
779 Package · npm
780 </div>
781 <h1 class="pkg-title">
782 <span class="pkg-title-grad">{fullName}</span>
783 </h1>
784 {pkg.description && (
785 <p class="pkg-sub">{pkg.description}</p>
786 )}
787 </div>
788 {latestVersion && (
789 <span class="pkg-version">v{latestVersion.version}</span>
790 )}
791 </header>
792
793 <div class="pkg-detail-grid">
25a91a6Claude794 <div>
f0b5874Claude795 <section class="pkg-section">
796 <h3 class="pkg-section-label">Install</h3>
797 <code class="pkg-code">
798 npm install {fullName}
799 {latestVersion ? `@${latestVersion.version}` : ""}
800 </code>
801 </section>
25a91a6Claude802
803 {pkg.readme && (
f0b5874Claude804 <section class="pkg-section">
805 <h3 class="pkg-section-label">Readme</h3>
806 <pre class="pkg-readme">{pkg.readme}</pre>
807 </section>
25a91a6Claude808 )}
809
f0b5874Claude810 <section class="pkg-section">
811 <h3 class="pkg-section-label">
812 Versions
813 <span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
814 {" "}({versions.length})
815 </span>
816 </h3>
817 {versions.length === 0 ? (
818 <div class="pkg-empty" style="padding: 28px 20px">
819 <div class="pkg-empty-inner">
820 <p class="pkg-empty-sub" style="margin-bottom: 0">No versions yet.</p>
821 </div>
822 </div>
823 ) : (
824 <div class="pkg-versions">
825 {versions.map((v) => (
826 <div class="pkg-version-row">
827 <div class="pkg-version-main">
828 <div class="pkg-version-line">
829 <span class="pkg-version">{v.version}</span>
830 {v.yanked && (
831 <span class="pkg-yanked">yanked</span>
832 )}
833 </div>
834 <div class="pkg-version-meta">
835 {humanSize(v.sizeBytes)} · published{" "}
836 {relTime(v.publishedAt)}
837 {v.shasum && (
838 <>
839 {" · sha1 "}
840 <code>{v.shasum.slice(0, 12)}</code>
841 </>
842 )}
843 </div>
25a91a6Claude844 </div>
f0b5874Claude845 <div class="pkg-version-actions">
846 <a
847 class="pkg-btn pkg-btn-ghost"
848 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
849 >
850 Download
851 </a>
852 {isOwner && !v.yanked && (
853 <form
854 method="post"
855 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
856 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
857 >
858 <button
859 type="submit"
860 class="pkg-btn pkg-btn-danger"
861 >
862 Yank
863 </button>
864 </form>
25a91a6Claude865 )}
866 </div>
867 </div>
f0b5874Claude868 ))}
869 </div>
870 )}
871 </section>
25a91a6Claude872 </div>
873
f0b5874Claude874 <aside class="pkg-side">
875 <p class="pkg-side-label">Registry</p>
876 <p class="pkg-side-value">
877 <code>http://{host}/npm/</code>
878 </p>
879 {pkg.homepage && (
880 <>
881 <p class="pkg-side-label">Homepage</p>
882 <p class="pkg-side-value">
883 <a href={pkg.homepage}>{pkg.homepage}</a>
884 </p>
885 </>
886 )}
887 {pkg.license && (
888 <>
889 <p class="pkg-side-label">License</p>
890 <p class="pkg-side-value">{pkg.license}</p>
891 </>
892 )}
893 <p class="pkg-side-label">Repository</p>
894 <p class="pkg-side-value">
895 <a href={`/${owner}/${repo}`}>
25a91a6Claude896 {owner}/{repo}
897 </a>
f0b5874Claude898 </p>
25a91a6Claude899 </aside>
900 </div>
901 </div>
f0b5874Claude902 <style dangerouslySetInnerHTML={{ __html: pkgStyles }} />
25a91a6Claude903 </Layout>
904 );
905});
906
907export default ui;