@storyshelf/app 0.6.1 → 0.7.0
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.
- package/README.md +42 -4
- package/dist/app-types.d.mts +1 -3
- package/dist/asset-manifest.mjs +13 -5
- package/dist/asset-manifest.mjs.map +1 -1
- package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
- package/dist/assets/htmx.mjs.map +1 -0
- package/dist/assets/mark.mjs +10 -0
- package/dist/assets/mark.mjs.map +1 -0
- package/dist/boot-tasks.mjs +80 -0
- package/dist/boot-tasks.mjs.map +1 -0
- package/dist/build-state-flags.mjs +18 -0
- package/dist/build-state-flags.mjs.map +1 -0
- package/dist/build-state-setup.mjs +75 -0
- package/dist/build-state-setup.mjs.map +1 -0
- package/dist/capture-alerts.mjs +46 -0
- package/dist/capture-alerts.mjs.map +1 -0
- package/dist/capture-process.mjs +59 -0
- package/dist/capture-process.mjs.map +1 -0
- package/dist/capture-setup.mjs +95 -117
- package/dist/capture-setup.mjs.map +1 -1
- package/dist/connections.mjs +34 -0
- package/dist/connections.mjs.map +1 -0
- package/dist/consumer-setup.mjs +58 -0
- package/dist/consumer-setup.mjs.map +1 -0
- package/dist/credentials.mjs +3 -2
- package/dist/credentials.mjs.map +1 -1
- package/dist/git-status.mjs +46 -0
- package/dist/git-status.mjs.map +1 -0
- package/dist/hidden-data.mjs +24 -0
- package/dist/hidden-data.mjs.map +1 -0
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +202 -124
- package/dist/index.mjs.map +1 -1
- package/dist/insights/budget.mjs +49 -0
- package/dist/insights/budget.mjs.map +1 -0
- package/dist/insights/deps.mjs +21 -0
- package/dist/insights/deps.mjs.map +1 -0
- package/dist/insights/errors.mjs +22 -0
- package/dist/insights/errors.mjs.map +1 -0
- package/dist/insights/health-panel.mjs +40 -0
- package/dist/insights/health-panel.mjs.map +1 -0
- package/dist/insights/job.mjs +103 -0
- package/dist/insights/job.mjs.map +1 -0
- package/dist/insights/load-health.mjs +59 -0
- package/dist/insights/load-health.mjs.map +1 -0
- package/dist/insights/load-triage.mjs +98 -0
- package/dist/insights/load-triage.mjs.map +1 -0
- package/dist/insights/panel.mjs +28 -0
- package/dist/insights/panel.mjs.map +1 -0
- package/dist/insights/request.mjs +113 -0
- package/dist/insights/request.mjs.map +1 -0
- package/dist/insights/stale.mjs +21 -0
- package/dist/insights/stale.mjs.map +1 -0
- package/dist/insights/usage-report.mjs +60 -0
- package/dist/insights/usage-report.mjs.map +1 -0
- package/dist/insights/view.mjs +48 -0
- package/dist/insights/view.mjs.map +1 -0
- package/dist/job-canceller.mjs +14 -0
- package/dist/job-canceller.mjs.map +1 -0
- package/dist/job-queue.mjs +51 -0
- package/dist/job-queue.mjs.map +1 -0
- package/dist/jobs-setup.mjs +132 -0
- package/dist/jobs-setup.mjs.map +1 -0
- package/dist/lifecycle.mjs +109 -19
- package/dist/lifecycle.mjs.map +1 -1
- package/dist/middleware/auth-gate.mjs +4 -2
- package/dist/middleware/auth-gate.mjs.map +1 -1
- package/dist/middleware/rate-limit.mjs +41 -33
- package/dist/middleware/rate-limit.mjs.map +1 -1
- package/dist/middleware/request-log.mjs +3 -2
- package/dist/middleware/request-log.mjs.map +1 -1
- package/dist/middleware/require-review.mjs +51 -0
- package/dist/middleware/require-review.mjs.map +1 -0
- package/dist/middleware/require-testing.mjs +41 -0
- package/dist/middleware/require-testing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs.map +1 -1
- package/dist/middleware/store-scope.mjs +12 -2
- package/dist/middleware/store-scope.mjs.map +1 -1
- package/dist/notifications-pause.mjs +33 -0
- package/dist/notifications-pause.mjs.map +1 -0
- package/dist/notify.d.mts +2 -1
- package/dist/notify.mjs +78 -39
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/a11y-violations.mjs +116 -0
- package/dist/pages/a11y-violations.mjs.map +1 -0
- package/dist/pages/admin-ai.mjs +78 -0
- package/dist/pages/admin-ai.mjs.map +1 -0
- package/dist/pages/admin-system-origin.mjs +123 -0
- package/dist/pages/admin-system-origin.mjs.map +1 -0
- package/dist/pages/admin-system-targets.mjs +53 -0
- package/dist/pages/admin-system-targets.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +63 -13
- package/dist/pages/admin-system.mjs.map +1 -1
- package/dist/pages/build-detail.mjs +104 -76
- package/dist/pages/build-detail.mjs.map +1 -1
- package/dist/pages/build-diff-actions.mjs +87 -33
- package/dist/pages/build-diff-actions.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +16 -14
- package/dist/pages/build-diff-comments.mjs.map +1 -1
- package/dist/pages/build-diff-environment.mjs +82 -0
- package/dist/pages/build-diff-environment.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +22 -4
- package/dist/pages/build-diff-header.mjs.map +1 -1
- package/dist/pages/build-diff-insight.mjs +113 -0
- package/dist/pages/build-diff-insight.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +55 -10
- package/dist/pages/build-diff-model.mjs.map +1 -1
- package/dist/pages/build-diff-nav.mjs +28 -12
- package/dist/pages/build-diff-nav.mjs.map +1 -1
- package/dist/pages/build-diff-shortcuts.mjs +5 -1
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
- package/dist/pages/build-diff-usage.mjs +63 -0
- package/dist/pages/build-diff-usage.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +62 -5
- package/dist/pages/build-diff-viewer.mjs.map +1 -1
- package/dist/pages/build-diff.mjs +34 -13
- package/dist/pages/build-diff.mjs.map +1 -1
- package/dist/pages/build-tests.mjs +74 -0
- package/dist/pages/build-tests.mjs.map +1 -0
- package/dist/pages/component-detail.mjs +249 -0
- package/dist/pages/component-detail.mjs.map +1 -0
- package/dist/pages/component-stories.mjs +168 -0
- package/dist/pages/component-stories.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +13 -26
- package/dist/pages/compute-jobs.mjs.map +1 -1
- package/dist/pages/design-links.mjs +182 -0
- package/dist/pages/design-links.mjs.map +1 -0
- package/dist/pages/environment-change.mjs +53 -0
- package/dist/pages/environment-change.mjs.map +1 -0
- package/dist/pages/health-panel.mjs +126 -0
- package/dist/pages/health-panel.mjs.map +1 -0
- package/dist/pages/library-grid.mjs +105 -0
- package/dist/pages/library-grid.mjs.map +1 -0
- package/dist/pages/library-model.mjs +47 -0
- package/dist/pages/library-model.mjs.map +1 -0
- package/dist/pages/library-snapshots.mjs +130 -0
- package/dist/pages/library-snapshots.mjs.map +1 -0
- package/dist/pages/library.mjs +112 -218
- package/dist/pages/library.mjs.map +1 -1
- package/dist/pages/notifications-paused.mjs +22 -0
- package/dist/pages/notifications-paused.mjs.map +1 -0
- package/dist/pages/profile.mjs +53 -48
- package/dist/pages/profile.mjs.map +1 -1
- package/dist/pages/project-builds-table.mjs +2 -2
- package/dist/pages/project-builds-table.mjs.map +1 -1
- package/dist/pages/project-builds.mjs +51 -21
- package/dist/pages/project-builds.mjs.map +1 -1
- package/dist/pages/project-settings.mjs +51 -12
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/projects-card.mjs +14 -1
- package/dist/pages/projects-card.mjs.map +1 -1
- package/dist/pages/projects.mjs +6 -2
- package/dist/pages/projects.mjs.map +1 -1
- package/dist/pages/review-changeset.mjs +116 -0
- package/dist/pages/review-changeset.mjs.map +1 -0
- package/dist/pages/review-comments.mjs +103 -0
- package/dist/pages/review-comments.mjs.map +1 -0
- package/dist/pages/review-verdict.mjs +102 -0
- package/dist/pages/review-verdict.mjs.map +1 -0
- package/dist/pages/settings-ai.mjs +69 -0
- package/dist/pages/settings-ai.mjs.map +1 -0
- package/dist/pages/settings-capture.mjs +124 -0
- package/dist/pages/settings-capture.mjs.map +1 -0
- package/dist/pages/settings-design-credential.mjs +99 -0
- package/dist/pages/settings-design-credential.mjs.map +1 -0
- package/dist/pages/settings-design-links.mjs +179 -0
- package/dist/pages/settings-design-links.mjs.map +1 -0
- package/dist/pages/settings-design.mjs +48 -0
- package/dist/pages/settings-design.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +46 -21
- package/dist/pages/settings-general.mjs.map +1 -1
- package/dist/pages/settings-labels.mjs +43 -2
- package/dist/pages/settings-labels.mjs.map +1 -1
- package/dist/pages/settings-links.mjs +112 -0
- package/dist/pages/settings-links.mjs.map +1 -0
- package/dist/pages/settings-notifications-git.mjs +128 -0
- package/dist/pages/settings-notifications-git.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +79 -42
- package/dist/pages/settings-notifications.mjs.map +1 -1
- package/dist/pages/settings-tests.mjs +33 -2
- package/dist/pages/settings-tests.mjs.map +1 -1
- package/dist/pages/target-choices.mjs +39 -0
- package/dist/pages/target-choices.mjs.map +1 -0
- package/dist/publish-setup.mjs +89 -0
- package/dist/publish-setup.mjs.map +1 -0
- package/dist/retention-timer.mjs +2 -1
- package/dist/retention-timer.mjs.map +1 -1
- package/dist/review-events.mjs +48 -0
- package/dist/review-events.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +45 -3
- package/dist/routers/admin-pages.mjs.map +1 -1
- package/dist/routers/admin-public-origin.mjs +85 -0
- package/dist/routers/admin-public-origin.mjs.map +1 -0
- package/dist/routers/assets.mjs +9 -2
- package/dist/routers/assets.mjs.map +1 -1
- package/dist/routers/auth.mjs +2 -2
- package/dist/routers/auth.mjs.map +1 -1
- package/dist/routers/baseline-guard.mjs +2 -2
- package/dist/routers/baseline-guard.mjs.map +1 -1
- package/dist/routers/baselines.handlers.mjs +64 -0
- package/dist/routers/baselines.handlers.mjs.map +1 -0
- package/dist/routers/baselines.mjs +125 -0
- package/dist/routers/baselines.mjs.map +1 -0
- package/dist/routers/build-cancel.mjs +68 -0
- package/dist/routers/build-cancel.mjs.map +1 -0
- package/dist/routers/build-changeset.mjs +70 -0
- package/dist/routers/build-changeset.mjs.map +1 -0
- package/dist/routers/build-reviews.handlers.mjs +86 -0
- package/dist/routers/build-reviews.handlers.mjs.map +1 -0
- package/dist/routers/build-reviews.mjs +74 -0
- package/dist/routers/build-reviews.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +46 -55
- package/dist/routers/builds.handlers.mjs.map +1 -1
- package/dist/routers/builds.mjs +71 -17
- package/dist/routers/builds.mjs.map +1 -1
- package/dist/routers/capture-config.mjs +35 -0
- package/dist/routers/capture-config.mjs.map +1 -0
- package/dist/routers/comments.mjs +41 -15
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/component-catalog.handlers.mjs +71 -0
- package/dist/routers/component-catalog.handlers.mjs.map +1 -0
- package/dist/routers/component.handlers.mjs +121 -0
- package/dist/routers/component.handlers.mjs.map +1 -0
- package/dist/routers/connections.mjs +118 -0
- package/dist/routers/connections.mjs.map +1 -0
- package/dist/routers/design-connect.handlers.mjs +44 -0
- package/dist/routers/design-connect.handlers.mjs.map +1 -0
- package/dist/routers/design.handlers.mjs +114 -0
- package/dist/routers/design.handlers.mjs.map +1 -0
- package/dist/routers/health-report.mjs +2 -3
- package/dist/routers/health-report.mjs.map +1 -1
- package/dist/routers/health.mjs +4 -3
- package/dist/routers/health.mjs.map +1 -1
- package/dist/routers/helpers.mjs +19 -3
- package/dist/routers/helpers.mjs.map +1 -1
- package/dist/routers/insights-ui.mjs +64 -0
- package/dist/routers/insights-ui.mjs.map +1 -0
- package/dist/routers/insights.handlers.mjs +45 -0
- package/dist/routers/insights.handlers.mjs.map +1 -0
- package/dist/routers/insights.mjs +189 -0
- package/dist/routers/insights.mjs.map +1 -0
- package/dist/routers/links.handlers.mjs +80 -0
- package/dist/routers/links.handlers.mjs.map +1 -0
- package/dist/routers/links.mjs +139 -0
- package/dist/routers/links.mjs.map +1 -0
- package/dist/routers/media.mjs +18 -4
- package/dist/routers/media.mjs.map +1 -1
- package/dist/routers/members.mjs +2 -1
- package/dist/routers/members.mjs.map +1 -1
- package/dist/routers/notification-channels.mjs +17 -23
- package/dist/routers/notification-channels.mjs.map +1 -1
- package/dist/routers/oauth-state.mjs +45 -0
- package/dist/routers/oauth-state.mjs.map +1 -0
- package/dist/routers/preview-base.mjs +38 -0
- package/dist/routers/preview-base.mjs.map +1 -0
- package/dist/routers/preview-token.mjs +51 -0
- package/dist/routers/preview-token.mjs.map +1 -0
- package/dist/routers/profile.mjs +4 -2
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/projects.mjs +17 -1
- package/dist/routers/projects.mjs.map +1 -1
- package/dist/routers/publish-upload.mjs +78 -0
- package/dist/routers/publish-upload.mjs.map +1 -0
- package/dist/routers/published-access.mjs +52 -0
- package/dist/routers/published-access.mjs.map +1 -0
- package/dist/routers/published-files.mjs +52 -0
- package/dist/routers/published-files.mjs.map +1 -0
- package/dist/routers/review-forms.mjs +72 -0
- package/dist/routers/review-forms.mjs.map +1 -0
- package/dist/routers/review-section.handlers.mjs +85 -0
- package/dist/routers/review-section.handlers.mjs.map +1 -0
- package/dist/routers/review-switch-off.mjs +29 -0
- package/dist/routers/review-switch-off.mjs.map +1 -0
- package/dist/routers/schemas.mjs +140 -22
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/scoped-models.mjs +22 -0
- package/dist/routers/scoped-models.mjs.map +1 -0
- package/dist/routers/settings-ai.mjs +35 -0
- package/dist/routers/settings-ai.mjs.map +1 -0
- package/dist/routers/settings-capture.mjs +77 -0
- package/dist/routers/settings-capture.mjs.map +1 -0
- package/dist/routers/settings-design.mjs +103 -0
- package/dist/routers/settings-design.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +55 -10
- package/dist/routers/settings-general.mjs.map +1 -1
- package/dist/routers/settings-git-channel.mjs +77 -0
- package/dist/routers/settings-git-channel.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +27 -1
- package/dist/routers/settings-labels.mjs.map +1 -1
- package/dist/routers/settings-links.mjs +63 -0
- package/dist/routers/settings-links.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +4 -7
- package/dist/routers/settings-members.mjs.map +1 -1
- package/dist/routers/settings-notifications.mjs +15 -10
- package/dist/routers/settings-notifications.mjs.map +1 -1
- package/dist/routers/settings-tokens.mjs +2 -5
- package/dist/routers/settings-tokens.mjs.map +1 -1
- package/dist/routers/settings-webhooks.mjs +7 -24
- package/dist/routers/settings-webhooks.mjs.map +1 -1
- package/dist/routers/settings.handlers.mjs +107 -26
- package/dist/routers/settings.handlers.mjs.map +1 -1
- package/dist/routers/settings.mjs +16 -4
- package/dist/routers/settings.mjs.map +1 -1
- package/dist/routers/signed-storybook.mjs +53 -0
- package/dist/routers/signed-storybook.mjs.map +1 -0
- package/dist/routers/snapshot-groups.mjs +63 -0
- package/dist/routers/snapshot-groups.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +103 -5
- package/dist/routers/snapshots.mjs.map +1 -1
- package/dist/routers/storybook.mjs +37 -88
- package/dist/routers/storybook.mjs.map +1 -1
- package/dist/routers/tokens.mjs +2 -5
- package/dist/routers/tokens.mjs.map +1 -1
- package/dist/routers/ui.mjs +34 -5
- package/dist/routers/ui.mjs.map +1 -1
- package/dist/routers/webhooks.mjs +5 -11
- package/dist/routers/webhooks.mjs.map +1 -1
- package/dist/routers/well-known.mjs +11 -4
- package/dist/routers/well-known.mjs.map +1 -1
- package/dist/routes/api-routes.mjs +93 -0
- package/dist/routes/api-routes.mjs.map +1 -0
- package/dist/routes/classification.mjs +38 -0
- package/dist/routes/classification.mjs.map +1 -0
- package/dist/routes/openapi-classes.mjs +22 -0
- package/dist/routes/openapi-classes.mjs.map +1 -0
- package/dist/routes/page-routes.mjs +104 -0
- package/dist/routes/page-routes.mjs.map +1 -0
- package/dist/runtime.mjs +18 -13
- package/dist/runtime.mjs.map +1 -1
- package/dist/serverless.mjs +5 -0
- package/dist/store.mjs +19 -1
- package/dist/store.mjs.map +1 -1
- package/dist/targets.mjs +46 -0
- package/dist/targets.mjs.map +1 -0
- package/dist/ui/buttons.mjs +16 -3
- package/dist/ui/buttons.mjs.map +1 -1
- package/dist/ui/design-reference.mjs +97 -0
- package/dist/ui/design-reference.mjs.map +1 -0
- package/dist/ui/document.mjs +1 -2
- package/dist/ui/document.mjs.map +1 -1
- package/dist/ui/forms.mjs +6 -5
- package/dist/ui/forms.mjs.map +1 -1
- package/dist/ui/icons/paths.mjs +1 -0
- package/dist/ui/icons/paths.mjs.map +1 -1
- package/dist/ui/preview-embed.mjs +82 -0
- package/dist/ui/preview-embed.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +85 -0
- package/dist/ui/shell/client-script.mjs.map +1 -1
- package/dist/ui/shell/nav.mjs +12 -8
- package/dist/ui/shell/nav.mjs.map +1 -1
- package/dist/ui/shell/review-script.mjs +8 -0
- package/dist/ui/shell/review-script.mjs.map +1 -1
- package/dist/ui/shell/sidebar.mjs +2 -2
- package/dist/ui/shell/sidebar.mjs.map +1 -1
- package/package.json +11 -6
- package/dist/asset-paths.mjs +0 -23
- package/dist/asset-paths.mjs.map +0 -1
- package/dist/assets/mark.svg +0 -1
- package/dist/pages/settings-status.mjs +0 -137
- package/dist/pages/settings-status.mjs.map +0 -1
- package/dist/routers/settings-status.mjs +0 -77
- package/dist/routers/settings-status.mjs.map +0 -1
- package/dist/routers/status-configs.mjs +0 -108
- package/dist/routers/status-configs.mjs.map +0 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { credentialKeys } from "../credential-keys.mjs";
|
|
5
|
+
import { getStore } from "../store.mjs";
|
|
6
|
+
import { notFound, resolveAuthorizedProject } from "./helpers.mjs";
|
|
7
|
+
import { hxRedirect } from "./htmx.mjs";
|
|
8
|
+
import { flash } from "./flash.mjs";
|
|
9
|
+
import { asString, renderSettingsPage } from "./settings.handlers.mjs";
|
|
10
|
+
import { DesignCredentialModel, DesignModel } from "@storyshelf/core/models";
|
|
11
|
+
import { InvalidDesignUrlError } from "@storyshelf/core/adapter/design";
|
|
12
|
+
//#region src/routers/settings-design.ts
|
|
13
|
+
/** Design settings (project design files, component mapping, provider credentials). */
|
|
14
|
+
function registerDesignSettings(app) {
|
|
15
|
+
const base = "/projects/:slug/settings/design";
|
|
16
|
+
app.get(base, async (c) => c.html(await renderSettingsPage(c, "design")));
|
|
17
|
+
app.post(`${base}/files`, handleAddFile);
|
|
18
|
+
app.post(`${base}/files/:id/delete`, handleRemoveLink);
|
|
19
|
+
app.post(`${base}/components`, handleMapComponent);
|
|
20
|
+
app.post(`${base}/components/:id/delete`, handleRemoveLink);
|
|
21
|
+
app.post(`${base}/:provider/token`, handleSetToken);
|
|
22
|
+
app.post(`${base}/:provider/token/delete`, handleRemoveToken);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Every design-settings mutation writes credentials or mappings: require a
|
|
26
|
+
* project admin server-side (the forms are only hidden from others, never trusted).
|
|
27
|
+
* With auth disabled the app has no roles and everyone may administer, as elsewhere.
|
|
28
|
+
*/
|
|
29
|
+
async function adminProject(c) {
|
|
30
|
+
return await resolveAuthorizedProject(c, c.req.param("slug") ?? "", "admin");
|
|
31
|
+
}
|
|
32
|
+
function designModel() {
|
|
33
|
+
const { db, design } = getStore();
|
|
34
|
+
return new DesignModel(db, design);
|
|
35
|
+
}
|
|
36
|
+
function credentialModel() {
|
|
37
|
+
const { db, config } = getStore();
|
|
38
|
+
return new DesignCredentialModel(db, credentialKeys(config));
|
|
39
|
+
}
|
|
40
|
+
/** The configured provider addressed by `:provider`, or a 404. */
|
|
41
|
+
function providerOf(c) {
|
|
42
|
+
const provider = getStore().design.find((candidate) => candidate.metadata.kind === c.req.param("provider"));
|
|
43
|
+
if (!provider) notFound("Unknown design provider");
|
|
44
|
+
return provider;
|
|
45
|
+
}
|
|
46
|
+
async function reject(c, errors) {
|
|
47
|
+
return c.html(await renderSettingsPage(c, "design", { errors }) ?? "", 400);
|
|
48
|
+
}
|
|
49
|
+
function done(c, slug, message) {
|
|
50
|
+
flash(c, message);
|
|
51
|
+
return hxRedirect(c, `/projects/${slug}/settings/design`);
|
|
52
|
+
}
|
|
53
|
+
async function handleAddFile(c) {
|
|
54
|
+
const project = await adminProject(c);
|
|
55
|
+
const form = await c.req.formData();
|
|
56
|
+
const label = asString(form.get("label")) ?? "";
|
|
57
|
+
try {
|
|
58
|
+
await designModel().addProjectLink(project.id, {
|
|
59
|
+
label,
|
|
60
|
+
url: asString(form.get("url")) ?? ""
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof InvalidDesignUrlError) return await reject(c, { file: error.message });
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
return done(c, project.slug, "Design file added");
|
|
67
|
+
}
|
|
68
|
+
async function handleMapComponent(c) {
|
|
69
|
+
const project = await adminProject(c);
|
|
70
|
+
const form = await c.req.formData();
|
|
71
|
+
const importPath = asString(form.get("importPath")) ?? "";
|
|
72
|
+
if (!importPath) return await reject(c, { component: "Enter the story file path" });
|
|
73
|
+
try {
|
|
74
|
+
await designModel().setComponentLink(project.id, importPath, asString(form.get("url")) ?? "");
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof InvalidDesignUrlError) return await reject(c, { component: error.message });
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
return done(c, project.slug, "Component mapped");
|
|
80
|
+
}
|
|
81
|
+
async function handleRemoveLink(c) {
|
|
82
|
+
const project = await adminProject(c);
|
|
83
|
+
await designModel().removeLink(project.id, c.req.param("id") ?? "");
|
|
84
|
+
return done(c, project.slug, "Link removed");
|
|
85
|
+
}
|
|
86
|
+
async function handleSetToken(c) {
|
|
87
|
+
const project = await adminProject(c);
|
|
88
|
+
const provider = providerOf(c);
|
|
89
|
+
const token = asString((await c.req.formData()).get("token"));
|
|
90
|
+
if (!token) return await reject(c, { [`token:${provider.metadata.kind}`]: "Token is required" });
|
|
91
|
+
await credentialModel().set(project.id, provider.metadata.kind, token);
|
|
92
|
+
return done(c, project.slug, `${provider.metadata.name} token saved`);
|
|
93
|
+
}
|
|
94
|
+
async function handleRemoveToken(c) {
|
|
95
|
+
const project = await adminProject(c);
|
|
96
|
+
const provider = providerOf(c);
|
|
97
|
+
await credentialModel().remove(project.id, provider.metadata.kind);
|
|
98
|
+
return done(c, project.slug, `${provider.metadata.name} token removed`);
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { registerDesignSettings };
|
|
102
|
+
|
|
103
|
+
//# sourceMappingURL=settings-design.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-design.mjs","names":[],"sources":["../../src/routers/settings-design.ts"],"sourcesContent":["import { InvalidDesignUrlError, type DesignProvider } from \"@storyshelf/core/adapter/design\";\nimport { DesignCredentialModel, DesignModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { credentialKeys } from \"../credential-keys.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { notFound, resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, renderSettingsPage } from \"./settings.handlers.ts\";\n\n/** Design settings (project design files, component mapping, provider credentials). */\nexport function registerDesignSettings(app: ShelfRouter): void {\n const base = \"/projects/:slug/settings/design\";\n app.get(base, async (c) => c.html(await renderSettingsPage(c, \"design\")));\n app.post(`${base}/files`, handleAddFile);\n app.post(`${base}/files/:id/delete`, handleRemoveLink);\n app.post(`${base}/components`, handleMapComponent);\n app.post(`${base}/components/:id/delete`, handleRemoveLink);\n app.post(`${base}/:provider/token`, handleSetToken);\n app.post(`${base}/:provider/token/delete`, handleRemoveToken);\n}\n\n/**\n * Every design-settings mutation writes credentials or mappings: require a\n * project admin server-side (the forms are only hidden from others, never trusted).\n * With auth disabled the app has no roles and everyone may administer, as elsewhere.\n */\nasync function adminProject(c: Context): Promise<Project> {\n return await resolveAuthorizedProject(c, c.req.param(\"slug\") ?? \"\", \"admin\");\n}\n\nfunction designModel(): DesignModel {\n const { db, design } = getStore();\n return new DesignModel(db, design);\n}\n\nfunction credentialModel(): DesignCredentialModel {\n const { db, config } = getStore();\n return new DesignCredentialModel(db, credentialKeys(config));\n}\n\n/** The configured provider addressed by `:provider`, or a 404. */\nfunction providerOf(c: Context): DesignProvider {\n const provider = getStore().design.find(\n (candidate) => candidate.metadata.kind === c.req.param(\"provider\"),\n );\n if (!provider) notFound(\"Unknown design provider\");\n return provider;\n}\n\nasync function reject(c: Context, errors: Record<string, string>): Promise<Response> {\n return c.html((await renderSettingsPage(c, \"design\", { errors })) ?? \"\", 400);\n}\n\nfunction done(c: Context, slug: string, message: string): Response {\n flash(c, message);\n return hxRedirect(c, `/projects/${slug}/settings/design`);\n}\n\nasync function handleAddFile(c: Context): Promise<Response> {\n const project = await adminProject(c);\n const form = await c.req.formData();\n const label = asString(form.get(\"label\")) ?? \"\";\n try {\n await designModel().addProjectLink(project.id, { label, url: asString(form.get(\"url\")) ?? \"\" });\n } catch (error) {\n if (error instanceof InvalidDesignUrlError) return await reject(c, { file: error.message });\n throw error;\n }\n return done(c, project.slug, \"Design file added\");\n}\n\nasync function handleMapComponent(c: Context): Promise<Response> {\n const project = await adminProject(c);\n const form = await c.req.formData();\n const importPath = asString(form.get(\"importPath\")) ?? \"\";\n if (!importPath) return await reject(c, { component: \"Enter the story file path\" });\n try {\n await designModel().setComponentLink(project.id, importPath, asString(form.get(\"url\")) ?? \"\");\n } catch (error) {\n if (error instanceof InvalidDesignUrlError) {\n return await reject(c, { component: error.message });\n }\n throw error;\n }\n return done(c, project.slug, \"Component mapped\");\n}\n\nasync function handleRemoveLink(c: Context): Promise<Response> {\n const project = await adminProject(c);\n await designModel().removeLink(project.id, c.req.param(\"id\") ?? \"\");\n return done(c, project.slug, \"Link removed\");\n}\n\nasync function handleSetToken(c: Context): Promise<Response> {\n const project = await adminProject(c);\n const provider = providerOf(c);\n const token = asString((await c.req.formData()).get(\"token\"));\n if (!token) return await reject(c, { [`token:${provider.metadata.kind}`]: \"Token is required\" });\n await credentialModel().set(project.id, provider.metadata.kind, token);\n return done(c, project.slug, `${provider.metadata.name} token saved`);\n}\n\nasync function handleRemoveToken(c: Context): Promise<Response> {\n const project = await adminProject(c);\n const provider = providerOf(c);\n await credentialModel().remove(project.id, provider.metadata.kind);\n return done(c, project.slug, `${provider.metadata.name} token removed`);\n}\n"],"mappings":"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"}
|
|
@@ -2,11 +2,14 @@ import "node:path";
|
|
|
2
2
|
import "node:url";
|
|
3
3
|
import.meta.url;
|
|
4
4
|
import { getStore } from "../store.mjs";
|
|
5
|
+
import { resolveAuthorizedProject } from "./helpers.mjs";
|
|
6
|
+
import { assertTargetsSelectable } from "../targets.mjs";
|
|
5
7
|
import { hxRedirect } from "./htmx.mjs";
|
|
6
8
|
import { flash } from "./flash.mjs";
|
|
9
|
+
import { runSwitchOffPass } from "./review-switch-off.mjs";
|
|
7
10
|
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
11
|
+
import { resolveBrowsers } from "@storyshelf/core/capture";
|
|
8
12
|
import { ProjectModel } from "@storyshelf/core/models";
|
|
9
|
-
import { BROWSER_NAMES } from "@storyshelf/core/adapter/capture-runner";
|
|
10
13
|
//#region src/routers/settings-general.ts
|
|
11
14
|
/** General project settings (name, repo, thresholds) and danger-zone delete. */
|
|
12
15
|
function registerGeneralSettings(app) {
|
|
@@ -14,6 +17,7 @@ function registerGeneralSettings(app) {
|
|
|
14
17
|
app.get("/projects/:slug/settings/tests", async (c) => c.html(await renderSettingsPage(c, "tests")));
|
|
15
18
|
app.post("/projects/:slug/settings", handleGeneralUpdate);
|
|
16
19
|
app.post("/projects/:slug/settings/tests", handleTestsUpdate);
|
|
20
|
+
app.post("/projects/:slug/settings/review", handleReviewUpdate);
|
|
17
21
|
app.post("/projects/:slug/delete", handleDeleteProject);
|
|
18
22
|
}
|
|
19
23
|
/** Read the general-settings form fields (name validated by the caller). */
|
|
@@ -33,15 +37,18 @@ async function handleGeneralUpdate(c) {
|
|
|
33
37
|
const form = await c.req.formData();
|
|
34
38
|
const name = form.get("name");
|
|
35
39
|
if (typeof name !== "string" || name.trim() === "") return c.html(await renderSettingsPage(c, "general", { errors: { name: "Name is required" } }) ?? "", 400);
|
|
36
|
-
|
|
40
|
+
return await persistGeneralUpdate(c, project, slug, {
|
|
37
41
|
...readGeneralFields(form),
|
|
38
42
|
name: name.trim()
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Refuse, with 400 and the reason, a browser the runner cannot render (ADR 0007, decision 7). */
|
|
46
|
+
async function assertBrowserChoice(project, browser) {
|
|
47
|
+
if (browser !== void 0) await assertTargetsSelectable([browser], resolveBrowsers(project));
|
|
42
48
|
}
|
|
43
49
|
async function persistGeneralUpdate(c, project, slug, fields) {
|
|
44
50
|
try {
|
|
51
|
+
await assertBrowserChoice(project, fields.browser);
|
|
45
52
|
await new ProjectModel(getStore().db).update(project.id, {
|
|
46
53
|
name: fields.name,
|
|
47
54
|
gitRepository: fields.gitRepository ?? void 0,
|
|
@@ -49,7 +56,8 @@ async function persistGeneralUpdate(c, project, slug, fields) {
|
|
|
49
56
|
pixelThreshold: fields.pixelThreshold ? Number(fields.pixelThreshold) : void 0,
|
|
50
57
|
maxDiffRatio: fields.maxDiffRatio ? Number(fields.maxDiffRatio) : void 0,
|
|
51
58
|
publicBranchRegex: fields.publicBranchRegex === "" ? null : fields.publicBranchRegex ?? void 0,
|
|
52
|
-
browser: fields.browser ?? void 0
|
|
59
|
+
browser: fields.browser ?? void 0,
|
|
60
|
+
...fields.browser && fields.browser !== project.browser ? { browsers: null } : {}
|
|
53
61
|
});
|
|
54
62
|
flash(c, "Project settings saved");
|
|
55
63
|
return hxRedirect(c, `/projects/${slug}/settings`);
|
|
@@ -61,16 +69,22 @@ async function persistGeneralUpdate(c, project, slug, fields) {
|
|
|
61
69
|
async function handleTestsUpdate(c) {
|
|
62
70
|
const slug = c.req.param("slug") ?? "";
|
|
63
71
|
const project = await findProject(slug);
|
|
64
|
-
const { executePlay, playTimeoutMs } = readTestsFields(await c.req.formData());
|
|
72
|
+
const { executePlay, runA11y, runTests, playTimeoutMs } = readTestsFields(await c.req.formData());
|
|
65
73
|
const rangeError = playTimeoutError(playTimeoutMs);
|
|
66
74
|
if (rangeError) return c.html(await renderSettingsPage(c, "tests", { globalError: rangeError }) ?? "", 400);
|
|
67
|
-
return await persistTestsUpdate(c, project, slug,
|
|
75
|
+
return await persistTestsUpdate(c, project, slug, {
|
|
76
|
+
executePlay,
|
|
77
|
+
runA11y,
|
|
78
|
+
runTests
|
|
79
|
+
}, playTimeoutMs);
|
|
68
80
|
}
|
|
69
81
|
/** Read the tests-tab form fields. */
|
|
70
82
|
function readTestsFields(form) {
|
|
71
83
|
const playTimeoutMsRaw = asString(form.get("playTimeoutMs"));
|
|
72
84
|
return {
|
|
73
85
|
executePlay: form.get("executePlay") === "true",
|
|
86
|
+
runA11y: form.get("runA11y") === "true",
|
|
87
|
+
runTests: form.get("runTests") === "true",
|
|
74
88
|
playTimeoutMs: playTimeoutMsRaw ? Number(playTimeoutMsRaw) : void 0
|
|
75
89
|
};
|
|
76
90
|
}
|
|
@@ -79,10 +93,10 @@ function playTimeoutError(playTimeoutMs) {
|
|
|
79
93
|
if (playTimeoutMs !== void 0 && (Number.isNaN(playTimeoutMs) || playTimeoutMs < 1e3 || playTimeoutMs > 3e4)) return "Play timeout must be between 1000 and 30000";
|
|
80
94
|
return null;
|
|
81
95
|
}
|
|
82
|
-
async function persistTestsUpdate(c, project, slug,
|
|
96
|
+
async function persistTestsUpdate(c, project, slug, switches, playTimeoutMs) {
|
|
83
97
|
try {
|
|
84
98
|
await new ProjectModel(getStore().db).update(project.id, {
|
|
85
|
-
|
|
99
|
+
...switches,
|
|
86
100
|
playTimeoutMs: playTimeoutMs ?? void 0
|
|
87
101
|
});
|
|
88
102
|
flash(c, "Test settings saved");
|
|
@@ -98,6 +112,37 @@ async function handleDeleteProject(c) {
|
|
|
98
112
|
flash(c, `Deleted ${project.name}`);
|
|
99
113
|
return hxRedirect(c, "/projects");
|
|
100
114
|
}
|
|
115
|
+
/** Largest approval count the form and the API accept. */
|
|
116
|
+
const MAX_APPROVALS = 20;
|
|
117
|
+
/**
|
|
118
|
+
* Review settings (ADR 0013, decision 1): the switch and the approvals a build needs. Project
|
|
119
|
+
* admins only. The route is classed `hosting`, not `review`: a review route answers 404 while
|
|
120
|
+
* review is off, and this is the form that turns it back on. Turning review off accepts the
|
|
121
|
+
* project's pending builds that no capture worked on, in one pass.
|
|
122
|
+
*/
|
|
123
|
+
async function handleReviewUpdate(c) {
|
|
124
|
+
const slug = c.req.param("slug") ?? "";
|
|
125
|
+
const project = await resolveAuthorizedProject(c, slug, "admin");
|
|
126
|
+
const form = await c.req.formData();
|
|
127
|
+
const approvals = approvalsOf(form, project);
|
|
128
|
+
if (approvals === null) return c.html(await renderSettingsPage(c, "general", { errors: { reviewApprovals: `Enter a whole number from 1 to ${MAX_APPROVALS}` } }) ?? "", 400);
|
|
129
|
+
const updated = await new ProjectModel(getStore().db).update(project.id, {
|
|
130
|
+
reviewEnabled: form.get("reviewEnabled") === "true",
|
|
131
|
+
reviewApprovals: approvals
|
|
132
|
+
});
|
|
133
|
+
const accepted = await runSwitchOffPass(project, updated);
|
|
134
|
+
flash(c, reviewSavedMessage(accepted));
|
|
135
|
+
return hxRedirect(c, `/projects/${slug}/settings`);
|
|
136
|
+
}
|
|
137
|
+
/** The approvals count of the form (the current one when blank); null when it is not 1 to 20. */
|
|
138
|
+
function approvalsOf(form, project) {
|
|
139
|
+
const raw = asString(form.get("reviewApprovals"));
|
|
140
|
+
const approvals = raw ? Number(raw) : project.reviewApprovals ?? 1;
|
|
141
|
+
return Number.isInteger(approvals) && approvals >= 1 && approvals <= MAX_APPROVALS ? approvals : null;
|
|
142
|
+
}
|
|
143
|
+
function reviewSavedMessage(accepted) {
|
|
144
|
+
return accepted > 0 ? `Review settings saved; accepted ${accepted} pending build${accepted === 1 ? "" : "s"}` : "Review settings saved";
|
|
145
|
+
}
|
|
101
146
|
//#endregion
|
|
102
147
|
export { registerGeneralSettings };
|
|
103
148
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-general.mjs","names":[],"sources":["../../src/routers/settings-general.ts"],"sourcesContent":["import { BROWSER_NAMES } from \"@storyshelf/core/adapter/capture-runner\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** General project settings (name, repo, thresholds) and danger-zone delete. */\nexport function registerGeneralSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings\", async (c) => c.html(await renderSettingsPage(c, \"general\")));\n app.get(\"/projects/:slug/settings/tests\", async (c) =>\n c.html(await renderSettingsPage(c, \"tests\")),\n );\n app.post(\"/projects/:slug/settings\", handleGeneralUpdate);\n app.post(\"/projects/:slug/settings/tests\", handleTestsUpdate);\n app.post(\"/projects/:slug/delete\", handleDeleteProject);\n}\n\ninterface GeneralFields {\n name: string;\n gitRepository?: string;\n gitDefaultBranch?: string;\n pixelThreshold?: string;\n maxDiffRatio?: string;\n publicBranchRegex?: string;\n browser?: string;\n}\n\n/** Read the general-settings form fields (name validated by the caller). */\nfunction readGeneralFields(form: FormData): Omit<GeneralFields, \"name\"> {\n return {\n gitRepository: asString(form.get(\"gitRepository\")),\n gitDefaultBranch: asString(form.get(\"gitDefaultBranch\")),\n pixelThreshold: asString(form.get(\"pixelThreshold\")),\n maxDiffRatio: asString(form.get(\"maxDiffRatio\")),\n publicBranchRegex: asString(form.get(\"publicBranchRegex\")),\n browser: asString(form.get(\"browser\")),\n };\n}\n\nasync function handleGeneralUpdate(c: Context): Promise<Response> {\n const slug = c.req.param(\"slug\") ?? \"\";\n const project = await findProject(slug);\n const form = await c.req.formData();\n const name = form.get(\"name\");\n if (typeof name !== \"string\" || name.trim() === \"\") {\n return c.html(\n (await renderSettingsPage(c, \"general\", { errors: { name: \"Name is required\" } })) ?? \"\",\n 400,\n );\n }\n const fields: GeneralFields = { ...readGeneralFields(form), name: name.trim() };\n if (\n fields.browser !== undefined &&\n !(BROWSER_NAMES as readonly string[]).includes(fields.browser)\n ) {\n return c.html(\n (await renderSettingsPage(c, \"general\", {\n globalError: `Invalid browser \"${fields.browser}\". Choose one of: ${BROWSER_NAMES.join(\", \")}.`,\n })) ?? \"\",\n 400,\n );\n }\n return await persistGeneralUpdate(c, project, slug, fields);\n}\n\nasync function persistGeneralUpdate(\n c: Context,\n project: Project,\n slug: string,\n fields: GeneralFields,\n): Promise<Response> {\n try {\n await new ProjectModel(getStore().db).update(project.id, {\n name: fields.name,\n gitRepository: fields.gitRepository ?? undefined,\n gitDefaultBranch: fields.gitDefaultBranch ?? undefined,\n pixelThreshold: fields.pixelThreshold ? Number(fields.pixelThreshold) : undefined,\n maxDiffRatio: fields.maxDiffRatio ? Number(fields.maxDiffRatio) : undefined,\n publicBranchRegex:\n fields.publicBranchRegex === \"\" ? null : (fields.publicBranchRegex ?? undefined),\n browser: fields.browser ?? undefined,\n });\n flash(c, \"Project settings saved\");\n return hxRedirect(c, `/projects/${slug}/settings`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to update\";\n return c.html((await renderSettingsPage(c, \"general\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleTestsUpdate(c: Context): Promise<Response> {\n const slug = c.req.param(\"slug\") ?? \"\";\n const project = await findProject(slug);\n const form = await c.req.formData();\n const { executePlay, playTimeoutMs } = readTestsFields(form);\n const rangeError = playTimeoutError(playTimeoutMs);\n if (rangeError) {\n return c.html((await renderSettingsPage(c, \"tests\", { globalError: rangeError })) ?? \"\", 400);\n }\n return await persistTestsUpdate(c, project, slug, executePlay, playTimeoutMs);\n}\n\n/** Read the tests-tab form fields. */\nfunction readTestsFields(form: FormData): {\n executePlay: boolean;\n playTimeoutMs: number | undefined;\n} {\n const playTimeoutMsRaw = asString(form.get(\"playTimeoutMs\"));\n return {\n executePlay: form.get(\"executePlay\") === \"true\",\n playTimeoutMs: playTimeoutMsRaw ? Number(playTimeoutMsRaw) : undefined,\n };\n}\n\n/** Range error for the play timeout, or null when valid. */\nfunction playTimeoutError(playTimeoutMs: number | undefined): string | null {\n if (\n playTimeoutMs !== undefined &&\n (Number.isNaN(playTimeoutMs) || playTimeoutMs < 1000 || playTimeoutMs > 30_000)\n ) {\n return \"Play timeout must be between 1000 and 30000\";\n }\n return null;\n}\n\nasync function persistTestsUpdate(\n c: Context,\n project: Project,\n slug: string,\n executePlay: boolean,\n playTimeoutMs: number | undefined,\n): Promise<Response> {\n try {\n await new ProjectModel(getStore().db).update(project.id, {\n executePlay,\n playTimeoutMs: playTimeoutMs ?? undefined,\n });\n flash(c, \"Test settings saved\");\n return hxRedirect(c, `/projects/${slug}/settings/tests`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to update\";\n return c.html((await renderSettingsPage(c, \"tests\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleDeleteProject(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new ProjectModel(getStore().db).remove(project.id);\n flash(c, `Deleted ${project.name}`);\n return hxRedirect(c, \"/projects\");\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"settings-general.mjs","names":[],"sources":["../../src/routers/settings-general.ts"],"sourcesContent":["import { resolveBrowsers } from \"@storyshelf/core/capture\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { assertTargetsSelectable } from \"../targets.ts\";\nimport { flash } from \"./flash.ts\";\nimport { resolveAuthorizedProject } from \"./helpers.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { runSwitchOffPass } from \"./review-switch-off.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** General project settings (name, repo, thresholds) and danger-zone delete. */\nexport function registerGeneralSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings\", async (c) => c.html(await renderSettingsPage(c, \"general\")));\n app.get(\"/projects/:slug/settings/tests\", async (c) =>\n c.html(await renderSettingsPage(c, \"tests\")),\n );\n app.post(\"/projects/:slug/settings\", handleGeneralUpdate);\n app.post(\"/projects/:slug/settings/tests\", handleTestsUpdate);\n app.post(\"/projects/:slug/settings/review\", handleReviewUpdate);\n app.post(\"/projects/:slug/delete\", handleDeleteProject);\n}\n\ninterface GeneralFields {\n name: string;\n gitRepository?: string;\n gitDefaultBranch?: string;\n pixelThreshold?: string;\n maxDiffRatio?: string;\n publicBranchRegex?: string;\n browser?: string;\n}\n\n/** Read the general-settings form fields (name validated by the caller). */\nfunction readGeneralFields(form: FormData): Omit<GeneralFields, \"name\"> {\n return {\n gitRepository: asString(form.get(\"gitRepository\")),\n gitDefaultBranch: asString(form.get(\"gitDefaultBranch\")),\n pixelThreshold: asString(form.get(\"pixelThreshold\")),\n maxDiffRatio: asString(form.get(\"maxDiffRatio\")),\n publicBranchRegex: asString(form.get(\"publicBranchRegex\")),\n browser: asString(form.get(\"browser\")),\n };\n}\n\nasync function handleGeneralUpdate(c: Context): Promise<Response> {\n const slug = c.req.param(\"slug\") ?? \"\";\n const project = await findProject(slug);\n const form = await c.req.formData();\n const name = form.get(\"name\");\n if (typeof name !== \"string\" || name.trim() === \"\") {\n return c.html(\n (await renderSettingsPage(c, \"general\", { errors: { name: \"Name is required\" } })) ?? \"\",\n 400,\n );\n }\n const fields: GeneralFields = { ...readGeneralFields(form), name: name.trim() };\n return await persistGeneralUpdate(c, project, slug, fields);\n}\n\n/** Refuse, with 400 and the reason, a browser the runner cannot render (ADR 0007, decision 7). */\nasync function assertBrowserChoice(project: Project, browser: string | undefined): Promise<void> {\n if (browser !== undefined) {\n await assertTargetsSelectable([browser], resolveBrowsers(project));\n }\n}\n\nasync function persistGeneralUpdate(\n c: Context,\n project: Project,\n slug: string,\n fields: GeneralFields,\n): Promise<Response> {\n try {\n await assertBrowserChoice(project, fields.browser);\n await new ProjectModel(getStore().db).update(project.id, {\n name: fields.name,\n gitRepository: fields.gitRepository ?? undefined,\n gitDefaultBranch: fields.gitDefaultBranch ?? undefined,\n pixelThreshold: fields.pixelThreshold ? Number(fields.pixelThreshold) : undefined,\n maxDiffRatio: fields.maxDiffRatio ? Number(fields.maxDiffRatio) : undefined,\n publicBranchRegex:\n fields.publicBranchRegex === \"\" ? null : (fields.publicBranchRegex ?? undefined),\n browser: fields.browser ?? undefined,\n // Picking a different single engine returns the project to one-browser capture.\n ...(fields.browser && fields.browser !== project.browser ? { browsers: null } : {}),\n });\n flash(c, \"Project settings saved\");\n return hxRedirect(c, `/projects/${slug}/settings`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to update\";\n return c.html((await renderSettingsPage(c, \"general\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleTestsUpdate(c: Context): Promise<Response> {\n const slug = c.req.param(\"slug\") ?? \"\";\n const project = await findProject(slug);\n const form = await c.req.formData();\n const { executePlay, runA11y, runTests, playTimeoutMs } = readTestsFields(form);\n const rangeError = playTimeoutError(playTimeoutMs);\n if (rangeError) {\n return c.html((await renderSettingsPage(c, \"tests\", { globalError: rangeError })) ?? \"\", 400);\n }\n return await persistTestsUpdate(\n c,\n project,\n slug,\n { executePlay, runA11y, runTests },\n playTimeoutMs,\n );\n}\n\n/** Read the tests-tab form fields. */\nfunction readTestsFields(form: FormData): {\n executePlay: boolean;\n runA11y: boolean;\n runTests: boolean;\n playTimeoutMs: number | undefined;\n} {\n const playTimeoutMsRaw = asString(form.get(\"playTimeoutMs\"));\n return {\n executePlay: form.get(\"executePlay\") === \"true\",\n runA11y: form.get(\"runA11y\") === \"true\",\n runTests: form.get(\"runTests\") === \"true\",\n playTimeoutMs: playTimeoutMsRaw ? Number(playTimeoutMsRaw) : undefined,\n };\n}\n\n/** Range error for the play timeout, or null when valid. */\nfunction playTimeoutError(playTimeoutMs: number | undefined): string | null {\n if (\n playTimeoutMs !== undefined &&\n (Number.isNaN(playTimeoutMs) || playTimeoutMs < 1000 || playTimeoutMs > 30_000)\n ) {\n return \"Play timeout must be between 1000 and 30000\";\n }\n return null;\n}\n\nasync function persistTestsUpdate(\n c: Context,\n project: Project,\n slug: string,\n switches: { executePlay: boolean; runA11y: boolean; runTests: boolean },\n playTimeoutMs: number | undefined,\n): Promise<Response> {\n try {\n await new ProjectModel(getStore().db).update(project.id, {\n ...switches,\n playTimeoutMs: playTimeoutMs ?? undefined,\n });\n flash(c, \"Test settings saved\");\n return hxRedirect(c, `/projects/${slug}/settings/tests`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to update\";\n return c.html((await renderSettingsPage(c, \"tests\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleDeleteProject(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new ProjectModel(getStore().db).remove(project.id);\n flash(c, `Deleted ${project.name}`);\n return hxRedirect(c, \"/projects\");\n}\n\n/** Largest approval count the form and the API accept. */\nconst MAX_APPROVALS = 20;\n\n/**\n * Review settings (ADR 0013, decision 1): the switch and the approvals a build needs. Project\n * admins only. The route is classed `hosting`, not `review`: a review route answers 404 while\n * review is off, and this is the form that turns it back on. Turning review off accepts the\n * project's pending builds that no capture worked on, in one pass.\n */\nasync function handleReviewUpdate(c: Context): Promise<Response> {\n const slug = c.req.param(\"slug\") ?? \"\";\n const project = await resolveAuthorizedProject(c, slug, \"admin\");\n const form = await c.req.formData();\n const approvals = approvalsOf(form, project);\n if (approvals === null) {\n return c.html(\n (await renderSettingsPage(c, \"general\", {\n errors: { reviewApprovals: `Enter a whole number from 1 to ${MAX_APPROVALS}` },\n })) ?? \"\",\n 400,\n );\n }\n const updated = await new ProjectModel(getStore().db).update(project.id, {\n reviewEnabled: form.get(\"reviewEnabled\") === \"true\",\n reviewApprovals: approvals,\n });\n const accepted = await runSwitchOffPass(project, updated);\n flash(c, reviewSavedMessage(accepted));\n return hxRedirect(c, `/projects/${slug}/settings`);\n}\n\n/** The approvals count of the form (the current one when blank); null when it is not 1 to 20. */\nfunction approvalsOf(form: FormData, project: Project): number | null {\n const raw = asString(form.get(\"reviewApprovals\"));\n const approvals = raw ? Number(raw) : (project.reviewApprovals ?? 1);\n return Number.isInteger(approvals) && approvals >= 1 && approvals <= MAX_APPROVALS\n ? approvals\n : null;\n}\n\nfunction reviewSavedMessage(accepted: number): string {\n return accepted > 0\n ? `Review settings saved; accepted ${accepted} pending build${accepted === 1 ? \"\" : \"s\"}`\n : \"Review settings saved\";\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { flash } from "./flash.mjs";
|
|
6
|
+
import { notificationChannelModel } from "./scoped-models.mjs";
|
|
7
|
+
import { asString, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
|
+
import { NotificationChannelModel } from "@storyshelf/core/models";
|
|
9
|
+
//#region src/routers/settings-git-channel.ts
|
|
10
|
+
/** Create a git (`state`) channel from the settings form: repository, status prefix and token. */
|
|
11
|
+
const CONFIG_FIELDS = [
|
|
12
|
+
"owner",
|
|
13
|
+
"repo",
|
|
14
|
+
"host",
|
|
15
|
+
"prefix"
|
|
16
|
+
];
|
|
17
|
+
/** Only the GitHub provider reads these: how it shows a build and the App that posts check runs. */
|
|
18
|
+
const GITHUB_FIELDS = [
|
|
19
|
+
"mode",
|
|
20
|
+
"appId",
|
|
21
|
+
"installationId"
|
|
22
|
+
];
|
|
23
|
+
/** The provider config from the form; blank optional fields are left out. */
|
|
24
|
+
function gitConfig(provider, form) {
|
|
25
|
+
const config = {};
|
|
26
|
+
const fields = provider === "github" ? [...CONFIG_FIELDS, ...GITHUB_FIELDS] : CONFIG_FIELDS;
|
|
27
|
+
for (const field of fields) {
|
|
28
|
+
const value = asString(form.get(field));
|
|
29
|
+
if (value) config[field] = value;
|
|
30
|
+
}
|
|
31
|
+
return config;
|
|
32
|
+
}
|
|
33
|
+
/** Render the tab again with field errors (HTTP 400). */
|
|
34
|
+
async function formError(c, errors) {
|
|
35
|
+
return c.html(await renderSettingsPage(c, "notifications", { errors }) ?? "", 400);
|
|
36
|
+
}
|
|
37
|
+
/** Map a failed validation to the form fields it concerns. */
|
|
38
|
+
function fieldErrors(secretField, checked) {
|
|
39
|
+
if (checked.field === "secret") return { [secretField]: checked.message };
|
|
40
|
+
if (checked.field === "provider") return { gitProvider: checked.message };
|
|
41
|
+
const errors = {};
|
|
42
|
+
for (const issue of checked.issues ?? []) errors[issue.path] ??= issue.message;
|
|
43
|
+
return Object.keys(errors).length > 0 ? errors : { gitProvider: checked.message };
|
|
44
|
+
}
|
|
45
|
+
/** Validate and store a git channel; the token lands in the encrypted secret column. */
|
|
46
|
+
async function createGitChannel(c, project, provider, form) {
|
|
47
|
+
const config = gitConfig(provider, form);
|
|
48
|
+
const checks = config["mode"] === "checks";
|
|
49
|
+
const token = asString(form.get(checks ? "privateKey" : "token"));
|
|
50
|
+
const problem = gitChannelProblem(project, provider, config, {
|
|
51
|
+
token,
|
|
52
|
+
secretField: checks ? "privateKey" : "token"
|
|
53
|
+
});
|
|
54
|
+
if (problem) return await formError(c, problem);
|
|
55
|
+
await notificationChannelModel().create({
|
|
56
|
+
projectId: project.id,
|
|
57
|
+
provider,
|
|
58
|
+
config,
|
|
59
|
+
secret: token
|
|
60
|
+
});
|
|
61
|
+
flash(c, "Git channel added");
|
|
62
|
+
return c.html(await renderSettingsPage(c, "notifications") ?? "", 201);
|
|
63
|
+
}
|
|
64
|
+
/** The form errors for a git channel input, or null when it is valid. */
|
|
65
|
+
function gitChannelProblem(project, provider, config, secret) {
|
|
66
|
+
const { notifiers } = getStore();
|
|
67
|
+
if (!NotificationChannelModel.isStateKind(notifiers, provider)) return { gitProvider: `Unknown git provider: ${provider}` };
|
|
68
|
+
const checked = NotificationChannelModel.validateInput(notifiers, provider, config, {
|
|
69
|
+
projectId: project.id,
|
|
70
|
+
hasSecret: Boolean(secret.token)
|
|
71
|
+
});
|
|
72
|
+
return checked.ok ? null : fieldErrors(secret.secretField, checked);
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { createGitChannel };
|
|
76
|
+
|
|
77
|
+
//# sourceMappingURL=settings-git-channel.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-git-channel.mjs","names":[],"sources":["../../src/routers/settings-git-channel.ts"],"sourcesContent":["/** Create a git (`state`) channel from the settings form: repository, status prefix and token. */\nimport { NotificationChannelModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Context } from \"hono\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { notificationChannelModel } from \"./scoped-models.ts\";\nimport { asString, renderSettingsPage } from \"./settings.handlers.ts\";\n\nconst CONFIG_FIELDS = [\"owner\", \"repo\", \"host\", \"prefix\"] as const;\n/** Only the GitHub provider reads these: how it shows a build and the App that posts check runs. */\nconst GITHUB_FIELDS = [\"mode\", \"appId\", \"installationId\"] as const;\n\n/** The provider config from the form; blank optional fields are left out. */\nfunction gitConfig(provider: string, form: FormData): Record<string, string> {\n const config: Record<string, string> = {};\n const fields = provider === \"github\" ? [...CONFIG_FIELDS, ...GITHUB_FIELDS] : CONFIG_FIELDS;\n for (const field of fields) {\n const value = asString(form.get(field));\n if (value) {\n config[field] = value;\n }\n }\n return config;\n}\n\n/** Render the tab again with field errors (HTTP 400). */\nasync function formError(c: Context, errors: Record<string, string>): Promise<Response> {\n return c.html((await renderSettingsPage(c, \"notifications\", { errors })) ?? \"\", 400);\n}\n\n/** Map a failed validation to the form fields it concerns. */\nfunction fieldErrors(\n secretField: \"token\" | \"privateKey\",\n checked: Extract<ReturnType<typeof NotificationChannelModel.validateInput>, { ok: false }>,\n): Record<string, string> {\n if (checked.field === \"secret\") {\n return { [secretField]: checked.message };\n }\n if (checked.field === \"provider\") {\n return { gitProvider: checked.message };\n }\n const errors: Record<string, string> = {};\n for (const issue of checked.issues ?? []) {\n errors[issue.path] ??= issue.message;\n }\n return Object.keys(errors).length > 0 ? errors : { gitProvider: checked.message };\n}\n\n/** Validate and store a git channel; the token lands in the encrypted secret column. */\nexport async function createGitChannel(\n c: Context,\n project: Project,\n provider: string,\n form: FormData,\n): Promise<Response> {\n const config = gitConfig(provider, form);\n // A GitHub App channel keeps its private key where other channels keep a token.\n const checks = config[\"mode\"] === \"checks\";\n const token = asString(form.get(checks ? \"privateKey\" : \"token\"));\n const problem = gitChannelProblem(project, provider, config, {\n token,\n secretField: checks ? \"privateKey\" : \"token\",\n });\n if (problem) {\n return await formError(c, problem);\n }\n await notificationChannelModel().create({\n projectId: project.id,\n provider,\n config,\n secret: token,\n });\n flash(c, \"Git channel added\");\n return c.html((await renderSettingsPage(c, \"notifications\")) ?? \"\", 201);\n}\n\n/** The form errors for a git channel input, or null when it is valid. */\nfunction gitChannelProblem(\n project: Project,\n provider: string,\n config: Record<string, string>,\n secret: { token: string | undefined; secretField: \"token\" | \"privateKey\" },\n): Record<string, string> | null {\n const { notifiers } = getStore();\n if (!NotificationChannelModel.isStateKind(notifiers, provider)) {\n return { gitProvider: `Unknown git provider: ${provider}` };\n }\n const checked = NotificationChannelModel.validateInput(notifiers, provider, config, {\n projectId: project.id,\n hasSecret: Boolean(secret.token),\n });\n return checked.ok ? null : fieldErrors(secret.secretField, checked);\n}\n"],"mappings":";;;;;;;;;;AASA,MAAM,gBAAgB;CAAC;CAAS;CAAQ;CAAQ;AAAQ;;AAExD,MAAM,gBAAgB;CAAC;CAAQ;CAAS;AAAgB;;AAGxD,SAAS,UAAU,UAAkB,MAAwC;CAC3E,MAAM,SAAiC,CAAC;CACxC,MAAM,SAAS,aAAa,WAAW,CAAC,GAAG,eAAe,GAAG,aAAa,IAAI;CAC9E,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,QAAQ,SAAS,KAAK,IAAI,KAAK,CAAC;EACtC,IAAI,OACF,OAAO,SAAS;CAEpB;CACA,OAAO;AACT;;AAGA,eAAe,UAAU,GAAY,QAAmD;CACtF,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,iBAAiB,EAAE,OAAO,CAAC,KAAM,IAAI,GAAG;AACrF;;AAGA,SAAS,YACP,aACA,SACwB;CACxB,IAAI,QAAQ,UAAU,UACpB,OAAO,GAAG,cAAc,QAAQ,QAAQ;CAE1C,IAAI,QAAQ,UAAU,YACpB,OAAO,EAAE,aAAa,QAAQ,QAAQ;CAExC,MAAM,SAAiC,CAAC;CACxC,KAAK,MAAM,SAAS,QAAQ,UAAU,CAAC,GACrC,OAAO,MAAM,UAAU,MAAM;CAE/B,OAAO,OAAO,KAAK,MAAM,CAAC,CAAC,SAAS,IAAI,SAAS,EAAE,aAAa,QAAQ,QAAQ;AAClF;;AAGA,eAAsB,iBACpB,GACA,SACA,UACA,MACmB;CACnB,MAAM,SAAS,UAAU,UAAU,IAAI;CAEvC,MAAM,SAAS,OAAO,YAAY;CAClC,MAAM,QAAQ,SAAS,KAAK,IAAI,SAAS,eAAe,OAAO,CAAC;CAChE,MAAM,UAAU,kBAAkB,SAAS,UAAU,QAAQ;EAC3D;EACA,aAAa,SAAS,eAAe;CACvC,CAAC;CACD,IAAI,SACF,OAAO,MAAM,UAAU,GAAG,OAAO;CAEnC,MAAM,yBAAyB,CAAC,CAAC,OAAO;EACtC,WAAW,QAAQ;EACnB;EACA;EACA,QAAQ;CACV,CAAC;CACD,MAAM,GAAG,mBAAmB;CAC5B,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,eAAe,KAAM,IAAI,GAAG;AACzE;;AAGA,SAAS,kBACP,SACA,UACA,QACA,QAC+B;CAC/B,MAAM,EAAE,cAAc,SAAS;CAC/B,IAAI,CAAC,yBAAyB,YAAY,WAAW,QAAQ,GAC3D,OAAO,EAAE,aAAa,yBAAyB,WAAW;CAE5D,MAAM,UAAU,yBAAyB,cAAc,WAAW,UAAU,QAAQ;EAClF,WAAW,QAAQ;EACnB,WAAW,QAAQ,OAAO,KAAK;CACjC,CAAC;CACD,OAAO,QAAQ,KAAK,OAAO,YAAY,OAAO,aAAa,OAAO;AACpE"}
|
|
@@ -9,8 +9,9 @@ import { LabelModel } from "@storyshelf/core/models";
|
|
|
9
9
|
//#region src/routers/settings-labels.ts
|
|
10
10
|
/** Label-type settings (create custom types, delete removable ones). */
|
|
11
11
|
function registerLabelSettings(app) {
|
|
12
|
-
app.get("/projects/:slug/settings/labels", async (c) => c.html(await renderSettingsPage(c, "labels")));
|
|
12
|
+
app.get("/projects/:slug/settings/labels", async (c) => c.html(await renderSettingsPage(c, "labels", { editKey: c.req.query("edit") })));
|
|
13
13
|
app.post("/projects/:slug/settings/labels", handleCreateLabelType);
|
|
14
|
+
app.post("/projects/:slug/settings/labels/:key", handleUpdateLabelType);
|
|
14
15
|
app.post("/projects/:slug/settings/labels/:key/delete", handleDeleteLabelType);
|
|
15
16
|
}
|
|
16
17
|
async function handleCreateLabelType(c) {
|
|
@@ -36,6 +37,31 @@ async function persistLabelType(c, projectId, projectSlug, key, labelName, linkT
|
|
|
36
37
|
return c.html(await renderSettingsPage(c, "labels", { globalError: message }) ?? "", 400);
|
|
37
38
|
}
|
|
38
39
|
}
|
|
40
|
+
async function handleUpdateLabelType(c) {
|
|
41
|
+
const project = await findProject(c.req.param("slug") ?? "");
|
|
42
|
+
const key = c.req.param("key") ?? "";
|
|
43
|
+
const form = await c.req.formData();
|
|
44
|
+
const name = asString(form.get("labelName"));
|
|
45
|
+
if (!name) return c.html(await renderSettingsPage(c, "labels", {
|
|
46
|
+
globalError: "Name is required",
|
|
47
|
+
editKey: key
|
|
48
|
+
}) ?? "", 400);
|
|
49
|
+
const template = asString(form.get("linkTemplate"));
|
|
50
|
+
return await persistLabelUpdate(c, project, key, {
|
|
51
|
+
name,
|
|
52
|
+
linkTemplate: template === void 0 || template === "" ? null : template
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
async function persistLabelUpdate(c, project, key, input) {
|
|
56
|
+
try {
|
|
57
|
+
if (!await new LabelModel(getStore().db).updateType(project.id, key, input)) throw new Error("Label type not found");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : "Failed to update label";
|
|
60
|
+
return c.html(await renderSettingsPage(c, "labels", { globalError: message }) ?? "", 400);
|
|
61
|
+
}
|
|
62
|
+
flash(c, "Label type saved");
|
|
63
|
+
return hxRedirect(c, `/projects/${project.slug}/settings/labels`);
|
|
64
|
+
}
|
|
39
65
|
async function handleDeleteLabelType(c) {
|
|
40
66
|
const project = await findProject(c.req.param("slug") ?? "");
|
|
41
67
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-labels.mjs","names":[],"sources":["../../src/routers/settings-labels.ts"],"sourcesContent":["import { LabelModel } from \"@storyshelf/core/models\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** Label-type settings (create custom types, delete removable ones). */\nexport function registerLabelSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/labels\", async (c) =>\n c.html(await renderSettingsPage(c, \"labels\")),\n );\n app.post(\"/projects/:slug/settings/labels\", handleCreateLabelType);\n app.post(\"/projects/:slug/settings/labels/:key/delete\", handleDeleteLabelType);\n}\n\nasync function handleCreateLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const key = asString(form.get(\"key\"));\n const labelName = asString(form.get(\"labelName\")) ?? asString(form.get(\"name\"));\n const linkTemplate = asString(form.get(\"linkTemplate\"));\n if (!key || !labelName) {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Key and name are required\" })) ?? \"\",\n 400,\n );\n }\n return await persistLabelType(c, project.id, project.slug, key, labelName, linkTemplate);\n}\n\nasync function persistLabelType(\n c: Context,\n projectId: string,\n projectSlug: string,\n key: string,\n labelName: string,\n linkTemplate: string | undefined,\n): Promise<Response> {\n try {\n await new LabelModel(getStore().db).createType(projectId, {\n key,\n name: labelName,\n linkTemplate,\n });\n flash(c, \"Label type added\");\n return hxRedirect(c, `/projects/${projectSlug}/settings/labels`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to create label\";\n return c.html((await renderSettingsPage(c, \"labels\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleDeleteLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n try {\n await new LabelModel(getStore().db).removeType(project.id, c.req.param(\"key\") ?? \"\");\n } catch {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Cannot delete built-in label\" })) ??\n \"\",\n 400,\n );\n }\n flash(c, \"Label type deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/labels`);\n}\n"],"mappings":";;;;;;;;;;AAQA,SAAgB,sBAAsB,KAAwB;CAC5D,IAAI,IAAI,mCAAmC,OAAO,MAChD,EAAE,KAAK,MAAM,mBAAmB,GAAG,
|
|
1
|
+
{"version":3,"file":"settings-labels.mjs","names":[],"sources":["../../src/routers/settings-labels.ts"],"sourcesContent":["import { LabelModel } from \"@storyshelf/core/models\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** Label-type settings (create custom types, delete removable ones). */\nexport function registerLabelSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/labels\", async (c) =>\n c.html(await renderSettingsPage(c, \"labels\", { editKey: c.req.query(\"edit\") })),\n );\n app.post(\"/projects/:slug/settings/labels\", handleCreateLabelType);\n app.post(\"/projects/:slug/settings/labels/:key\", handleUpdateLabelType);\n app.post(\"/projects/:slug/settings/labels/:key/delete\", handleDeleteLabelType);\n}\n\nasync function handleCreateLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const key = asString(form.get(\"key\"));\n const labelName = asString(form.get(\"labelName\")) ?? asString(form.get(\"name\"));\n const linkTemplate = asString(form.get(\"linkTemplate\"));\n if (!key || !labelName) {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Key and name are required\" })) ?? \"\",\n 400,\n );\n }\n return await persistLabelType(c, project.id, project.slug, key, labelName, linkTemplate);\n}\n\nasync function persistLabelType(\n c: Context,\n projectId: string,\n projectSlug: string,\n key: string,\n labelName: string,\n linkTemplate: string | undefined,\n): Promise<Response> {\n try {\n await new LabelModel(getStore().db).createType(projectId, {\n key,\n name: labelName,\n linkTemplate,\n });\n flash(c, \"Label type added\");\n return hxRedirect(c, `/projects/${projectSlug}/settings/labels`);\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to create label\";\n return c.html((await renderSettingsPage(c, \"labels\", { globalError: message })) ?? \"\", 400);\n }\n}\n\nasync function handleUpdateLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const key = c.req.param(\"key\") ?? \"\";\n const form = await c.req.formData();\n const name = asString(form.get(\"labelName\"));\n if (!name) {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Name is required\", editKey: key })) ??\n \"\",\n 400,\n );\n }\n const template = asString(form.get(\"linkTemplate\"));\n const linkTemplate = template === undefined || template === \"\" ? null : template;\n return await persistLabelUpdate(c, project, key, { name, linkTemplate });\n}\n\nasync function persistLabelUpdate(\n c: Context,\n project: { id: string; slug: string },\n key: string,\n input: { name: string; linkTemplate: string | null },\n): Promise<Response> {\n try {\n const updated = await new LabelModel(getStore().db).updateType(project.id, key, input);\n if (!updated) {\n throw new Error(\"Label type not found\");\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : \"Failed to update label\";\n return c.html((await renderSettingsPage(c, \"labels\", { globalError: message })) ?? \"\", 400);\n }\n flash(c, \"Label type saved\");\n return hxRedirect(c, `/projects/${project.slug}/settings/labels`);\n}\n\nasync function handleDeleteLabelType(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n try {\n await new LabelModel(getStore().db).removeType(project.id, c.req.param(\"key\") ?? \"\");\n } catch {\n return c.html(\n (await renderSettingsPage(c, \"labels\", { globalError: \"Cannot delete built-in label\" })) ??\n \"\",\n 400,\n );\n }\n flash(c, \"Label type deleted\");\n return hxRedirect(c, `/projects/${project.slug}/settings/labels`);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { hxRedirect } from "./htmx.mjs";
|
|
6
|
+
import { flash } from "./flash.mjs";
|
|
7
|
+
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
|
+
import { PackageUsageModel, ProjectModel } from "@storyshelf/core/models";
|
|
9
|
+
//#region src/routers/settings-links.ts
|
|
10
|
+
/** Package-link settings (link a consumed package to its upstream project, or unlink it). */
|
|
11
|
+
function registerLinkSettings(app) {
|
|
12
|
+
app.get("/projects/:slug/settings/links", async (c) => c.html(await renderSettingsPage(c, "links")));
|
|
13
|
+
app.post("/projects/:slug/settings/links", handleAddLink);
|
|
14
|
+
app.post("/projects/:slug/settings/links/delete", handleRemoveLink);
|
|
15
|
+
}
|
|
16
|
+
async function rejectLink(c, message) {
|
|
17
|
+
return c.html(await renderSettingsPage(c, "links", { globalError: message }) ?? "", 400);
|
|
18
|
+
}
|
|
19
|
+
/** Replace one package's link, keeping the project's other links. */
|
|
20
|
+
async function saveLink(project, packageName, upstreamId) {
|
|
21
|
+
const model = new PackageUsageModel(getStore().db);
|
|
22
|
+
const kept = (await model.listLinks(project.id)).filter((link) => link.packageName !== packageName);
|
|
23
|
+
await model.replaceLinks(project.id, [...kept.map((link) => ({
|
|
24
|
+
upstreamId: link.upstreamId,
|
|
25
|
+
packageName: link.packageName
|
|
26
|
+
})), {
|
|
27
|
+
upstreamId,
|
|
28
|
+
packageName
|
|
29
|
+
}]);
|
|
30
|
+
}
|
|
31
|
+
/** Read the add-link form fields. */
|
|
32
|
+
async function readLinkForm(c) {
|
|
33
|
+
const form = await c.req.formData();
|
|
34
|
+
return {
|
|
35
|
+
packageName: asString(form.get("package")),
|
|
36
|
+
upstreamSlug: asString(form.get("upstreamSlug"))
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function handleAddLink(c) {
|
|
40
|
+
const project = await findProject(c.req.param("slug") ?? "");
|
|
41
|
+
const { packageName, upstreamSlug } = await readLinkForm(c);
|
|
42
|
+
const upstream = upstreamSlug ? await new ProjectModel(getStore().db).getBySlug(upstreamSlug) : null;
|
|
43
|
+
if (!packageName || !upstream || upstream.id === project.id) return await rejectLink(c, "Enter a package and choose a different, existing upstream project");
|
|
44
|
+
await saveLink(project, packageName, upstream.id);
|
|
45
|
+
flash(c, "Package linked");
|
|
46
|
+
return hxRedirect(c, `/projects/${project.slug}/settings/links`);
|
|
47
|
+
}
|
|
48
|
+
async function handleRemoveLink(c) {
|
|
49
|
+
const project = await findProject(c.req.param("slug") ?? "");
|
|
50
|
+
const packageName = asString((await c.req.formData()).get("package"));
|
|
51
|
+
const model = new PackageUsageModel(getStore().db);
|
|
52
|
+
const kept = (await model.listLinks(project.id)).filter((link) => link.packageName !== packageName);
|
|
53
|
+
await model.replaceLinks(project.id, kept.map((link) => ({
|
|
54
|
+
upstreamId: link.upstreamId,
|
|
55
|
+
packageName: link.packageName
|
|
56
|
+
})));
|
|
57
|
+
flash(c, "Link removed");
|
|
58
|
+
return hxRedirect(c, `/projects/${project.slug}/settings/links`);
|
|
59
|
+
}
|
|
60
|
+
//#endregion
|
|
61
|
+
export { registerLinkSettings };
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=settings-links.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"settings-links.mjs","names":[],"sources":["../../src/routers/settings-links.ts"],"sourcesContent":["import { PackageUsageModel, ProjectModel } from \"@storyshelf/core/models\";\nimport type { Context } from \"hono\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n\n/** Package-link settings (link a consumed package to its upstream project, or unlink it). */\nexport function registerLinkSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/links\", async (c) =>\n c.html(await renderSettingsPage(c, \"links\")),\n );\n app.post(\"/projects/:slug/settings/links\", handleAddLink);\n app.post(\"/projects/:slug/settings/links/delete\", handleRemoveLink);\n}\n\nasync function rejectLink(c: Context, message: string): Promise<Response> {\n return c.html((await renderSettingsPage(c, \"links\", { globalError: message })) ?? \"\", 400);\n}\n\n/** Replace one package's link, keeping the project's other links. */\nasync function saveLink(\n project: { id: string },\n packageName: string,\n upstreamId: string,\n): Promise<void> {\n const model = new PackageUsageModel(getStore().db);\n const kept = (await model.listLinks(project.id)).filter(\n (link) => link.packageName !== packageName,\n );\n await model.replaceLinks(project.id, [\n ...kept.map((link) => ({ upstreamId: link.upstreamId, packageName: link.packageName })),\n { upstreamId, packageName },\n ]);\n}\n\n/** Read the add-link form fields. */\nasync function readLinkForm(c: Context): Promise<{ packageName?: string; upstreamSlug?: string }> {\n const form = await c.req.formData();\n return {\n packageName: asString(form.get(\"package\")),\n upstreamSlug: asString(form.get(\"upstreamSlug\")),\n };\n}\n\nasync function handleAddLink(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const { packageName, upstreamSlug } = await readLinkForm(c);\n const upstream = upstreamSlug\n ? await new ProjectModel(getStore().db).getBySlug(upstreamSlug)\n : null;\n if (!packageName || !upstream || upstream.id === project.id) {\n return await rejectLink(c, \"Enter a package and choose a different, existing upstream project\");\n }\n await saveLink(project, packageName, upstream.id);\n flash(c, \"Package linked\");\n return hxRedirect(c, `/projects/${project.slug}/settings/links`);\n}\n\nasync function handleRemoveLink(c: Context): Promise<Response> {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const packageName = asString((await c.req.formData()).get(\"package\"));\n const model = new PackageUsageModel(getStore().db);\n const kept = (await model.listLinks(project.id)).filter(\n (link) => link.packageName !== packageName,\n );\n await model.replaceLinks(\n project.id,\n kept.map((link) => ({ upstreamId: link.upstreamId, packageName: link.packageName })),\n );\n flash(c, \"Link removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/links`);\n}\n"],"mappings":";;;;;;;;;;AASA,SAAgB,qBAAqB,KAAwB;CAC3D,IAAI,IAAI,kCAAkC,OAAO,MAC/C,EAAE,KAAK,MAAM,mBAAmB,GAAG,OAAO,CAAC,CAC7C;CACA,IAAI,KAAK,kCAAkC,aAAa;CACxD,IAAI,KAAK,yCAAyC,gBAAgB;AACpE;AAEA,eAAe,WAAW,GAAY,SAAoC;CACxE,OAAO,EAAE,KAAM,MAAM,mBAAmB,GAAG,SAAS,EAAE,aAAa,QAAQ,CAAC,KAAM,IAAI,GAAG;AAC3F;;AAGA,eAAe,SACb,SACA,aACA,YACe;CACf,MAAM,QAAQ,IAAI,kBAAkB,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,QAAQ,MAAM,MAAM,UAAU,QAAQ,EAAE,EAAA,CAAG,QAC9C,SAAS,KAAK,gBAAgB,WACjC;CACA,MAAM,MAAM,aAAa,QAAQ,IAAI,CACnC,GAAG,KAAK,KAAK,UAAU;EAAE,YAAY,KAAK;EAAY,aAAa,KAAK;CAAY,EAAE,GACtF;EAAE;EAAY;CAAY,CAC5B,CAAC;AACH;;AAGA,eAAe,aAAa,GAAsE;CAChG,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;CAClC,OAAO;EACL,aAAa,SAAS,KAAK,IAAI,SAAS,CAAC;EACzC,cAAc,SAAS,KAAK,IAAI,cAAc,CAAC;CACjD;AACF;AAEA,eAAe,cAAc,GAA+B;CAC1D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,EAAE,aAAa,iBAAiB,MAAM,aAAa,CAAC;CAC1D,MAAM,WAAW,eACb,MAAM,IAAI,aAAa,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,UAAU,YAAY,IAC5D;CACJ,IAAI,CAAC,eAAe,CAAC,YAAY,SAAS,OAAO,QAAQ,IACvD,OAAO,MAAM,WAAW,GAAG,mEAAmE;CAEhG,MAAM,SAAS,SAAS,aAAa,SAAS,EAAE;CAChD,MAAM,GAAG,gBAAgB;CACzB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,gBAAgB;AACjE;AAEA,eAAe,iBAAiB,GAA+B;CAC7D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;CAC3D,MAAM,cAAc,UAAU,MAAM,EAAE,IAAI,SAAS,EAAA,CAAG,IAAI,SAAS,CAAC;CACpE,MAAM,QAAQ,IAAI,kBAAkB,SAAS,CAAC,CAAC,EAAE;CACjD,MAAM,QAAQ,MAAM,MAAM,UAAU,QAAQ,EAAE,EAAA,CAAG,QAC9C,SAAS,KAAK,gBAAgB,WACjC;CACA,MAAM,MAAM,aACV,QAAQ,IACR,KAAK,KAAK,UAAU;EAAE,YAAY,KAAK;EAAY,aAAa,KAAK;CAAY,EAAE,CACrF;CACA,MAAM,GAAG,cAAc;CACvB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,gBAAgB;AACjE"}
|
|
@@ -6,6 +6,7 @@ import { hxRedirect } from "./htmx.mjs";
|
|
|
6
6
|
import { flash } from "./flash.mjs";
|
|
7
7
|
import { asString, findProject, renderSettingsPage } from "./settings.handlers.mjs";
|
|
8
8
|
import { MemberModel, ProjectGroupMappingModel } from "@storyshelf/core/models";
|
|
9
|
+
import { isProjectRole } from "@storyshelf/core/types";
|
|
9
10
|
//#region src/routers/settings-members.ts
|
|
10
11
|
/** Project member settings (assign roles, remove members). */
|
|
11
12
|
function registerMemberSettings(app) {
|
|
@@ -15,7 +16,7 @@ function registerMemberSettings(app) {
|
|
|
15
16
|
const form = await c.req.formData();
|
|
16
17
|
const userId = asString(form.get("userId"));
|
|
17
18
|
const role = asString(form.get("role"));
|
|
18
|
-
if (!userId || !role) return c.html(await renderSettingsPage(c, "members", { globalError: "User and role are required" }) ?? "", 400);
|
|
19
|
+
if (!userId || !isProjectRole(role)) return c.html(await renderSettingsPage(c, "members", { globalError: "User and role are required" }) ?? "", 400);
|
|
19
20
|
await new MemberModel(getStore().db).set(project.id, userId, role);
|
|
20
21
|
flash(c, "Member saved");
|
|
21
22
|
return hxRedirect(c, `/projects/${project.slug}/settings/members`);
|
|
@@ -35,12 +36,8 @@ function registerGroupMappingSettings(app) {
|
|
|
35
36
|
const form = await c.req.formData();
|
|
36
37
|
const groupName = asString(form.get("groupName"));
|
|
37
38
|
const role = asString(form.get("role"));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"developer",
|
|
41
|
-
"approver",
|
|
42
|
-
"admin"
|
|
43
|
-
].includes(role)) return c.html(await renderSettingsPage(c, "members", { globalError: "Group and role are required" }) ?? "", 400);
|
|
39
|
+
const inputError = ProjectGroupMappingModel.inputError(groupName, role);
|
|
40
|
+
if (inputError || !groupName || !isProjectRole(role)) return c.html(await renderSettingsPage(c, "members", { globalError: inputError ?? "Invalid input" }) ?? "", 400);
|
|
44
41
|
await new ProjectGroupMappingModel(getStore().db).create(project.id, groupName, role);
|
|
45
42
|
flash(c, "Group mapping added");
|
|
46
43
|
return hxRedirect(c, `/projects/${project.slug}/settings/members`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-members.mjs","names":[],"sources":["../../src/routers/settings-members.ts"],"sourcesContent":["import { MemberModel, ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport
|
|
1
|
+
{"version":3,"file":"settings-members.mjs","names":[],"sources":["../../src/routers/settings-members.ts"],"sourcesContent":["import { MemberModel, ProjectGroupMappingModel } from \"@storyshelf/core/models\";\nimport { isProjectRole } from \"@storyshelf/core/types\";\nimport type { ShelfRouter } from \"../app-types.ts\";\nimport { getStore } from \"../store.ts\";\nimport { flash } from \"./flash.ts\";\nimport { hxRedirect } from \"./htmx.ts\";\nimport { asString, findProject, renderSettingsPage } from \"./settings.handlers.ts\";\n/** Project member settings (assign roles, remove members). */\nexport function registerMemberSettings(app: ShelfRouter): void {\n app.get(\"/projects/:slug/settings/members\", async (c) =>\n c.html(await renderSettingsPage(c, \"members\")),\n );\n\n app.post(\"/projects/:slug/settings/members\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const userId = asString(form.get(\"userId\"));\n const role = asString(form.get(\"role\"));\n if (!userId || !isProjectRole(role)) {\n return c.html(\n (await renderSettingsPage(c, \"members\", { globalError: \"User and role are required\" })) ??\n \"\",\n 400,\n );\n }\n await new MemberModel(getStore().db).set(project.id, userId, role);\n flash(c, \"Member saved\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n\n app.post(\"/projects/:slug/settings/members/:userId/remove\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new MemberModel(getStore().db).remove(project.id, c.req.param(\"userId\") ?? \"\");\n flash(c, \"Member removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n\n registerGroupMappingSettings(app);\n}\n\n/** Identity-provider group mapping settings (exact-match group to role). */\nfunction registerGroupMappingSettings(app: ShelfRouter): void {\n app.post(\"/projects/:slug/settings/members/groups\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n const form = await c.req.formData();\n const groupName = asString(form.get(\"groupName\"));\n const role = asString(form.get(\"role\"));\n const inputError = ProjectGroupMappingModel.inputError(groupName, role);\n if (inputError || !groupName || !isProjectRole(role)) {\n return c.html(\n (await renderSettingsPage(c, \"members\", { globalError: inputError ?? \"Invalid input\" })) ??\n \"\",\n 400,\n );\n }\n await new ProjectGroupMappingModel(getStore().db).create(project.id, groupName, role);\n flash(c, \"Group mapping added\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n\n app.post(\"/projects/:slug/settings/members/groups/:mappingId/remove\", async (c) => {\n const project = await findProject(c.req.param(\"slug\") ?? \"\");\n await new ProjectGroupMappingModel(getStore().db).remove(\n project.id,\n c.req.param(\"mappingId\") ?? \"\",\n );\n flash(c, \"Group mapping removed\");\n return hxRedirect(c, `/projects/${project.slug}/settings/members`);\n });\n}\n"],"mappings":";;;;;;;;;;;AAQA,SAAgB,uBAAuB,KAAwB;CAC7D,IAAI,IAAI,oCAAoC,OAAO,MACjD,EAAE,KAAK,MAAM,mBAAmB,GAAG,SAAS,CAAC,CAC/C;CAEA,IAAI,KAAK,oCAAoC,OAAO,MAAM;EACxD,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,SAAS,SAAS,KAAK,IAAI,QAAQ,CAAC;EAC1C,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,CAAC;EACtC,IAAI,CAAC,UAAU,CAAC,cAAc,IAAI,GAChC,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,WAAW,EAAE,aAAa,6BAA6B,CAAC,KACnF,IACF,GACF;EAEF,MAAM,IAAI,YAAY,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,QAAQ,IAAI,QAAQ,IAAI;EACjE,MAAM,GAAG,cAAc;EACvB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;CAED,IAAI,KAAK,mDAAmD,OAAO,MAAM;EACvE,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,IAAI,YAAY,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,EAAE,IAAI,MAAM,QAAQ,KAAK,EAAE;EACnF,MAAM,GAAG,gBAAgB;EACzB,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;CAED,6BAA6B,GAAG;AAClC;;AAGA,SAAS,6BAA6B,KAAwB;CAC5D,IAAI,KAAK,2CAA2C,OAAO,MAAM;EAC/D,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,OAAO,MAAM,EAAE,IAAI,SAAS;EAClC,MAAM,YAAY,SAAS,KAAK,IAAI,WAAW,CAAC;EAChD,MAAM,OAAO,SAAS,KAAK,IAAI,MAAM,CAAC;EACtC,MAAM,aAAa,yBAAyB,WAAW,WAAW,IAAI;EACtE,IAAI,cAAc,CAAC,aAAa,CAAC,cAAc,IAAI,GACjD,OAAO,EAAE,KACN,MAAM,mBAAmB,GAAG,WAAW,EAAE,aAAa,cAAc,gBAAgB,CAAC,KACpF,IACF,GACF;EAEF,MAAM,IAAI,yBAAyB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,QAAQ,IAAI,WAAW,IAAI;EACpF,MAAM,GAAG,qBAAqB;EAC9B,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;CAED,IAAI,KAAK,6DAA6D,OAAO,MAAM;EACjF,MAAM,UAAU,MAAM,YAAY,EAAE,IAAI,MAAM,MAAM,KAAK,EAAE;EAC3D,MAAM,IAAI,yBAAyB,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,OAChD,QAAQ,IACR,EAAE,IAAI,MAAM,WAAW,KAAK,EAC9B;EACA,MAAM,GAAG,uBAAuB;EAChC,OAAO,WAAW,GAAG,aAAa,QAAQ,KAAK,kBAAkB;CACnE,CAAC;AACH"}
|