@storyshelf/core 0.1.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/README.md +50 -46
  2. package/dist/adapter/auth.d.mts +4 -4
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/{adapters → adapter}/capture-queue.d.mts +6 -4
  5. package/dist/adapter/capture-queue.mjs +4 -0
  6. package/dist/adapter/capture-runner.d.mts +7 -7
  7. package/dist/adapter/database.d.mts +20 -9
  8. package/dist/adapter/database.mjs +60 -1
  9. package/dist/adapter/database.mjs.map +1 -0
  10. package/dist/adapter/git-host/comments.d.mts +41 -0
  11. package/dist/adapter/git-host/comments.mjs +54 -0
  12. package/dist/adapter/git-host/comments.mjs.map +1 -0
  13. package/dist/adapter/git-host/helpers.d.mts +1 -0
  14. package/dist/adapter/git-host/helpers.mjs +1 -0
  15. package/dist/adapter/git-host/helpers.mjs.map +1 -1
  16. package/dist/adapter/git-host.d.mts +3 -7
  17. package/dist/adapter/init.d.mts +58 -0
  18. package/dist/adapter/init.mjs +93 -0
  19. package/dist/adapter/init.mjs.map +1 -0
  20. package/dist/adapter/metadata.d.mts +83 -0
  21. package/dist/adapter/metadata.mjs +4 -0
  22. package/dist/adapter/storage.d.mts +15 -4
  23. package/dist/adapters/webhook-events.mjs +20 -4
  24. package/dist/adapters/webhook-events.mjs.map +1 -1
  25. package/dist/capture/adapter.d.mts +7 -1
  26. package/dist/capture/adapter.mjs +3 -0
  27. package/dist/capture/adapter.mjs.map +1 -1
  28. package/dist/capture/dispatch.mjs +150 -0
  29. package/dist/capture/dispatch.mjs.map +1 -0
  30. package/dist/capture/orchestrator.d.mts +9 -0
  31. package/dist/capture/orchestrator.mjs +11 -44
  32. package/dist/capture/orchestrator.mjs.map +1 -1
  33. package/dist/capture/pipeline.d.mts +4 -1
  34. package/dist/capture/pipeline.mjs +6 -6
  35. package/dist/capture/pipeline.mjs.map +1 -1
  36. package/dist/capture/queue.d.mts +4 -1
  37. package/dist/capture/queue.mjs +3 -1
  38. package/dist/capture/queue.mjs.map +1 -1
  39. package/dist/capture/skip-checks.mjs +76 -0
  40. package/dist/capture/skip-checks.mjs.map +1 -0
  41. package/dist/capture/statics.mjs +124 -0
  42. package/dist/capture/statics.mjs.map +1 -0
  43. package/dist/capture/status-fanout.mjs +1 -0
  44. package/dist/capture/status-fanout.mjs.map +1 -1
  45. package/dist/capture/storybook.d.mts +1 -0
  46. package/dist/capture/storybook.mjs +1 -0
  47. package/dist/capture/storybook.mjs.map +1 -1
  48. package/dist/capture.d.mts +8 -0
  49. package/dist/capture.mjs +9 -0
  50. package/dist/config.d.mts +37 -22
  51. package/dist/config.mjs +28 -2
  52. package/dist/config.mjs.map +1 -1
  53. package/dist/ddl.d.mts +2 -1
  54. package/dist/ddl.mjs +2 -1
  55. package/dist/ddl.mjs.map +1 -1
  56. package/dist/diff/engine.d.mts +8 -0
  57. package/dist/diff/engine.mjs +8 -0
  58. package/dist/diff/engine.mjs.map +1 -1
  59. package/dist/diff/options.d.mts +3 -1
  60. package/dist/diff.d.mts +5 -0
  61. package/dist/diff.mjs +6 -0
  62. package/dist/index.d.mts +34 -24
  63. package/dist/index.mjs +211 -252
  64. package/dist/index.mjs.map +1 -1
  65. package/dist/logger.d.mts +3 -3
  66. package/dist/logger.mjs.map +1 -1
  67. package/dist/middleware/auth-gate.mjs +36 -0
  68. package/dist/middleware/auth-gate.mjs.map +1 -0
  69. package/dist/middleware/csrf.mjs +1 -0
  70. package/dist/middleware/csrf.mjs.map +1 -1
  71. package/dist/middleware/rate-limit.mjs +1 -0
  72. package/dist/middleware/rate-limit.mjs.map +1 -1
  73. package/dist/middleware/request-log.mjs +28 -0
  74. package/dist/middleware/request-log.mjs.map +1 -0
  75. package/dist/middleware/store-scope.mjs +29 -0
  76. package/dist/middleware/store-scope.mjs.map +1 -0
  77. package/dist/models/baseline.mjs +9 -5
  78. package/dist/models/baseline.mjs.map +1 -1
  79. package/dist/models/build.mjs +5 -1
  80. package/dist/models/build.mjs.map +1 -1
  81. package/dist/models/comment.mjs +4 -1
  82. package/dist/models/comment.mjs.map +1 -1
  83. package/dist/models/label.mjs +3 -1
  84. package/dist/models/label.mjs.map +1 -1
  85. package/dist/models/member.mjs +2 -1
  86. package/dist/models/member.mjs.map +1 -1
  87. package/dist/models/project.mjs +2 -1
  88. package/dist/models/project.mjs.map +1 -1
  89. package/dist/models/snapshot.mjs +2 -1
  90. package/dist/models/snapshot.mjs.map +1 -1
  91. package/dist/models/status-config.mjs +2 -1
  92. package/dist/models/status-config.mjs.map +1 -1
  93. package/dist/models/token.mjs +2 -1
  94. package/dist/models/token.mjs.map +1 -1
  95. package/dist/models/webhook.mjs +12 -3
  96. package/dist/models/webhook.mjs.map +1 -1
  97. package/dist/pages/build-detail.mjs +10 -4
  98. package/dist/pages/build-detail.mjs.map +1 -1
  99. package/dist/pages/build-diff-comments.mjs +113 -0
  100. package/dist/pages/build-diff-comments.mjs.map +1 -0
  101. package/dist/pages/build-diff-header.mjs +163 -0
  102. package/dist/pages/build-diff-header.mjs.map +1 -0
  103. package/dist/pages/build-diff-nav.mjs +66 -0
  104. package/dist/pages/build-diff-nav.mjs.map +1 -0
  105. package/dist/pages/build-diff-viewer.mjs +197 -0
  106. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  107. package/dist/pages/build-diff.mjs +66 -413
  108. package/dist/pages/build-diff.mjs.map +1 -1
  109. package/dist/pages/compute-jobs.mjs +3 -1
  110. package/dist/pages/compute-jobs.mjs.map +1 -1
  111. package/dist/pages/label-detail.mjs +4 -3
  112. package/dist/pages/label-detail.mjs.map +1 -1
  113. package/dist/pages/login.mjs +1 -0
  114. package/dist/pages/login.mjs.map +1 -1
  115. package/dist/pages/project-builds.mjs +6 -3
  116. package/dist/pages/project-builds.mjs.map +1 -1
  117. package/dist/pages/project-create.mjs +1 -0
  118. package/dist/pages/project-create.mjs.map +1 -1
  119. package/dist/pages/project-settings.mjs +1 -0
  120. package/dist/pages/project-settings.mjs.map +1 -1
  121. package/dist/pages/projects.mjs +12 -6
  122. package/dist/pages/projects.mjs.map +1 -1
  123. package/dist/pages/root.mjs +7 -3
  124. package/dist/pages/root.mjs.map +1 -1
  125. package/dist/pages/settings-general.mjs +1 -0
  126. package/dist/pages/settings-general.mjs.map +1 -1
  127. package/dist/pages/settings-labels.mjs +2 -1
  128. package/dist/pages/settings-labels.mjs.map +1 -1
  129. package/dist/pages/settings-members.mjs +2 -1
  130. package/dist/pages/settings-members.mjs.map +1 -1
  131. package/dist/pages/settings-status.mjs +3 -1
  132. package/dist/pages/settings-status.mjs.map +1 -1
  133. package/dist/pages/settings-tests.mjs +4 -3
  134. package/dist/pages/settings-tests.mjs.map +1 -1
  135. package/dist/pages/settings-tokens.mjs +1 -0
  136. package/dist/pages/settings-tokens.mjs.map +1 -1
  137. package/dist/pages/settings-webhooks.mjs +1 -0
  138. package/dist/pages/settings-webhooks.mjs.map +1 -1
  139. package/dist/pages/storybook.mjs +38 -1
  140. package/dist/pages/storybook.mjs.map +1 -1
  141. package/dist/{utils/paths.d.mts → paths.d.mts} +5 -0
  142. package/dist/{utils/paths.mjs → paths.mjs} +5 -0
  143. package/dist/paths.mjs.map +1 -0
  144. package/dist/retention/purge.mjs +2 -1
  145. package/dist/retention/purge.mjs.map +1 -1
  146. package/dist/routers/admin.mjs +2 -1
  147. package/dist/routers/admin.mjs.map +1 -1
  148. package/dist/routers/assets.mjs +1 -0
  149. package/dist/routers/assets.mjs.map +1 -1
  150. package/dist/routers/auth.mjs +1 -0
  151. package/dist/routers/auth.mjs.map +1 -1
  152. package/dist/routers/builds.handlers.mjs +114 -19
  153. package/dist/routers/builds.handlers.mjs.map +1 -1
  154. package/dist/routers/builds.mjs +55 -34
  155. package/dist/routers/builds.mjs.map +1 -1
  156. package/dist/routers/comments.mjs +1 -0
  157. package/dist/routers/comments.mjs.map +1 -1
  158. package/dist/routers/health.mjs +137 -0
  159. package/dist/routers/health.mjs.map +1 -0
  160. package/dist/routers/helpers.mjs +8 -2
  161. package/dist/routers/helpers.mjs.map +1 -1
  162. package/dist/routers/htmx.mjs +2 -0
  163. package/dist/routers/htmx.mjs.map +1 -1
  164. package/dist/routers/labels.mjs +2 -1
  165. package/dist/routers/labels.mjs.map +1 -1
  166. package/dist/routers/media.mjs +3 -2
  167. package/dist/routers/media.mjs.map +1 -1
  168. package/dist/routers/members.mjs +1 -0
  169. package/dist/routers/members.mjs.map +1 -1
  170. package/dist/routers/projects.mjs +2 -1
  171. package/dist/routers/projects.mjs.map +1 -1
  172. package/dist/routers/schemas.mjs +25 -0
  173. package/dist/routers/schemas.mjs.map +1 -1
  174. package/dist/routers/settings-general.mjs +95 -0
  175. package/dist/routers/settings-general.mjs.map +1 -0
  176. package/dist/routers/settings-labels.mjs +49 -0
  177. package/dist/routers/settings-labels.mjs.map +1 -0
  178. package/dist/routers/settings-members.mjs +30 -0
  179. package/dist/routers/settings-members.mjs.map +1 -0
  180. package/dist/routers/settings-status.mjs +73 -0
  181. package/dist/routers/settings-status.mjs.map +1 -0
  182. package/dist/routers/settings-tokens.mjs +40 -0
  183. package/dist/routers/settings-tokens.mjs.map +1 -0
  184. package/dist/routers/settings-webhooks.mjs +63 -0
  185. package/dist/routers/settings-webhooks.mjs.map +1 -0
  186. package/dist/routers/settings.handlers.mjs +93 -0
  187. package/dist/routers/settings.handlers.mjs.map +1 -0
  188. package/dist/routers/settings.mjs +14 -246
  189. package/dist/routers/settings.mjs.map +1 -1
  190. package/dist/routers/snapshots.mjs +2 -1
  191. package/dist/routers/snapshots.mjs.map +1 -1
  192. package/dist/routers/status-configs.mjs +2 -1
  193. package/dist/routers/status-configs.mjs.map +1 -1
  194. package/dist/routers/storybook.mjs +12 -4
  195. package/dist/routers/storybook.mjs.map +1 -1
  196. package/dist/routers/tokens.mjs +2 -1
  197. package/dist/routers/tokens.mjs.map +1 -1
  198. package/dist/routers/ui.mjs +5 -4
  199. package/dist/routers/ui.mjs.map +1 -1
  200. package/dist/routers/webhooks.mjs +7 -6
  201. package/dist/routers/webhooks.mjs.map +1 -1
  202. package/dist/schema/baseline.d.mts +18 -0
  203. package/dist/schema/baseline.mjs +22 -0
  204. package/dist/schema/baseline.mjs.map +1 -0
  205. package/dist/schema/build.d.mts +26 -0
  206. package/dist/schema/build.mjs +29 -0
  207. package/dist/schema/build.mjs.map +1 -0
  208. package/dist/schema/comment.d.mts +19 -0
  209. package/dist/schema/comment.mjs +26 -0
  210. package/dist/schema/comment.mjs.map +1 -0
  211. package/dist/schema/label.d.mts +25 -0
  212. package/dist/schema/label.mjs +30 -0
  213. package/dist/schema/label.mjs.map +1 -0
  214. package/dist/schema/member.d.mts +15 -0
  215. package/dist/schema/member.mjs +19 -0
  216. package/dist/schema/member.mjs.map +1 -0
  217. package/dist/schema/project.d.mts +34 -0
  218. package/dist/schema/project.mjs +25 -0
  219. package/dist/schema/project.mjs.map +1 -0
  220. package/dist/schema/snapshot.d.mts +30 -0
  221. package/dist/schema/snapshot.mjs +34 -0
  222. package/dist/schema/snapshot.mjs.map +1 -0
  223. package/dist/schema/status-config.d.mts +16 -0
  224. package/dist/schema/status-config.mjs +20 -0
  225. package/dist/schema/status-config.mjs.map +1 -0
  226. package/dist/schema/token.d.mts +15 -0
  227. package/dist/schema/token.mjs +19 -0
  228. package/dist/schema/token.mjs.map +1 -0
  229. package/dist/schema/user.d.mts +17 -0
  230. package/dist/schema/user.mjs +19 -0
  231. package/dist/schema/user.mjs.map +1 -0
  232. package/dist/schema/webhook.d.mts +16 -0
  233. package/dist/schema/webhook.mjs +20 -0
  234. package/dist/schema/webhook.mjs.map +1 -0
  235. package/dist/schema.d.mts +52 -4433
  236. package/dist/schema.mjs +37 -139
  237. package/dist/schema.mjs.map +1 -1
  238. package/dist/store.mjs +2 -0
  239. package/dist/store.mjs.map +1 -1
  240. package/dist/types.d.mts +13 -2
  241. package/dist/types.mjs +8 -0
  242. package/dist/types.mjs.map +1 -1
  243. package/dist/ui/document.mjs +1 -0
  244. package/dist/ui/document.mjs.map +1 -1
  245. package/dist/ui/feedback.mjs +24 -0
  246. package/dist/ui/feedback.mjs.map +1 -0
  247. package/dist/ui/{components.mjs → forms.mjs} +6 -16
  248. package/dist/ui/forms.mjs.map +1 -0
  249. package/dist/ui/styles.mjs +1 -0
  250. package/dist/ui/styles.mjs.map +1 -1
  251. package/dist/ui/theme.mjs +2 -0
  252. package/dist/ui/theme.mjs.map +1 -1
  253. package/dist/urls.d.mts +8 -0
  254. package/dist/urls.mjs +7 -0
  255. package/dist/urls.mjs.map +1 -1
  256. package/dist/utils/hash.mjs +3 -0
  257. package/dist/utils/hash.mjs.map +1 -1
  258. package/dist/utils/ulid.mjs +2 -0
  259. package/dist/utils/ulid.mjs.map +1 -1
  260. package/package.json +47 -45
  261. package/dist/adapters/metadata.d.mts +0 -24
  262. package/dist/models/baseline.d.mts +0 -20
  263. package/dist/models/build.d.mts +0 -50
  264. package/dist/models/comment.d.mts +0 -20
  265. package/dist/models/label.d.mts +0 -49
  266. package/dist/models/member.d.mts +0 -27
  267. package/dist/models/project.d.mts +0 -42
  268. package/dist/models/snapshot.d.mts +0 -46
  269. package/dist/models/token.d.mts +0 -33
  270. package/dist/models/webhook.d.mts +0 -37
  271. package/dist/retention/purge.d.mts +0 -28
  272. package/dist/ui/components.mjs.map +0 -1
  273. package/dist/ui/document.d.mts +0 -8
  274. package/dist/utils/paths.mjs.map +0 -1
  275. package/dist/utils/ulid.d.mts +0 -8
