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

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.tsxBlame925 lines · 2 contributors
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";
88f5bd1ccantynz-alt33import { isRepoMember } from "../lib/package-access";
25a91a6Claude34
35const ui = new Hono<AuthEnv>();
36ui.use("*", softAuth);
37
38async function loadRepo(owner: string, repo: string) {
39 const [row] = await db
40 .select({
41 id: repositories.id,
42 name: repositories.name,
43 ownerId: repositories.ownerId,
44 defaultBranch: repositories.defaultBranch,
45 starCount: repositories.starCount,
46 forkCount: repositories.forkCount,
47 })
48 .from(repositories)
49 .innerJoin(users, eq(repositories.ownerId, users.id))
50 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
51 .limit(1);
52 return row || null;
53}
54
55function relTime(d: Date | string | null): string {
56 if (!d) return "";
57 const t = typeof d === "string" ? new Date(d) : d;
58 const diff = Date.now() - t.getTime();
59 const mins = Math.floor(diff / 60000);
60 if (mins < 1) return "just now";
61 if (mins < 60) return `${mins}m ago`;
62 const hrs = Math.floor(mins / 60);
63 if (hrs < 24) return `${hrs}h ago`;
64 const days = Math.floor(hrs / 24);
65 if (days < 30) return `${days}d ago`;
66 return t.toLocaleDateString();
67}
68
69function fullPkgName(pkg: { scope: string | null; name: string }): string {
70 return pkg.scope ? `${pkg.scope}/${pkg.name}` : pkg.name;
71}
72
73function humanSize(bytes: number): string {
74 if (bytes < 1024) return `${bytes} B`;
75 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
76 return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
77}
78
f0b5874Claude79// ─── Scoped CSS (.pkg-*) ────────────────────────────────────────────────────
80const pkgStyles = `
eed4684Claude81 .pkg-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
f0b5874Claude82
83 .pkg-head {
84 display: flex;
85 align-items: flex-end;
86 justify-content: space-between;
87 gap: var(--space-4);
88 flex-wrap: wrap;
89 margin-bottom: var(--space-5);
90 }
91 .pkg-head-text { flex: 1; min-width: 280px; }
92 .pkg-eyebrow {
93 display: inline-flex;
94 align-items: center;
95 gap: 8px;
96 text-transform: uppercase;
97 font-family: var(--font-mono);
98 font-size: 11px;
99 letter-spacing: 0.16em;
100 color: var(--text-muted);
101 font-weight: 600;
102 margin-bottom: 10px;
103 }
104 .pkg-eyebrow-dot {
105 width: 8px; height: 8px;
106 border-radius: 9999px;
6fd5915Claude107 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
108 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
f0b5874Claude109 }
110 .pkg-title {
111 font-family: var(--font-display);
112 font-size: clamp(24px, 3.4vw, 36px);
113 font-weight: 800;
114 letter-spacing: -0.028em;
115 line-height: 1.1;
116 margin: 0 0 6px;
117 color: var(--text-strong);
118 }
119 .pkg-title-grad {
6fd5915Claude120 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
f0b5874Claude121 -webkit-background-clip: text;
122 background-clip: text;
123 -webkit-text-fill-color: transparent;
124 color: transparent;
125 }
126 .pkg-sub {
127 margin: 0;
128 font-size: 14px;
129 color: var(--text-muted);
130 line-height: 1.5;
131 max-width: 640px;
132 }
133
134 /* Buttons */
135 .pkg-btn {
136 display: inline-flex;
137 align-items: center;
138 justify-content: center;
139 gap: 6px;
140 padding: 9px 16px;
141 border-radius: 10px;
142 font-size: 13px;
143 font-weight: 600;
144 text-decoration: none;
145 border: 1px solid transparent;
146 cursor: pointer;
147 font: inherit;
148 line-height: 1;
149 white-space: nowrap;
150 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
151 }
152 .pkg-btn-primary {
6fd5915Claude153 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f0b5874Claude154 color: #ffffff;
6fd5915Claude155 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
f0b5874Claude156 }
157 .pkg-btn-primary:hover {
158 transform: translateY(-1px);
6fd5915Claude159 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
f0b5874Claude160 text-decoration: none;
161 color: #ffffff;
162 }
163 .pkg-btn-ghost {
164 background: transparent;
165 color: var(--text);
166 border-color: var(--border-strong);
167 padding: 6px 12px;
168 font-size: 12px;
169 }
170 .pkg-btn-ghost:hover {
6fd5915Claude171 background: rgba(91,110,232,0.06);
172 border-color: rgba(91,110,232,0.45);
f0b5874Claude173 color: var(--text-strong);
174 text-decoration: none;
175 }
176 .pkg-btn-danger {
177 background: transparent;
e589f77ccantynz-alt178 color: var(--red);
f0b5874Claude179 border-color: rgba(248,113,113,0.35);
180 padding: 6px 12px;
181 font-size: 12px;
182 }
183 .pkg-btn-danger:hover {
184 border-style: dashed;
185 border-color: rgba(248,113,113,0.70);
186 background: rgba(248,113,113,0.06);
187 color: #fecaca;
188 text-decoration: none;
189 }
190
191 /* Crumbs */
192 .pkg-crumbs {
193 display: flex;
194 align-items: center;
195 gap: 12px;
196 flex-wrap: wrap;
197 margin-bottom: var(--space-4);
198 font-size: 12.5px;
199 }
200 .pkg-crumbs a {
201 display: inline-flex;
202 align-items: center;
203 gap: 5px;
204 padding: 6px 11px;
205 background: rgba(255,255,255,0.025);
206 border: 1px solid var(--border);
207 border-radius: 8px;
208 color: var(--text-muted);
209 text-decoration: none;
210 font-weight: 500;
211 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
212 }
213 .pkg-crumbs a:hover {
214 border-color: var(--border-strong);
215 color: var(--text-strong);
216 background: rgba(255,255,255,0.04);
217 text-decoration: none;
218 }
219 .pkg-crumbs span.cur {
220 font-family: var(--font-mono);
221 font-size: 12px;
222 color: var(--text);
223 }
224
225 /* Package list */
226 .pkg-list { display: flex; flex-direction: column; gap: 10px; }
227 .pkg-card {
228 display: block;
229 text-decoration: none;
230 color: inherit;
231 background: var(--bg-elevated);
232 border: 1px solid var(--border);
233 border-radius: 12px;
234 padding: 14px 16px;
235 transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
236 }
237 .pkg-card:hover {
238 border-color: var(--border-strong);
239 background: rgba(255,255,255,0.03);
240 text-decoration: none;
241 color: inherit;
242 }
243 .pkg-card-row {
244 display: flex;
245 align-items: baseline;
246 justify-content: space-between;
247 gap: 14px;
248 flex-wrap: wrap;
249 }
250 .pkg-card-name {
251 font-family: var(--font-display);
252 font-weight: 700;
253 font-size: 15.5px;
254 color: var(--text-strong);
255 letter-spacing: -0.005em;
256 }
257 .pkg-card-desc {
258 margin-top: 4px;
259 font-size: 13px;
260 color: var(--text-muted);
261 line-height: 1.45;
262 }
263 .pkg-card-meta {
264 display: flex;
265 align-items: center;
266 gap: 10px;
267 flex-wrap: wrap;
268 font-size: 12px;
269 color: var(--text-muted);
270 font-variant-numeric: tabular-nums;
271 white-space: nowrap;
272 }
273 .pkg-version {
274 display: inline-flex;
275 align-items: center;
276 padding: 3px 9px;
277 border-radius: 9999px;
278 font-family: var(--font-mono);
279 font-size: 11.5px;
280 font-weight: 600;
6fd5915Claude281 background: rgba(91,110,232,0.12);
f0b5874Claude282 color: #c4b5fd;
6fd5915Claude283 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.30);
f0b5874Claude284 font-variant-numeric: tabular-nums;
285 }
286 .pkg-version.is-empty {
287 background: rgba(148,163,184,0.12);
288 color: #cbd5e1;
289 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
290 }
291 .pkg-card-meta .sep { opacity: 0.4; }
292
293 /* Detail grid */
294 .pkg-detail-grid {
295 display: grid;
296 grid-template-columns: minmax(0, 1fr) 300px;
297 gap: 24px;
298 align-items: start;
299 }
300 @media (max-width: 880px) {
301 .pkg-detail-grid { grid-template-columns: 1fr; }
302 }
303 .pkg-section { margin-bottom: 20px; }
304 .pkg-section-label {
305 font-family: var(--font-mono);
306 font-size: 11px;
307 text-transform: uppercase;
308 letter-spacing: 0.16em;
309 font-weight: 600;
310 color: var(--text-muted);
311 margin: 0 0 8px;
312 }
313 .pkg-code {
314 display: block;
315 background: rgba(255,255,255,0.03);
316 border: 1px solid var(--border);
317 border-radius: 10px;
318 padding: 12px 14px;
319 font-family: var(--font-mono);
320 font-size: 13px;
321 color: var(--text);
322 overflow-x: auto;
323 white-space: pre;
324 }
325 .pkg-readme {
326 background: rgba(255,255,255,0.02);
327 border: 1px solid var(--border);
328 border-radius: 10px;
329 padding: 14px 16px;
330 font-family: var(--font-mono);
331 font-size: 12.5px;
332 line-height: 1.55;
333 white-space: pre-wrap;
334 color: var(--text);
335 max-height: 480px;
336 overflow: auto;
337 }
338
339 /* Version list */
340 .pkg-versions {
341 background: var(--bg-elevated);
342 border: 1px solid var(--border);
343 border-radius: 12px;
344 overflow: hidden;
345 }
346 .pkg-version-row {
347 display: flex;
348 align-items: center;
349 justify-content: space-between;
350 gap: 12px;
351 padding: 12px 14px;
352 border-bottom: 1px solid var(--border);
353 }
354 .pkg-version-row:last-child { border-bottom: 0; }
355 .pkg-version-row:hover { background: rgba(255,255,255,0.02); }
356 .pkg-version-main { flex: 1; min-width: 0; }
357 .pkg-version-line {
358 display: flex;
359 align-items: center;
360 gap: 8px;
361 flex-wrap: wrap;
362 }
363 .pkg-version-meta {
364 margin-top: 4px;
365 font-size: 12px;
366 color: var(--text-muted);
367 font-variant-numeric: tabular-nums;
368 }
369 .pkg-version-meta code {
370 font-family: var(--font-mono);
371 font-size: 11px;
372 padding: 1px 6px;
373 border-radius: 6px;
374 background: rgba(255,255,255,0.04);
375 border: 1px solid var(--border);
376 }
377 .pkg-version-actions {
378 display: flex;
379 gap: 6px;
380 align-items: center;
381 flex-wrap: wrap;
382 }
383 .pkg-version-actions form { margin: 0; }
384 .pkg-yanked {
385 display: inline-flex;
386 align-items: center;
387 gap: 4px;
388 padding: 2px 8px;
389 border-radius: 9999px;
390 font-size: 11px;
391 font-weight: 600;
392 text-transform: uppercase;
393 letter-spacing: 0.04em;
394 background: rgba(248,113,113,0.12);
e589f77ccantynz-alt395 color: var(--red);
f0b5874Claude396 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
397 }
398
399 /* Sidebar */
400 .pkg-side {
401 background: var(--bg-elevated);
402 border: 1px solid var(--border);
403 border-radius: 12px;
404 padding: 14px 16px;
405 position: relative;
406 overflow: hidden;
407 }
408 .pkg-side::before {
409 content: '';
410 position: absolute;
411 top: 0; left: 0; right: 0;
412 height: 2px;
6fd5915Claude413 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f0b5874Claude414 opacity: 0.55;
415 }
416 .pkg-side-label {
417 font-family: var(--font-mono);
418 font-size: 10.5px;
419 text-transform: uppercase;
420 letter-spacing: 0.14em;
421 color: var(--text-muted);
422 margin: 0 0 4px;
423 font-weight: 600;
424 }
425 .pkg-side-value {
426 font-size: 13px;
427 color: var(--text);
428 margin: 0 0 14px;
429 word-break: break-all;
430 }
431 .pkg-side-value:last-child { margin-bottom: 0; }
432 .pkg-side-value code {
433 font-family: var(--font-mono);
434 font-size: 12px;
435 }
436
437 /* Empty */
438 .pkg-empty {
439 position: relative;
440 overflow: hidden;
441 padding: clamp(32px, 6vw, 56px) clamp(20px, 4vw, 36px);
442 text-align: center;
443 background: var(--bg-elevated);
444 border: 1px dashed var(--border-strong);
445 border-radius: 16px;
446 }
447 .pkg-empty-orb {
448 position: absolute;
449 inset: -40% 30% auto 30%;
450 height: 280px;
6fd5915Claude451 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
f0b5874Claude452 filter: blur(70px);
453 opacity: 0.7;
454 pointer-events: none;
455 z-index: 0;
456 }
457 .pkg-empty-inner { position: relative; z-index: 1; }
458 .pkg-empty-icon {
459 width: 56px; height: 56px;
460 border-radius: 9999px;
6fd5915Claude461 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
462 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
f0b5874Claude463 display: inline-flex;
464 align-items: center;
465 justify-content: center;
466 color: #c4b5fd;
467 margin-bottom: 14px;
468 }
469 .pkg-empty-title {
470 font-family: var(--font-display);
471 font-size: 18px;
472 font-weight: 700;
473 margin: 0 0 6px;
474 color: var(--text-strong);
475 }
476 .pkg-empty-sub {
477 margin: 0 auto 16px;
478 font-size: 13.5px;
479 color: var(--text-muted);
480 max-width: 480px;
481 line-height: 1.5;
482 }
483 .pkg-empty-howto {
484 text-align: left;
485 background: rgba(255,255,255,0.02);
486 border: 1px solid var(--border);
487 border-radius: 12px;
488 padding: 16px 18px;
489 font-size: 13px;
490 max-width: 640px;
491 margin: 16px auto 0;
492 line-height: 1.55;
493 }
494 .pkg-empty-howto ol { padding-left: 20px; margin: 8px 0 0; }
495 .pkg-empty-howto li { margin-bottom: 6px; }
496 .pkg-empty-howto code {
497 font-family: var(--font-mono);
498 font-size: 11.5px;
499 padding: 1px 6px;
500 border-radius: 6px;
501 background: rgba(255,255,255,0.04);
502 border: 1px solid var(--border);
503 }
504 .pkg-empty-howto pre {
505 background: rgba(0,0,0,0.25);
506 border: 1px solid var(--border);
507 border-radius: 8px;
508 padding: 8px 12px;
509 font-family: var(--font-mono);
510 font-size: 11.5px;
511 margin: 6px 0;
512 white-space: pre-wrap;
513 color: var(--text);
514 }
515`;
516
517function IconBox() {
518 return (
519 <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">
520 <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" />
521 <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
522 <line x1="12" y1="22.08" x2="12" y2="12" />
523 </svg>
524 );
525}
526
25a91a6Claude527// ---------------------------------------------------------------------------
528// List page
529// ---------------------------------------------------------------------------
530
531ui.get("/:owner/:repo/packages", async (c) => {
532 const user = c.get("user");
533 const { owner, repo } = c.req.param();
534 const repoRow = await loadRepo(owner, repo);
535 if (!repoRow) return c.notFound();
536
537 let rows: (Package & { latestVersion: string | null })[] = [];
538 try {
539 const pkgs = await db
540 .select()
541 .from(packages)
542 .where(
543 and(
544 eq(packages.repositoryId, repoRow.id),
545 eq(packages.ecosystem, "npm")
546 )
547 )
548 .orderBy(desc(packages.updatedAt));
549
88f5bd1ccantynz-alt550 // The private-repo case is already handled upstream by the platform gate
551 // in app.tsx, which this owner/repo path shape does inherit. What it does
552 // not cover is an individually-private package inside a PUBLIC repo, so
553 // drop those unless the viewer is a member rather than a public reader.
554 const visiblePkgs = (await isRepoMember(repoRow.id, user?.id ?? null))
555 ? pkgs
556 : pkgs.filter((p) => p.visibility !== "private");
557
25a91a6Claude558 // Fetch the "latest" tag for each package.
559 const latest = await Promise.all(
88f5bd1ccantynz-alt560 visiblePkgs.map(async (p) => {
25a91a6Claude561 try {
562 const [tag] = await db
563 .select({
564 version: packageVersions.version,
565 })
566 .from(packageTags)
567 .innerJoin(
568 packageVersions,
569 eq(packageTags.versionId, packageVersions.id)
570 )
571 .where(
572 and(
573 eq(packageTags.packageId, p.id),
574 eq(packageTags.tag, "latest")
575 )
576 )
577 .limit(1);
578 return tag?.version || null;
579 } catch {
580 return null;
581 }
582 })
583 );
88f5bd1ccantynz-alt584 rows = visiblePkgs.map((p, i) => ({ ...p, latestVersion: latest[i] }));
25a91a6Claude585 } catch (err) {
586 console.error("[packages] ui list:", err);
587 return c.text("Service unavailable", 503);
588 }
589
590 const unread = user ? await getUnreadCount(user.id) : 0;
591 const host = new URL(c.req.url).host;
592 const registryUrl = `${new URL(c.req.url).protocol}//${host}/npm/`;
593
594 return c.html(
595 <Layout
596 title={`Packages — ${owner}/${repo}`}
597 user={user}
598 notificationCount={unread}
599 >
600 <RepoHeader
601 owner={owner}
602 repo={repo}
603 starCount={repoRow.starCount}
604 forkCount={repoRow.forkCount}
605 currentUser={user?.username || null}
606 />
607 <RepoNav owner={owner} repo={repo} active="code" />
608
f0b5874Claude609 <div class="pkg-wrap">
610 <header class="pkg-head">
611 <div class="pkg-head-text">
612 <div class="pkg-eyebrow">
613 <span class="pkg-eyebrow-dot" aria-hidden="true" />
614 Repository · Packages
615 </div>
616 <h1 class="pkg-title">
617 <span class="pkg-title-grad">Published artifacts.</span>
618 </h1>
619 <p class="pkg-sub">
620 Every npm package shipped from {owner}/{repo}, scoped to the
621 repo's own registry namespace.
622 </p>
623 </div>
624 </header>
25a91a6Claude625
626 {rows.length === 0 ? (
f0b5874Claude627 <div class="pkg-empty">
628 <div class="pkg-empty-orb" aria-hidden="true" />
629 <div class="pkg-empty-inner">
630 <div class="pkg-empty-icon" aria-hidden="true">
631 <IconBox />
632 </div>
633 <h3 class="pkg-empty-title">Publish your first package</h3>
634 <p class="pkg-empty-sub">
635 Wire your repo to the Gluecron npm registry and run{" "}
636 <code>npm publish</code> — your tarball will appear here.
637 </p>
638 <div class="pkg-empty-howto">
639 <strong>To publish:</strong>
640 <ol>
641 <li>
642 Create a personal access token at{" "}
643 <a href="/settings/tokens">/settings/tokens</a>.
644 </li>
645 <li>
646 Add to your <code>.npmrc</code>:
647 <pre>
648 registry={registryUrl}
649 {"\n"}
650 //{host}/npm/:_authToken=YOUR_PAT
651 </pre>
652 </li>
653 <li>
654 In <code>package.json</code>, point{" "}
655 <code>repository.url</code> at this repo
656 (<code>{`http://${host}/${owner}/${repo}.git`}</code>).
657 </li>
658 <li>
659 Run <code>npm publish</code>.
660 </li>
661 </ol>
662 </div>
25a91a6Claude663 </div>
664 </div>
665 ) : (
f0b5874Claude666 <div class="pkg-list">
25a91a6Claude667 {rows.map((p) => {
668 const fullName = fullPkgName(p);
669 return (
670 <a
671 href={`/${owner}/${repo}/packages/${encodeURIComponent(fullName)}`}
f0b5874Claude672 class="pkg-card"
25a91a6Claude673 >
f0b5874Claude674 <div class="pkg-card-row">
25a91a6Claude675 <div style="flex: 1; min-width: 0">
f0b5874Claude676 <div class="pkg-card-name">{fullName}</div>
25a91a6Claude677 {p.description && (
f0b5874Claude678 <div class="pkg-card-desc">{p.description}</div>
25a91a6Claude679 )}
680 </div>
f0b5874Claude681 <div class="pkg-card-meta">
25a91a6Claude682 {p.latestVersion ? (
f0b5874Claude683 <span class="pkg-version">{p.latestVersion}</span>
25a91a6Claude684 ) : (
f0b5874Claude685 <span class="pkg-version is-empty">no versions</span>
25a91a6Claude686 )}
f0b5874Claude687 <span class="sep">·</span>
25a91a6Claude688 <span>{relTime(p.updatedAt)}</span>
689 </div>
690 </div>
691 </a>
692 );
693 })}
694 </div>
695 )}
696 </div>
f0b5874Claude697 <style dangerouslySetInnerHTML={{ __html: pkgStyles }} />
25a91a6Claude698 </Layout>
699 );
700});
701
702// ---------------------------------------------------------------------------
703// Detail page
704// ---------------------------------------------------------------------------
705
706ui.get("/:owner/:repo/packages/:pkgName{.+}", async (c) => {
707 const user = c.get("user");
708 const { owner, repo, pkgName } = c.req.param();
709 const parsed = parsePackageName(pkgName);
710 if (!parsed) {
711 return c.text("Invalid package name", 400);
712 }
713
714 const repoRow = await loadRepo(owner, repo);
715 if (!repoRow) return c.notFound();
716
717 let pkg: Package | null = null;
718 let versions: PackageVersion[] = [];
719 let tags: PackageTag[] = [];
720 try {
721 const candidates = await db
722 .select()
723 .from(packages)
724 .where(
725 and(
726 eq(packages.repositoryId, repoRow.id),
727 eq(packages.ecosystem, "npm"),
728 eq(packages.name, parsed.name)
729 )
730 )
731 .limit(10);
732 pkg =
733 candidates.find((p) => (p.scope ?? null) === (parsed.scope ?? null)) ||
734 null;
88f5bd1ccantynz-alt735 // As in the listing above: the repo-private case is covered upstream, but
736 // an individually-private package in a public repo is not. Treat it as
737 // absent for non-members so the 404 below is what they get.
738 if (
739 pkg?.visibility === "private" &&
740 !(await isRepoMember(repoRow.id, user?.id ?? null))
741 ) {
742 pkg = null;
743 }
25a91a6Claude744 if (pkg) {
745 versions = await db
746 .select()
747 .from(packageVersions)
748 .where(eq(packageVersions.packageId, pkg.id))
749 .orderBy(desc(packageVersions.publishedAt));
750 tags = await db
751 .select()
752 .from(packageTags)
753 .where(eq(packageTags.packageId, pkg.id));
754 }
755 } catch (err) {
756 console.error("[packages] ui detail:", err);
757 return c.text("Service unavailable", 503);
758 }
759
760 if (!pkg) return c.notFound();
761
762 const unread = user ? await getUnreadCount(user.id) : 0;
763 const fullName = fullPkgName(pkg);
764 const latestTag = tags.find((t) => t.tag === "latest");
765 const latestVersion =
766 latestTag && versions.find((v) => v.id === latestTag.versionId);
767 const isOwner = !!user && user.id === repoRow.ownerId;
768 const host = new URL(c.req.url).host;
769
770 return c.html(
771 <Layout
772 title={`${fullName} — ${owner}/${repo}`}
773 user={user}
774 notificationCount={unread}
775 >
776 <RepoHeader
777 owner={owner}
778 repo={repo}
779 starCount={repoRow.starCount}
780 forkCount={repoRow.forkCount}
781 currentUser={user?.username || null}
782 />
783 <RepoNav owner={owner} repo={repo} active="code" />
784
f0b5874Claude785 <div class="pkg-wrap">
786 <div class="pkg-crumbs">
787 <a href={`/${owner}/${repo}/packages`}>
788 Packages
789 </a>
790 <span class="cur">{fullName}</span>
25a91a6Claude791 </div>
792
f0b5874Claude793 <header class="pkg-head">
794 <div class="pkg-head-text">
795 <div class="pkg-eyebrow">
796 <span class="pkg-eyebrow-dot" aria-hidden="true" />
797 Package · npm
798 </div>
799 <h1 class="pkg-title">
800 <span class="pkg-title-grad">{fullName}</span>
801 </h1>
802 {pkg.description && (
803 <p class="pkg-sub">{pkg.description}</p>
804 )}
805 </div>
806 {latestVersion && (
807 <span class="pkg-version">v{latestVersion.version}</span>
808 )}
809 </header>
810
811 <div class="pkg-detail-grid">
25a91a6Claude812 <div>
f0b5874Claude813 <section class="pkg-section">
814 <h3 class="pkg-section-label">Install</h3>
815 <code class="pkg-code">
816 npm install {fullName}
817 {latestVersion ? `@${latestVersion.version}` : ""}
818 </code>
819 </section>
25a91a6Claude820
821 {pkg.readme && (
f0b5874Claude822 <section class="pkg-section">
823 <h3 class="pkg-section-label">Readme</h3>
824 <pre class="pkg-readme">{pkg.readme}</pre>
825 </section>
25a91a6Claude826 )}
827
f0b5874Claude828 <section class="pkg-section">
829 <h3 class="pkg-section-label">
830 Versions
831 <span style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
832 {" "}({versions.length})
833 </span>
834 </h3>
835 {versions.length === 0 ? (
836 <div class="pkg-empty" style="padding: 28px 20px">
837 <div class="pkg-empty-inner">
838 <p class="pkg-empty-sub" style="margin-bottom: 0">No versions yet.</p>
839 </div>
840 </div>
841 ) : (
842 <div class="pkg-versions">
843 {versions.map((v) => (
844 <div class="pkg-version-row">
845 <div class="pkg-version-main">
846 <div class="pkg-version-line">
847 <span class="pkg-version">{v.version}</span>
848 {v.yanked && (
849 <span class="pkg-yanked">yanked</span>
850 )}
851 </div>
852 <div class="pkg-version-meta">
853 {humanSize(v.sizeBytes)} · published{" "}
854 {relTime(v.publishedAt)}
855 {v.shasum && (
856 <>
857 {" · sha1 "}
858 <code>{v.shasum.slice(0, 12)}</code>
859 </>
860 )}
861 </div>
25a91a6Claude862 </div>
f0b5874Claude863 <div class="pkg-version-actions">
864 <a
865 class="pkg-btn pkg-btn-ghost"
866 href={`/npm/${encodeURIComponent(fullName)}/-/${pkg.name}-${v.version}.tgz`}
867 >
868 Download
869 </a>
870 {isOwner && !v.yanked && (
871 <form
872 method="post"
873 action={`/api/packages/${owner}/${repo}/${encodeURIComponent(fullName)}/${v.version}/yank`}
874 onsubmit="return confirm('Yank this version? It will still download, but will be flagged as yanked.')"
875 >
876 <button
877 type="submit"
878 class="pkg-btn pkg-btn-danger"
879 >
880 Yank
881 </button>
882 </form>
25a91a6Claude883 )}
884 </div>
885 </div>
f0b5874Claude886 ))}
887 </div>
888 )}
889 </section>
25a91a6Claude890 </div>
891
f0b5874Claude892 <aside class="pkg-side">
893 <p class="pkg-side-label">Registry</p>
894 <p class="pkg-side-value">
895 <code>http://{host}/npm/</code>
896 </p>
897 {pkg.homepage && (
898 <>
899 <p class="pkg-side-label">Homepage</p>
900 <p class="pkg-side-value">
901 <a href={pkg.homepage}>{pkg.homepage}</a>
902 </p>
903 </>
904 )}
905 {pkg.license && (
906 <>
907 <p class="pkg-side-label">License</p>
908 <p class="pkg-side-value">{pkg.license}</p>
909 </>
910 )}
911 <p class="pkg-side-label">Repository</p>
912 <p class="pkg-side-value">
913 <a href={`/${owner}/${repo}`}>
25a91a6Claude914 {owner}/{repo}
915 </a>
f0b5874Claude916 </p>
25a91a6Claude917 </aside>
918 </div>
919 </div>
f0b5874Claude920 <style dangerouslySetInnerHTML={{ __html: pkgStyles }} />
25a91a6Claude921 </Layout>
922 );
923});
924
925export default ui;