Commit6fc53bdunknown_key
feat(BLOCK-A): theme toggle, audit log UI, reactions, draft PRs
feat(BLOCK-A): theme toggle, audit log UI, reactions, draft PRs A1 — Dark/light theme toggle: `/theme/toggle` + `/theme/set?mode=` cookie (1-year, SameSite=Lax). Pre-paint inline script flips data-theme before the first frame so there's no FOUC. CSS variables swap via :root[data-theme='light']. Nav shows sun/moon icon based on the current theme. Works for logged-out visitors (routes live outside /settings/*). A2 — Audit log UI: `GET /settings/audit` shows the caller's 200 most-recent sensitive events; `GET /:owner/:repo/settings/audit` shows the repo's events (owner-only). Joins against `users` for actor name; truncates metadata JSON to 80 chars with full text in the tooltip. Reads the existing `audit_log` table written by `notify.ts#audit()`. A3 — Reactions on issues, PRs, and their comments. New `src/lib/reactions.ts` exports `ALLOWED_EMOJIS` (8), `EMOJI_GLYPH`, `summariseReactions`, `toggleReaction`. REST API at `POST /api/reactions/:targetType/:targetId/:emoji/toggle` (authed, form- or fetch-compatible) and `GET /api/reactions/:t/:id` for read-only summaries. `ReactionsBar` component is no-JS compatible (one form per emoji button). Wired into issue detail and PR detail. A4 — Draft PR lifecycle: "Create draft" button on the new-PR form (skips AI review), "Draft" tab on the PR list with its own count, "Ready for review" + "Convert to draft" toggles on PR detail. Merging a draft is blocked with a user-facing error — must be marked ready first. Ready-for-review triggers AI review. 91/91 tests pass (was 81). Added 10 tests covering theme toggle, reactions API, and audit-page auth gating. BUILD_BIBLE.md §2 scorecard updated (3 rows ✅), §3 Block A marked complete, §4 locked list extended with new files + invariants.
11 files changed+1112−706fc53bd118abae73d72323caf08a44de65a55fea
11 changed files+1112−70
ModifiedBUILD_BIBLE.md+19−11View fileUnifiedSplit
@@ -88,8 +88,8 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
8888| Milestones | ✅ | `src/routes/insights.tsx` |
8989| Pull requests (CRUD / review / merge) | ✅ | |
9090| PR inline comments | ✅ | file+line anchored |
91| Draft PRs | 🟡 | schema flag, no UI toggle |
92| Reactions (emoji) | 🟡 | table exists, no UI |
91| Draft PRs | ✅ | create as draft, ready-for-review toggle, dedicated tab, merge blocked until ready |
92| Reactions (emoji) | ✅ | 8 reactions, toggle via `POST /api/reactions/:t/:id/:emoji/toggle` on issues + PRs + comments |
9393| Mentions + notifications | ✅ | `src/routes/notifications.tsx` |
9494| Code owners | ✅ | `src/lib/codeowners.ts` |
9595| Issue templates | ❌ | |
@@ -153,14 +153,14 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
153153| Request-ID tracing | ✅ | `src/middleware/request-context.ts` |
154154| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics` |
155155| Audit log (table) | ✅ | `audit_log` table |
156| Audit log UI | ❌ | data only |
156| Audit log UI | ✅ | `/settings/audit` (personal) + `/:owner/:repo/settings/audit` (per-repo, owner-only) |
157157| Traffic analytics per repo | ❌ | |
158158| Email notifications | ❌ | in-app only |
159159| Email digest | ❌ | |
160160| Mobile PWA | 🟡 | responsive CSS, no manifest |
161161| Native mobile apps | ❌ | |
162| Dark mode | ✅ | only mode |
163| Light-mode toggle | ❌ | |
162| Dark mode | ✅ | default |
163| Light-mode toggle | ✅ | `/theme/toggle` + `theme` cookie, pre-paint script avoids FOUC, nav sun/moon icon |
164164| Keyboard shortcuts | ✅ | `/shortcuts` page |
165165| Command palette | 🟡 | Cmd+K → Ask AI, no generic palette |
166166
@@ -172,10 +172,10 @@ Each block is a self-contained unit. Order matters for dependencies. Each block
172172
173173### BLOCK A — Hardening the current surface
174174Polish what's shipped before adding more. **Priority: do this first if parity gaps are minor.**
175- **A1** — Dark/light theme toggle (cookie, CSS variable swap)
176- **A2** — Audit log UI page (`/settings/audit` + `/:owner/:repo/settings/audit`)
177- **A3** — Reactions UI on issues / PRs / comments (data exists)
178- **A4** — Draft PR toggle + filter
175- **A1** — Dark/light theme toggle (cookie, CSS variable swap) ✅
176- **A2** — Audit log UI page (`/settings/audit` + `/:owner/:repo/settings/audit`) ✅
177- **A3** — Reactions UI on issues / PRs / comments (data exists) ✅
178- **A4** — Draft PR toggle + filter ✅
179179- **A5** — Issue + PR templates (`.github/*_TEMPLATE.md` auto-prefill)
180180- **A6** — Saved replies per user
181181- **A7** — Environments + deployment history UI (`deployments` table)
@@ -278,11 +278,15 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
278278- `src/lib/repo-bootstrap.ts` — green defaults on repo creation
279279- `src/lib/gate.ts` — gate orchestration + persistence
280280- `src/lib/cache.ts` — LRU cache, git-cache invalidation
281- `src/lib/reactions.ts` — `summariseReactions`, `toggleReaction`, `ALLOWED_EMOJIS`, `EMOJI_GLYPH`, `isAllowedEmoji`, `isAllowedTarget`
281282
282283### 4.6 Routes (locked endpoints — behaviour must be preserved)
283284- `src/routes/git.ts` — Smart HTTP (clone/push)
284285- `src/routes/api.ts` — REST (`POST /api/repos`, `GET /api/users/:u/repos`, `GET /api/repos/:o/:n`, `POST /api/setup`)
285286- `src/routes/hooks.ts` — `POST /api/hooks/gatetest` (bearer/HMAC), `GET /api/hooks/ping`, `POST /api/v1/gate-runs` (PAT backup), `GET /api/v1/gate-runs`. See `GATETEST_HOOK.md`.
287- `src/routes/theme.ts` — `GET /theme/toggle`, `GET /theme/set?mode=`. Writes `theme` cookie (`dark`|`light`, 1-year). Layout reads via pre-paint inline script.
288- `src/routes/audit.tsx` — `GET /settings/audit` (personal) + `GET /:owner/:repo/settings/audit` (owner-only).
289- `src/routes/reactions.ts` — `POST /api/reactions/:targetType/:targetId/:emoji/toggle` (authed, form- or fetch-compatible), `GET /api/reactions/:targetType/:targetId`. Targets: `issue|pr|issue_comment|pr_comment`. Emojis: 8 canonical.
286290- `src/routes/auth.tsx` — register / login / logout
287291- `src/routes/web.tsx` — home / new / browse / blob / commits / raw / blame / star / search / profile
288292- `src/routes/issues.tsx` — issue CRUD + comments + labels + lock
@@ -308,7 +312,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
308312### 4.7 Views (locked contracts)
309313- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
310314- `src/views/components.tsx` — `RepoHeader`, `RepoNav` (active: `code|issues|pulls|commits|releases|gates|insights|...`), `RepoCard`, etc.
311- Nav links: logo · search · Explore · Ask · Notifications · New · Profile (or Sign in / Register)
315- `src/views/reactions.tsx` — `ReactionsBar` (no-JS compatible, form-per-emoji)
316- Nav links: logo · search · theme-toggle · Explore · Ask · Notifications · New · Profile (or Sign in / Register)
312317- Keyboard chords: `/`, `Cmd+K`, `?`, `n`, `g d`, `g n`, `g e`, `g a`
313318
314319### 4.8 Tests (locked)
@@ -322,6 +327,9 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
322327- `c.header("X-Request-Id", ...)` set by request-context on every response.
323328- Secret scanner skips binary/lock paths (`shouldSkipPath`).
324329- `SECRET_PATTERNS` is an exported array. Its shape is `{ type, regex, severity }`.
330- Theme routes live outside `/settings/*` (they must work for logged-out visitors). Cookie name: `theme`, values: `dark|light`.
331- Draft PRs cannot be merged — `/pulls/:n/merge` returns a redirect with the draft error when `pr.isDraft=true`.
332- Reactions API accepts only `ALLOWED_EMOJIS` and `ALLOWED_TARGETS`. Toggle is idempotent per (user, target, emoji).
325333
326334---
327335
@@ -331,7 +339,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
331339```bash
332340bun install
333341bun dev # hot reload
334bun test # 76 tests currently pass
342bun test # 91 tests currently pass
335343bun run db:migrate
336344```
337345
Modifiedsrc/__tests__/green-ecosystem.test.ts+98−0View fileUnifiedSplit
@@ -15,6 +15,12 @@ import {
1515} from "../lib/codeowners";
1616import { generateCommitMessage } from "../lib/ai-generators";
1717import { isAiAvailable } from "../lib/ai-client";
18import {
19 isAllowedEmoji,
20 isAllowedTarget,
21 ALLOWED_EMOJIS,
22 EMOJI_GLYPH,
23} from "../lib/reactions";
1824
1925describe("secret scanner", () => {
2026 it("detects AWS access keys", () => {
@@ -223,3 +229,95 @@ describe("GateTest inbound hook", () => {
223229 expect(res.status).toBe(401);
224230 });
225231});
232
233describe("theme toggle", () => {
234 it("GET /theme/toggle sets a cookie and redirects", async () => {
235 const res = await app.request("/theme/toggle");
236 // 302 redirect; no cookie yet means we flip from the default (dark) → light
237 expect([301, 302, 303, 307]).toContain(res.status);
238 const setCookie = res.headers.get("set-cookie") || "";
239 expect(/theme=light/.test(setCookie)).toBe(true);
240 });
241
242 it("GET /theme/toggle flips an existing 'light' cookie back to dark", async () => {
243 const res = await app.request("/theme/toggle", {
244 headers: { cookie: "theme=light" },
245 });
246 const setCookie = res.headers.get("set-cookie") || "";
247 expect(/theme=dark/.test(setCookie)).toBe(true);
248 });
249
250 it("GET /theme/set?mode=light returns JSON when asked", async () => {
251 const res = await app.request("/theme/set?mode=light", {
252 headers: { accept: "application/json" },
253 });
254 expect(res.status).toBe(200);
255 const body = await res.json();
256 expect(body.ok).toBe(true);
257 expect(body.theme).toBe("light");
258 });
259
260 it("GET /theme/set rejects unknown modes", async () => {
261 const res = await app.request("/theme/set?mode=neon", {
262 headers: { accept: "application/json" },
263 });
264 expect(res.status).toBe(400);
265 });
266
267 it("home page includes the pre-paint theme script + data-theme attribute", async () => {
268 const res = await app.request("/");
269 const html = await res.text();
270 expect(html).toContain("data-theme");
271 expect(html).toContain("theme-icon-");
272 // The pre-paint script reads the cookie.
273 expect(html).toContain("document.cookie");
274 });
275});
276
277describe("reactions", () => {
278 it("allowed emojis and targets are self-consistent", () => {
279 expect(ALLOWED_EMOJIS.length).toBeGreaterThanOrEqual(6);
280 for (const e of ALLOWED_EMOJIS) {
281 expect(isAllowedEmoji(e)).toBe(true);
282 expect(EMOJI_GLYPH[e]).toBeTruthy();
283 }
284 expect(isAllowedEmoji("nope")).toBe(false);
285 expect(isAllowedTarget("issue")).toBe(true);
286 expect(isAllowedTarget("martian")).toBe(false);
287 });
288
289 it("POST /api/reactions/.../toggle requires auth", async () => {
290 const res = await app.request(
291 "/api/reactions/issue/00000000-0000-0000-0000-000000000000/thumbs_up/toggle",
292 { method: "POST" }
293 );
294 // Unauthenticated -> redirect to /login (302)
295 expect([301, 302, 303, 307]).toContain(res.status);
296 });
297
298 it("GET /api/reactions/:type/:id returns empty summary when no reactions exist", async () => {
299 const res = await app.request(
300 "/api/reactions/issue/00000000-0000-0000-0000-000000000000"
301 );
302 expect(res.status).toBe(200);
303 const body = await res.json();
304 expect(body.ok).toBe(true);
305 expect(Array.isArray(body.reactions)).toBe(true);
306 });
307
308 it("rejects unknown target type on the listing endpoint", async () => {
309 const res = await app.request(
310 "/api/reactions/martian/00000000-0000-0000-0000-000000000000"
311 );
312 expect(res.status).toBe(400);
313 });
314});
315
316describe("audit log UI", () => {
317 it("GET /settings/audit redirects unauthenticated users to /login", async () => {
318 const res = await app.request("/settings/audit");
319 expect([301, 302, 303, 307]).toContain(res.status);
320 const loc = res.headers.get("location") || "";
321 expect(loc.startsWith("/login")).toBe(true);
322 });
323});
Modifiedsrc/app.tsx+12−0View fileUnifiedSplit
@@ -28,6 +28,9 @@ import insightsRoutes from "./routes/insights";
2828import searchRoutes from "./routes/search";
2929import healthRoutes from "./routes/health";
3030import hookRoutes from "./routes/hooks";
31import themeRoutes from "./routes/theme";
32import auditRoutes from "./routes/audit";
33import reactionRoutes from "./routes/reactions";
3134import webRoutes from "./routes/web";
3235
3336const app = new Hono();
@@ -65,6 +68,15 @@ app.route("/", authRoutes);
6568// Settings routes (profile, SSH keys)
6669app.route("/", settingsRoutes);
6770
71// Theme toggle (dark/light cookie)
72app.route("/", themeRoutes);
73
74// Audit log UI
75app.route("/", auditRoutes);
76
77// Reactions API (issues, PRs, comments)
78app.route("/", reactionRoutes);
79
6880// API tokens
6981app.route("/", tokenRoutes);
7082
Addedsrc/lib/reactions.ts+135−0View fileUnifiedSplit
@@ -0,0 +1,135 @@
1/**
2 * Reactions helper — aggregate + toggle logic over the `reactions` table.
3 * Universal target pointer: (targetType, targetId).
4 */
5
6import { and, eq, sql } from "drizzle-orm";
7import { db } from "../db";
8import { reactions } from "../db/schema";
9
10export type TargetType = "issue" | "pr" | "issue_comment" | "pr_comment";
11
12export const ALLOWED_TARGETS: TargetType[] = [
13 "issue",
14 "pr",
15 "issue_comment",
16 "pr_comment",
17];
18
19export type Emoji =
20 | "thumbs_up"
21 | "thumbs_down"
22 | "rocket"
23 | "heart"
24 | "eyes"
25 | "laugh"
26 | "hooray"
27 | "confused";
28
29export const ALLOWED_EMOJIS: Emoji[] = [
30 "thumbs_up",
31 "thumbs_down",
32 "rocket",
33 "heart",
34 "eyes",
35 "laugh",
36 "hooray",
37 "confused",
38];
39
40export const EMOJI_GLYPH: Record<Emoji, string> = {
41 thumbs_up: "\uD83D\uDC4D",
42 thumbs_down: "\uD83D\uDC4E",
43 rocket: "\uD83D\uDE80",
44 heart: "\u2764\uFE0F",
45 eyes: "\uD83D\uDC40",
46 laugh: "\uD83D\uDE04",
47 hooray: "\uD83C\uDF89",
48 confused: "\uD83D\uDE15",
49};
50
51export function isAllowedEmoji(x: unknown): x is Emoji {
52 return typeof x === "string" && (ALLOWED_EMOJIS as string[]).includes(x);
53}
54
55export function isAllowedTarget(x: unknown): x is TargetType {
56 return typeof x === "string" && (ALLOWED_TARGETS as string[]).includes(x);
57}
58
59export type ReactionSummary = {
60 emoji: Emoji;
61 count: number;
62 reactedByMe: boolean;
63};
64
65/**
66 * Load reaction counts for a single target, + whether current user reacted.
67 */
68export async function summariseReactions(
69 targetType: TargetType,
70 targetId: string,
71 currentUserId: string | null | undefined
72): Promise<ReactionSummary[]> {
73 try {
74 const rows = await db
75 .select({
76 emoji: reactions.emoji,
77 count: sql<number>`count(*)::int`,
78 mine: sql<number>`sum(case when ${reactions.userId} = ${currentUserId || null} then 1 else 0 end)::int`,
79 })
80 .from(reactions)
81 .where(
82 and(
83 eq(reactions.targetType, targetType),
84 eq(reactions.targetId, targetId)
85 )
86 )
87 .groupBy(reactions.emoji);
88
89 return rows
90 .filter((r) => isAllowedEmoji(r.emoji))
91 .map((r) => ({
92 emoji: r.emoji as Emoji,
93 count: Number(r.count) || 0,
94 reactedByMe: Number(r.mine) > 0,
95 }));
96 } catch {
97 return [];
98 }
99}
100
101/**
102 * Toggle the user's reaction. Returns the new `reactedByMe` state.
103 */
104export async function toggleReaction(
105 userId: string,
106 targetType: TargetType,
107 targetId: string,
108 emoji: Emoji
109): Promise<{ added: boolean }> {
110 const existing = await db
111 .select({ id: reactions.id })
112 .from(reactions)
113 .where(
114 and(
115 eq(reactions.userId, userId),
116 eq(reactions.targetType, targetType),
117 eq(reactions.targetId, targetId),
118 eq(reactions.emoji, emoji)
119 )
120 )
121 .limit(1);
122
123 if (existing.length > 0) {
124 await db.delete(reactions).where(eq(reactions.id, existing[0].id));
125 return { added: false };
126 }
127
128 await db.insert(reactions).values({
129 userId,
130 targetType,
131 targetId,
132 emoji,
133 });
134 return { added: true };
135}
Addedsrc/routes/audit.tsx+234−0View fileUnifiedSplit
@@ -0,0 +1,234 @@
1/**
2 * Audit log UI — personal audit (who has done what with *my* account) and
3 * per-repo audit (who has done what in *my* repo). Reads the `audit_log`
4 * table written by `src/lib/notify.ts#audit()`.
5 */
6
7import { Hono } from "hono";
8import { desc, eq, and } from "drizzle-orm";
9import { db } from "../db";
10import { auditLog, repositories, users } from "../db/schema";
11import type { AuthEnv } from "../middleware/auth";
12import { requireAuth } from "../middleware/auth";
13import { Layout } from "../views/layout";
14
15const audit = new Hono<AuthEnv>();
16
17audit.use("/settings/audit", requireAuth);
18audit.use("/:owner/:repo/settings/audit", requireAuth);
19
20const LIMIT = 200;
21
22type AuditRow = {
23 id: string;
24 action: string;
25 targetType: string | null;
26 targetId: string | null;
27 ip: string | null;
28 userAgent: string | null;
29 metadata: string | null;
30 createdAt: Date;
31 actor: string | null;
32};
33
34function renderMetadata(raw: string | null): string {
35 if (!raw) return "";
36 try {
37 const parsed = JSON.parse(raw);
38 return JSON.stringify(parsed);
39 } catch {
40 return raw;
41 }
42}
43
44function timeAgo(d: Date): string {
45 const ms = Date.now() - d.getTime();
46 const s = Math.floor(ms / 1000);
47 if (s < 60) return `${s}s ago`;
48 const m = Math.floor(s / 60);
49 if (m < 60) return `${m}m ago`;
50 const h = Math.floor(m / 60);
51 if (h < 24) return `${h}h ago`;
52 const days = Math.floor(h / 24);
53 if (days < 30) return `${days}d ago`;
54 return d.toISOString().slice(0, 10);
55}
56
57function AuditTable({ rows }: { rows: AuditRow[] }) {
58 if (rows.length === 0) {
59 return (
60 <div class="empty-state">
61 <h2>No audit events yet</h2>
62 <p>Sensitive actions will appear here as they happen.</p>
63 </div>
64 );
65 }
66 return (
67 <div class="audit-log">
68 <table class="audit-table">
69 <thead>
70 <tr>
71 <th>When</th>
72 <th>Actor</th>
73 <th>Action</th>
74 <th>Target</th>
75 <th>IP</th>
76 <th>Details</th>
77 </tr>
78 </thead>
79 <tbody>
80 {rows.map((r) => (
81 <tr>
82 <td class="audit-when" title={r.createdAt.toISOString()}>
83 {timeAgo(new Date(r.createdAt))}
84 </td>
85 <td>{r.actor || <span class="audit-muted">system</span>}</td>
86 <td>
87 <code class="audit-action">{r.action}</code>
88 </td>
89 <td class="audit-target">
90 {r.targetType ? (
91 <span>
92 {r.targetType}
93 {r.targetId ? <code> {r.targetId.slice(0, 8)}</code> : null}
94 </span>
95 ) : (
96 <span class="audit-muted">—</span>
97 )}
98 </td>
99 <td>
100 <code class="audit-ip">{r.ip || "—"}</code>
101 </td>
102 <td class="audit-meta">
103 {r.metadata ? (
104 <code title={r.metadata}>{renderMetadata(r.metadata).slice(0, 80)}</code>
105 ) : (
106 <span class="audit-muted">—</span>
107 )}
108 </td>
109 </tr>
110 ))}
111 </tbody>
112 </table>
113 </div>
114 );
115}
116
117// Personal audit — events where userId = current user.
118audit.get("/settings/audit", async (c) => {
119 const user = c.get("user")!;
120 let rows: AuditRow[] = [];
121 try {
122 const raw = await db
123 .select({
124 id: auditLog.id,
125 action: auditLog.action,
126 targetType: auditLog.targetType,
127 targetId: auditLog.targetId,
128 ip: auditLog.ip,
129 userAgent: auditLog.userAgent,
130 metadata: auditLog.metadata,
131 createdAt: auditLog.createdAt,
132 actor: users.username,
133 })
134 .from(auditLog)
135 .leftJoin(users, eq(users.id, auditLog.userId))
136 .where(eq(auditLog.userId, user.id))
137 .orderBy(desc(auditLog.createdAt))
138 .limit(LIMIT);
139 rows = raw as AuditRow[];
140 } catch (err) {
141 console.error("[audit] personal:", err);
142 }
143
144 return c.html(
145 <Layout title="Audit log" user={user}>
146 <div class="settings-container" style="max-width: 1000px">
147 <h2>Audit log</h2>
148 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
149 The most recent {LIMIT} sensitive actions tied to your account — logins,
150 token activity, merges, deploys, branch protection changes.
151 </p>
152 <AuditTable rows={rows} />
153 </div>
154 </Layout>
155 );
156});
157
158// Per-repo audit — events with repositoryId = this repo. Owner-only.
159audit.get("/:owner/:repo/settings/audit", async (c) => {
160 const user = c.get("user")!;
161 const { owner, repo } = c.req.param();
162
163 let repoRow: { id: string; ownerId: string; name: string } | null = null;
164 try {
165 const [r] = await db
166 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
167 .from(repositories)
168 .innerJoin(users, eq(users.id, repositories.ownerId))
169 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
170 .limit(1);
171 repoRow = (r as any) || null;
172 } catch (err) {
173 console.error("[audit] repo lookup:", err);
174 }
175 if (!repoRow) return c.notFound();
176 if (repoRow.ownerId !== user.id) {
177 return c.html(
178 <Layout title="Audit log" user={user}>
179 <div class="empty-state">
180 <h2>Forbidden</h2>
181 <p>Only the repository owner can view the audit log.</p>
182 </div>
183 </Layout>,
184 403
185 );
186 }
187
188 let rows: AuditRow[] = [];
189 try {
190 const raw = await db
191 .select({
192 id: auditLog.id,
193 action: auditLog.action,
194 targetType: auditLog.targetType,
195 targetId: auditLog.targetId,
196 ip: auditLog.ip,
197 userAgent: auditLog.userAgent,
198 metadata: auditLog.metadata,
199 createdAt: auditLog.createdAt,
200 actor: users.username,
201 })
202 .from(auditLog)
203 .leftJoin(users, eq(users.id, auditLog.userId))
204 .where(eq(auditLog.repositoryId, repoRow.id))
205 .orderBy(desc(auditLog.createdAt))
206 .limit(LIMIT);
207 rows = raw as AuditRow[];
208 } catch (err) {
209 console.error("[audit] repo:", err);
210 }
211
212 return c.html(
213 <Layout title={`${owner}/${repo} — audit`} user={user}>
214 <div class="settings-container" style="max-width: 1000px">
215 <div class="breadcrumb">
216 <a href={`/${owner}/${repo}`}>
217 {owner}/{repo}
218 </a>
219 <span>/</span>
220 <a href={`/${owner}/${repo}/settings`}>settings</a>
221 <span>/</span>
222 <span>audit</span>
223 </div>
224 <h2>Audit log</h2>
225 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
226 Who did what in <code>{owner}/{repo}</code> — most recent {LIMIT} events.
227 </p>
228 <AuditTable rows={rows} />
229 </div>
230 </Layout>
231 );
232});
233
234export default audit;
Modifiedsrc/routes/issues.tsx+27−1View fileUnifiedSplit
@@ -15,6 +15,8 @@ import {
1515} from "../db/schema";
1616import { Layout } from "../views/layout";
1717import { RepoHeader, RepoNav } from "../views/components";
18import { ReactionsBar } from "../views/reactions";
19import { summariseReactions } from "../lib/reactions";
1820import { renderMarkdown } from "../lib/markdown";
1921import { softAuth, requireAuth } from "../middleware/auth";
2022import type { AuthEnv } from "../middleware/auth";
@@ -301,6 +303,14 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
301303 .where(eq(issueComments.issueId, issue.id))
302304 .orderBy(asc(issueComments.createdAt));
303305
306 // Load reactions for the issue + each comment in parallel.
307 const [issueReactions, ...commentReactions] = await Promise.all([
308 summariseReactions("issue", issue.id, user?.id),
309 ...comments.map((row) =>
310 summariseReactions("issue_comment", row.comment.id, user?.id)
311 ),
312 ]);
313
304314 const canManage =
305315 user &&
306316 (user.id === resolved.owner.id || user.id === issue.authorId);
@@ -342,10 +352,18 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
342352 <div class="markdown-body">
343353 {html([renderMarkdown(issue.body)] as unknown as TemplateStringsArray)}
344354 </div>
355 <div style="padding: 0 16px 12px">
356 <ReactionsBar
357 targetType="issue"
358 targetId={issue.id}
359 summaries={issueReactions}
360 canReact={!!user}
361 />
362 </div>
345363 </div>
346364 )}
347365
348 {comments.map(({ comment, author: commentAuthor }) => (
366 {comments.map(({ comment, author: commentAuthor }, i) => (
349367 <div class="issue-comment-box">
350368 <div class="comment-header">
351369 <strong>{commentAuthor.username}</strong> commented{" "}
@@ -354,6 +372,14 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, async (c) => {
354372 <div class="markdown-body">
355373 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
356374 </div>
375 <div style="padding: 0 16px 12px">
376 <ReactionsBar
377 targetType="issue_comment"
378 targetId={comment.id}
379 summaries={commentReactions[i] || []}
380 canReact={!!user}
381 />
382 </div>
357383 </div>
358384 ))}
359385
Modifiedsrc/routes/pulls.tsx+253−55View fileUnifiedSplit
@@ -13,6 +13,8 @@ import {
1313} from "../db/schema";
1414import { Layout } from "../views/layout";
1515import { RepoHeader, DiffView } from "../views/components";
16import { ReactionsBar } from "../views/reactions";
17import { summariseReactions } from "../lib/reactions";
1618import { renderMarkdown } from "../lib/markdown";
1719import { softAuth, requireAuth } from "../middleware/auth";
1820import type { AuthEnv } from "../middleware/auth";
@@ -91,6 +93,15 @@ pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
9193 const resolved = await resolveRepo(ownerName, repoName);
9294 if (!resolved) return c.notFound();
9395
96 // "draft" is a virtual filter — rows are state='open' + isDraft=true.
97 const stateFilter =
98 state === "draft"
99 ? and(
100 eq(pullRequests.state, "open"),
101 eq(pullRequests.isDraft, true)
102 )
103 : eq(pullRequests.state, state);
104
94105 const prList = await db
95106 .select({
96107 pr: pullRequests,
@@ -99,16 +110,14 @@ pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
99110 .from(pullRequests)
100111 .innerJoin(users, eq(pullRequests.authorId, users.id))
101112 .where(
102 and(
103 eq(pullRequests.repositoryId, resolved.repo.id),
104 eq(pullRequests.state, state)
105 )
113 and(eq(pullRequests.repositoryId, resolved.repo.id), stateFilter)
106114 )
107115 .orderBy(desc(pullRequests.createdAt));
108116
109117 const [counts] = await db
110118 .select({
111119 open: sql<number>`count(*) filter (where ${pullRequests.state} = 'open')`,
120 draft: sql<number>`count(*) filter (where ${pullRequests.state} = 'open' and ${pullRequests.isDraft} = true)`,
112121 closed: sql<number>`count(*) filter (where ${pullRequests.state} = 'closed')`,
113122 merged: sql<number>`count(*) filter (where ${pullRequests.state} = 'merged')`,
114123 })
@@ -127,6 +136,12 @@ pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
127136 >
128137 {counts?.open ?? 0} Open
129138 </a>
139 <a
140 href={`/${ownerName}/${repoName}/pulls?state=draft`}
141 class={state === "draft" ? "active" : ""}
142 >
143 {counts?.draft ?? 0} Draft
144 </a>
130145 <a
131146 href={`/${ownerName}/${repoName}/pulls?state=merged`}
132147 class={state === "merged" ? "active" : ""}
@@ -155,32 +170,46 @@ pulls.get("/:owner/:repo/pulls", softAuth, async (c) => {
155170 </div>
156171 ) : (
157172 <div class="issue-list">
158 {prList.map(({ pr, author }) => (
159 <div class="issue-item">
160 <div
161 class={`issue-state-icon ${pr.state === "open" ? "state-open" : pr.state === "merged" ? "state-merged" : "state-closed"}`}
162 >
163 {pr.state === "open"
164 ? "\u25CB"
165 : pr.state === "merged"
166 ? "\u2B8C"
167 : "\u2713"}
168 </div>
169 <div>
170 <div class="issue-title">
171 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
172 {pr.title}
173 </a>
174 </div>
175 <div class="issue-meta">
176 #{pr.number}{" "}
177 {pr.headBranch} → {pr.baseBranch}{" "}
178 by {author.username}{" "}
179 {formatRelative(pr.createdAt)}
173 {prList.map(({ pr, author }) => {
174 const isDraft = pr.state === "open" && pr.isDraft;
175 const stateClass = isDraft
176 ? "state-draft"
177 : pr.state === "open"
178 ? "state-open"
179 : pr.state === "merged"
180 ? "state-merged"
181 : "state-closed";
182 const stateIcon = isDraft
183 ? "\u270E"
184 : pr.state === "open"
185 ? "\u25CB"
186 : pr.state === "merged"
187 ? "\u2B8C"
188 : "\u2713";
189 return (
190 <div class="issue-item">
191 <div class={`issue-state-icon ${stateClass}`}>{stateIcon}</div>
192 <div>
193 <div class="issue-title">
194 <a href={`/${ownerName}/${repoName}/pulls/${pr.number}`}>
195 {pr.title}
196 </a>
197 {isDraft && (
198 <span class="issue-badge draft-badge" style="margin-left: 8px; font-size: 11px; padding: 2px 8px">
199 Draft
200 </span>
201 )}
202 </div>
203 <div class="issue-meta">
204 #{pr.number}{" "}
205 {pr.headBranch} → {pr.baseBranch}{" "}
206 by {author.username}{" "}
207 {formatRelative(pr.createdAt)}
208 </div>
180209 </div>
181210 </div>
182 </div>
183 ))}
211 );
212 })}
184213 </div>
185214 )}
186215 </Layout>
@@ -244,9 +273,20 @@ pulls.get(
244273 style="font-family: var(--font-mono); font-size: 13px"
245274 />
246275 </div>
247 <button type="submit" class="btn btn-primary">
248 Create pull request
249 </button>
276 <div style="display: flex; gap: 8px">
277 <button type="submit" class="btn btn-primary">
278 Create pull request
279 </button>
280 <button
281 type="submit"
282 name="draft"
283 value="1"
284 class="btn"
285 title="Create a draft PR — skips AI review and cannot be merged until marked ready"
286 >
287 Create draft
288 </button>
289 </div>
250290 </form>
251291 </div>
252292 </Layout>
@@ -283,6 +323,8 @@ pulls.post(
283323 const resolved = await resolveRepo(ownerName, repoName);
284324 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
285325
326 const isDraft = String(body.draft || "") === "1";
327
286328 const [pr] = await db
287329 .insert(pullRequests)
288330 .values({
@@ -292,11 +334,12 @@ pulls.post(
292334 body: prBody || null,
293335 baseBranch,
294336 headBranch,
337 isDraft,
295338 })
296339 .returning();
297340
298 // Trigger AI code review asynchronously
299 if (isAiReviewEnabled()) {
341 // Skip AI review on drafts — it runs again when the PR is marked ready.
342 if (!isDraft && isAiReviewEnabled()) {
300343 triggerAiReview(ownerName, repoName, pr.id, title, prBody, baseBranch, headBranch).catch(
301344 (err) => console.error("[ai-review] Failed:", err)
302345 );
@@ -345,6 +388,14 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
345388 .where(eq(prComments.pullRequestId, pr.id))
346389 .orderBy(asc(prComments.createdAt));
347390
391 // Reactions for the PR body + each comment, in parallel.
392 const [prReactions, ...prCommentReactions] = await Promise.all([
393 summariseReactions("pr", pr.id, user?.id),
394 ...comments.map((row) =>
395 summariseReactions("pr_comment", row.comment.id, user?.id)
396 ),
397 ]);
398
348399 const canManage =
349400 user &&
350401 (user.id === resolved.owner.id || user.id === pr.authorId);
@@ -417,15 +468,21 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
417468 </span>
418469 </h2>
419470 <div style="margin: 8px 0 20px; display: flex; align-items: center; gap: 8px">
420 <span
421 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
422 >
423 {pr.state === "open"
424 ? "\u25CB Open"
425 : pr.state === "merged"
426 ? "\u2B8C Merged"
427 : "\u2713 Closed"}
428 </span>
471 {pr.state === "open" && pr.isDraft ? (
472 <span class="issue-badge draft-badge">
473 {"\u270E Draft"}
474 </span>
475 ) : (
476 <span
477 class={`issue-badge ${pr.state === "open" ? "badge-open" : pr.state === "merged" ? "badge-merged" : "badge-closed"}`}
478 >
479 {pr.state === "open"
480 ? "\u25CB Open"
481 : pr.state === "merged"
482 ? "\u2B8C Merged"
483 : "\u2713 Closed"}
484 </span>
485 )}
429486 <span style="color: var(--text-muted); font-size: 14px">
430487 <strong style="color: var(--text)">
431488 {author?.username}
@@ -463,10 +520,18 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
463520 <div class="markdown-body">
464521 {html([renderMarkdown(pr.body)] as unknown as TemplateStringsArray)}
465522 </div>
523 <div style="padding: 0 16px 12px">
524 <ReactionsBar
525 targetType="pr"
526 targetId={pr.id}
527 summaries={prReactions}
528 canReact={!!user}
529 />
530 </div>
466531 </div>
467532 )}
468533
469 {comments.map(({ comment, author: commentAuthor }) => (
534 {comments.map(({ comment, author: commentAuthor }, i) => (
470535 <div
471536 class={`issue-comment-box ${comment.isAiReview ? "ai-review" : ""}`}
472537 >
@@ -489,6 +554,14 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
489554 <div class="markdown-body">
490555 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
491556 </div>
557 <div style="padding: 0 16px 12px">
558 <ReactionsBar
559 targetType="pr_comment"
560 targetId={comment.id}
561 summaries={prCommentReactions[i] || []}
562 canReact={!!user}
563 />
564 </div>
492565 </div>
493566 ))}
494567
@@ -541,18 +614,40 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, async (c) => {
541614 </button>
542615 {canManage && (
543616 <>
544 <button
545 type="submit"
546 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
547 class="btn"
548 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
549 >
550 {gateChecks.every((c) => c.passed)
551 ? "Merge pull request"
552 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
553 ? "Merge with auto-resolve"
554 : "Merge pull request"}
555 </button>
617 {pr.isDraft ? (
618 <button
619 type="submit"
620 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/ready`}
621 class="btn"
622 style="background: rgba(63, 185, 80, 0.15); border-color: var(--green); color: var(--green)"
623 title="Mark this draft PR as ready for review — triggers AI review"
624 >
625 Ready for review
626 </button>
627 ) : (
628 <>
629 <button
630 type="submit"
631 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/merge`}
632 class="btn"
633 style={`background: ${gateChecks.every((c) => c.passed) ? "rgba(63, 185, 80, 0.15)" : "rgba(248, 81, 73, 0.1)"}; border-color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}; color: ${gateChecks.every((c) => c.passed) ? "var(--green)" : "var(--red)"}`}
634 >
635 {gateChecks.every((c) => c.passed)
636 ? "Merge pull request"
637 : gateChecks.some((c) => !c.passed && c.name === "Merge check")
638 ? "Merge with auto-resolve"
639 : "Merge pull request"}
640 </button>
641 <button
642 type="submit"
643 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/draft`}
644 class="btn"
645 title="Convert back to draft"
646 >
647 Convert to draft
648 </button>
649 </>
650 )}
556651 <button
557652 type="submit"
558653 formaction={`/${ownerName}/${repoName}/pulls/${pr.number}/close`}
@@ -643,6 +738,15 @@ pulls.post(
643738 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
644739 }
645740
741 // Draft PRs cannot be merged — must be marked ready first.
742 if (pr.isDraft) {
743 return c.redirect(
744 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent(
745 "This PR is a draft. Mark it as ready for review before merging."
746 )}`
747 );
748 }
749
646750 // Resolve head SHA
647751 const headSha = await resolveRef(ownerName, repoName, pr.headBranch);
648752 if (!headSha) {
@@ -752,6 +856,100 @@ pulls.post(
752856 }
753857);
754858
859// Toggle draft state — mark a PR as "ready for review". Triggers AI review if it
860// hasn't run yet on this PR.
861pulls.post(
862 "/:owner/:repo/pulls/:number/ready",
863 softAuth,
864 requireAuth,
865 async (c) => {
866 const { owner: ownerName, repo: repoName } = c.req.param();
867 const prNum = parseInt(c.req.param("number"), 10);
868 const user = c.get("user")!;
869
870 const resolved = await resolveRepo(ownerName, repoName);
871 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
872
873 const [pr] = await db
874 .select()
875 .from(pullRequests)
876 .where(
877 and(
878 eq(pullRequests.repositoryId, resolved.repo.id),
879 eq(pullRequests.number, prNum)
880 )
881 )
882 .limit(1);
883 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
884
885 // Only the author or repo owner can toggle draft state.
886 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
887 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
888 }
889
890 if (pr.state === "open" && pr.isDraft) {
891 await db
892 .update(pullRequests)
893 .set({ isDraft: false, updatedAt: new Date() })
894 .where(eq(pullRequests.id, pr.id));
895
896 if (isAiReviewEnabled()) {
897 triggerAiReview(
898 ownerName,
899 repoName,
900 pr.id,
901 pr.title,
902 pr.body,
903 pr.baseBranch,
904 pr.headBranch
905 ).catch((err) => console.error("[ai-review] ready trigger failed:", err));
906 }
907 }
908
909 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
910 }
911);
912
913// Convert a PR back to draft.
914pulls.post(
915 "/:owner/:repo/pulls/:number/draft",
916 softAuth,
917 requireAuth,
918 async (c) => {
919 const { owner: ownerName, repo: repoName } = c.req.param();
920 const prNum = parseInt(c.req.param("number"), 10);
921 const user = c.get("user")!;
922
923 const resolved = await resolveRepo(ownerName, repoName);
924 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
925
926 const [pr] = await db
927 .select()
928 .from(pullRequests)
929 .where(
930 and(
931 eq(pullRequests.repositoryId, resolved.repo.id),
932 eq(pullRequests.number, prNum)
933 )
934 )
935 .limit(1);
936 if (!pr) return c.redirect(`/${ownerName}/${repoName}/pulls`);
937
938 if (pr.authorId !== user.id && resolved.owner.id !== user.id) {
939 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
940 }
941
942 if (pr.state === "open" && !pr.isDraft) {
943 await db
944 .update(pullRequests)
945 .set({ isDraft: true, updatedAt: new Date() })
946 .where(eq(pullRequests.id, pr.id));
947 }
948
949 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
950 }
951);
952
755953// Close PR
756954pulls.post(
757955 "/:owner/:repo/pulls/:number/close",
Addedsrc/routes/reactions.ts+71−0View fileUnifiedSplit
@@ -0,0 +1,71 @@
1/**
2 * Reactions API — toggle + list reactions on issues, PRs, and their comments.
3 *
4 * POST /api/reactions/:targetType/:targetId/:emoji/toggle
5 * Body-less; auth required. Returns JSON {ok, added, counts}.
6 * If the request accepts text/html (form submission), redirects back.
7 *
8 * GET /api/reactions/:targetType/:targetId
9 * Returns the emoji -> count summary plus `reactedByMe` for the caller.
10 */
11
12import { Hono } from "hono";
13import type { AuthEnv } from "../middleware/auth";
14import { requireAuth, softAuth } from "../middleware/auth";
15import {
16 isAllowedEmoji,
17 isAllowedTarget,
18 summariseReactions,
19 toggleReaction,
20} from "../lib/reactions";
21
22const reactions = new Hono<AuthEnv>();
23
24reactions.use("/api/reactions/*", softAuth);
25
26reactions.get("/api/reactions/:targetType/:targetId", async (c) => {
27 const user = c.get("user");
28 const { targetType, targetId } = c.req.param();
29 if (!isAllowedTarget(targetType)) {
30 return c.json({ ok: false, error: "unknown target type" }, 400);
31 }
32 const rows = await summariseReactions(targetType, targetId, user?.id);
33 return c.json({ ok: true, reactions: rows });
34});
35
36reactions.post(
37 "/api/reactions/:targetType/:targetId/:emoji/toggle",
38 requireAuth,
39 async (c) => {
40 const user = c.get("user")!;
41 const { targetType, targetId, emoji } = c.req.param();
42 if (!isAllowedTarget(targetType)) {
43 return c.json({ ok: false, error: "unknown target type" }, 400);
44 }
45 if (!isAllowedEmoji(emoji)) {
46 return c.json({ ok: false, error: "unknown emoji" }, 400);
47 }
48
49 try {
50 const { added } = await toggleReaction(
51 user.id,
52 targetType,
53 targetId,
54 emoji
55 );
56 const summary = await summariseReactions(targetType, targetId, user.id);
57
58 const accept = c.req.header("accept") || "";
59 if (accept.includes("text/html")) {
60 const ref = c.req.header("referer");
61 return c.redirect(ref || "/");
62 }
63 return c.json({ ok: true, added, reactions: summary });
64 } catch (err) {
65 console.error("[reactions] toggle:", err);
66 return c.json({ ok: false, error: "server error" }, 500);
67 }
68 }
69);
70
71export default reactions;
Addedsrc/routes/theme.ts+64−0View fileUnifiedSplit
@@ -0,0 +1,64 @@
1/**
2 * Theme toggle — flips a "theme" cookie between dark and light.
3 * Cookie is read by both the SSR layout (via middleware-injected prop) and
4 * a pre-paint inline script to avoid FOUC.
5 */
6
7import { Hono } from "hono";
8import { getCookie, setCookie } from "hono/cookie";
9
10const theme = new Hono();
11
12function readTheme(c: any): "dark" | "light" {
13 const v = getCookie(c, "theme");
14 return v === "light" ? "light" : "dark";
15}
16
17function writeTheme(c: any, value: "dark" | "light") {
18 setCookie(c, "theme", value, {
19 path: "/",
20 maxAge: 60 * 60 * 24 * 365, // 1 year
21 sameSite: "Lax",
22 httpOnly: false, // must be readable by the pre-paint script
23 });
24}
25
26function redirectBack(c: any): Response {
27 const ref = c.req.header("referer");
28 // Only follow same-origin referers — anything else falls back to /.
29 try {
30 if (ref) {
31 const u = new URL(ref);
32 const host = c.req.header("host");
33 if (u.host === host) return c.redirect(u.pathname + u.search);
34 }
35 } catch {
36 // fall through
37 }
38 return c.redirect("/");
39}
40
41// GET /theme/toggle — flip current value, redirect back.
42// Lives outside /settings/* so it doesn't get blocked by the settings
43// auth middleware — theme should work for logged-out visitors too.
44theme.get("/theme/toggle", (c) => {
45 const next = readTheme(c) === "light" ? "dark" : "light";
46 writeTheme(c, next);
47 return redirectBack(c);
48});
49
50// GET /theme/set?mode=dark|light — explicit setter (for tests + API).
51theme.get("/theme/set", (c) => {
52 const mode = c.req.query("mode");
53 if (mode !== "dark" && mode !== "light") {
54 return c.json({ ok: false, error: "mode must be 'dark' or 'light'" }, 400);
55 }
56 writeTheme(c, mode);
57 if ((c.req.header("accept") || "").includes("application/json")) {
58 return c.json({ ok: true, theme: mode });
59 }
60 return redirectBack(c);
61});
62
63export { readTheme };
64export default theme;
Modifiedsrc/views/layout.tsx+131−3View fileUnifiedSplit
@@ -7,14 +7,17 @@ export const Layout: FC<
77 title?: string;
88 user?: User | null;
99 notificationCount?: number;
10 theme?: "dark" | "light";
1011 }>
11> = ({ children, title, user, notificationCount }) => {
12> = ({ children, title, user, notificationCount, theme }) => {
13 const initialTheme = theme === "light" ? "light" : "dark";
1214 return (
13 <html lang="en">
15 <html lang="en" data-theme={initialTheme}>
1416 <head>
1517 <meta charset="UTF-8" />
1618 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1719 <title>{title ? `${title} — gluecron` : "gluecron"}</title>
20 <script>{themeInitScript}</script>
1821 <style>{css}</style>
1922 <style>{hljsThemeCss}</style>
2023 </head>
@@ -35,6 +38,15 @@ export const Layout: FC<
3538 </form>
3639 </div>
3740 <div class="nav-right">
41 <a
42 href="/theme/toggle"
43 class="nav-link nav-theme"
44 title="Toggle theme"
45 aria-label="Toggle theme"
46 >
47 <span class="theme-icon-dark">{"\u263E"}</span>
48 <span class="theme-icon-light">{"\u2600"}</span>
49 </a>
3850 <a href="/explore" class="nav-link">
3951 Explore
4052 </a>
@@ -91,6 +103,19 @@ export const Layout: FC<
91103 );
92104};
93105
106// Runs before paint — reads the theme cookie and flips data-theme so there's
107// no dark-to-light flash on load. SSR default is dark.
108const themeInitScript = `
109 (function(){
110 try {
111 var m = document.cookie.match(/(?:^|; )theme=([^;]+)/);
112 var t = m ? decodeURIComponent(m[1]) : 'dark';
113 if (t !== 'light' && t !== 'dark') t = 'dark';
114 document.documentElement.setAttribute('data-theme', t);
115 } catch(_){}
116 })();
117`;
118
94119const navScript = `
95120 (function(){
96121 var chord = null;
@@ -135,7 +160,7 @@ const navScript = `
135160`;
136161
137162const css = `
138 :root {
163 :root, :root[data-theme='dark'] {
139164 --bg: #0d1117;
140165 --bg-secondary: #161b22;
141166 --bg-tertiary: #21262d;
@@ -153,6 +178,26 @@ const css = `
153178 --radius: 6px;
154179 }
155180
181 :root[data-theme='light'] {
182 --bg: #ffffff;
183 --bg-secondary: #f6f8fa;
184 --bg-tertiary: #eaeef2;
185 --border: #d0d7de;
186 --text: #1f2328;
187 --text-muted: #656d76;
188 --text-link: #0969da;
189 --accent: #0969da;
190 --accent-hover: #0550ae;
191 --green: #1a7f37;
192 --red: #cf222e;
193 --yellow: #9a6700;
194 }
195
196 /* Theme toggle — show the icon for the *opposite* theme so users see what they'll switch to. */
197 .nav-theme { display: inline-flex; align-items: center; font-size: 16px; line-height: 1; }
198 :root[data-theme='dark'] .theme-icon-dark { display: none; }
199 :root[data-theme='light'] .theme-icon-light { display: none; }
200
156201 * { margin: 0; padding: 0; box-sizing: border-box; }
157202
158203 body {
@@ -839,6 +884,89 @@ const css = `
839884 .search-hit .hit-path { font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); }
840885 .search-hit pre { margin-top: 8px; font-size: 12px; }
841886
887 /* Audit log */
888 .audit-log {
889 border: 1px solid var(--border);
890 border-radius: var(--radius);
891 overflow-x: auto;
892 background: var(--bg-secondary);
893 }
894 .audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
895 .audit-table th, .audit-table td {
896 text-align: left;
897 padding: 8px 12px;
898 border-bottom: 1px solid var(--border);
899 vertical-align: top;
900 }
901 .audit-table th {
902 background: var(--bg-tertiary);
903 font-size: 11px;
904 text-transform: uppercase;
905 letter-spacing: 0.5px;
906 color: var(--text-muted);
907 font-weight: 600;
908 }
909 .audit-table tr:last-child td { border-bottom: none; }
910 .audit-when { white-space: nowrap; color: var(--text-muted); }
911 .audit-muted { color: var(--text-muted); font-style: italic; }
912 .audit-action {
913 font-family: var(--font-mono);
914 font-size: 11px;
915 padding: 1px 6px;
916 background: var(--bg-tertiary);
917 border: 1px solid var(--border);
918 border-radius: 3px;
919 color: var(--text-link);
920 }
921 .audit-ip, .audit-meta code {
922 font-family: var(--font-mono);
923 font-size: 11px;
924 color: var(--text-muted);
925 }
926 .audit-target code {
927 font-family: var(--font-mono);
928 font-size: 11px;
929 color: var(--text-muted);
930 }
931
932 /* Reactions */
933 .reactions {
934 display: flex;
935 gap: 6px;
936 flex-wrap: wrap;
937 margin-top: 8px;
938 }
939 .reaction-btn {
940 display: inline-flex;
941 align-items: center;
942 gap: 4px;
943 padding: 2px 8px;
944 border-radius: 12px;
945 background: var(--bg-tertiary);
946 border: 1px solid var(--border);
947 color: var(--text);
948 font-size: 12px;
949 cursor: pointer;
950 font-family: inherit;
951 }
952 .reaction-btn:hover { background: var(--border); }
953 .reaction-btn.active { background: rgba(31, 111, 235, 0.15); border-color: var(--accent); color: var(--accent); }
954 .reaction-picker {
955 display: inline-flex;
956 gap: 4px;
957 align-items: center;
958 }
959 .reaction-picker form { display: inline; }
960 .reaction-count { font-size: 11px; font-weight: 600; }
961
962 /* Draft PR */
963 .draft-badge {
964 background: rgba(139, 148, 158, 0.15);
965 color: var(--text-muted);
966 border: 1px solid var(--text-muted);
967 }
968 .state-draft { color: var(--text-muted); }
969
842970 /* Toasts (prepared for future UI hooks) */
843971 .toast-container {
844972 position: fixed; bottom: 24px; right: 24px;
Addedsrc/views/reactions.tsx+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1/**
2 * Reactions bar — displays current counts + a "+ react" picker.
3 * Works without JavaScript: each emoji is its own POST form.
4 */
5
6import type { FC } from "hono/jsx";
7import {
8 ALLOWED_EMOJIS,
9 EMOJI_GLYPH,
10 type Emoji,
11 type ReactionSummary,
12 type TargetType,
13} from "../lib/reactions";
14
15export const ReactionsBar: FC<{
16 targetType: TargetType;
17 targetId: string;
18 summaries: ReactionSummary[];
19 canReact: boolean;
20}> = ({ targetType, targetId, summaries, canReact }) => {
21 const byEmoji = new Map<Emoji, ReactionSummary>();
22 for (const s of summaries) byEmoji.set(s.emoji, s);
23
24 const action = (emoji: Emoji) =>
25 `/api/reactions/${targetType}/${targetId}/${emoji}/toggle`;
26
27 const visible = summaries.filter((s) => s.count > 0);
28
29 return (
30 <div class="reactions" data-target={`${targetType}:${targetId}`}>
31 {visible.map((s) => (
32 <form method="POST" action={action(s.emoji)} style="display: inline">
33 <button
34 type="submit"
35 class={`reaction-btn ${s.reactedByMe ? "active" : ""}`}
36 title={s.emoji.replace(/_/g, " ")}
37 disabled={!canReact}
38 >
39 <span>{EMOJI_GLYPH[s.emoji]}</span>
40 <span class="reaction-count">{s.count}</span>
41 </button>
42 </form>
43 ))}
44 {canReact && (
45 <details class="reaction-picker">
46 <summary class="reaction-btn" title="Add reaction">
47 {"\u271A"}
48 </summary>
49 <div style="display: flex; gap: 4px; padding: 4px">
50 {ALLOWED_EMOJIS.filter((e) => !byEmoji.get(e)?.reactedByMe).map(
51 (emoji) => (
52 <form method="POST" action={action(emoji)} style="display: inline">
53 <button
54 type="submit"
55 class="reaction-btn"
56 title={emoji.replace(/_/g, " ")}
57 >
58 <span>{EMOJI_GLYPH[emoji]}</span>
59 </button>
60 </form>
61 )
62 )}
63 </div>
64 </details>
65 )}
66 </div>
67 );
68};
069