@@ -1,18 +1,23 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
- import { webhooks } from "../schema.mjs";
5
4
  import { ulid } from "../utils/ulid.mjs";
5
+ import { webhooks } from "../schema/webhook.mjs";
6
+ import { decrypt, encrypt } from "../utils/encrypt.mjs";
6
7
  import { eq } from "drizzle-orm";
7
8
  //#region src/models/webhook.ts
9
+ /** Webhook subscriptions for project events. */
8
10
  /** Data operations for webhook subscriptions. */
9
11
  var WebhookModel = class {
10
12
  db;
13
+ secret;
11
14
  /**
12
15
  * @param db - Database adapter.
16
+ * @param secret - Server secret for webhook-secret encryption (throws on write/decrypt when unset).
13
17
  */
14
- constructor(db) {
18
+ constructor(db, secret) {
15
19
  this.db = db;
20
+ this.secret = secret;
16
21
  }
17
22
  /** List all webhooks for a project. */
18
23
  async list(projectId) {
@@ -31,12 +36,16 @@ var WebhookModel = class {
31
36
  id: ulid(),
32
37
  projectId,
33
38
  url: input.url,
34
- secret: input.secret,
39
+ secretEncrypted: encrypt(this.secret, input.secret),
35
40
  events: input.events && input.events.length > 0 ? JSON.stringify(input.events) : null,
36
41
  createdAt: now,
37
42
  updatedAt: now
38
43
  });
39
44
  }
45
+ /** Decrypt the secret for a stored row (in memory only, at send time). */
46
+ decryptSecret(row) {
47
+ return decrypt(this.secret, row.secretEncrypted);
48
+ }
40
49
  /** Fetch a webhook by id scoped to a project, or null if not found. */
41
50
  async get(projectId, id) {
42
51
  const found = (await this.db.list(webhooks, {
@@ -1 +1 @@
1
- {"version":3,"file":"webhook.mjs","names":[],"sources":["../../src/models/webhook.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { webhooks, type Webhook } from \"../schema.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\nexport interface WebhookCreateInput {\n url: string;\n events?: string[];\n secret: string;\n}\n\n/** Data operations for webhook subscriptions. */\nexport class WebhookModel {\n /**\n * @param db - Database adapter.\n */\n constructor(private readonly db: DatabaseAdapter) {}\n\n /** List all webhooks for a project. */\n async list(projectId: string): Promise<Webhook[]> {\n return await this.db.list(webhooks, { where: eq(webhooks.projectId, projectId) });\n }\n\n /**\n * Create a webhook subscription for a project.\n *\n * @param projectId - Project ID.\n * @param input - Webhook creation input.\n * @returns The created webhook.\n */\n async create(projectId: string, input: WebhookCreateInput): Promise<Webhook> {\n const now = new Date().toISOString();\n return await this.db.insert(webhooks, {\n id: ulid(),\n projectId,\n url: input.url,\n secret: input.secret,\n events: input.events && input.events.length > 0 ? JSON.stringify(input.events) : null,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** Fetch a webhook by id scoped to a project, or null if not found. */\n async get(projectId: string, id: string): Promise<Webhook | null> {\n const rows = await this.db.list(webhooks, { where: eq(webhooks.id, id), limit: 1 });\n const found = rows[0] ?? null;\n return found?.projectId === projectId ? found : null;\n }\n\n /** Remove a webhook if it belongs to the given project. */\n async remove(projectId: string, id: string): Promise<void> {\n const existing = await this.get(projectId, id);\n if (existing) {\n await this.db.remove(webhooks, existing.id);\n }\n }\n\n /** Decode the JSON-serialized event list of a webhook. */\n static eventsOf(webhook: Webhook): string[] {\n if (!webhook.events) {\n return [];\n }\n try {\n const parsed: unknown = JSON.parse(webhook.events);\n return Array.isArray(parsed) ? parsed.filter((event): event is string => typeof event === \"string\") : [];\n } catch {\n return [];\n }\n }\n}\n\nexport type { Webhook };"],"mappings":";;;;;;;;AAaA,IAAa,eAAb,MAA0B;CAIK;;;;CAA7B,YAAY,IAAsC;EAArB,KAAA,KAAA;CAAsB;;CAGnD,MAAM,KAAK,WAAuC;EAChD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,WAAW,SAAS,EAAE,CAAC;CAClF;;;;;;;;CASA,MAAM,OAAO,WAAmB,OAA6C;EAC3E,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA,KAAK,MAAM;GACX,QAAQ,MAAM;GACd,QAAQ,MAAM,UAAU,MAAM,OAAO,SAAS,IAAI,KAAK,UAAU,MAAM,MAAM,IAAI;GACjF,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,MAAM,IAAI,WAAmB,IAAqC;EAEhE,MAAM,SAAQ,MADK,KAAK,GAAG,KAAK,UAAU;GAAE,OAAO,GAAG,SAAS,IAAI,EAAE;GAAG,OAAO;EAAE,CAAC,EAAA,CAC/D,MAAM;EACzB,OAAO,OAAO,cAAc,YAAY,QAAQ;CAClD;;CAGA,MAAM,OAAO,WAAmB,IAA2B;EACzD,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,EAAE;EAC7C,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,UAAU,SAAS,EAAE;CAE9C;;CAGA,OAAO,SAAS,SAA4B;EAC1C,IAAI,CAAC,QAAQ,QACX,OAAO,CAAC;EAEV,IAAI;GACF,MAAM,SAAkB,KAAK,MAAM,QAAQ,MAAM;GACjD,OAAO,MAAM,QAAQ,MAAM,IAAI,OAAO,QAAQ,UAA2B,OAAO,UAAU,QAAQ,IAAI,CAAC;EACzG,QAAQ;GACN,OAAO,CAAC;EACV;CACF;AACF"}
1
+ {"version":3,"file":"webhook.mjs","names":[],"sources":["../../src/models/webhook.ts"],"sourcesContent":["/** Webhook subscriptions for project events. */\nimport { eq } from \"drizzle-orm\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport { webhooks } from \"../schema/webhook.ts\";\nimport type { Webhook } from \"../schema/webhook.ts\";\nimport { decrypt, encrypt } from \"../utils/encrypt.ts\";\nimport { ulid } from \"../utils/ulid.ts\";\n\n/** Input for creating a webhook subscription. */\nexport interface WebhookCreateInput {\n url: string;\n events?: string[];\n secret: string;\n}\n\n/** Data operations for webhook subscriptions. */\nexport class WebhookModel {\n /**\n * @param db - Database adapter.\n * @param secret - Server secret for webhook-secret encryption (throws on write/decrypt when unset).\n */\n constructor(\n private readonly db: DatabaseAdapter,\n private readonly secret?: string,\n ) {}\n\n /** List all webhooks for a project. */\n async list(projectId: string): Promise<Webhook[]> {\n return await this.db.list(webhooks, { where: eq(webhooks.projectId, projectId) });\n }\n\n /**\n * Create a webhook subscription for a project.\n *\n * @param projectId - Project ID.\n * @param input - Webhook creation input.\n * @returns The created webhook.\n */\n async create(projectId: string, input: WebhookCreateInput): Promise<Webhook> {\n const now = new Date().toISOString();\n return await this.db.insert(webhooks, {\n id: ulid(),\n projectId,\n url: input.url,\n secretEncrypted: encrypt(this.secret, input.secret),\n events: input.events && input.events.length > 0 ? JSON.stringify(input.events) : null,\n createdAt: now,\n updatedAt: now,\n });\n }\n\n /** Decrypt the secret for a stored row (in memory only, at send time). */\n decryptSecret(row: Webhook): string {\n return decrypt(this.secret, row.secretEncrypted);\n }\n\n /** Fetch a webhook by id scoped to a project, or null if not found. */\n async get(projectId: string, id: string): Promise<Webhook | null> {\n const rows = await this.db.list(webhooks, { where: eq(webhooks.id, id), limit: 1 });\n const found = rows[0] ?? null;\n return found?.projectId === projectId ? found : null;\n }\n\n /** Remove a webhook if it belongs to the given project. */\n async remove(projectId: string, id: string): Promise<void> {\n const existing = await this.get(projectId, id);\n if (existing) {\n await this.db.remove(webhooks, existing.id);\n }\n }\n\n /** Decode the JSON-serialized event list of a webhook. */\n static eventsOf(webhook: Webhook): string[] {\n if (!webhook.events) {\n return [];\n }\n try {\n const parsed: unknown = JSON.parse(webhook.events);\n return Array.isArray(parsed)\n ? parsed.filter((event): event is string => typeof event === \"string\")\n : [];\n } catch {\n return [];\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAgBA,IAAa,eAAb,MAA0B;CAML;CACA;;;;;CAFnB,YACE,IACA,QACA;EAFiB,KAAA,KAAA;EACA,KAAA,SAAA;CAChB;;CAGH,MAAM,KAAK,WAAuC;EAChD,OAAO,MAAM,KAAK,GAAG,KAAK,UAAU,EAAE,OAAO,GAAG,SAAS,WAAW,SAAS,EAAE,CAAC;CAClF;;;;;;;;CASA,MAAM,OAAO,WAAmB,OAA6C;EAC3E,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,OAAO,MAAM,KAAK,GAAG,OAAO,UAAU;GACpC,IAAI,KAAK;GACT;GACA,KAAK,MAAM;GACX,iBAAiB,QAAQ,KAAK,QAAQ,MAAM,MAAM;GAClD,QAAQ,MAAM,UAAU,MAAM,OAAO,SAAS,IAAI,KAAK,UAAU,MAAM,MAAM,IAAI;GACjF,WAAW;GACX,WAAW;EACb,CAAC;CACH;;CAGA,cAAc,KAAsB;EAClC,OAAO,QAAQ,KAAK,QAAQ,IAAI,eAAe;CACjD;;CAGA,MAAM,IAAI,WAAmB,IAAqC;EAEhE,MAAM,SAAQ,MADK,KAAK,GAAG,KAAK,UAAU;GAAE,OAAO,GAAG,SAAS,IAAI,EAAE;GAAG,OAAO;EAAE,CAAC,EAAA,CAC/D,MAAM;EACzB,OAAO,OAAO,cAAc,YAAY,QAAQ;CAClD;;CAGA,MAAM,OAAO,WAAmB,IAA2B;EACzD,MAAM,WAAW,MAAM,KAAK,IAAI,WAAW,EAAE;EAC7C,IAAI,UACF,MAAM,KAAK,GAAG,OAAO,UAAU,SAAS,EAAE;CAE9C;;CAGA,OAAO,SAAS,SAA4B;EAC1C,IAAI,CAAC,QAAQ,QACX,OAAO,CAAC;EAEV,IAAI;GACF,MAAM,SAAkB,KAAK,MAAM,QAAQ,MAAM;GACjD,OAAO,MAAM,QAAQ,MAAM,IACvB,OAAO,QAAQ,UAA2B,OAAO,UAAU,QAAQ,IACnE,CAAC;EACP,QAAQ;GACN,OAAO,CAAC;EACV;CACF;AACF"}
@@ -3,13 +3,14 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { BuildModel } from "../models/build.mjs";
5
5
  import { ProjectModel } from "../models/project.mjs";
6
+ import { SnapshotModel } from "../models/snapshot.mjs";
6
7
  import { getStore } from "../store.mjs";
7
8
  import { DocumentLayout } from "../ui/document.mjs";
8
- import { SnapshotModel } from "../models/snapshot.mjs";
9
9
  import { CommentModel } from "../models/comment.mjs";
10
- import { Badge, statusTone } from "../ui/components.mjs";
10
+ import { Badge, statusTone } from "../ui/feedback.mjs";
11
11
  import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
12
12
  //#region src/pages/build-detail.tsx
13
+ /** Build overview page: snapshot grid, bulk actions, and build comments. */
13
14
  async function renderBuildDetailPage(buildId) {
14
15
  const { db } = getStore();
15
16
  const build = await new BuildModel(db).get(buildId);
@@ -80,7 +81,8 @@ async function renderBuildDetailPage(buildId) {
80
81
  build.message ?? "No commit message",
81
82
  " ",
82
83
  build.authorName ? `· ${build.authorName}${build.authorEmail ? ` <${build.authorEmail}>` : ""}` : "",
83
- " · ",
84
+ " ",
85
+ "· ",
84
86
  new Date(build.createdAt).toLocaleString()
85
87
  ]
86
88
  })] }), /* @__PURE__ */ jsxs("div", {
@@ -212,7 +214,11 @@ async function renderBuildDetailPage(buildId) {
212
214
  style: "font-weight:650; font-size:.95rem; line-height:1.2;",
213
215
  children: [snap.storyTitle, /* @__PURE__ */ jsxs("span", {
214
216
  style: "color:var(--text-secondary); font-weight:400;",
215
- children: [" / ", snap.storyName]
217
+ children: [
218
+ " ",
219
+ "/ ",
220
+ snap.storyName
221
+ ]
216
222
  })]
217
223
  }),
218
224
  /* @__PURE__ */ jsxs("div", {
@@ -1 +1 @@
1
- {"version":3,"file":"build-detail.mjs","names":[],"sources":["../../src/pages/build-detail.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\n\nimport { BuildModel } from \"../models/build.ts\";\nimport { CommentModel } from \"../models/comment.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { getStore } from \"../store.ts\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { Badge, statusTone } from \"../ui/components.tsx\";\n\nexport async function renderBuildDetailPage(buildId: string): Promise<RenderedContent | null> {\n const {db} = getStore();\n const build = await new BuildModel(db).get(buildId);\n if (!build) {\n return null;\n }\n const project = await new ProjectModel(db).get(build.projectId);\n if (!project) {\n return null;\n }\n const snapshots = await new SnapshotModel(db).listByBuild(build.id);\n const comments = await new CommentModel(db).listByBuild(build.id);\n const canReview = !getStore().authEnabled || Boolean(getStore().user);\n\n const grouped = new Map<string, typeof snapshots>();\n for (const snap of snapshots) {\n const key = snap.status;\n const list = grouped.get(key) ?? [];\n list.push(snap);\n grouped.set(key, list);\n }\n\n return (\n <DocumentLayout title={`Build ${build.gitBranch}`} nav={{ active: \"builds\", projectSlug: project.slug, projectName: project.name }}>\n <div class=\"page-header\">\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\">\n <ol>\n <li>\n <a href=\"/projects\">Projects</a>\n </li>\n <li>\n <a href={`/projects/${project.slug}/builds`}>{project.name}</a>\n </li>\n <li>\n <span aria-current=\"page\">\n {build.gitBranch} · {build.gitSha.slice(0, 7)}\n </span>\n </li>\n </ol>\n </nav>\n <div class=\"page-header__row\">\n <div>\n <h1 class=\"page-header__title\">\n {build.gitBranch} <span style=\"color:var(--text-secondary); font-weight:400;\">· {build.gitSha.slice(0, 7)}</span> <Badge tone={statusTone(build.status)}>{build.status}</Badge>\n </h1>\n <p class=\"page-header__desc\">\n {build.message ?? \"No commit message\"} {build.authorName ? `· ${build.authorName}${build.authorEmail ? ` <${build.authorEmail}>` : \"\"}` : \"\"} · {new Date(build.createdAt).toLocaleString()}\n </p>\n </div>\n <div class=\"page-header__actions\">\n <a class=\"btn btn--primary\" href={`/projects/${project.slug}/builds/${build.id}/diff`}>\n Review diffs\n </a>\n <form method=\"post\" action={`/api/v1/projects/${project.slug}/builds/${build.id}/retry`} hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/retry`} hx-target=\"body\">\n <button class=\"btn btn--secondary\" type=\"submit\">\n Retry capture\n </button>\n </form>\n </div>\n </div>\n </div>\n\n <div class=\"grid grid--3\" style=\"margin-bottom:1rem;\">\n <div class=\"card card--padded stat\">\n <div class=\"stat__value\">{snapshots.length}</div>\n <div class=\"stat__label\">Snapshots</div>\n </div>\n <div class=\"card card--padded stat\">\n <div class=\"stat__value\" style=\"color: var(--status-new);\">\n {build.changedCount}\n </div>\n <div class=\"stat__label\">Changed / new</div>\n </div>\n <div class=\"card card--padded stat\">\n <div class=\"stat__value\" style=\"color: var(--status-approved);\">\n {build.approvedCount}\n </div>\n <div class=\"stat__label\">Approved / unchanged</div>\n </div>\n </div>\n\n {canReview && (build.status === \"reviewing\" || build.status === \"comparing\") ? (\n <div class=\"card card--padded\" style=\"display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; margin-bottom:1rem;\">\n <span class=\"field__hint\">Bulk actions:</span>\n <form method=\"post\" action={`/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`} hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`} hx-target=\"body\">\n <button class=\"btn btn--primary\" type=\"submit\">\n Approve all\n </button>\n </form>\n <form method=\"post\" action={`/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`} hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`} hx-target=\"body\">\n <button class=\"btn btn--danger\" type=\"submit\">\n Reject all\n </button>\n </form>\n <span class=\"field__hint\">Or review individually in the diff view.</span>\n </div>\n ) : null}\n\n {snapshots.length === 0 ? (\n <div class=\"empty\">\n <h2 class=\"empty__title\">No snapshots yet</h2>\n <p class=\"empty__desc\">Capture is pending or failed. Try retrying capture.</p>\n </div>\n ) : (\n <div class=\"snapshot-grid\">\n {snapshots.map(\n (snap): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <div key={snap.id} class=\"snapshot-card\">\n <div class=\"snapshot-card__head\">\n <Badge tone={statusTone(snap.status)}>{snap.status}</Badge>\n <span class=\"snapshot-card__meta\">\n {snap.viewportName} · {snap.viewportWidth}×{snap.viewportHeight}\n </span>\n </div>\n <div class=\"snapshot-card__body\">\n <div style=\"font-weight:650; font-size:.95rem; line-height:1.2;\">\n {snap.storyTitle}\n <span style=\"color:var(--text-secondary); font-weight:400;\"> / {snap.storyName}</span>\n </div>\n <div class=\"field__hint\" style=\"font-size:.8rem;\">\n {snap.storyId}\n {snap.diffRatio !== null && snap.diffRatio !== undefined ? ` · diff ${(snap.diffRatio * 100).toFixed(2)}%` : \"\"}\n {snap.diffPixels === null ? \"\" : ` · ${snap.diffPixels} px`}\n </div>\n <div style=\"display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.2rem;\">\n <a class=\"btn btn--secondary\" href={`/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snap.id}`}>\n Review\n </a>\n {canReview && (snap.status === \"new\" || snap.status === \"changed\") ? (\n <>\n <form method=\"post\" action={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/approve`} hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/approve`} hx-target=\"body\">\n <button class=\"btn btn--ghost\" type=\"submit\">\n Approve\n </button>\n </form>\n <form method=\"post\" action={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/reject`} hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/reject`} hx-target=\"body\">\n <button class=\"btn btn--ghost\" type=\"submit\">\n Reject\n </button>\n </form>\n </>\n ) : null}\n </div>\n </div>\n </div>\n ))}\n </div>\n )}\n\n <div class=\"card card--padded\" style=\"margin-top:1rem;\">\n <h2 style=\"margin:0 0 .5rem;\">Comments</h2>\n {comments.length === 0 ? <p class=\"field__hint\">No comments. Add one in the diff review page for a specific snapshot.</p> : null}\n <div style=\"display:grid; gap:.6rem;\">\n {comments.map(\n (comment): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <div key={comment.id} class=\"comment\">\n <div class=\"comment__head\">\n <strong>{comment.userId}</strong>\n <span>· {new Date(comment.createdAt).toLocaleString()}</span>\n {comment.snapshotId ? <Badge tone=\"neutral\">{snapshots.find((snapshot) => snapshot.id === comment.snapshotId)?.storyName ?? comment.snapshotId.slice(0, 6)}</Badge> : <Badge tone=\"neutral\">build</Badge>}\n {comment.resolved ? <Badge tone=\"success\">resolved</Badge> : null}\n </div>\n <p class=\"comment__body\">{comment.body}</p>\n </div>\n ))}\n </div>\n\n <form method=\"post\" action={`/api/v1/projects/${project.slug}/builds/${build.id}/comments`} hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/comments`} hx-target=\"body\" style=\"margin-top:1rem; display:grid; gap:.5rem; max-width:640px;\">\n <label class=\"field__label\" for=\"comment-body\">\n Add build comment\n </label>\n <textarea class=\"field__input field__input--textarea\" id=\"comment-body\" name=\"body\" rows={3} required placeholder=\"Leave a comment on this build…\" />\n <div>\n <button class=\"btn btn--primary\" type=\"submit\">\n Comment\n </button>\n </div>\n </form>\n </div>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"build-detail.mjs","names":[],"sources":["../../src/pages/build-detail.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { CommentModel } from \"../models/comment.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport { getStore } from \"../store.ts\";\nimport { Badge, statusTone } from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\n\n/** Build overview page: snapshot grid, bulk actions, and build comments. */\nexport async function renderBuildDetailPage(buildId: string): Promise<RenderedContent | null> {\n const { db } = getStore();\n const build = await new BuildModel(db).get(buildId);\n if (!build) {\n return null;\n }\n const project = await new ProjectModel(db).get(build.projectId);\n if (!project) {\n return null;\n }\n const snapshots = await new SnapshotModel(db).listByBuild(build.id);\n const comments = await new CommentModel(db).listByBuild(build.id);\n const canReview = !getStore().authEnabled || Boolean(getStore().user);\n\n const grouped = new Map<string, typeof snapshots>();\n for (const snap of snapshots) {\n const key = snap.status;\n const list = grouped.get(key) ?? [];\n list.push(snap);\n grouped.set(key, list);\n }\n\n return (\n <DocumentLayout\n title={`Build ${build.gitBranch}`}\n nav={{ active: \"builds\", projectSlug: project.slug, projectName: project.name }}\n >\n <div class=\"page-header\">\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\">\n <ol>\n <li>\n <a href=\"/projects\">Projects</a>\n </li>\n <li>\n <a href={`/projects/${project.slug}/builds`}>{project.name}</a>\n </li>\n <li>\n <span aria-current=\"page\">\n {build.gitBranch} · {build.gitSha.slice(0, 7)}\n </span>\n </li>\n </ol>\n </nav>\n <div class=\"page-header__row\">\n <div>\n <h1 class=\"page-header__title\">\n {build.gitBranch}{\" \"}\n <span style=\"color:var(--text-secondary); font-weight:400;\">\n · {build.gitSha.slice(0, 7)}\n </span>{\" \"}\n <Badge tone={statusTone(build.status)}>{build.status}</Badge>\n </h1>\n <p class=\"page-header__desc\">\n {build.message ?? \"No commit message\"}{\" \"}\n {build.authorName\n ? `· ${build.authorName}${build.authorEmail ? ` <${build.authorEmail}>` : \"\"}`\n : \"\"}{\" \"}\n · {new Date(build.createdAt).toLocaleString()}\n </p>\n </div>\n <div class=\"page-header__actions\">\n <a class=\"btn btn--primary\" href={`/projects/${project.slug}/builds/${build.id}/diff`}>\n Review diffs\n </a>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/retry`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/retry`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--secondary\" type=\"submit\">\n Retry capture\n </button>\n </form>\n </div>\n </div>\n </div>\n\n <div class=\"grid grid--3\" style=\"margin-bottom:1rem;\">\n <div class=\"card card--padded stat\">\n <div class=\"stat__value\">{snapshots.length}</div>\n <div class=\"stat__label\">Snapshots</div>\n </div>\n <div class=\"card card--padded stat\">\n <div class=\"stat__value\" style=\"color: var(--status-new);\">\n {build.changedCount}\n </div>\n <div class=\"stat__label\">Changed / new</div>\n </div>\n <div class=\"card card--padded stat\">\n <div class=\"stat__value\" style=\"color: var(--status-approved);\">\n {build.approvedCount}\n </div>\n <div class=\"stat__label\">Approved / unchanged</div>\n </div>\n </div>\n\n {canReview && (build.status === \"reviewing\" || build.status === \"comparing\") ? (\n <div\n class=\"card card--padded\"\n style=\"display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; margin-bottom:1rem;\"\n >\n <span class=\"field__hint\">Bulk actions:</span>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--primary\" type=\"submit\">\n Approve all\n </button>\n </form>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--danger\" type=\"submit\">\n Reject all\n </button>\n </form>\n <span class=\"field__hint\">Or review individually in the diff view.</span>\n </div>\n ) : null}\n\n {snapshots.length === 0 ? (\n <div class=\"empty\">\n <h2 class=\"empty__title\">No snapshots yet</h2>\n <p class=\"empty__desc\">Capture is pending or failed. Try retrying capture.</p>\n </div>\n ) : (\n <div class=\"snapshot-grid\">\n {snapshots.map((snap): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <div key={snap.id} class=\"snapshot-card\">\n <div class=\"snapshot-card__head\">\n <Badge tone={statusTone(snap.status)}>{snap.status}</Badge>\n <span class=\"snapshot-card__meta\">\n {snap.viewportName} · {snap.viewportWidth}×{snap.viewportHeight}\n </span>\n </div>\n <div class=\"snapshot-card__body\">\n <div style=\"font-weight:650; font-size:.95rem; line-height:1.2;\">\n {snap.storyTitle}\n <span style=\"color:var(--text-secondary); font-weight:400;\">\n {\" \"}\n / {snap.storyName}\n </span>\n </div>\n <div class=\"field__hint\" style=\"font-size:.8rem;\">\n {snap.storyId}\n {snap.diffRatio !== null && snap.diffRatio !== undefined\n ? ` · diff ${(snap.diffRatio * 100).toFixed(2)}%`\n : \"\"}\n {snap.diffPixels === null ? \"\" : ` · ${snap.diffPixels} px`}\n </div>\n <div style=\"display:flex; gap:.4rem; flex-wrap:wrap; margin-top:.2rem;\">\n <a\n class=\"btn btn--secondary\"\n href={`/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snap.id}`}\n >\n Review\n </a>\n {canReview && (snap.status === \"new\" || snap.status === \"changed\") ? (\n <>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/approve`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/approve`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--ghost\" type=\"submit\">\n Approve\n </button>\n </form>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/reject`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/reject`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--ghost\" type=\"submit\">\n Reject\n </button>\n </form>\n </>\n ) : null}\n </div>\n </div>\n </div>\n ))}\n </div>\n )}\n\n <div class=\"card card--padded\" style=\"margin-top:1rem;\">\n <h2 style=\"margin:0 0 .5rem;\">Comments</h2>\n {comments.length === 0 ? (\n <p class=\"field__hint\">\n No comments. Add one in the diff review page for a specific snapshot.\n </p>\n ) : null}\n <div style=\"display:grid; gap:.6rem;\">\n {comments.map((comment): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <div key={comment.id} class=\"comment\">\n <div class=\"comment__head\">\n <strong>{comment.userId}</strong>\n <span>· {new Date(comment.createdAt).toLocaleString()}</span>\n {comment.snapshotId ? (\n <Badge tone=\"neutral\">\n {snapshots.find((snapshot) => snapshot.id === comment.snapshotId)?.storyName ??\n comment.snapshotId.slice(0, 6)}\n </Badge>\n ) : (\n <Badge tone=\"neutral\">build</Badge>\n )}\n {comment.resolved ? <Badge tone=\"success\">resolved</Badge> : null}\n </div>\n <p class=\"comment__body\">{comment.body}</p>\n </div>\n ))}\n </div>\n\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/comments`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/comments`}\n hx-target=\"body\"\n style=\"margin-top:1rem; display:grid; gap:.5rem; max-width:640px;\"\n >\n <label class=\"field__label\" for=\"comment-body\">\n Add build comment\n </label>\n <textarea\n class=\"field__input field__input--textarea\"\n id=\"comment-body\"\n name=\"body\"\n rows={3}\n required\n placeholder=\"Leave a comment on this build…\"\n />\n <div>\n <button class=\"btn btn--primary\" type=\"submit\">\n Comment\n </button>\n </div>\n </form>\n </div>\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,113 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Badge } from "../ui/feedback.mjs";
5
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
6
+ //#region src/pages/build-diff-comments.tsx
7
+ /** One comment with an optional resolve action. */
8
+ function CommentCard(props) {
9
+ const { project, build, comment, canReview } = props;
10
+ return /* @__PURE__ */ jsxs("div", {
11
+ class: "comment",
12
+ children: [
13
+ /* @__PURE__ */ jsxs("div", {
14
+ class: "comment__head",
15
+ children: [
16
+ /* @__PURE__ */ jsx("strong", { children: comment.userId }),
17
+ /* @__PURE__ */ jsxs("span", { children: ["· ", new Date(comment.createdAt).toLocaleString()] }),
18
+ comment.resolved ? /* @__PURE__ */ jsx(Badge, {
19
+ tone: "success",
20
+ children: "resolved"
21
+ }) : null
22
+ ]
23
+ }),
24
+ /* @__PURE__ */ jsx("p", {
25
+ class: "comment__body",
26
+ children: comment.body
27
+ }),
28
+ !comment.resolved && canReview ? /* @__PURE__ */ jsx("form", {
29
+ method: "post",
30
+ action: `/api/v1/projects/${project.slug}/builds/${build.id}/comments/${comment.id}/resolve`,
31
+ "hx-post": `/api/v1/projects/${project.slug}/builds/${build.id}/comments/${comment.id}/resolve`,
32
+ "hx-target": "body",
33
+ style: "margin-top:.5rem;",
34
+ children: /* @__PURE__ */ jsx("button", {
35
+ class: "btn btn--ghost",
36
+ type: "submit",
37
+ children: "Mark resolved"
38
+ })
39
+ }) : null
40
+ ]
41
+ }, comment.id);
42
+ }
43
+ /** Reply form scoped to the selected snapshot. */
44
+ function CommentForm(props) {
45
+ const { project, build, selectedId } = props;
46
+ return /* @__PURE__ */ jsxs("form", {
47
+ method: "post",
48
+ action: `/api/v1/projects/${project.slug}/builds/${build.id}/comments`,
49
+ "hx-post": `/api/v1/projects/${project.slug}/builds/${build.id}/comments`,
50
+ "hx-target": "body",
51
+ style: "margin-top:1rem; display:grid; gap:.5rem;",
52
+ children: [
53
+ /* @__PURE__ */ jsx("input", {
54
+ type: "hidden",
55
+ name: "snapshotId",
56
+ value: selectedId
57
+ }),
58
+ /* @__PURE__ */ jsx("label", {
59
+ class: "field__label",
60
+ for: "comment-body",
61
+ children: "Add comment"
62
+ }),
63
+ /* @__PURE__ */ jsx("textarea", {
64
+ class: "field__input field__input--textarea",
65
+ id: "comment-body",
66
+ name: "body",
67
+ rows: 3,
68
+ required: true,
69
+ placeholder: "Leave feedback on this snapshot…"
70
+ }),
71
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx("button", {
72
+ class: "btn btn--primary",
73
+ type: "submit",
74
+ children: "Comment"
75
+ }) })
76
+ ]
77
+ });
78
+ }
79
+ /** Threaded comments for the selected snapshot plus the reply form. */
80
+ function DiffComments(props) {
81
+ const { project, build, selectedId, comments, canReview } = props;
82
+ const visible = comments.filter((c) => !c.snapshotId || c.snapshotId === selectedId);
83
+ return /* @__PURE__ */ jsxs("div", {
84
+ class: "card card--padded",
85
+ children: [
86
+ /* @__PURE__ */ jsx("h3", {
87
+ style: "margin:0 0 .5rem;",
88
+ children: "Comments"
89
+ }),
90
+ /* @__PURE__ */ jsxs("div", {
91
+ style: "display:grid; gap:.75rem;",
92
+ children: [visible.map((comment) => /* @__PURE__ */ jsx(CommentCard, {
93
+ project,
94
+ build,
95
+ comment,
96
+ canReview
97
+ }, comment.id)), visible.length === 0 ? /* @__PURE__ */ jsx("p", {
98
+ class: "field__hint",
99
+ children: "No comments on this snapshot."
100
+ }) : null]
101
+ }),
102
+ /* @__PURE__ */ jsx(CommentForm, {
103
+ project,
104
+ build,
105
+ selectedId
106
+ })
107
+ ]
108
+ });
109
+ }
110
+ //#endregion
111
+ export { DiffComments };
112
+
113
+ //# sourceMappingURL=build-diff-comments.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff-comments.mjs","names":[],"sources":["../../src/pages/build-diff-comments.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Comment } from \"../schema/comment.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport { Badge } from \"../ui/components.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/** Snapshot comment thread props. */\nexport interface DiffCommentsProps {\n project: Project;\n build: Build;\n selectedId: string;\n comments: Comment[];\n canReview: boolean;\n}\n\ninterface CommentCardProps {\n project: Project;\n build: Build;\n comment: Comment;\n canReview: boolean;\n}\n\n/** One comment with an optional resolve action. */\nfunction CommentCard(props: CommentCardProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, comment, canReview } = props;\n return (\n <div key={comment.id} class=\"comment\">\n <div class=\"comment__head\">\n <strong>{comment.userId}</strong>\n <span>· {new Date(comment.createdAt).toLocaleString()}</span>\n {comment.resolved ? <Badge tone=\"success\">resolved</Badge> : null}\n </div>\n <p class=\"comment__body\">{comment.body}</p>\n {!comment.resolved && canReview ? (\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/comments/${comment.id}/resolve`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/comments/${comment.id}/resolve`}\n hx-target=\"body\"\n style=\"margin-top:.5rem;\"\n >\n <button class=\"btn btn--ghost\" type=\"submit\">\n Mark resolved\n </button>\n </form>\n ) : null}\n </div>\n );\n}\n\ninterface CommentFormProps {\n project: Project;\n build: Build;\n selectedId: string;\n}\n\n/** Reply form scoped to the selected snapshot. */\nfunction CommentForm(props: CommentFormProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, selectedId } = props;\n return (\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/comments`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/comments`}\n hx-target=\"body\"\n style=\"margin-top:1rem; display:grid; gap:.5rem;\"\n >\n <input type=\"hidden\" name=\"snapshotId\" value={selectedId} />\n <label class=\"field__label\" for=\"comment-body\">\n Add comment\n </label>\n <textarea\n class=\"field__input field__input--textarea\"\n id=\"comment-body\"\n name=\"body\"\n rows={3}\n required\n placeholder=\"Leave feedback on this snapshot…\"\n />\n <div>\n <button class=\"btn btn--primary\" type=\"submit\">\n Comment\n </button>\n </div>\n </form>\n );\n}\n\n/** Threaded comments for the selected snapshot plus the reply form. */\nexport function DiffComments(\n props: DiffCommentsProps,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, selectedId, comments, canReview } = props;\n const visible = comments.filter((c) => !c.snapshotId || c.snapshotId === selectedId);\n return (\n <div class=\"card card--padded\">\n <h3 style=\"margin:0 0 .5rem;\">Comments</h3>\n <div style=\"display:grid; gap:.75rem;\">\n {visible.map((comment): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <CommentCard\n key={comment.id}\n project={project}\n build={build}\n comment={comment}\n canReview={canReview}\n />\n ))}\n {visible.length === 0 ? <p class=\"field__hint\">No comments on this snapshot.</p> : null}\n </div>\n\n <CommentForm project={project} build={build} selectedId={selectedId} />\n </div>\n );\n}\n"],"mappings":";;;;;;;AAyBA,SAAS,YAAY,OAAyE;CAC5F,MAAM,EAAE,SAAS,OAAO,SAAS,cAAc;CAC/C,OACE,qBAAC,OAAD;EAAsB,OAAM;EAA5B,UAAA;GACE,qBAAC,OAAD;IAAK,OAAM;IAAX,UAAA;KACE,oBAAC,UAAD,EAAA,UAAS,QAAQ,OAAe,CAAA;KAChC,qBAAC,QAAD,EAAA,UAAA,CAAM,MAAG,IAAI,KAAK,QAAQ,SAAS,CAAC,CAAC,eAAe,CAAQ,EAAA,CAAA;KAC3D,QAAQ,WAAW,oBAAC,OAAD;MAAO,MAAK;MAAU,UAAA;KAAe,CAAA,IAAI;IAC1D;;GACL,oBAAC,KAAD;IAAG,OAAM;IAAiB,UAAA,QAAQ;GAAQ,CAAA;GACzC,CAAC,QAAQ,YAAY,YACpB,oBAAC,QAAD;IACE,QAAO;IACP,QAAQ,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG,YAAY,QAAQ,GAAG;IACnF,WAAS,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG,YAAY,QAAQ,GAAG;IACpF,aAAU;IACV,OAAM;IAEN,UAAA,oBAAC,UAAD;KAAQ,OAAM;KAAiB,MAAK;KAAS,UAAA;IAErC,CAAA;GACJ,CAAA,IACJ;EACD;CApBK,GAAA,QAAQ,EAoBb;AAET;;AASA,SAAS,YAAY,OAAyE;CAC5F,MAAM,EAAE,SAAS,OAAO,eAAe;CACvC,OACE,qBAAC,QAAD;EACE,QAAO;EACP,QAAQ,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG;EAC5D,WAAS,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG;EAC7D,aAAU;EACV,OAAM;EALR,UAAA;GAOE,oBAAC,SAAD;IAAO,MAAK;IAAS,MAAK;IAAa,OAAO;GAAa,CAAA;GAC3D,oBAAC,SAAD;IAAO,OAAM;IAAe,KAAI;IAAe,UAAA;GAExC,CAAA;GACP,oBAAC,YAAD;IACE,OAAM;IACN,IAAG;IACH,MAAK;IACL,MAAM;IACN,UAAA;IACA,aAAY;GACb,CAAA;GACD,oBAAC,OAAD,EAAA,UACE,oBAAC,UAAD;IAAQ,OAAM;IAAmB,MAAK;IAAS,UAAA;GAEvC,CAAA,EACL,CAAA;EACD;;AAEV;;AAGA,SAAgB,aACd,OACgD;CAChD,MAAM,EAAE,SAAS,OAAO,YAAY,UAAU,cAAc;CAC5D,MAAM,UAAU,SAAS,QAAQ,MAAM,CAAC,EAAE,cAAc,EAAE,eAAe,UAAU;CACnF,OACE,qBAAC,OAAD;EAAK,OAAM;EAAX,UAAA;GACE,oBAAC,MAAD;IAAI,OAAM;IAAoB,UAAA;GAAY,CAAA;GAC1C,qBAAC,OAAD;IAAK,OAAM;IAAX,UAAA,CACG,QAAQ,KAAK,YACZ,oBAAC,aAAD;KAEW;KACF;KACE;KACE;IACZ,GALM,QAAQ,EAKd,CACF,GACA,QAAQ,WAAW,IAAI,oBAAC,KAAD;KAAG,OAAM;KAAc,UAAA;IAAgC,CAAA,IAAI,IAChF;;GAEL,oBAAC,aAAD;IAAsB;IAAgB;IAAmB;GAAa,CAAA;EACnE;;AAET"}
@@ -0,0 +1,163 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Badge, statusTone } from "../ui/feedback.mjs";
5
+ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
6
+ //#region src/pages/build-diff-header.tsx
7
+ /** Approve-all / reject-all bulk review actions. */
8
+ function ReviewActions(props) {
9
+ const { project, build, pendingCount } = props;
10
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("form", {
11
+ method: "post",
12
+ action: `/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`,
13
+ "hx-post": `/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`,
14
+ "hx-target": "body",
15
+ children: /* @__PURE__ */ jsxs("button", {
16
+ class: "btn btn--primary",
17
+ type: "submit",
18
+ children: [
19
+ "Approve all (",
20
+ pendingCount,
21
+ ")"
22
+ ]
23
+ })
24
+ }), /* @__PURE__ */ jsx("form", {
25
+ method: "post",
26
+ action: `/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`,
27
+ "hx-post": `/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`,
28
+ "hx-target": "body",
29
+ children: /* @__PURE__ */ jsx("button", {
30
+ class: "btn btn--danger",
31
+ type: "submit",
32
+ children: "Reject all"
33
+ })
34
+ })] });
35
+ }
36
+ /** Snapshot/pending/approved counters. */
37
+ function DiffStats(props) {
38
+ const { snapshots, pendingCount, approvedCount } = props;
39
+ return /* @__PURE__ */ jsx("div", {
40
+ class: "card card--padded",
41
+ style: "margin-bottom:1rem;",
42
+ children: /* @__PURE__ */ jsxs("div", {
43
+ class: "grid grid--3",
44
+ style: "text-align:center;",
45
+ children: [
46
+ /* @__PURE__ */ jsxs("div", {
47
+ class: "stat",
48
+ children: [/* @__PURE__ */ jsx("div", {
49
+ class: "stat__value",
50
+ children: snapshots.length
51
+ }), /* @__PURE__ */ jsx("div", {
52
+ class: "stat__label",
53
+ children: "Snapshots"
54
+ })]
55
+ }),
56
+ /* @__PURE__ */ jsxs("div", {
57
+ class: "stat",
58
+ children: [/* @__PURE__ */ jsx("div", {
59
+ class: "stat__value",
60
+ style: "color: var(--status-new);",
61
+ children: pendingCount
62
+ }), /* @__PURE__ */ jsx("div", {
63
+ class: "stat__label",
64
+ children: "Needs review"
65
+ })]
66
+ }),
67
+ /* @__PURE__ */ jsxs("div", {
68
+ class: "stat",
69
+ children: [/* @__PURE__ */ jsx("div", {
70
+ class: "stat__value",
71
+ style: "color: var(--status-approved);",
72
+ children: approvedCount
73
+ }), /* @__PURE__ */ jsx("div", {
74
+ class: "stat__label",
75
+ children: "Approved"
76
+ })]
77
+ })
78
+ ]
79
+ })
80
+ });
81
+ }
82
+ /** Projects → project → build breadcrumb trail. */
83
+ function DiffBreadcrumbs(props) {
84
+ const { project, build } = props;
85
+ return /* @__PURE__ */ jsx("nav", {
86
+ class: "breadcrumbs",
87
+ "aria-label": "Breadcrumb",
88
+ children: /* @__PURE__ */ jsxs("ol", { children: [
89
+ /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
90
+ href: "/projects",
91
+ children: "Projects"
92
+ }) }),
93
+ /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
94
+ href: `/projects/${project.slug}/builds`,
95
+ children: project.name
96
+ }) }),
97
+ /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", {
98
+ href: `/projects/${project.slug}/builds/${build.id}`,
99
+ children: ["Build ", build.gitBranch]
100
+ }) }),
101
+ /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("span", {
102
+ "aria-current": "page",
103
+ children: "Review"
104
+ }) })
105
+ ] })
106
+ });
107
+ }
108
+ /** Page header with breadcrumbs, review actions, and snapshot stats. */
109
+ function DiffHeader(props) {
110
+ const { project, build, snapshots, pendingCount, canReview } = props;
111
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
112
+ class: "page-header",
113
+ children: [/* @__PURE__ */ jsx(DiffBreadcrumbs, {
114
+ project,
115
+ build
116
+ }), /* @__PURE__ */ jsxs("div", {
117
+ class: "page-header__row",
118
+ children: [/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsxs("h1", {
119
+ class: "page-header__title",
120
+ children: [
121
+ build.gitBranch,
122
+ " ",
123
+ /* @__PURE__ */ jsxs("span", {
124
+ style: "color:var(--text-secondary); font-weight:400;",
125
+ children: ["· ", build.gitSha.slice(0, 7)]
126
+ })
127
+ ]
128
+ }), /* @__PURE__ */ jsxs("p", {
129
+ class: "page-header__desc",
130
+ children: [
131
+ build.message ?? "No message",
132
+ " ",
133
+ build.authorName ? `· ${build.authorName}` : "",
134
+ " ·",
135
+ " ",
136
+ /* @__PURE__ */ jsx(Badge, {
137
+ tone: statusTone(build.status),
138
+ children: build.status
139
+ })
140
+ ]
141
+ })] }), /* @__PURE__ */ jsxs("div", {
142
+ class: "page-header__actions",
143
+ children: [/* @__PURE__ */ jsx("a", {
144
+ class: "btn btn--secondary",
145
+ href: `/projects/${project.slug}/builds/${build.id}`,
146
+ children: "Build overview"
147
+ }), canReview && pendingCount > 0 ? /* @__PURE__ */ jsx(ReviewActions, {
148
+ project,
149
+ build,
150
+ pendingCount
151
+ }) : null]
152
+ })]
153
+ })]
154
+ }), /* @__PURE__ */ jsx(DiffStats, {
155
+ snapshots,
156
+ pendingCount,
157
+ approvedCount: build.approvedCount
158
+ })] });
159
+ }
160
+ //#endregion
161
+ export { DiffHeader };
162
+
163
+ //# sourceMappingURL=build-diff-header.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff-header.mjs","names":[],"sources":["../../src/pages/build-diff-header.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport type { Snapshot } from \"../schema/snapshot.ts\";\nimport { Badge, statusTone } from \"../ui/components.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/** Header + stats props for the diff review page. */\nexport interface DiffHeaderProps {\n project: Project;\n build: Build;\n snapshots: Snapshot[];\n pendingCount: number;\n canReview: boolean;\n}\n\ninterface ReviewActionsProps {\n project: Project;\n build: Build;\n pendingCount: number;\n}\n\n/** Approve-all / reject-all bulk review actions. */\nfunction ReviewActions(props: ReviewActionsProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, pendingCount } = props;\n return (\n <>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/approve-all`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--primary\" type=\"submit\">\n Approve all ({pendingCount})\n </button>\n </form>\n <form\n method=\"post\"\n action={`/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`}\n hx-post={`/api/v1/projects/${project.slug}/builds/${build.id}/reject-all`}\n hx-target=\"body\"\n >\n <button class=\"btn btn--danger\" type=\"submit\">\n Reject all\n </button>\n </form>\n </>\n );\n}\n\ninterface DiffStatsProps {\n snapshots: Snapshot[];\n pendingCount: number;\n approvedCount: number;\n}\n\n/** Snapshot/pending/approved counters. */\nfunction DiffStats(props: DiffStatsProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { snapshots, pendingCount, approvedCount } = props;\n return (\n <div class=\"card card--padded\" style=\"margin-bottom:1rem;\">\n <div class=\"grid grid--3\" style=\"text-align:center;\">\n <div class=\"stat\">\n <div class=\"stat__value\">{snapshots.length}</div>\n <div class=\"stat__label\">Snapshots</div>\n </div>\n <div class=\"stat\">\n <div class=\"stat__value\" style=\"color: var(--status-new);\">\n {pendingCount}\n </div>\n <div class=\"stat__label\">Needs review</div>\n </div>\n <div class=\"stat\">\n <div class=\"stat__value\" style=\"color: var(--status-approved);\">\n {approvedCount}\n </div>\n <div class=\"stat__label\">Approved</div>\n </div>\n </div>\n </div>\n );\n}\n\ninterface DiffBreadcrumbsProps {\n project: Project;\n build: Build;\n}\n\n/** Projects → project → build breadcrumb trail. */\nfunction DiffBreadcrumbs(\n props: DiffBreadcrumbsProps,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build } = props;\n return (\n <nav class=\"breadcrumbs\" aria-label=\"Breadcrumb\">\n <ol>\n <li>\n <a href=\"/projects\">Projects</a>\n </li>\n <li>\n <a href={`/projects/${project.slug}/builds`}>{project.name}</a>\n </li>\n <li>\n <a href={`/projects/${project.slug}/builds/${build.id}`}>Build {build.gitBranch}</a>\n </li>\n <li>\n <span aria-current=\"page\">Review</span>\n </li>\n </ol>\n </nav>\n );\n}\n\n/** Page header with breadcrumbs, review actions, and snapshot stats. */\nexport function DiffHeader(props: DiffHeaderProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, pendingCount, canReview } = props;\n return (\n <>\n <div class=\"page-header\">\n <DiffBreadcrumbs project={project} build={build} />\n <div class=\"page-header__row\">\n <div>\n <h1 class=\"page-header__title\">\n {build.gitBranch}{\" \"}\n <span style=\"color:var(--text-secondary); font-weight:400;\">\n · {build.gitSha.slice(0, 7)}\n </span>\n </h1>\n <p class=\"page-header__desc\">\n {build.message ?? \"No message\"} {build.authorName ? `· ${build.authorName}` : \"\"} ·{\" \"}\n <Badge tone={statusTone(build.status)}>{build.status}</Badge>\n </p>\n </div>\n <div class=\"page-header__actions\">\n <a class=\"btn btn--secondary\" href={`/projects/${project.slug}/builds/${build.id}`}>\n Build overview\n </a>\n {canReview && pendingCount > 0 ? (\n <ReviewActions project={project} build={build} pendingCount={pendingCount} />\n ) : null}\n </div>\n </div>\n </div>\n <DiffStats\n snapshots={snapshots}\n pendingCount={pendingCount}\n approvedCount={build.approvedCount}\n />\n </>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,66 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Badge, statusTone } from "../ui/feedback.mjs";
5
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
6
+ //#region src/pages/build-diff-nav.tsx
7
+ /** Left-rail snapshot list with per-story status and diff ratios. */
8
+ function DiffNav(props) {
9
+ const { project, build, snapshots, selectedId } = props;
10
+ return /* @__PURE__ */ jsx("div", {
11
+ style: "flex: 0 0 320px; max-width: 36%; min-width: 260px;",
12
+ children: /* @__PURE__ */ jsxs("div", {
13
+ class: "card",
14
+ children: [/* @__PURE__ */ jsxs("div", {
15
+ style: "padding:.6rem .75rem; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;",
16
+ children: [/* @__PURE__ */ jsx("strong", { children: "Snapshots" }), /* @__PURE__ */ jsxs("span", {
17
+ class: "field__hint",
18
+ children: [snapshots.length, " total"]
19
+ })]
20
+ }), /* @__PURE__ */ jsx("div", {
21
+ "data-diff-nav": true,
22
+ "data-current": selectedId,
23
+ style: "max-height: 70vh; overflow:auto;",
24
+ children: snapshots.map((snap) => /* @__PURE__ */ jsxs("a", {
25
+ href: `/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snap.id}`,
26
+ "data-snapshot-link": true,
27
+ "data-snapshot-id": snap.id,
28
+ class: `snapshot-nav ${selectedId === snap.id ? "snapshot-nav--active" : ""}`,
29
+ style: `display:flex; flex-direction:column; gap:.2rem; padding:.6rem .75rem; border-bottom:1px solid var(--border); text-decoration:none; color:inherit; background:${selectedId === snap.id ? "var(--surface-muted)" : "transparent"};`,
30
+ "hx-get": `/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snap.id}`,
31
+ "hx-target": "body",
32
+ "hx-push-url": "true",
33
+ children: [/* @__PURE__ */ jsxs("span", {
34
+ style: "display:flex; gap:.4rem; align-items:center; flex-wrap:wrap;",
35
+ children: [/* @__PURE__ */ jsx(Badge, {
36
+ tone: statusTone(snap.status),
37
+ children: snap.status
38
+ }), /* @__PURE__ */ jsxs("span", {
39
+ style: "font-weight:600; font-size:.9rem;",
40
+ children: [
41
+ snap.storyTitle,
42
+ " / ",
43
+ snap.storyName
44
+ ]
45
+ })]
46
+ }), /* @__PURE__ */ jsxs("span", {
47
+ class: "field__hint",
48
+ style: "font-size:.8rem;",
49
+ children: [
50
+ snap.viewportName,
51
+ " · ",
52
+ snap.viewportWidth,
53
+ "×",
54
+ snap.viewportHeight,
55
+ snap.diffRatio !== null && snap.diffRatio !== void 0 ? ` · ${(snap.diffRatio * 100).toFixed(2)}%` : ""
56
+ ]
57
+ })]
58
+ }, snap.id))
59
+ })]
60
+ })
61
+ });
62
+ }
63
+ //#endregion
64
+ export { DiffNav };
65
+
66
+ //# sourceMappingURL=build-diff-nav.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-diff-nav.mjs","names":[],"sources":["../../src/pages/build-diff-nav.tsx"],"sourcesContent":["import type { HtmlEscapedString } from \"hono/utils/html\";\nimport type { Build } from \"../schema/build.ts\";\nimport type { Project } from \"../schema/project.ts\";\nimport type { Snapshot } from \"../schema/snapshot.ts\";\nimport { Badge, statusTone } from \"../ui/components.tsx\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/** Snapshot navigator props for the diff review page. */\nexport interface DiffNavProps {\n project: Project;\n build: Build;\n snapshots: Snapshot[];\n selectedId?: string;\n}\n\n/** Left-rail snapshot list with per-story status and diff ratios. */\nexport function DiffNav(props: DiffNavProps): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, selectedId } = props;\n return (\n <div style=\"flex: 0 0 320px; max-width: 36%; min-width: 260px;\">\n <div class=\"card\">\n <div style=\"padding:.6rem .75rem; border-bottom:1px solid var(--border); display:flex; justify-content:space-between; align-items:center;\">\n <strong>Snapshots</strong>\n <span class=\"field__hint\">{snapshots.length} total</span>\n </div>\n <div data-diff-nav data-current={selectedId} style=\"max-height: 70vh; overflow:auto;\">\n {snapshots.map((snap): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <a\n key={snap.id}\n href={`/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snap.id}`}\n data-snapshot-link\n data-snapshot-id={snap.id}\n class={`snapshot-nav ${selectedId === snap.id ? \"snapshot-nav--active\" : \"\"}`}\n style={`display:flex; flex-direction:column; gap:.2rem; padding:.6rem .75rem; border-bottom:1px solid var(--border); text-decoration:none; color:inherit; background:${selectedId === snap.id ? \"var(--surface-muted)\" : \"transparent\"};`}\n hx-get={`/projects/${project.slug}/builds/${build.id}/diff?snapshot=${snap.id}`}\n hx-target=\"body\"\n hx-push-url=\"true\"\n >\n <span style=\"display:flex; gap:.4rem; align-items:center; flex-wrap:wrap;\">\n <Badge tone={statusTone(snap.status)}>{snap.status}</Badge>\n <span style=\"font-weight:600; font-size:.9rem;\">\n {snap.storyTitle} / {snap.storyName}\n </span>\n </span>\n <span class=\"field__hint\" style=\"font-size:.8rem;\">\n {snap.viewportName} · {snap.viewportWidth}×{snap.viewportHeight}\n {snap.diffRatio !== null && snap.diffRatio !== undefined\n ? ` · ${(snap.diffRatio * 100).toFixed(2)}%`\n : \"\"}\n </span>\n </a>\n ))}\n </div>\n </div>\n </div>\n );\n}\n"],"mappings":";;;;;;;AAiBA,SAAgB,QAAQ,OAAqE;CAC3F,MAAM,EAAE,SAAS,OAAO,WAAW,eAAe;CAClD,OACE,oBAAC,OAAD;EAAK,OAAM;EACT,UAAA,qBAAC,OAAD;GAAK,OAAM;GAAX,UAAA,CACE,qBAAC,OAAD;IAAK,OAAM;IAAX,UAAA,CACE,oBAAC,UAAD,EAAA,UAAQ,YAAiB,CAAA,GACzB,qBAAC,QAAD;KAAM,OAAM;KAAZ,UAAA,CAA2B,UAAU,QAAO,QAAY;IACrD,CAAA,CAAA;GACL,CAAA,GAAA,oBAAC,OAAD;IAAK,iBAAA;IAAc,gBAAc;IAAY,OAAM;IAChD,UAAA,UAAU,KAAK,SACd,qBAAC,KAAD;KAEE,MAAM,aAAa,QAAQ,KAAK,UAAU,MAAM,GAAG,iBAAiB,KAAK;KACzE,sBAAA;KACA,oBAAkB,KAAK;KACvB,OAAO,gBAAgB,eAAe,KAAK,KAAK,yBAAyB;KACzE,OAAO,gKAAgK,eAAe,KAAK,KAAK,yBAAyB,cAAc;KACvO,UAAQ,aAAa,QAAQ,KAAK,UAAU,MAAM,GAAG,iBAAiB,KAAK;KAC3E,aAAU;KACV,eAAY;KATd,UAAA,CAWE,qBAAC,QAAD;MAAM,OAAM;MAAZ,UAAA,CACE,oBAAC,OAAD;OAAO,MAAM,WAAW,KAAK,MAAM;OAAI,UAAA,KAAK;MAAc,CAAA,GAC1D,qBAAC,QAAD;OAAM,OAAM;OAAZ,UAAA;QACG,KAAK;QAAW;QAAI,KAAK;OACtB;MACF,CAAA,CAAA;KACN,CAAA,GAAA,qBAAC,QAAD;MAAM,OAAM;MAAc,OAAM;MAAhC,UAAA;OACG,KAAK;OAAa;OAAI,KAAK;OAAc;OAAE,KAAK;OAChD,KAAK,cAAc,QAAQ,KAAK,cAAc,KAAA,IAC3C,OAAO,KAAK,YAAY,IAAA,CAAK,QAAQ,CAAC,EAAE,KACxC;MACA;KACL,CAAA,CAAA;IAtBI,GAAA,KAAK,EAsBT,CACJ;GACE,CAAA,CACF;;CACF,CAAA;AAET"}