@storyshelf/app 0.5.3

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 (268) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/app-types.d.mts +54 -0
  4. package/dist/asset-manifest.mjs +41 -0
  5. package/dist/asset-manifest.mjs.map +1 -0
  6. package/dist/asset-paths.mjs +23 -0
  7. package/dist/asset-paths.mjs.map +1 -0
  8. package/dist/assets/htmx.min.js +3304 -0
  9. package/dist/assets/mark.svg +1 -0
  10. package/dist/capture-setup.mjs +139 -0
  11. package/dist/capture-setup.mjs.map +1 -0
  12. package/dist/credential-keys.mjs +20 -0
  13. package/dist/credential-keys.mjs.map +1 -0
  14. package/dist/credentials.mjs +56 -0
  15. package/dist/credentials.mjs.map +1 -0
  16. package/dist/index.d.mts +18 -0
  17. package/dist/index.mjs +310 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/lifecycle.mjs +132 -0
  20. package/dist/lifecycle.mjs.map +1 -0
  21. package/dist/middleware/auth-gate.mjs +36 -0
  22. package/dist/middleware/auth-gate.mjs.map +1 -0
  23. package/dist/middleware/csrf.mjs +75 -0
  24. package/dist/middleware/csrf.mjs.map +1 -0
  25. package/dist/middleware/rate-limit.mjs +68 -0
  26. package/dist/middleware/rate-limit.mjs.map +1 -0
  27. package/dist/middleware/request-log.mjs +30 -0
  28. package/dist/middleware/request-log.mjs.map +1 -0
  29. package/dist/middleware/server-timing.mjs +39 -0
  30. package/dist/middleware/server-timing.mjs.map +1 -0
  31. package/dist/middleware/setup-gate.mjs +37 -0
  32. package/dist/middleware/setup-gate.mjs.map +1 -0
  33. package/dist/middleware/store-scope.mjs +33 -0
  34. package/dist/middleware/store-scope.mjs.map +1 -0
  35. package/dist/notify.d.mts +17 -0
  36. package/dist/notify.mjs +141 -0
  37. package/dist/notify.mjs.map +1 -0
  38. package/dist/pages/admin-system.mjs +243 -0
  39. package/dist/pages/admin-system.mjs.map +1 -0
  40. package/dist/pages/build-detail.mjs +392 -0
  41. package/dist/pages/build-detail.mjs.map +1 -0
  42. package/dist/pages/build-diff-actions.mjs +162 -0
  43. package/dist/pages/build-diff-actions.mjs.map +1 -0
  44. package/dist/pages/build-diff-comments.mjs +104 -0
  45. package/dist/pages/build-diff-comments.mjs.map +1 -0
  46. package/dist/pages/build-diff-header.mjs +117 -0
  47. package/dist/pages/build-diff-header.mjs.map +1 -0
  48. package/dist/pages/build-diff-model.mjs +48 -0
  49. package/dist/pages/build-diff-model.mjs.map +1 -0
  50. package/dist/pages/build-diff-nav.mjs +122 -0
  51. package/dist/pages/build-diff-nav.mjs.map +1 -0
  52. package/dist/pages/build-diff-shortcuts.mjs +78 -0
  53. package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
  54. package/dist/pages/build-diff-stale.mjs +70 -0
  55. package/dist/pages/build-diff-stale.mjs.map +1 -0
  56. package/dist/pages/build-diff-viewer.mjs +70 -0
  57. package/dist/pages/build-diff-viewer.mjs.map +1 -0
  58. package/dist/pages/build-diff.mjs +110 -0
  59. package/dist/pages/build-diff.mjs.map +1 -0
  60. package/dist/pages/compute-jobs.mjs +165 -0
  61. package/dist/pages/compute-jobs.mjs.map +1 -0
  62. package/dist/pages/invite.mjs +91 -0
  63. package/dist/pages/invite.mjs.map +1 -0
  64. package/dist/pages/label-detail.mjs +154 -0
  65. package/dist/pages/label-detail.mjs.map +1 -0
  66. package/dist/pages/library.mjs +343 -0
  67. package/dist/pages/library.mjs.map +1 -0
  68. package/dist/pages/login.mjs +117 -0
  69. package/dist/pages/login.mjs.map +1 -0
  70. package/dist/pages/passkey-ceremony.mjs +97 -0
  71. package/dist/pages/passkey-ceremony.mjs.map +1 -0
  72. package/dist/pages/profile.mjs +364 -0
  73. package/dist/pages/profile.mjs.map +1 -0
  74. package/dist/pages/project-builds-table.mjs +151 -0
  75. package/dist/pages/project-builds-table.mjs.map +1 -0
  76. package/dist/pages/project-builds.mjs +143 -0
  77. package/dist/pages/project-builds.mjs.map +1 -0
  78. package/dist/pages/project-create.mjs +90 -0
  79. package/dist/pages/project-create.mjs.map +1 -0
  80. package/dist/pages/project-settings.mjs +95 -0
  81. package/dist/pages/project-settings.mjs.map +1 -0
  82. package/dist/pages/projects-card.mjs +194 -0
  83. package/dist/pages/projects-card.mjs.map +1 -0
  84. package/dist/pages/projects.mjs +129 -0
  85. package/dist/pages/projects.mjs.map +1 -0
  86. package/dist/pages/root.mjs +98 -0
  87. package/dist/pages/root.mjs.map +1 -0
  88. package/dist/pages/settings-general.mjs +138 -0
  89. package/dist/pages/settings-general.mjs.map +1 -0
  90. package/dist/pages/settings-labels.mjs +107 -0
  91. package/dist/pages/settings-labels.mjs.map +1 -0
  92. package/dist/pages/settings-members.mjs +192 -0
  93. package/dist/pages/settings-members.mjs.map +1 -0
  94. package/dist/pages/settings-notifications.mjs +240 -0
  95. package/dist/pages/settings-notifications.mjs.map +1 -0
  96. package/dist/pages/settings-status.mjs +137 -0
  97. package/dist/pages/settings-status.mjs.map +1 -0
  98. package/dist/pages/settings-tests.mjs +111 -0
  99. package/dist/pages/settings-tests.mjs.map +1 -0
  100. package/dist/pages/settings-tokens.mjs +90 -0
  101. package/dist/pages/settings-tokens.mjs.map +1 -0
  102. package/dist/pages/settings-webhooks.mjs +104 -0
  103. package/dist/pages/settings-webhooks.mjs.map +1 -0
  104. package/dist/pages/storybook.mjs +116 -0
  105. package/dist/pages/storybook.mjs.map +1 -0
  106. package/dist/retention-timer.mjs +65 -0
  107. package/dist/retention-timer.mjs.map +1 -0
  108. package/dist/routers/admin-pages.mjs +48 -0
  109. package/dist/routers/admin-pages.mjs.map +1 -0
  110. package/dist/routers/admin.mjs +113 -0
  111. package/dist/routers/admin.mjs.map +1 -0
  112. package/dist/routers/assets.mjs +64 -0
  113. package/dist/routers/assets.mjs.map +1 -0
  114. package/dist/routers/attempts.mjs +96 -0
  115. package/dist/routers/attempts.mjs.map +1 -0
  116. package/dist/routers/auth-sync.mjs +64 -0
  117. package/dist/routers/auth-sync.mjs.map +1 -0
  118. package/dist/routers/auth.mjs +236 -0
  119. package/dist/routers/auth.mjs.map +1 -0
  120. package/dist/routers/baseline-guard.mjs +83 -0
  121. package/dist/routers/baseline-guard.mjs.map +1 -0
  122. package/dist/routers/builds.handlers.mjs +217 -0
  123. package/dist/routers/builds.handlers.mjs.map +1 -0
  124. package/dist/routers/builds.mjs +381 -0
  125. package/dist/routers/builds.mjs.map +1 -0
  126. package/dist/routers/comments.mjs +109 -0
  127. package/dist/routers/comments.mjs.map +1 -0
  128. package/dist/routers/flash.mjs +35 -0
  129. package/dist/routers/flash.mjs.map +1 -0
  130. package/dist/routers/health-report.mjs +159 -0
  131. package/dist/routers/health-report.mjs.map +1 -0
  132. package/dist/routers/health.mjs +46 -0
  133. package/dist/routers/health.mjs.map +1 -0
  134. package/dist/routers/helpers.mjs +110 -0
  135. package/dist/routers/helpers.mjs.map +1 -0
  136. package/dist/routers/htmx.mjs +24 -0
  137. package/dist/routers/htmx.mjs.map +1 -0
  138. package/dist/routers/labels.mjs +118 -0
  139. package/dist/routers/labels.mjs.map +1 -0
  140. package/dist/routers/media.mjs +53 -0
  141. package/dist/routers/media.mjs.map +1 -0
  142. package/dist/routers/members.mjs +177 -0
  143. package/dist/routers/members.mjs.map +1 -0
  144. package/dist/routers/notification-channels.mjs +190 -0
  145. package/dist/routers/notification-channels.mjs.map +1 -0
  146. package/dist/routers/notification-subscriptions.mjs +120 -0
  147. package/dist/routers/notification-subscriptions.mjs.map +1 -0
  148. package/dist/routers/profile.mjs +276 -0
  149. package/dist/routers/profile.mjs.map +1 -0
  150. package/dist/routers/projects.mjs +133 -0
  151. package/dist/routers/projects.mjs.map +1 -0
  152. package/dist/routers/review-next.mjs +47 -0
  153. package/dist/routers/review-next.mjs.map +1 -0
  154. package/dist/routers/schemas.mjs +337 -0
  155. package/dist/routers/schemas.mjs.map +1 -0
  156. package/dist/routers/settings-general.mjs +104 -0
  157. package/dist/routers/settings-general.mjs.map +1 -0
  158. package/dist/routers/settings-labels.mjs +52 -0
  159. package/dist/routers/settings-labels.mjs.map +1 -0
  160. package/dist/routers/settings-members.mjs +58 -0
  161. package/dist/routers/settings-members.mjs.map +1 -0
  162. package/dist/routers/settings-notifications.mjs +108 -0
  163. package/dist/routers/settings-notifications.mjs.map +1 -0
  164. package/dist/routers/settings-status.mjs +77 -0
  165. package/dist/routers/settings-status.mjs.map +1 -0
  166. package/dist/routers/settings-tokens.mjs +40 -0
  167. package/dist/routers/settings-tokens.mjs.map +1 -0
  168. package/dist/routers/settings-webhooks.mjs +67 -0
  169. package/dist/routers/settings-webhooks.mjs.map +1 -0
  170. package/dist/routers/settings.handlers.mjs +149 -0
  171. package/dist/routers/settings.handlers.mjs.map +1 -0
  172. package/dist/routers/settings.mjs +25 -0
  173. package/dist/routers/settings.mjs.map +1 -0
  174. package/dist/routers/snapshots.mjs +240 -0
  175. package/dist/routers/snapshots.mjs.map +1 -0
  176. package/dist/routers/status-configs.mjs +108 -0
  177. package/dist/routers/status-configs.mjs.map +1 -0
  178. package/dist/routers/storybook.mjs +171 -0
  179. package/dist/routers/storybook.mjs.map +1 -0
  180. package/dist/routers/tokens.mjs +91 -0
  181. package/dist/routers/tokens.mjs.map +1 -0
  182. package/dist/routers/ui.mjs +166 -0
  183. package/dist/routers/ui.mjs.map +1 -0
  184. package/dist/routers/webhooks.mjs +97 -0
  185. package/dist/routers/webhooks.mjs.map +1 -0
  186. package/dist/routers/well-known.mjs +43 -0
  187. package/dist/routers/well-known.mjs.map +1 -0
  188. package/dist/runtime.mjs +52 -0
  189. package/dist/runtime.mjs.map +1 -0
  190. package/dist/store.mjs +20 -0
  191. package/dist/store.mjs.map +1 -0
  192. package/dist/ui/avatar.mjs +76 -0
  193. package/dist/ui/avatar.mjs.map +1 -0
  194. package/dist/ui/buttons.mjs +205 -0
  195. package/dist/ui/buttons.mjs.map +1 -0
  196. package/dist/ui/code-block.mjs +47 -0
  197. package/dist/ui/code-block.mjs.map +1 -0
  198. package/dist/ui/compare-stage.mjs +344 -0
  199. package/dist/ui/compare-stage.mjs.map +1 -0
  200. package/dist/ui/csrf-field.mjs +20 -0
  201. package/dist/ui/csrf-field.mjs.map +1 -0
  202. package/dist/ui/css.mjs +36 -0
  203. package/dist/ui/css.mjs.map +1 -0
  204. package/dist/ui/document.mjs +130 -0
  205. package/dist/ui/document.mjs.map +1 -0
  206. package/dist/ui/dropdown.mjs +181 -0
  207. package/dist/ui/dropdown.mjs.map +1 -0
  208. package/dist/ui/feedback.mjs +341 -0
  209. package/dist/ui/feedback.mjs.map +1 -0
  210. package/dist/ui/filter-input.mjs +58 -0
  211. package/dist/ui/filter-input.mjs.map +1 -0
  212. package/dist/ui/forms.mjs +291 -0
  213. package/dist/ui/forms.mjs.map +1 -0
  214. package/dist/ui/icons/icon.mjs +53 -0
  215. package/dist/ui/icons/icon.mjs.map +1 -0
  216. package/dist/ui/icons/paths.mjs +61 -0
  217. package/dist/ui/icons/paths.mjs.map +1 -0
  218. package/dist/ui/icons/sprite.mjs +26 -0
  219. package/dist/ui/icons/sprite.mjs.map +1 -0
  220. package/dist/ui/kbd.mjs +33 -0
  221. package/dist/ui/kbd.mjs.map +1 -0
  222. package/dist/ui/layout.mjs +173 -0
  223. package/dist/ui/layout.mjs.map +1 -0
  224. package/dist/ui/progress.mjs +44 -0
  225. package/dist/ui/progress.mjs.map +1 -0
  226. package/dist/ui/segmented.mjs +77 -0
  227. package/dist/ui/segmented.mjs.map +1 -0
  228. package/dist/ui/shell/account.mjs +161 -0
  229. package/dist/ui/shell/account.mjs.map +1 -0
  230. package/dist/ui/shell/bare.mjs +69 -0
  231. package/dist/ui/shell/bare.mjs.map +1 -0
  232. package/dist/ui/shell/boot-script.mjs +46 -0
  233. package/dist/ui/shell/boot-script.mjs.map +1 -0
  234. package/dist/ui/shell/client-script.mjs +259 -0
  235. package/dist/ui/shell/client-script.mjs.map +1 -0
  236. package/dist/ui/shell/nav.mjs +111 -0
  237. package/dist/ui/shell/nav.mjs.map +1 -0
  238. package/dist/ui/shell/review-script.mjs +195 -0
  239. package/dist/ui/shell/review-script.mjs.map +1 -0
  240. package/dist/ui/shell/sidebar.mjs +323 -0
  241. package/dist/ui/shell/sidebar.mjs.map +1 -0
  242. package/dist/ui/shell/topbar.mjs +268 -0
  243. package/dist/ui/shell/topbar.mjs.map +1 -0
  244. package/dist/ui/stacks.mjs +82 -0
  245. package/dist/ui/stacks.mjs.map +1 -0
  246. package/dist/ui/styles/base.mjs +41 -0
  247. package/dist/ui/styles/base.mjs.map +1 -0
  248. package/dist/ui/styles/behavior.mjs +43 -0
  249. package/dist/ui/styles/behavior.mjs.map +1 -0
  250. package/dist/ui/styles/review-layout.mjs +347 -0
  251. package/dist/ui/styles/review-layout.mjs.map +1 -0
  252. package/dist/ui/styles/review.mjs +86 -0
  253. package/dist/ui/styles/review.mjs.map +1 -0
  254. package/dist/ui/styles/tokens.mjs +132 -0
  255. package/dist/ui/styles/tokens.mjs.map +1 -0
  256. package/dist/ui/styles.mjs +19 -0
  257. package/dist/ui/styles.mjs.map +1 -0
  258. package/dist/ui/subnav.mjs +91 -0
  259. package/dist/ui/subnav.mjs.map +1 -0
  260. package/dist/ui/table.mjs +73 -0
  261. package/dist/ui/table.mjs.map +1 -0
  262. package/dist/ui/theme.mjs +64 -0
  263. package/dist/ui/theme.mjs.map +1 -0
  264. package/dist/ui/thumbnail.mjs +51 -0
  265. package/dist/ui/thumbnail.mjs.map +1 -0
  266. package/dist/ui/time.mjs +50 -0
  267. package/dist/ui/time.mjs.map +1 -0
  268. package/package.json +92 -0
