Blame · Line-by-line history
pinned-repos.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.
| 8ec29ff | 1 | /** |
| 2 | * Block J13 — Pinned repos management UI. | |
| 3 | * | |
| 4 | * GET /settings/pins — pick up to 6 repos to feature on your profile | |
| 5 | * POST /settings/pins — save the selection (replaces prior set) | |
| 6 | */ | |
| 7 | ||
| 8 | import { Hono } from "hono"; | |
| 9 | import { Layout } from "../views/layout"; | |
| 10 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 11 | import type { AuthEnv } from "../middleware/auth"; | |
| 12 | import { | |
| 13 | MAX_PINS, | |
| 14 | listPinCandidates, | |
| 15 | listPinnedForUser, | |
| 16 | setPinsForUser, | |
| 17 | } from "../lib/pinned-repos"; | |
| 18 | ||
| 19 | const pins = new Hono<AuthEnv>(); | |
| 20 | ||
| 21 | pins.get("/settings/pins", softAuth, requireAuth, async (c) => { | |
| 22 | const user = c.get("user")!; | |
| 23 | const [pinned, candidates] = await Promise.all([ | |
| 24 | listPinnedForUser(user.id), | |
| 25 | listPinCandidates(user.id), | |
| 26 | ]); | |
| 27 | const pinnedIds = new Set(pinned.map((p) => p.repositoryId)); | |
| 28 | const error = c.req.query("error"); | |
| 29 | const saved = c.req.query("saved") === "1"; | |
| 30 | ||
| 31 | return c.html( | |
| 32 | <Layout title="Pinned repositories" user={user}> | |
| 33 | <h2>Pinned repositories</h2> | |
| 34 | <p style="color: var(--text-muted); max-width: 620px"> | |
| 35 | Choose up to {MAX_PINS} repositories to feature at the top of your | |
| 36 | profile. They appear in the order you select them. | |
| 37 | </p> | |
| 38 | ||
| 39 | {error && <div class="auth-error">{decodeURIComponent(error)}</div>} | |
| 40 | {saved && ( | |
| 41 | <div | |
| 42 | style="padding: 10px 12px; background: rgba(63, 185, 80, 0.1); border: 1px solid var(--green); color: var(--green); border-radius: var(--radius); margin: 12px 0" | |
| 43 | > | |
| 44 | Saved. | |
| 45 | </div> | |
| 46 | )} | |
| 47 | ||
| 48 | <form method="POST" action="/settings/pins" style="margin-top: 16px"> | |
| 49 | {candidates.length === 0 ? ( | |
| 50 | <div class="empty-state"> | |
| 51 | <p>You don't own any repositories yet.</p> | |
| 52 | </div> | |
| 53 | ) : ( | |
| 54 | <ul | |
| 55 | style="list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px" | |
| 56 | data-testid="pin-candidates" | |
| 57 | > | |
| 58 | {candidates.map((c) => ( | |
| 59 | <li | |
| 60 | style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; background: var(--bg-secondary)" | |
| 61 | > | |
| 62 | <label | |
| 63 | style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px" | |
| 64 | > | |
| 65 | <input | |
| 66 | type="checkbox" | |
| 67 | name="repoId" | |
| 68 | value={c.id} | |
| 69 | checked={pinnedIds.has(c.id)} | |
| 70 | /> | |
| 71 | <span style="flex: 1"> | |
| 72 | <strong>{c.name}</strong> | |
| 73 | {c.isPrivate && ( | |
| 74 | <span | |
| 75 | style="margin-left: 6px; font-size: 10px; padding: 1px 6px; border-radius: 10px; background: rgba(139, 148, 158, 0.15); color: var(--text-muted); text-transform: uppercase" | |
| 76 | > | |
| 77 | Private | |
| 78 | </span> | |
| 79 | )} | |
| 80 | </span> | |
| 81 | </label> | |
| 82 | </li> | |
| 83 | ))} | |
| 84 | </ul> | |
| 85 | )} | |
| 86 | ||
| 87 | <div style="margin-top: 16px; display: flex; gap: 8px; align-items: center"> | |
| 88 | <button type="submit" class="btn btn-primary"> | |
| 89 | Save pinned repositories | |
| 90 | </button> | |
| 91 | <span style="color: var(--text-muted); font-size: 12px"> | |
| 92 | The first {MAX_PINS} you tick are kept. | |
| 93 | </span> | |
| 94 | </div> | |
| 95 | </form> | |
| 96 | ||
| 97 | {pinned.length > 0 && ( | |
| 98 | <div style="margin-top: 28px"> | |
| 99 | <h3>Current pins (preview)</h3> | |
| 100 | <ul | |
| 101 | style="list-style: none; padding: 0; margin: 8px 0 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px" | |
| 102 | > | |
| 103 | {pinned.map((p) => ( | |
| 104 | <li | |
| 105 | style="border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px" | |
| 106 | > | |
| 107 | <div> | |
| 108 | <a href={`/${p.ownerUsername}/${p.name}`}> | |
| 109 | <strong>{p.ownerUsername}/{p.name}</strong> | |
| 110 | </a> | |
| 111 | </div> | |
| 112 | {p.description && ( | |
| 113 | <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px"> | |
| 114 | {p.description} | |
| 115 | </div> | |
| 116 | )} | |
| 117 | <div style="color: var(--text-muted); font-size: 11px; margin-top: 6px"> | |
| 118 | {p.starCount} {"\u2605"} · {p.forkCount} forks | |
| 119 | </div> | |
| 120 | </li> | |
| 121 | ))} | |
| 122 | </ul> | |
| 123 | </div> | |
| 124 | )} | |
| 125 | </Layout> | |
| 126 | ); | |
| 127 | }); | |
| 128 | ||
| 129 | pins.post("/settings/pins", softAuth, requireAuth, async (c) => { | |
| 130 | const user = c.get("user")!; | |
| 131 | const form = await c.req.parseBody({ all: true }); | |
| 132 | const raw = form.repoId; | |
| 133 | const ids: string[] = Array.isArray(raw) | |
| 134 | ? raw.map((x) => String(x)) | |
| 135 | : raw != null | |
| 136 | ? [String(raw)] | |
| 137 | : []; | |
| 138 | await setPinsForUser(user.id, ids); | |
| 139 | return c.redirect("/settings/pins?saved=1"); | |
| 140 | }); | |
| 141 | ||
| 142 | export default pins; |