@@ -0,0 +1,111 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
5
+ import { Alert, Meta } from "../ui/feedback.mjs";
6
+ import { CheckField, Field, FormActions } from "../ui/forms.mjs";
7
+ import { Card, SectionTitle } from "../ui/layout.mjs";
8
+ import { csrfField } from "../ui/csrf-field.mjs";
9
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
10
+ //#region src/pages/settings-tests.tsx
11
+ /** Interaction-tests settings tab: play execution toggle and timeout. */
12
+ function renderSettingsTests(project, isAdmin, formState) {
13
+ return /* @__PURE__ */ jsxs("div", {
14
+ class: "grid max-w-form",
15
+ children: [
16
+ formState?.globalError ? /* @__PURE__ */ jsx(Alert, {
17
+ tone: "danger",
18
+ children: formState.globalError
19
+ }) : null,
20
+ /* @__PURE__ */ jsxs(Card, { children: [
21
+ /* @__PURE__ */ jsx(SectionTitle, { children: "Interaction tests" }),
22
+ /* @__PURE__ */ jsxs(Meta, { children: [
23
+ "When enabled, Storybook ",
24
+ /* @__PURE__ */ jsx("code", { children: "play" }),
25
+ " functions run before each screenshot. Failures block the build unless the story is marked",
26
+ /* @__PURE__ */ jsx("code", { children: "flaky-test" }),
27
+ " via ",
28
+ /* @__PURE__ */ jsx("code", { children: "tags: ['flaky-test']" }),
29
+ " or",
30
+ /* @__PURE__ */ jsx("code", { children: "parameters: { flakyTest: true }" }),
31
+ " (supports both",
32
+ " ",
33
+ /* @__PURE__ */ jsx("code", { children: "chromatic" }),
34
+ " and ",
35
+ /* @__PURE__ */ jsx("code", { children: "storyshelf" }),
36
+ " keys, story-level, case-insensitive). Use ",
37
+ /* @__PURE__ */ jsx("code", { children: "disableSnapshot" }),
38
+ " to skip a story entirely."
39
+ ] }),
40
+ /* @__PURE__ */ jsxs("form", {
41
+ method: "post",
42
+ action: `/projects/${project.slug}/settings/tests`,
43
+ "hx-post": `/projects/${project.slug}/settings/tests`,
44
+ "hx-target": "body",
45
+ "hx-swap": "outerHTML",
46
+ children: [
47
+ csrfField(),
48
+ /* @__PURE__ */ jsx(CheckField, {
49
+ label: "Enable interaction tests (play)",
50
+ name: "executePlay",
51
+ value: "true",
52
+ checked: project.executePlay ? true : void 0,
53
+ disabled: !isAdmin,
54
+ hint: "When enabled, failing play blocks the build (failed). Flaky stories show warnings but status stays successful."
55
+ }),
56
+ /* @__PURE__ */ jsx(Field, {
57
+ label: "Play timeout (ms)",
58
+ name: "playTimeoutMs",
59
+ type: "number",
60
+ min: "1000",
61
+ max: "30000",
62
+ step: "1000",
63
+ value: String(project.playTimeoutMs),
64
+ disabled: !isAdmin,
65
+ hint: "Timeout for each play function (1000–30000 ms, default 10000)."
66
+ }),
67
+ isAdmin ? /* @__PURE__ */ jsx(FormActions, { children: /* @__PURE__ */ jsx(Button, {
68
+ variant: "primary",
69
+ type: "submit",
70
+ icon: "check",
71
+ children: "Save changes"
72
+ }) }) : /* @__PURE__ */ jsx(Meta, { children: "You need admin access to edit settings." })
73
+ ]
74
+ })
75
+ ] }),
76
+ /* @__PURE__ */ jsxs(Card, { children: [
77
+ /* @__PURE__ */ jsx(SectionTitle, {
78
+ level: 3,
79
+ children: "How to mark stories"
80
+ }),
81
+ /* @__PURE__ */ jsx(Meta, {
82
+ as: "pre",
83
+ children: `// Disable snapshot entirely (skip capture + play)
84
+ export const Hidden: Story = {
85
+ parameters: { storyshelf: { disableSnapshot: true } }
86
+ };
87
+ // Mark as flaky (non-blocking, shows warning)
88
+ export const Flaky: Story = {
89
+ tags: ['flaky-test'],
90
+ play: async ({canvasElement}) => { /* ... */ }
91
+ };
92
+ // or via parameters (both keys work, storyshelf wins)
93
+ export const Flaky2: Story = {
94
+ parameters: { chromatic: { flakyTest: true } }
95
+ };`
96
+ }),
97
+ /* @__PURE__ */ jsxs(Meta, { children: [
98
+ "Tags are story-level and case-insensitive (",
99
+ /* @__PURE__ */ jsx("code", { children: "flaky-test" }),
100
+ "). Whole story is non-blocking when flaky. GitHub status stays",
101
+ /* @__PURE__ */ jsx("code", { children: "success" }),
102
+ " with a warning comment."
103
+ ] })
104
+ ] })
105
+ ]
106
+ });
107
+ }
108
+ //#endregion
109
+ export { renderSettingsTests };
110
+
111
+ //# sourceMappingURL=settings-tests.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-tests.mjs","names":[],"sources":["../../src/pages/settings-tests.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport {\n Alert,\n Button,\n Card,\n CheckField,\n Field,\n FormActions,\n Meta,\n SectionTitle,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\n\n/** Interaction-tests settings tab: play execution toggle and timeout. */\nexport function renderSettingsTests(\n project: Project,\n isAdmin: boolean,\n formState?: { globalError?: string },\n): unknown {\n return (\n <div class=\"grid max-w-form\">\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n <Card>\n <SectionTitle>Interaction tests</SectionTitle>\n <Meta>\n When enabled, Storybook <code>play</code> functions run before each screenshot. Failures\n block the build unless the story is marked\n <code>flaky-test</code> via <code>tags: ['flaky-test']</code> or\n <code>parameters: &#123; flakyTest: true &#125;</code> (supports both{\" \"}\n <code>chromatic</code> and <code>storyshelf</code> keys, story-level, case-insensitive).\n Use <code>disableSnapshot</code> to skip a story entirely.\n </Meta>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/tests`}\n hx-post={`/projects/${project.slug}/settings/tests`}\n hx-target=\"body\"\n hx-swap=\"outerHTML\"\n >\n {csrfField()}\n <CheckField\n label=\"Enable interaction tests (play)\"\n name=\"executePlay\"\n value=\"true\"\n checked={project.executePlay ? true : undefined}\n disabled={!isAdmin}\n hint=\"When enabled, failing play blocks the build (failed). Flaky stories show warnings but status stays successful.\"\n />\n <Field\n label=\"Play timeout (ms)\"\n name=\"playTimeoutMs\"\n type=\"number\"\n min=\"1000\"\n max=\"30000\"\n step=\"1000\"\n value={String(project.playTimeoutMs)}\n disabled={!isAdmin}\n hint=\"Timeout for each play function (1000–30000 ms, default 10000).\"\n />\n {isAdmin ? (\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"check\">\n Save changes\n </Button>\n </FormActions>\n ) : (\n <Meta>You need admin access to edit settings.</Meta>\n )}\n </form>\n </Card>\n <Card>\n <SectionTitle level={3}>How to mark stories</SectionTitle>\n <Meta as=\"pre\">\n {`// Disable snapshot entirely (skip capture + play)\nexport const Hidden: Story = {\n parameters: { storyshelf: { disableSnapshot: true } }\n};\n// Mark as flaky (non-blocking, shows warning)\nexport const Flaky: Story = {\n tags: ['flaky-test'],\n play: async ({canvasElement}) => { /* ... */ }\n};\n// or via parameters (both keys work, storyshelf wins)\nexport const Flaky2: Story = {\n parameters: { chromatic: { flakyTest: true } }\n};`}\n </Meta>\n <Meta>\n Tags are story-level and case-insensitive (<code>flaky-test</code>). Whole story is\n non-blocking when flaky. GitHub status stays\n <code>success</code> with a warning comment.\n </Meta>\n </Card>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;AAcA,SAAgB,oBACd,SACA,SACA,WACS;CACT,OACE,qBAAC,OAAD;EAAK,OAAM;EAAX,UAAA;GACG,WAAW,cAAc,oBAAC,OAAD;IAAO,MAAK;IAAU,UAAA,UAAU;GAAmB,CAAA,IAAI;GACjF,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD,EAAA,UAAc,oBAA+B,CAAA;IAC7C,qBAAC,MAAD,EAAA,UAAA;KAAM;KACoB,oBAAC,QAAD,EAAA,UAAM,OAAU,CAAA;KAAC;KAEzC,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAAK,oBAAC,QAAD,EAAA,UAAM,uBAA0B,CAAA;KAAC;KAC7D,oBAAC,QAAD,EAAA,UAAM,kCAA+C,CAAA;KAAC;KAAgB;KACtE,oBAAC,QAAD,EAAA,UAAM,YAAe,CAAA;KAAC;KAAK,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAC9C,oBAAC,QAAD,EAAA,UAAM,kBAAqB,CAAA;KAAC;IAC5B,EAAA,CAAA;IACN,qBAAC,QAAD;KACE,QAAO;KACP,QAAQ,aAAa,QAAQ,KAAK;KAClC,WAAS,aAAa,QAAQ,KAAK;KACnC,aAAU;KACV,WAAQ;KALV,UAAA;MAOG,UAAU;MACX,oBAAC,YAAD;OACE,OAAM;OACN,MAAK;OACL,OAAM;OACN,SAAS,QAAQ,cAAc,OAAO,KAAA;OACtC,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACD,oBAAC,OAAD;OACE,OAAM;OACN,MAAK;OACL,MAAK;OACL,KAAI;OACJ,KAAI;OACJ,MAAK;OACL,OAAO,OAAO,QAAQ,aAAa;OACnC,UAAU,CAAC;OACX,MAAK;MACN,CAAA;MACA,UACC,oBAAC,aAAD,EAAA,UACE,oBAAC,QAAD;OAAQ,SAAQ;OAAU,MAAK;OAAS,MAAK;OAAQ,UAAA;MAE7C,CAAA,EACG,CAAA,IAEb,oBAAC,MAAD,EAAA,UAAM,0CAA6C,CAAA;KAEjD;;GACF,EAAA,CAAA;GACN,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD;KAAc,OAAO;KAAG,UAAA;IAAiC,CAAA;IACzD,oBAAC,MAAD;KAAM,IAAG;KACN,UAAA;;;;;;;;;;;;;IAaG,CAAA;IACN,qBAAC,MAAD,EAAA,UAAA;KAAM;KACuC,oBAAC,QAAD,EAAA,UAAM,aAAgB,CAAA;KAAC;KAElE,oBAAC,QAAD,EAAA,UAAM,UAAa,CAAA;KAAC;IAChB,EAAA,CAAA;GACF,EAAA,CAAA;EACH;;AAET"}
@@ -0,0 +1,90 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
5
+ import { Alert, Meta } from "../ui/feedback.mjs";
6
+ import { Field, FormActions } from "../ui/forms.mjs";
7
+ import { Card, SectionTitle } from "../ui/layout.mjs";
8
+ import { Table } from "../ui/table.mjs";
9
+ import { csrfField } from "../ui/csrf-field.mjs";
10
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
11
+ //#region src/pages/settings-tokens.tsx
12
+ /** Tokens settings tab: project CLI tokens plus the create-token form. */
13
+ function renderSettingsTokens(project, tokens, isAdmin, formState) {
14
+ return /* @__PURE__ */ jsxs("div", {
15
+ class: "grid max-w-form",
16
+ children: [
17
+ formState?.secret ? /* @__PURE__ */ jsxs(Alert, {
18
+ tone: "success",
19
+ title: "Token created",
20
+ children: ["Copy now — shown once: ", /* @__PURE__ */ jsx("code", { children: formState.secret })]
21
+ }) : null,
22
+ formState?.globalError ? /* @__PURE__ */ jsx(Alert, {
23
+ tone: "danger",
24
+ children: formState.globalError
25
+ }) : null,
26
+ /* @__PURE__ */ jsxs(Card, { children: [
27
+ /* @__PURE__ */ jsx(SectionTitle, { children: "API tokens" }),
28
+ /* @__PURE__ */ jsx(Meta, { children: "Tokens are used by the CLI to upload builds. They are scoped to this project." }),
29
+ /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
30
+ /* @__PURE__ */ jsx("th", { children: "Name" }),
31
+ /* @__PURE__ */ jsx("th", { children: "Created" }),
32
+ /* @__PURE__ */ jsx("th", { children: "Last used" }),
33
+ /* @__PURE__ */ jsx("th", { children: /* @__PURE__ */ jsx("span", {
34
+ class: "visually-hidden",
35
+ children: "Actions"
36
+ }) })
37
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: tokens.map((token) => /* @__PURE__ */ jsxs("tr", { children: [
38
+ /* @__PURE__ */ jsx("td", { children: token.name }),
39
+ /* @__PURE__ */ jsx("td", { children: new Date(token.createdAt).toLocaleDateString() }),
40
+ /* @__PURE__ */ jsx("td", { children: token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : "—" }),
41
+ /* @__PURE__ */ jsx("td", { children: isAdmin ? /* @__PURE__ */ jsxs("form", {
42
+ method: "post",
43
+ action: `/projects/${project.slug}/settings/tokens/${token.id}/delete`,
44
+ "hx-post": `/projects/${project.slug}/settings/tokens/${token.id}/delete`,
45
+ "hx-target": "body",
46
+ "hx-confirm": "Delete this token? CI jobs using it will stop working.",
47
+ children: [csrfField(), /* @__PURE__ */ jsx(Button, {
48
+ variant: "ghost",
49
+ type: "submit",
50
+ children: "Revoke"
51
+ })]
52
+ }) : null })
53
+ ] }, token.id)) })] }) }),
54
+ tokens.length === 0 ? /* @__PURE__ */ jsx(Meta, { children: "No tokens yet. Create one for CI." }) : null
55
+ ] }),
56
+ isAdmin ? /* @__PURE__ */ jsxs(Card, { children: [
57
+ /* @__PURE__ */ jsx(SectionTitle, {
58
+ level: 3,
59
+ children: "Create token"
60
+ }),
61
+ /* @__PURE__ */ jsxs("form", {
62
+ method: "post",
63
+ action: `/projects/${project.slug}/settings/tokens`,
64
+ "hx-post": `/projects/${project.slug}/settings/tokens`,
65
+ "hx-target": "body",
66
+ children: [
67
+ csrfField(),
68
+ /* @__PURE__ */ jsx(Field, {
69
+ label: "Name",
70
+ name: "tokenName",
71
+ required: true,
72
+ placeholder: "ci"
73
+ }),
74
+ /* @__PURE__ */ jsx(FormActions, { children: /* @__PURE__ */ jsx(Button, {
75
+ variant: "primary",
76
+ type: "submit",
77
+ icon: "plus",
78
+ children: "Create token"
79
+ }) })
80
+ ]
81
+ }),
82
+ /* @__PURE__ */ jsx(Meta, { children: "Token value is shown once after creation. Store it securely." })
83
+ ] }) : null
84
+ ]
85
+ });
86
+ }
87
+ //#endregion
88
+ export { renderSettingsTokens };
89
+
90
+ //# sourceMappingURL=settings-tokens.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-tokens.mjs","names":[],"sources":["../../src/pages/settings-tokens.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport type { Token } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport {\n Alert,\n Button,\n Card,\n Field,\n FormActions,\n Meta,\n SectionTitle,\n Table,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\n\n/** Tokens settings tab: project CLI tokens plus the create-token form. */\nexport function renderSettingsTokens(\n project: Project,\n tokens: Omit<Token, \"hash\">[],\n isAdmin: boolean,\n formState?: { globalError?: string; secret?: string },\n): unknown {\n return (\n <div class=\"grid max-w-form\">\n {formState?.secret ? (\n <Alert tone=\"success\" title=\"Token created\">\n Copy now — shown once: <code>{formState.secret}</code>\n </Alert>\n ) : null}\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n <Card>\n <SectionTitle>API tokens</SectionTitle>\n <Meta>Tokens are used by the CLI to upload builds. They are scoped to this project.</Meta>\n <Table>\n <table>\n <thead>\n <tr>\n <th>Name</th>\n <th>Created</th>\n <th>Last used</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {tokens.map((token): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <tr key={token.id}>\n <td>{token.name}</td>\n <td>{new Date(token.createdAt).toLocaleDateString()}</td>\n <td>\n {token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleDateString() : \"—\"}\n </td>\n <td>\n {isAdmin ? (\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/tokens/${token.id}/delete`}\n hx-post={`/projects/${project.slug}/settings/tokens/${token.id}/delete`}\n hx-target=\"body\"\n hx-confirm=\"Delete this token? CI jobs using it will stop working.\"\n >\n {csrfField()}\n <Button variant=\"ghost\" type=\"submit\">\n Revoke\n </Button>\n </form>\n ) : null}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </Table>\n {tokens.length === 0 ? <Meta>No tokens yet. Create one for CI.</Meta> : null}\n </Card>\n\n {isAdmin ? (\n <Card>\n <SectionTitle level={3}>Create token</SectionTitle>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/tokens`}\n hx-post={`/projects/${project.slug}/settings/tokens`}\n hx-target=\"body\"\n >\n {csrfField()}\n <Field label=\"Name\" name=\"tokenName\" required placeholder=\"ci\" />\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"plus\">\n Create token\n </Button>\n </FormActions>\n </form>\n <Meta>Token value is shown once after creation. Store it securely.</Meta>\n </Card>\n ) : null}\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAgBA,SAAgB,qBACd,SACA,QACA,SACA,WACS;CACT,OACE,qBAAC,OAAD;EAAK,OAAM;EAAX,UAAA;GACG,WAAW,SACV,qBAAC,OAAD;IAAO,MAAK;IAAU,OAAM;IAA5B,UAAA,CAA4C,2BACnB,oBAAC,QAAD,EAAA,UAAO,UAAU,OAAa,CAAA,CAChD;GACL,CAAA,IAAA;GACH,WAAW,cAAc,oBAAC,OAAD;IAAO,MAAK;IAAU,UAAA,UAAU;GAAmB,CAAA,IAAI;GACjF,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD,EAAA,UAAc,aAAwB,CAAA;IACtC,oBAAC,MAAD,EAAA,UAAM,gFAAmF,CAAA;IACzF,oBAAC,OAAD,EAAA,UACE,qBAAC,SAAD,EAAA,UAAA,CACE,oBAAC,SAAD,EAAA,UACE,qBAAC,MAAD,EAAA,UAAA;KACE,oBAAC,MAAD,EAAA,UAAI,OAAQ,CAAA;KACZ,oBAAC,MAAD,EAAA,UAAI,UAAW,CAAA;KACf,oBAAC,MAAD,EAAA,UAAI,YAAa,CAAA;KACjB,oBAAC,MAAD,EAAA,UACE,oBAAC,QAAD;MAAM,OAAM;MAAkB,UAAA;KAAa,CAAA,EACzC,CAAA;IACF,EAAA,CAAA,EACC,CAAA,GACP,oBAAC,SAAD,EAAA,UACG,OAAO,KAAK,UACX,qBAAC,MAAD,EAAA,UAAA;KACE,oBAAC,MAAD,EAAA,UAAK,MAAM,KAAS,CAAA;KACpB,oBAAC,MAAD,EAAA,UAAK,IAAI,KAAK,MAAM,SAAS,CAAC,CAAC,mBAAmB,EAAM,CAAA;KACxD,oBAAC,MAAD,EAAA,UACG,MAAM,aAAa,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,mBAAmB,IAAI,IACpE,CAAA;KACJ,oBAAC,MAAD,EAAA,UACG,UACC,qBAAC,QAAD;MACE,QAAO;MACP,QAAQ,aAAa,QAAQ,KAAK,mBAAmB,MAAM,GAAG;MAC9D,WAAS,aAAa,QAAQ,KAAK,mBAAmB,MAAM,GAAG;MAC/D,aAAU;MACV,cAAW;MALb,UAAA,CAOG,UAAU,GACX,oBAAC,QAAD;OAAQ,SAAQ;OAAQ,MAAK;OAAS,UAAA;MAE9B,CAAA,CACJ;KACJ,CAAA,IAAA,KACF,CAAA;IACF,EAAA,GAtBK,MAAM,EAsBX,CACL,EACI,CAAA,CACF,EAAA,CAAA,EACF,CAAA;IACN,OAAO,WAAW,IAAI,oBAAC,MAAD,EAAA,UAAM,oCAAuC,CAAA,IAAI;GACpE,EAAA,CAAA;GAEL,UACC,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD;KAAc,OAAO;KAAG,UAAA;IAA0B,CAAA;IAClD,qBAAC,QAAD;KACE,QAAO;KACP,QAAQ,aAAa,QAAQ,KAAK;KAClC,WAAS,aAAa,QAAQ,KAAK;KACnC,aAAU;KAJZ,UAAA;MAMG,UAAU;MACX,oBAAC,OAAD;OAAO,OAAM;OAAO,MAAK;OAAY,UAAA;OAAS,aAAY;MAAM,CAAA;MAChE,oBAAC,aAAD,EAAA,UACE,oBAAC,QAAD;OAAQ,SAAQ;OAAU,MAAK;OAAS,MAAK;OAAO,UAAA;MAE5C,CAAA,EACG,CAAA;KACT;;IACN,oBAAC,MAAD,EAAA,UAAM,+DAAkE,CAAA;GACpE,EAAA,CAAA,IACJ;EACD;;AAET"}
@@ -0,0 +1,104 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { Button } from "../ui/buttons.mjs";
5
+ import { Alert, Badge, Meta } from "../ui/feedback.mjs";
6
+ import { Field, FormActions } from "../ui/forms.mjs";
7
+ import { Card, SectionTitle } from "../ui/layout.mjs";
8
+ import { HStack } from "../ui/stacks.mjs";
9
+ import { Table } from "../ui/table.mjs";
10
+ import { csrfField } from "../ui/csrf-field.mjs";
11
+ import { jsx, jsxs } from "hono/jsx/jsx-runtime";
12
+ //#region src/pages/settings-webhooks.tsx
13
+ /** Webhooks settings tab: webhook list plus the create-webhook form. */
14
+ function renderSettingsWebhooks(project, webhooks, isAdmin, formState) {
15
+ return /* @__PURE__ */ jsxs("div", {
16
+ class: "grid max-w-form",
17
+ children: [
18
+ formState?.secret ? /* @__PURE__ */ jsxs(Alert, {
19
+ tone: "success",
20
+ title: "Webhook created",
21
+ children: ["Copy the signing secret now — shown once: ", /* @__PURE__ */ jsx("code", { children: formState.secret })]
22
+ }) : null,
23
+ formState?.globalError ? /* @__PURE__ */ jsx(Alert, {
24
+ tone: "danger",
25
+ children: formState.globalError
26
+ }) : null,
27
+ /* @__PURE__ */ jsxs(Card, { children: [
28
+ /* @__PURE__ */ jsx(SectionTitle, { children: "Webhooks" }),
29
+ /* @__PURE__ */ jsx(Meta, { children: "Notify external services when builds are created, updated, approved or rejected. Payloads are POSTed as JSON and signed with the webhook secret." }),
30
+ /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
31
+ /* @__PURE__ */ jsx("th", { children: "URL" }),
32
+ /* @__PURE__ */ jsx("th", { children: "Events" }),
33
+ /* @__PURE__ */ jsx("th", { children: /* @__PURE__ */ jsx("span", {
34
+ class: "visually-hidden",
35
+ children: "Actions"
36
+ }) })
37
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: webhooks.map((webhook) => /* @__PURE__ */ jsxs("tr", { children: [
38
+ /* @__PURE__ */ jsx("td", {
39
+ class: "truncate max-w-cell",
40
+ title: webhook.url,
41
+ children: webhook.url
42
+ }),
43
+ /* @__PURE__ */ jsx("td", { children: webhook.events.length === 0 ? /* @__PURE__ */ jsx(Meta, {
44
+ as: "span",
45
+ children: "all events"
46
+ }) : /* @__PURE__ */ jsx(HStack, { children: webhook.events.map((event) => /* @__PURE__ */ jsx(Badge, {
47
+ tone: "neutral",
48
+ children: event
49
+ })) }) }),
50
+ /* @__PURE__ */ jsx("td", { children: isAdmin ? /* @__PURE__ */ jsxs("form", {
51
+ method: "post",
52
+ action: `/projects/${project.slug}/settings/webhooks/${webhook.id}/delete`,
53
+ "hx-post": `/projects/${project.slug}/settings/webhooks/${webhook.id}/delete`,
54
+ "hx-target": "body",
55
+ "hx-confirm": "Delete this webhook?",
56
+ children: [csrfField(), /* @__PURE__ */ jsx(Button, {
57
+ variant: "ghost",
58
+ size: "sm",
59
+ type: "submit",
60
+ children: "Delete"
61
+ })]
62
+ }) : null })
63
+ ] }, webhook.id)) })] }) }),
64
+ webhooks.length === 0 ? /* @__PURE__ */ jsx(Meta, { children: "No webhooks configured." }) : null
65
+ ] }),
66
+ isAdmin ? /* @__PURE__ */ jsxs(Card, { children: [/* @__PURE__ */ jsx(SectionTitle, {
67
+ level: 3,
68
+ children: "Create webhook"
69
+ }), /* @__PURE__ */ jsxs("form", {
70
+ method: "post",
71
+ action: `/projects/${project.slug}/settings/webhooks`,
72
+ "hx-post": `/projects/${project.slug}/settings/webhooks`,
73
+ "hx-target": "body",
74
+ children: [
75
+ csrfField(),
76
+ /* @__PURE__ */ jsx(Field, {
77
+ label: "URL",
78
+ name: "url",
79
+ type: "url",
80
+ required: true,
81
+ placeholder: "https://example.com/hooks/storyshelf",
82
+ error: formState?.errors?.["url"]
83
+ }),
84
+ /* @__PURE__ */ jsx(Field, {
85
+ label: "Events",
86
+ name: "events",
87
+ placeholder: "build.created, build.approved, snapshot.reviewed",
88
+ hint: "Comma-separated. Leave blank to receive all events."
89
+ }),
90
+ /* @__PURE__ */ jsx(FormActions, { children: /* @__PURE__ */ jsx(Button, {
91
+ variant: "primary",
92
+ type: "submit",
93
+ icon: "plus",
94
+ children: "Add webhook"
95
+ }) })
96
+ ]
97
+ })] }) : null
98
+ ]
99
+ });
100
+ }
101
+ //#endregion
102
+ export { renderSettingsWebhooks };
103
+
104
+ //# sourceMappingURL=settings-webhooks.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settings-webhooks.mjs","names":[],"sources":["../../src/pages/settings-webhooks.tsx"],"sourcesContent":["import type { Project } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport {\n Alert,\n Badge,\n Button,\n Card,\n Field,\n FormActions,\n HStack,\n Meta,\n SectionTitle,\n Table,\n} from \"../ui/components.tsx\";\nimport { csrfField } from \"../ui/csrf-field.tsx\";\n\n/** Webhook row as rendered in the webhooks settings tab. */\nexport interface SettingsWebhook {\n id: string;\n url: string;\n events: string[];\n}\n\n/** Webhooks settings tab: webhook list plus the create-webhook form. */\nexport function renderSettingsWebhooks(\n project: Project,\n webhooks: SettingsWebhook[],\n isAdmin: boolean,\n formState?: { errors?: Record<string, string>; globalError?: string; secret?: string },\n): unknown {\n return (\n <div class=\"grid max-w-form\">\n {formState?.secret ? (\n <Alert tone=\"success\" title=\"Webhook created\">\n Copy the signing secret now — shown once: <code>{formState.secret}</code>\n </Alert>\n ) : null}\n {formState?.globalError ? <Alert tone=\"danger\">{formState.globalError}</Alert> : null}\n\n <Card>\n <SectionTitle>Webhooks</SectionTitle>\n <Meta>\n Notify external services when builds are created, updated, approved or rejected. Payloads\n are POSTed as JSON and signed with the webhook secret.\n </Meta>\n <Table>\n <table>\n <thead>\n <tr>\n <th>URL</th>\n <th>Events</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {webhooks.map((webhook): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <tr key={webhook.id}>\n <td class=\"truncate max-w-cell\" title={webhook.url}>\n {webhook.url}\n </td>\n <td>\n {webhook.events.length === 0 ? (\n <Meta as=\"span\">all events</Meta>\n ) : (\n <HStack>\n {webhook.events.map(\n (event): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <Badge tone=\"neutral\">{event}</Badge>\n ),\n )}\n </HStack>\n )}\n </td>\n <td>\n {isAdmin ? (\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/webhooks/${webhook.id}/delete`}\n hx-post={`/projects/${project.slug}/settings/webhooks/${webhook.id}/delete`}\n hx-target=\"body\"\n hx-confirm=\"Delete this webhook?\"\n >\n {csrfField()}\n <Button variant=\"ghost\" size=\"sm\" type=\"submit\">\n Delete\n </Button>\n </form>\n ) : null}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </Table>\n {webhooks.length === 0 ? <Meta>No webhooks configured.</Meta> : null}\n </Card>\n\n {isAdmin ? (\n <Card>\n <SectionTitle level={3}>Create webhook</SectionTitle>\n <form\n method=\"post\"\n action={`/projects/${project.slug}/settings/webhooks`}\n hx-post={`/projects/${project.slug}/settings/webhooks`}\n hx-target=\"body\"\n >\n {csrfField()}\n <Field\n label=\"URL\"\n name=\"url\"\n type=\"url\"\n required\n placeholder=\"https://example.com/hooks/storyshelf\"\n error={formState?.errors?.[\"url\"]}\n />\n <Field\n label=\"Events\"\n name=\"events\"\n placeholder=\"build.created, build.approved, snapshot.reviewed\"\n hint=\"Comma-separated. Leave blank to receive all events.\"\n />\n <FormActions>\n <Button variant=\"primary\" type=\"submit\" icon=\"plus\">\n Add webhook\n </Button>\n </FormActions>\n </form>\n </Card>\n ) : null}\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;;;AAwBA,SAAgB,uBACd,SACA,UACA,SACA,WACS;CACT,OACE,qBAAC,OAAD;EAAK,OAAM;EAAX,UAAA;GACG,WAAW,SACV,qBAAC,OAAD;IAAO,MAAK;IAAU,OAAM;IAA5B,UAAA,CAA8C,8CACF,oBAAC,QAAD,EAAA,UAAO,UAAU,OAAa,CAAA,CACnE;GACL,CAAA,IAAA;GACH,WAAW,cAAc,oBAAC,OAAD;IAAO,MAAK;IAAU,UAAA,UAAU;GAAmB,CAAA,IAAI;GAEjF,qBAAC,MAAD,EAAA,UAAA;IACE,oBAAC,cAAD,EAAA,UAAc,WAAsB,CAAA;IACpC,oBAAC,MAAD,EAAA,UAAM,mJAGA,CAAA;IACN,oBAAC,OAAD,EAAA,UACE,qBAAC,SAAD,EAAA,UAAA,CACE,oBAAC,SAAD,EAAA,UACE,qBAAC,MAAD,EAAA,UAAA;KACE,oBAAC,MAAD,EAAA,UAAI,MAAO,CAAA;KACX,oBAAC,MAAD,EAAA,UAAI,SAAU,CAAA;KACd,oBAAC,MAAD,EAAA,UACE,oBAAC,QAAD;MAAM,OAAM;MAAkB,UAAA;KAAa,CAAA,EACzC,CAAA;IACF,EAAA,CAAA,EACC,CAAA,GACP,oBAAC,SAAD,EAAA,UACG,SAAS,KAAK,YACb,qBAAC,MAAD,EAAA,UAAA;KACE,oBAAC,MAAD;MAAI,OAAM;MAAsB,OAAO,QAAQ;MAC5C,UAAA,QAAQ;KACP,CAAA;KACJ,oBAAC,MAAD,EAAA,UACG,QAAQ,OAAO,WAAW,IACzB,oBAAC,MAAD;MAAM,IAAG;MAAO,UAAA;KAAgB,CAAA,IAEhC,oBAAC,QAAD,EAAA,UACG,QAAQ,OAAO,KACb,UACC,oBAAC,OAAD;MAAO,MAAK;MAAW,UAAA;KAAa,CAAA,CAExC,EACM,CAAA,EAER,CAAA;KACJ,oBAAC,MAAD,EAAA,UACG,UACC,qBAAC,QAAD;MACE,QAAO;MACP,QAAQ,aAAa,QAAQ,KAAK,qBAAqB,QAAQ,GAAG;MAClE,WAAS,aAAa,QAAQ,KAAK,qBAAqB,QAAQ,GAAG;MACnE,aAAU;MACV,cAAW;MALb,UAAA,CAOG,UAAU,GACX,oBAAC,QAAD;OAAQ,SAAQ;OAAQ,MAAK;OAAK,MAAK;OAAS,UAAA;MAExC,CAAA,CACJ;KACJ,CAAA,IAAA,KACF,CAAA;IACF,EAAA,GAjCK,QAAQ,EAiCb,CACL,EACI,CAAA,CACF,EAAA,CAAA,EACF,CAAA;IACN,SAAS,WAAW,IAAI,oBAAC,MAAD,EAAA,UAAM,0BAA6B,CAAA,IAAI;GAC5D,EAAA,CAAA;GAEL,UACC,qBAAC,MAAD,EAAA,UAAA,CACE,oBAAC,cAAD;IAAc,OAAO;IAAG,UAAA;GAA4B,CAAA,GACpD,qBAAC,QAAD;IACE,QAAO;IACP,QAAQ,aAAa,QAAQ,KAAK;IAClC,WAAS,aAAa,QAAQ,KAAK;IACnC,aAAU;IAJZ,UAAA;KAMG,UAAU;KACX,oBAAC,OAAD;MACE,OAAM;MACN,MAAK;MACL,MAAK;MACL,UAAA;MACA,aAAY;MACZ,OAAO,WAAW,SAAS;KAC5B,CAAA;KACD,oBAAC,OAAD;MACE,OAAM;MACN,MAAK;MACL,aAAY;MACZ,MAAK;KACN,CAAA;KACD,oBAAC,aAAD,EAAA,UACE,oBAAC,QAAD;MAAQ,SAAQ;MAAU,MAAK;MAAS,MAAK;MAAO,UAAA;KAE5C,CAAA,EACG,CAAA;IACT;GACF,CAAA,CAAA,EAAA,CAAA,IACJ;EACD;;AAET"}
@@ -0,0 +1,116 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ //#region src/pages/storybook.tsx
6
+ /**
7
+ * Placeholder shown while a build's statics are not yet available.
8
+ * Refreshes itself; once extraction lands, the route serves the live page.
9
+ */
10
+ function renderStorybookPreparingPage(project, build, slug) {
11
+ const { ui } = getStore();
12
+ const name = ui.name ?? "StoryShelf";
13
+ return `<!doctype html>
14
+ <html lang="en" data-theme="system">
15
+ <head>
16
+ <meta charset="utf-8" />
17
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
18
+ <meta http-equiv="refresh" content="5" />
19
+ <title>${escapeHtml(project.name)} Storybook · ${name}</title>
20
+ <style>
21
+ html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }
22
+ header { display: flex; align-items: center; gap: .75rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); }
23
+ header a { color: inherit; text-decoration: none; }
24
+ header .crumb { opacity: .7; }
25
+ main { flex: 1; display: flex; align-items: center; justify-content: center; opacity: .7; }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <header>
30
+ <a href="/">${name}</a>
31
+ <span class="crumb">/</span>
32
+ <a href="/projects/${escapeAttr(slug)}/builds">${escapeHtml(project.name)}</a>
33
+ <span class="crumb">/</span>
34
+ <span>${escapeHtml(build.gitBranch)}</span>
35
+ <span class="crumb">· ${build.gitSha.slice(0, 7)}</span>
36
+ </header>
37
+ <main>
38
+ <p>Preparing preview — this page refreshes automatically. <a href="/projects/${escapeAttr(slug)}/builds/${escapeAttr(build.id)}">View build status</a></p>
39
+ </main>
40
+ </body>
41
+ </html>`;
42
+ }
43
+ /**
44
+ * Landing page for a published Storybook build. Presents the live Storybook
45
+ * (via an iframe against the same build's statics) with the project chrome, so
46
+ * the URL is shareable with designers/managers (ADR 0011).
47
+ */
48
+ function renderStorybookPage(project, build, slug, options) {
49
+ const { ui } = getStore();
50
+ const name = ui.name ?? "StoryShelf";
51
+ const iframeSrc = storybookIframeSrc(options?.storyId, options?.viewMode);
52
+ const copyUrl = storybookCopyUrl(slug, build.id, options?.storyId, options?.viewMode);
53
+ return `<!doctype html>
54
+ <html lang="en" data-theme="system">
55
+ <head>
56
+ <meta charset="utf-8" />
57
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
58
+ <title>${escapeHtml(project.name)} Storybook · ${name}</title>
59
+ <style>
60
+ html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }
61
+ header { display: flex; align-items: center; gap: .5rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); flex-wrap: wrap; }
62
+ header a, header button { color: inherit; text-decoration: none; }
63
+ header .crumb { opacity: .7; }
64
+ header .branch { font-weight: 600; }
65
+ header .spacer { flex: 1; }
66
+ header .actions { display: flex; gap: .5rem; align-items: center; }
67
+ header .btn-link { padding: .35rem .6rem; border: 1px solid var(--border, #ddd); border-radius: 6px; font-size: .85rem; background: var(--surface-card, #fff); cursor: pointer; }
68
+ iframe { flex: 1; border: 0; width: 100%; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <header>
73
+ <a href="/">${name}</a>
74
+ <span class="crumb">/</span>
75
+ <a href="/projects/${escapeAttr(slug)}/builds">${escapeHtml(project.name)}</a>
76
+ <span class="crumb">/</span>
77
+ <span class="branch">${escapeHtml(build.gitBranch)}</span>
78
+ <span class="crumb">· ${build.gitSha.slice(0, 7)}</span>
79
+ <span class="spacer"></span>
80
+ <span class="actions">
81
+ <a class="btn-link" href="./index.html" target="_blank" rel="noopener" title="Open full Storybook manager in new tab">View full Storybook ↗</a>
82
+ <button class="btn-link" type="button" data-copy-url="${escapeAttr(copyUrl)}" onclick="navigator.clipboard.writeText(location.origin+this.dataset.copyUrl).then(()=>{this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy link',1500)})">Copy link</button>
83
+ </span>
84
+ </header>
85
+ <iframe src="${escapeAttr(iframeSrc)}" title="${escapeAttr(project.name)} Storybook"></iframe>
86
+ </body>
87
+ </html>`;
88
+ }
89
+ function storybookIframeSrc(storyId, viewMode) {
90
+ const id = storyId?.trim() ?? "";
91
+ if (id !== "" && /^[a-zA-Z0-9._/-]+$/u.test(id)) return `./iframe.html?id=${encodeURIComponent(id)}${viewMode === "docs" ? "&viewMode=docs" : ""}`;
92
+ return "./iframe.html";
93
+ }
94
+ function storybookCopyUrl(slug, buildId, storyId, viewMode) {
95
+ const id = storyId?.trim() ?? "";
96
+ if (id !== "" && /^[a-zA-Z0-9._/-]+$/u.test(id)) return `/projects/${slug}/storybook/build/${buildId}/?storyId=${encodeURIComponent(id)}${viewMode === "docs" ? "&viewMode=docs" : ""}`;
97
+ return `/projects/${slug}/storybook/build/${buildId}/`;
98
+ }
99
+ function escapeHtml(value) {
100
+ return value.replaceAll(/[&<>"']/gu, (ch) => {
101
+ switch (ch) {
102
+ case "&": return "&amp;";
103
+ case "<": return "&lt;";
104
+ case ">": return "&gt;";
105
+ case "\"": return "&quot;";
106
+ default: return "&#39;";
107
+ }
108
+ });
109
+ }
110
+ function escapeAttr(value) {
111
+ return escapeHtml(value);
112
+ }
113
+ //#endregion
114
+ export { renderStorybookPage, renderStorybookPreparingPage };
115
+
116
+ //# sourceMappingURL=storybook.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storybook.mjs","names":[],"sources":["../../src/pages/storybook.tsx"],"sourcesContent":["import type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { getStore } from \"../store.ts\";\n\n/**\n * Placeholder shown while a build's statics are not yet available.\n * Refreshes itself; once extraction lands, the route serves the live page.\n */\nexport function renderStorybookPreparingPage(project: Project, build: Build, slug: string): string {\n const { ui } = getStore();\n const name = ui.name ?? \"StoryShelf\";\n return `<!doctype html>\n<html lang=\"en\" data-theme=\"system\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta http-equiv=\"refresh\" content=\"5\" />\n <title>${escapeHtml(project.name)} Storybook · ${name}</title>\n <style>\n html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }\n header { display: flex; align-items: center; gap: .75rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); }\n header a { color: inherit; text-decoration: none; }\n header .crumb { opacity: .7; }\n main { flex: 1; display: flex; align-items: center; justify-content: center; opacity: .7; }\n </style>\n </head>\n <body>\n <header>\n <a href=\"/\">${name}</a>\n <span class=\"crumb\">/</span>\n <a href=\"/projects/${escapeAttr(slug)}/builds\">${escapeHtml(project.name)}</a>\n <span class=\"crumb\">/</span>\n <span>${escapeHtml(build.gitBranch)}</span>\n <span class=\"crumb\">· ${build.gitSha.slice(0, 7)}</span>\n </header>\n <main>\n <p>Preparing preview — this page refreshes automatically. <a href=\"/projects/${escapeAttr(slug)}/builds/${escapeAttr(build.id)}\">View build status</a></p>\n </main>\n </body>\n</html>`;\n}\n/**\n * Landing page for a published Storybook build. Presents the live Storybook\n * (via an iframe against the same build's statics) with the project chrome, so\n * the URL is shareable with designers/managers (ADR 0011).\n */\nexport function renderStorybookPage(\n project: Project,\n build: Build,\n slug: string,\n options?: { storyId?: string; viewMode?: \"docs\" },\n): string {\n const { ui } = getStore();\n const name = ui.name ?? \"StoryShelf\";\n const iframeSrc = storybookIframeSrc(options?.storyId, options?.viewMode);\n const copyUrl = storybookCopyUrl(slug, build.id, options?.storyId, options?.viewMode);\n return `<!doctype html>\n<html lang=\"en\" data-theme=\"system\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>${escapeHtml(project.name)} Storybook · ${name}</title>\n <style>\n html, body { margin: 0; height: 100%; display: flex; flex-direction: column; font-family: system-ui, sans-serif; }\n header { display: flex; align-items: center; gap: .5rem; padding: .5rem 1rem; border-bottom: 1px solid var(--border, #ddd); flex-wrap: wrap; }\n header a, header button { color: inherit; text-decoration: none; }\n header .crumb { opacity: .7; }\n header .branch { font-weight: 600; }\n header .spacer { flex: 1; }\n header .actions { display: flex; gap: .5rem; align-items: center; }\n header .btn-link { padding: .35rem .6rem; border: 1px solid var(--border, #ddd); border-radius: 6px; font-size: .85rem; background: var(--surface-card, #fff); cursor: pointer; }\n iframe { flex: 1; border: 0; width: 100%; }\n </style>\n </head>\n <body>\n <header>\n <a href=\"/\">${name}</a>\n <span class=\"crumb\">/</span>\n <a href=\"/projects/${escapeAttr(slug)}/builds\">${escapeHtml(project.name)}</a>\n <span class=\"crumb\">/</span>\n <span class=\"branch\">${escapeHtml(build.gitBranch)}</span>\n <span class=\"crumb\">· ${build.gitSha.slice(0, 7)}</span>\n <span class=\"spacer\"></span>\n <span class=\"actions\">\n <a class=\"btn-link\" href=\"./index.html\" target=\"_blank\" rel=\"noopener\" title=\"Open full Storybook manager in new tab\">View full Storybook ↗</a>\n <button class=\"btn-link\" type=\"button\" data-copy-url=\"${escapeAttr(copyUrl)}\" onclick=\"navigator.clipboard.writeText(location.origin+this.dataset.copyUrl).then(()=>{this.textContent='Copied!'; setTimeout(()=>this.textContent='Copy link',1500)})\">Copy link</button>\n </span>\n </header>\n <iframe src=\"${escapeAttr(iframeSrc)}\" title=\"${escapeAttr(project.name)} Storybook\"></iframe>\n </body>\n</html>`;\n}\n\nfunction storybookIframeSrc(storyId?: string, viewMode?: \"docs\"): string {\n const id = storyId?.trim() ?? \"\";\n if (id !== \"\" && /^[a-zA-Z0-9._/-]+$/u.test(id)) {\n const mode = viewMode === \"docs\" ? \"&viewMode=docs\" : \"\";\n return `./iframe.html?id=${encodeURIComponent(id)}${mode}`;\n }\n return \"./iframe.html\";\n}\n\nfunction storybookCopyUrl(\n slug: string,\n buildId: string,\n storyId?: string,\n viewMode?: \"docs\",\n): string {\n const id = storyId?.trim() ?? \"\";\n if (id !== \"\" && /^[a-zA-Z0-9._/-]+$/u.test(id)) {\n const mode = viewMode === \"docs\" ? \"&viewMode=docs\" : \"\";\n return `/projects/${slug}/storybook/build/${buildId}/?storyId=${encodeURIComponent(id)}${mode}`;\n }\n return `/projects/${slug}/storybook/build/${buildId}/`;\n}\n\nfunction escapeHtml(value: string): string {\n return value.replaceAll(/[&<>\"']/gu, (ch) => {\n switch (ch) {\n case \"&\": {\n return \"&amp;\";\n }\n case \"<\": {\n return \"&lt;\";\n }\n case \">\": {\n return \"&gt;\";\n }\n case '\"': {\n return \"&quot;\";\n }\n default: {\n return \"&#39;\";\n }\n }\n });\n}\n\nfunction escapeAttr(value: string): string {\n return escapeHtml(value);\n}\n"],"mappings":";;;;;;;;;AAQA,SAAgB,6BAA6B,SAAkB,OAAc,MAAsB;CACjG,MAAM,EAAE,OAAO,SAAS;CACxB,MAAM,OAAO,GAAG,QAAQ;CACxB,OAAO;;;;;;aAMI,WAAW,QAAQ,IAAI,EAAE,eAAe,KAAK;;;;;;;;;;;oBAWtC,KAAK;;2BAEE,WAAW,IAAI,EAAE,WAAW,WAAW,QAAQ,IAAI,EAAE;;cAElE,WAAW,MAAM,SAAS,EAAE;8BACZ,MAAM,OAAO,MAAM,GAAG,CAAC,EAAE;;;qFAG8B,WAAW,IAAI,EAAE,UAAU,WAAW,MAAM,EAAE,EAAE;;;;AAIrI;;;;;;AAMA,SAAgB,oBACd,SACA,OACA,MACA,SACQ;CACR,MAAM,EAAE,OAAO,SAAS;CACxB,MAAM,OAAO,GAAG,QAAQ;CACxB,MAAM,YAAY,mBAAmB,SAAS,SAAS,SAAS,QAAQ;CACxE,MAAM,UAAU,iBAAiB,MAAM,MAAM,IAAI,SAAS,SAAS,SAAS,QAAQ;CACpF,OAAO;;;;;aAKI,WAAW,QAAQ,IAAI,EAAE,eAAe,KAAK;;;;;;;;;;;;;;;oBAetC,KAAK;;2BAEE,WAAW,IAAI,EAAE,WAAW,WAAW,QAAQ,IAAI,EAAE;;6BAEnD,WAAW,MAAM,SAAS,EAAE;8BAC3B,MAAM,OAAO,MAAM,GAAG,CAAC,EAAE;;;;gEAIS,WAAW,OAAO,EAAE;;;mBAGjE,WAAW,SAAS,EAAE,WAAW,WAAW,QAAQ,IAAI,EAAE;;;AAG7E;AAEA,SAAS,mBAAmB,SAAkB,UAA2B;CACvE,MAAM,KAAK,SAAS,KAAK,KAAK;CAC9B,IAAI,OAAO,MAAM,sBAAsB,KAAK,EAAE,GAE5C,OAAO,oBAAoB,mBAAmB,EAAE,IADnC,aAAa,SAAS,mBAAmB;CAGxD,OAAO;AACT;AAEA,SAAS,iBACP,MACA,SACA,SACA,UACQ;CACR,MAAM,KAAK,SAAS,KAAK,KAAK;CAC9B,IAAI,OAAO,MAAM,sBAAsB,KAAK,EAAE,GAE5C,OAAO,aAAa,KAAK,mBAAmB,QAAQ,YAAY,mBAAmB,EAAE,IADxE,aAAa,SAAS,mBAAmB;CAGxD,OAAO,aAAa,KAAK,mBAAmB,QAAQ;AACtD;AAEA,SAAS,WAAW,OAAuB;CACzC,OAAO,MAAM,WAAW,cAAc,OAAO;EAC3C,QAAQ,IAAR;GACE,KAAK,KACH,OAAO;GAET,KAAK,KACH,OAAO;GAET,KAAK,KACH,OAAO;GAET,KAAK,MACH,OAAO;GAET,SACE,OAAO;EAEX;CACF,CAAC;AACH;AAEA,SAAS,WAAW,OAAuB;CACzC,OAAO,WAAW,KAAK;AACzB"}
@@ -0,0 +1,65 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { ProjectModel } from "@storyshelf/core/models";
5
+ import { Retention } from "@storyshelf/core/retention";
6
+ //#region src/retention-timer.ts
7
+ const DEFAULT_BRANCH_TTL_DAYS = 30;
8
+ const DEFAULT_BRANCH_GC_INTERVAL_MS = 864e5;
9
+ /**
10
+ * Start a daily sweep that removes baselines for branches that have not
11
+ * been updated within `branchTtlDays`. Returns a timer handle or `null`
12
+ * when GC is disabled (`branchTtlDays: null` or non-positive interval).
13
+ *
14
+ * @param db - Database adapter to list projects
15
+ * @param storage - Storage adapter for baseline cleanup
16
+ * @param config - Shelf config supplying `branchTtlDays` and `branchGcIntervalMs`
17
+ * @param logger - Structured logger for sweep diagnostics
18
+ * @returns Timer handle with `stop()`, or `null` if GC is disabled
19
+ */
20
+ function startBranchGcTimer(db, storage, config, logger) {
21
+ const ttlDays = config.branchTtlDays ?? DEFAULT_BRANCH_TTL_DAYS;
22
+ if (ttlDays === null) return null;
23
+ const intervalMs = config.branchGcIntervalMs ?? DEFAULT_BRANCH_GC_INTERVAL_MS;
24
+ if (intervalMs <= 0) return null;
25
+ return scheduleBranchGc(createBranchGcRunner(db, storage, logger, ttlDays), intervalMs);
26
+ }
27
+ function createBranchGcRunner(db, storage, logger, ttlDays) {
28
+ return async () => {
29
+ try {
30
+ const projects = await new ProjectModel(db).list();
31
+ const retention = new Retention(db, storage, {
32
+ builds: db.tables.builds,
33
+ buildLabels: db.tables.buildLabels,
34
+ baselines: db.tables.baselines
35
+ }, logger);
36
+ const results = await Promise.all(projects.map((project) => retention.purgeStaleBranches(project, ttlDays)));
37
+ const totalBranches = results.reduce((sum, r) => sum + r.removedBranches, 0);
38
+ const totalBaselines = results.reduce((sum, r) => sum + r.removedBaselines, 0);
39
+ if (totalBranches > 0 || totalBaselines > 0) logger.info({
40
+ totalBranches,
41
+ totalBaselines
42
+ }, "branch GC daily sweep complete");
43
+ } catch (error) {
44
+ logger.error({ err: error }, "branch GC sweep failed");
45
+ }
46
+ };
47
+ }
48
+ function scheduleBranchGc(run, intervalMs) {
49
+ const timeout = setTimeout(() => {
50
+ run();
51
+ }, Math.min(36e5, intervalMs));
52
+ timeout.unref?.();
53
+ const interval = setInterval(() => {
54
+ run();
55
+ }, intervalMs);
56
+ interval.unref?.();
57
+ return { stop: () => {
58
+ clearTimeout(timeout);
59
+ clearInterval(interval);
60
+ } };
61
+ }
62
+ //#endregion
63
+ export { startBranchGcTimer };
64
+
65
+ //# sourceMappingURL=retention-timer.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retention-timer.mjs","names":[],"sources":["../src/retention-timer.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\nimport type { ShelfConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\n/* oxlint-disable typescript/promise-function-async -- interval helpers return promise factories */\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { Retention } from \"@storyshelf/core/retention\";\n\nconst DEFAULT_BRANCH_TTL_DAYS = 30;\nconst DEFAULT_BRANCH_GC_INTERVAL_MS = 86_400_000;\n\n/** Handle for the background branch-GC interval; call `stop()` on server shutdown. */\nexport interface BranchGcTimer {\n stop(): void;\n}\n\n/**\n * Start a daily sweep that removes baselines for branches that have not\n * been updated within `branchTtlDays`. Returns a timer handle or `null`\n * when GC is disabled (`branchTtlDays: null` or non-positive interval).\n *\n * @param db - Database adapter to list projects\n * @param storage - Storage adapter for baseline cleanup\n * @param config - Shelf config supplying `branchTtlDays` and `branchGcIntervalMs`\n * @param logger - Structured logger for sweep diagnostics\n * @returns Timer handle with `stop()`, or `null` if GC is disabled\n */\nexport function startBranchGcTimer(\n db: DatabaseAdapter,\n storage: StorageAdapter,\n config: ShelfConfig,\n logger: Logger,\n): BranchGcTimer | null {\n const ttlDays = config.branchTtlDays ?? DEFAULT_BRANCH_TTL_DAYS;\n if (ttlDays === null) {\n return null;\n }\n const intervalMs = config.branchGcIntervalMs ?? DEFAULT_BRANCH_GC_INTERVAL_MS;\n if (intervalMs <= 0) {\n return null;\n }\n const run = createBranchGcRunner(db, storage, logger, ttlDays);\n return scheduleBranchGc(run, intervalMs);\n}\n\n// oxlint-disable-next-line typescript/promise-function-async\nfunction createBranchGcRunner(\n db: DatabaseAdapter,\n storage: StorageAdapter,\n logger: Logger,\n ttlDays: number,\n): () => Promise<void> {\n return async (): Promise<void> => {\n try {\n const projects = await new ProjectModel(db).list();\n const retention = new Retention(\n db,\n storage,\n {\n builds: db.tables.builds,\n buildLabels: db.tables.buildLabels,\n baselines: db.tables.baselines,\n },\n logger,\n );\n const results = await Promise.all(\n projects.map((project) => retention.purgeStaleBranches(project, ttlDays)),\n );\n const totalBranches = results.reduce(\n (sum: number, r: { removedBranches: number }) => sum + r.removedBranches,\n 0,\n );\n const totalBaselines = results.reduce(\n (sum: number, r: { removedBaselines: number }) => sum + r.removedBaselines,\n 0,\n );\n if (totalBranches > 0 || totalBaselines > 0) {\n logger.info({ totalBranches, totalBaselines }, \"branch GC daily sweep complete\");\n }\n } catch (error) {\n logger.error({ err: error }, \"branch GC sweep failed\");\n }\n };\n}\n\nfunction scheduleBranchGc(run: () => Promise<void>, intervalMs: number): BranchGcTimer {\n const firstDelay = Math.min(3_600_000, intervalMs);\n const timeout = setTimeout(() => {\n // oxlint-disable-next-line no-void -- fire-and-forget interval task\n void run();\n }, firstDelay);\n timeout.unref?.();\n\n const interval = setInterval(() => {\n // oxlint-disable-next-line no-void -- fire-and-forget interval task\n void run();\n }, intervalMs);\n interval.unref?.();\n\n return {\n stop: () => {\n clearTimeout(timeout);\n clearInterval(interval);\n },\n };\n}\n"],"mappings":";;;;;;AAQA,MAAM,0BAA0B;AAChC,MAAM,gCAAgC;;;;;;;;;;;;AAkBtC,SAAgB,mBACd,IACA,SACA,QACA,QACsB;CACtB,MAAM,UAAU,OAAO,iBAAiB;CACxC,IAAI,YAAY,MACd,OAAO;CAET,MAAM,aAAa,OAAO,sBAAsB;CAChD,IAAI,cAAc,GAChB,OAAO;CAGT,OAAO,iBADK,qBAAqB,IAAI,SAAS,QAAQ,OAC5B,GAAG,UAAU;AACzC;AAGA,SAAS,qBACP,IACA,SACA,QACA,SACqB;CACrB,OAAO,YAA2B;EAChC,IAAI;GACF,MAAM,WAAW,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,KAAK;GACjD,MAAM,YAAY,IAAI,UACpB,IACA,SACA;IACE,QAAQ,GAAG,OAAO;IAClB,aAAa,GAAG,OAAO;IACvB,WAAW,GAAG,OAAO;GACvB,GACA,MACF;GACA,MAAM,UAAU,MAAM,QAAQ,IAC5B,SAAS,KAAK,YAAY,UAAU,mBAAmB,SAAS,OAAO,CAAC,CAC1E;GACA,MAAM,gBAAgB,QAAQ,QAC3B,KAAa,MAAmC,MAAM,EAAE,iBACzD,CACF;GACA,MAAM,iBAAiB,QAAQ,QAC5B,KAAa,MAAoC,MAAM,EAAE,kBAC1D,CACF;GACA,IAAI,gBAAgB,KAAK,iBAAiB,GACxC,OAAO,KAAK;IAAE;IAAe;GAAe,GAAG,gCAAgC;EAEnF,SAAS,OAAO;GACd,OAAO,MAAM,EAAE,KAAK,MAAM,GAAG,wBAAwB;EACvD;CACF;AACF;AAEA,SAAS,iBAAiB,KAA0B,YAAmC;CAErF,MAAM,UAAU,iBAAiB;EAE/B,IAAS;CACX,GAJmB,KAAK,IAAI,MAAW,UAI3B,CAAC;CACb,QAAQ,QAAQ;CAEhB,MAAM,WAAW,kBAAkB;EAEjC,IAAS;CACX,GAAG,UAAU;CACb,SAAS,QAAQ;CAEjB,OAAO,EACL,YAAY;EACV,aAAa,OAAO;EACpB,cAAc,QAAQ;CACxB,EACF;AACF"}
@@ -0,0 +1,48 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { loadCredentialProbe, reencryptWithCurrent } from "../credentials.mjs";
6
+ import { renderAdminSystemPage } from "../pages/admin-system.mjs";
7
+ import { hxRedirect } from "./htmx.mjs";
8
+ import { flash } from "./flash.mjs";
9
+ import { collectHealthReport } from "./health-report.mjs";
10
+ import { requireSiteAdmin } from "./helpers.mjs";
11
+ //#region src/routers/admin-pages.ts
12
+ /** Register the site-admin System page (adapter inventory + in-depth health). */
13
+ function registerAdminPages(app, deps, auth) {
14
+ app.get("/admin", async (c) => {
15
+ requireSiteAdmin(c);
16
+ const { authEnabled, config, db } = getStore();
17
+ const credentials = await loadCredentialProbe(db, config);
18
+ const report = await collectHealthReport(deps.sources, deps.getSettled(), deps.bootTimeMs, deps.version, credentials);
19
+ const authMethods = auth?.loginMethods().map((method) => method.label);
20
+ c.header("Cache-Control", "no-store");
21
+ return c.html(renderAdminSystemPage({
22
+ report,
23
+ authEnabled,
24
+ config,
25
+ authMethods,
26
+ credentials
27
+ }));
28
+ });
29
+ app.post("/admin/credentials/reencrypt", async (c) => {
30
+ requireSiteAdmin(c);
31
+ const { db, config } = getStore();
32
+ if (!config.previousSecret) {
33
+ flash(c, "No previous secret is configured", "warning");
34
+ return hxRedirect(c, "/admin");
35
+ }
36
+ const result = await reencryptWithCurrent(db, config);
37
+ flash(c, reencryptMessage(result), result.failed > 0 ? "warning" : "success");
38
+ return hxRedirect(c, "/admin");
39
+ });
40
+ }
41
+ function reencryptMessage(result) {
42
+ const base = `Re-encrypted ${result.reencrypted} credential(s)`;
43
+ return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;
44
+ }
45
+ //#endregion
46
+ export { registerAdminPages };
47
+
48
+ //# sourceMappingURL=admin-pages.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-pages.mjs","names":[],"sources":["../../src/routers/admin-pages.ts"],"sourcesContent":["import type { Auth } from \"@storyshelf/auth\";\nimport type { AdapterSetupResult, AdapterSetupSources } from \"@storyshelf/core/adapter/setup\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { loadCredentialProbe, reencryptWithCurrent } from \"../credentials.ts\";\nimport { renderAdminSystemPage } from \"../pages/admin-system.tsx\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { collectHealthReport } from \"./health-report.ts\";\nimport { requireSiteAdmin } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\n\n/** Dependencies for the admin System page (same probing inputs as deep health). */\nexport interface AdminPageDeps {\n sources: AdapterSetupSources;\n getSettled: () => AdapterSetupResult | null;\n bootTimeMs: number;\n version: string;\n}\n\n/** Register the site-admin System page (adapter inventory + in-depth health). */\nexport function registerAdminPages(app: ShelfRouter, deps: AdminPageDeps, auth?: Auth): void {\n app.get(\"/admin\", async (c) => {\n requireSiteAdmin(c);\n const { authEnabled, config, db } = getStore();\n const credentials = await loadCredentialProbe(db, config);\n const report = await collectHealthReport(\n deps.sources,\n deps.getSettled(),\n deps.bootTimeMs,\n deps.version,\n credentials,\n );\n const authMethods = auth?.loginMethods().map((method) => method.label);\n c.header(\"Cache-Control\", \"no-store\");\n return c.html(renderAdminSystemPage({ report, authEnabled, config, authMethods, credentials }));\n });\n\n app.post(\"/admin/credentials/reencrypt\", async (c) => {\n requireSiteAdmin(c);\n const { db, config } = getStore();\n if (!config.previousSecret) {\n flash(c, \"No previous secret is configured\", \"warning\");\n return hxRedirect(c, \"/admin\");\n }\n const result = await reencryptWithCurrent(db, config);\n flash(c, reencryptMessage(result), result.failed > 0 ? \"warning\" : \"success\");\n return hxRedirect(c, \"/admin\");\n });\n}\n\nfunction reencryptMessage(result: { reencrypted: number; failed: number }): string {\n const base = `Re-encrypted ${result.reencrypted} credential(s)`;\n return result.failed > 0 ? `${base}; ${result.failed} failed — see server logs` : base;\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,SAAgB,mBAAmB,KAAkB,MAAqB,MAAmB;CAC3F,IAAI,IAAI,UAAU,OAAO,MAAM;EAC7B,iBAAiB,CAAC;EAClB,MAAM,EAAE,aAAa,QAAQ,OAAO,SAAS;EAC7C,MAAM,cAAc,MAAM,oBAAoB,IAAI,MAAM;EACxD,MAAM,SAAS,MAAM,oBACnB,KAAK,SACL,KAAK,WAAW,GAChB,KAAK,YACL,KAAK,SACL,WACF;EACA,MAAM,cAAc,MAAM,aAAa,CAAC,CAAC,KAAK,WAAW,OAAO,KAAK;EACrE,EAAE,OAAO,iBAAiB,UAAU;EACpC,OAAO,EAAE,KAAK,sBAAsB;GAAE;GAAQ;GAAa;GAAQ;GAAa;EAAY,CAAC,CAAC;CAChG,CAAC;CAED,IAAI,KAAK,gCAAgC,OAAO,MAAM;EACpD,iBAAiB,CAAC;EAClB,MAAM,EAAE,IAAI,WAAW,SAAS;EAChC,IAAI,CAAC,OAAO,gBAAgB;GAC1B,MAAM,GAAG,oCAAoC,SAAS;GACtD,OAAO,WAAW,GAAG,QAAQ;EAC/B;EACA,MAAM,SAAS,MAAM,qBAAqB,IAAI,MAAM;EACpD,MAAM,GAAG,iBAAiB,MAAM,GAAG,OAAO,SAAS,IAAI,YAAY,SAAS;EAC5E,OAAO,WAAW,GAAG,QAAQ;CAC/B,CAAC;AACH;AAEA,SAAS,iBAAiB,QAAyD;CACjF,MAAM,OAAO,gBAAgB,OAAO,YAAY;CAChD,OAAO,OAAO,SAAS,IAAI,GAAG,KAAK,IAAI,OAAO,OAAO,6BAA6B;AACpF"}