@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/app-types.d.mts +54 -0
- package/dist/asset-manifest.mjs +41 -0
- package/dist/asset-manifest.mjs.map +1 -0
- package/dist/asset-paths.mjs +23 -0
- package/dist/asset-paths.mjs.map +1 -0
- package/dist/assets/htmx.min.js +3304 -0
- package/dist/assets/mark.svg +1 -0
- package/dist/capture-setup.mjs +139 -0
- package/dist/capture-setup.mjs.map +1 -0
- package/dist/credential-keys.mjs +20 -0
- package/dist/credential-keys.mjs.map +1 -0
- package/dist/credentials.mjs +56 -0
- package/dist/credentials.mjs.map +1 -0
- package/dist/index.d.mts +18 -0
- package/dist/index.mjs +310 -0
- package/dist/index.mjs.map +1 -0
- package/dist/lifecycle.mjs +132 -0
- package/dist/lifecycle.mjs.map +1 -0
- package/dist/middleware/auth-gate.mjs +36 -0
- package/dist/middleware/auth-gate.mjs.map +1 -0
- package/dist/middleware/csrf.mjs +75 -0
- package/dist/middleware/csrf.mjs.map +1 -0
- package/dist/middleware/rate-limit.mjs +68 -0
- package/dist/middleware/rate-limit.mjs.map +1 -0
- package/dist/middleware/request-log.mjs +30 -0
- package/dist/middleware/request-log.mjs.map +1 -0
- package/dist/middleware/server-timing.mjs +39 -0
- package/dist/middleware/server-timing.mjs.map +1 -0
- package/dist/middleware/setup-gate.mjs +37 -0
- package/dist/middleware/setup-gate.mjs.map +1 -0
- package/dist/middleware/store-scope.mjs +33 -0
- package/dist/middleware/store-scope.mjs.map +1 -0
- package/dist/notify.d.mts +17 -0
- package/dist/notify.mjs +141 -0
- package/dist/notify.mjs.map +1 -0
- package/dist/pages/admin-system.mjs +243 -0
- package/dist/pages/admin-system.mjs.map +1 -0
- package/dist/pages/build-detail.mjs +392 -0
- package/dist/pages/build-detail.mjs.map +1 -0
- package/dist/pages/build-diff-actions.mjs +162 -0
- package/dist/pages/build-diff-actions.mjs.map +1 -0
- package/dist/pages/build-diff-comments.mjs +104 -0
- package/dist/pages/build-diff-comments.mjs.map +1 -0
- package/dist/pages/build-diff-header.mjs +117 -0
- package/dist/pages/build-diff-header.mjs.map +1 -0
- package/dist/pages/build-diff-model.mjs +48 -0
- package/dist/pages/build-diff-model.mjs.map +1 -0
- package/dist/pages/build-diff-nav.mjs +122 -0
- package/dist/pages/build-diff-nav.mjs.map +1 -0
- package/dist/pages/build-diff-shortcuts.mjs +78 -0
- package/dist/pages/build-diff-shortcuts.mjs.map +1 -0
- package/dist/pages/build-diff-stale.mjs +70 -0
- package/dist/pages/build-diff-stale.mjs.map +1 -0
- package/dist/pages/build-diff-viewer.mjs +70 -0
- package/dist/pages/build-diff-viewer.mjs.map +1 -0
- package/dist/pages/build-diff.mjs +110 -0
- package/dist/pages/build-diff.mjs.map +1 -0
- package/dist/pages/compute-jobs.mjs +165 -0
- package/dist/pages/compute-jobs.mjs.map +1 -0
- package/dist/pages/invite.mjs +91 -0
- package/dist/pages/invite.mjs.map +1 -0
- package/dist/pages/label-detail.mjs +154 -0
- package/dist/pages/label-detail.mjs.map +1 -0
- package/dist/pages/library.mjs +343 -0
- package/dist/pages/library.mjs.map +1 -0
- package/dist/pages/login.mjs +117 -0
- package/dist/pages/login.mjs.map +1 -0
- package/dist/pages/passkey-ceremony.mjs +97 -0
- package/dist/pages/passkey-ceremony.mjs.map +1 -0
- package/dist/pages/profile.mjs +364 -0
- package/dist/pages/profile.mjs.map +1 -0
- package/dist/pages/project-builds-table.mjs +151 -0
- package/dist/pages/project-builds-table.mjs.map +1 -0
- package/dist/pages/project-builds.mjs +143 -0
- package/dist/pages/project-builds.mjs.map +1 -0
- package/dist/pages/project-create.mjs +90 -0
- package/dist/pages/project-create.mjs.map +1 -0
- package/dist/pages/project-settings.mjs +95 -0
- package/dist/pages/project-settings.mjs.map +1 -0
- package/dist/pages/projects-card.mjs +194 -0
- package/dist/pages/projects-card.mjs.map +1 -0
- package/dist/pages/projects.mjs +129 -0
- package/dist/pages/projects.mjs.map +1 -0
- package/dist/pages/root.mjs +98 -0
- package/dist/pages/root.mjs.map +1 -0
- package/dist/pages/settings-general.mjs +138 -0
- package/dist/pages/settings-general.mjs.map +1 -0
- package/dist/pages/settings-labels.mjs +107 -0
- package/dist/pages/settings-labels.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +192 -0
- package/dist/pages/settings-members.mjs.map +1 -0
- package/dist/pages/settings-notifications.mjs +240 -0
- package/dist/pages/settings-notifications.mjs.map +1 -0
- package/dist/pages/settings-status.mjs +137 -0
- package/dist/pages/settings-status.mjs.map +1 -0
- package/dist/pages/settings-tests.mjs +111 -0
- package/dist/pages/settings-tests.mjs.map +1 -0
- package/dist/pages/settings-tokens.mjs +90 -0
- package/dist/pages/settings-tokens.mjs.map +1 -0
- package/dist/pages/settings-webhooks.mjs +104 -0
- package/dist/pages/settings-webhooks.mjs.map +1 -0
- package/dist/pages/storybook.mjs +116 -0
- package/dist/pages/storybook.mjs.map +1 -0
- package/dist/retention-timer.mjs +65 -0
- package/dist/retention-timer.mjs.map +1 -0
- package/dist/routers/admin-pages.mjs +48 -0
- package/dist/routers/admin-pages.mjs.map +1 -0
- package/dist/routers/admin.mjs +113 -0
- package/dist/routers/admin.mjs.map +1 -0
- package/dist/routers/assets.mjs +64 -0
- package/dist/routers/assets.mjs.map +1 -0
- package/dist/routers/attempts.mjs +96 -0
- package/dist/routers/attempts.mjs.map +1 -0
- package/dist/routers/auth-sync.mjs +64 -0
- package/dist/routers/auth-sync.mjs.map +1 -0
- package/dist/routers/auth.mjs +236 -0
- package/dist/routers/auth.mjs.map +1 -0
- package/dist/routers/baseline-guard.mjs +83 -0
- package/dist/routers/baseline-guard.mjs.map +1 -0
- package/dist/routers/builds.handlers.mjs +217 -0
- package/dist/routers/builds.handlers.mjs.map +1 -0
- package/dist/routers/builds.mjs +381 -0
- package/dist/routers/builds.mjs.map +1 -0
- package/dist/routers/comments.mjs +109 -0
- package/dist/routers/comments.mjs.map +1 -0
- package/dist/routers/flash.mjs +35 -0
- package/dist/routers/flash.mjs.map +1 -0
- package/dist/routers/health-report.mjs +159 -0
- package/dist/routers/health-report.mjs.map +1 -0
- package/dist/routers/health.mjs +46 -0
- package/dist/routers/health.mjs.map +1 -0
- package/dist/routers/helpers.mjs +110 -0
- package/dist/routers/helpers.mjs.map +1 -0
- package/dist/routers/htmx.mjs +24 -0
- package/dist/routers/htmx.mjs.map +1 -0
- package/dist/routers/labels.mjs +118 -0
- package/dist/routers/labels.mjs.map +1 -0
- package/dist/routers/media.mjs +53 -0
- package/dist/routers/media.mjs.map +1 -0
- package/dist/routers/members.mjs +177 -0
- package/dist/routers/members.mjs.map +1 -0
- package/dist/routers/notification-channels.mjs +190 -0
- package/dist/routers/notification-channels.mjs.map +1 -0
- package/dist/routers/notification-subscriptions.mjs +120 -0
- package/dist/routers/notification-subscriptions.mjs.map +1 -0
- package/dist/routers/profile.mjs +276 -0
- package/dist/routers/profile.mjs.map +1 -0
- package/dist/routers/projects.mjs +133 -0
- package/dist/routers/projects.mjs.map +1 -0
- package/dist/routers/review-next.mjs +47 -0
- package/dist/routers/review-next.mjs.map +1 -0
- package/dist/routers/schemas.mjs +337 -0
- package/dist/routers/schemas.mjs.map +1 -0
- package/dist/routers/settings-general.mjs +104 -0
- package/dist/routers/settings-general.mjs.map +1 -0
- package/dist/routers/settings-labels.mjs +52 -0
- package/dist/routers/settings-labels.mjs.map +1 -0
- package/dist/routers/settings-members.mjs +58 -0
- package/dist/routers/settings-members.mjs.map +1 -0
- package/dist/routers/settings-notifications.mjs +108 -0
- package/dist/routers/settings-notifications.mjs.map +1 -0
- package/dist/routers/settings-status.mjs +77 -0
- package/dist/routers/settings-status.mjs.map +1 -0
- package/dist/routers/settings-tokens.mjs +40 -0
- package/dist/routers/settings-tokens.mjs.map +1 -0
- package/dist/routers/settings-webhooks.mjs +67 -0
- package/dist/routers/settings-webhooks.mjs.map +1 -0
- package/dist/routers/settings.handlers.mjs +149 -0
- package/dist/routers/settings.handlers.mjs.map +1 -0
- package/dist/routers/settings.mjs +25 -0
- package/dist/routers/settings.mjs.map +1 -0
- package/dist/routers/snapshots.mjs +240 -0
- package/dist/routers/snapshots.mjs.map +1 -0
- package/dist/routers/status-configs.mjs +108 -0
- package/dist/routers/status-configs.mjs.map +1 -0
- package/dist/routers/storybook.mjs +171 -0
- package/dist/routers/storybook.mjs.map +1 -0
- package/dist/routers/tokens.mjs +91 -0
- package/dist/routers/tokens.mjs.map +1 -0
- package/dist/routers/ui.mjs +166 -0
- package/dist/routers/ui.mjs.map +1 -0
- package/dist/routers/webhooks.mjs +97 -0
- package/dist/routers/webhooks.mjs.map +1 -0
- package/dist/routers/well-known.mjs +43 -0
- package/dist/routers/well-known.mjs.map +1 -0
- package/dist/runtime.mjs +52 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/store.mjs +20 -0
- package/dist/store.mjs.map +1 -0
- package/dist/ui/avatar.mjs +76 -0
- package/dist/ui/avatar.mjs.map +1 -0
- package/dist/ui/buttons.mjs +205 -0
- package/dist/ui/buttons.mjs.map +1 -0
- package/dist/ui/code-block.mjs +47 -0
- package/dist/ui/code-block.mjs.map +1 -0
- package/dist/ui/compare-stage.mjs +344 -0
- package/dist/ui/compare-stage.mjs.map +1 -0
- package/dist/ui/csrf-field.mjs +20 -0
- package/dist/ui/csrf-field.mjs.map +1 -0
- package/dist/ui/css.mjs +36 -0
- package/dist/ui/css.mjs.map +1 -0
- package/dist/ui/document.mjs +130 -0
- package/dist/ui/document.mjs.map +1 -0
- package/dist/ui/dropdown.mjs +181 -0
- package/dist/ui/dropdown.mjs.map +1 -0
- package/dist/ui/feedback.mjs +341 -0
- package/dist/ui/feedback.mjs.map +1 -0
- package/dist/ui/filter-input.mjs +58 -0
- package/dist/ui/filter-input.mjs.map +1 -0
- package/dist/ui/forms.mjs +291 -0
- package/dist/ui/forms.mjs.map +1 -0
- package/dist/ui/icons/icon.mjs +53 -0
- package/dist/ui/icons/icon.mjs.map +1 -0
- package/dist/ui/icons/paths.mjs +61 -0
- package/dist/ui/icons/paths.mjs.map +1 -0
- package/dist/ui/icons/sprite.mjs +26 -0
- package/dist/ui/icons/sprite.mjs.map +1 -0
- package/dist/ui/kbd.mjs +33 -0
- package/dist/ui/kbd.mjs.map +1 -0
- package/dist/ui/layout.mjs +173 -0
- package/dist/ui/layout.mjs.map +1 -0
- package/dist/ui/progress.mjs +44 -0
- package/dist/ui/progress.mjs.map +1 -0
- package/dist/ui/segmented.mjs +77 -0
- package/dist/ui/segmented.mjs.map +1 -0
- package/dist/ui/shell/account.mjs +161 -0
- package/dist/ui/shell/account.mjs.map +1 -0
- package/dist/ui/shell/bare.mjs +69 -0
- package/dist/ui/shell/bare.mjs.map +1 -0
- package/dist/ui/shell/boot-script.mjs +46 -0
- package/dist/ui/shell/boot-script.mjs.map +1 -0
- package/dist/ui/shell/client-script.mjs +259 -0
- package/dist/ui/shell/client-script.mjs.map +1 -0
- package/dist/ui/shell/nav.mjs +111 -0
- package/dist/ui/shell/nav.mjs.map +1 -0
- package/dist/ui/shell/review-script.mjs +195 -0
- package/dist/ui/shell/review-script.mjs.map +1 -0
- package/dist/ui/shell/sidebar.mjs +323 -0
- package/dist/ui/shell/sidebar.mjs.map +1 -0
- package/dist/ui/shell/topbar.mjs +268 -0
- package/dist/ui/shell/topbar.mjs.map +1 -0
- package/dist/ui/stacks.mjs +82 -0
- package/dist/ui/stacks.mjs.map +1 -0
- package/dist/ui/styles/base.mjs +41 -0
- package/dist/ui/styles/base.mjs.map +1 -0
- package/dist/ui/styles/behavior.mjs +43 -0
- package/dist/ui/styles/behavior.mjs.map +1 -0
- package/dist/ui/styles/review-layout.mjs +347 -0
- package/dist/ui/styles/review-layout.mjs.map +1 -0
- package/dist/ui/styles/review.mjs +86 -0
- package/dist/ui/styles/review.mjs.map +1 -0
- package/dist/ui/styles/tokens.mjs +132 -0
- package/dist/ui/styles/tokens.mjs.map +1 -0
- package/dist/ui/styles.mjs +19 -0
- package/dist/ui/styles.mjs.map +1 -0
- package/dist/ui/subnav.mjs +91 -0
- package/dist/ui/subnav.mjs.map +1 -0
- package/dist/ui/table.mjs +73 -0
- package/dist/ui/table.mjs.map +1 -0
- package/dist/ui/theme.mjs +64 -0
- package/dist/ui/theme.mjs.map +1 -0
- package/dist/ui/thumbnail.mjs +51 -0
- package/dist/ui/thumbnail.mjs.map +1 -0
- package/dist/ui/time.mjs +50 -0
- package/dist/ui/time.mjs.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"label-detail.mjs","names":[],"sources":["../../src/pages/label-detail.tsx"],"sourcesContent":["import { BuildModel } from \"@storyshelf/core/models\";\nimport { LabelModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { getStore } from \"../store.ts\";\nimport {\n Badge,\n Button,\n EmptyState,\n HStack,\n Meta,\n PageHeader,\n Table,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\n/** Resolve a label type's `link_template` against a build, or return null. */\nexport function resolveLabelLink(\n template: string | null,\n project: { gitRepository: string | null },\n build: { gitBranch: string },\n value: string,\n): string | null {\n if (!template) {\n return null;\n }\n const repo = project.gitRepository ?? \"\";\n return template\n .replaceAll(\"{value}\", value)\n .replaceAll(\"{repo}\", repo)\n .replaceAll(\"{branch}\", build.gitBranch);\n}\n\n/** Label page: every build bearing `key=value`, latest first (ADR 0013). */\nexport async function renderLabelDetailPage(\n slug: string,\n key: string,\n value: string,\n): Promise<RenderedContent | null> {\n const projects = await new ProjectModel(getStore().db).list();\n const project = projects.find((item) => item.slug === slug);\n if (!project) {\n return null;\n }\n const [builds, labelType] = await Promise.all([\n new BuildModel(getStore().db).list(project.id, { labelKey: key, labelValue: value }),\n new LabelModel(getStore().db).getType(project.id, key),\n ]);\n\n return (\n <DocumentLayout\n title={`${key}: ${value}`}\n nav={{\n active: \"labels\",\n projectSlug: project.slug,\n projectName: project.name,\n trail: [{ label: `${labelType ? labelType.name : key}: ${value}` }],\n }}\n >\n <PageHeader\n title={\n <>\n {labelType ? labelType.name : key}: {value}\n </>\n }\n description={\n <>\n <Badge tone=\"neutral\">{key}</Badge> Builds bearing this{\" \"}\n {labelType ? labelType.name.toLowerCase() : \"label\"}, latest first.\n </>\n }\n actions={\n <Button variant=\"ghost\" href={`/projects/${project.slug}/labels`}>\n All labels\n </Button>\n }\n />\n\n {builds.length === 0 ? (\n <EmptyState\n title=\"No builds\"\n description={`No build currently bears the label ${key}: ${value}.`}\n />\n ) : (\n <Table>\n <table>\n <thead>\n <tr>\n <th>Build</th>\n <th>Status</th>\n <th>Author</th>\n <th>Created</th>\n <th>\n <span class=\"visually-hidden\">Actions</span>\n </th>\n </tr>\n </thead>\n <tbody>\n {builds.map((build): HtmlEscapedString | Promise<HtmlEscapedString> => {\n const link = labelType?.linkTemplate\n ? resolveLabelLink(labelType.linkTemplate, project, build, value)\n : null;\n return (\n <tr key={build.id}>\n <td>\n <strong>{build.gitBranch}</strong>\n <Meta as=\"div\" mono>\n {build.gitSha.slice(0, 7)}{\" \"}\n {build.message ? `· ${build.message.slice(0, 60)}` : \"\"}\n </Meta>\n </td>\n <td>\n <Badge tone={statusTone(build.status)}>{build.status}</Badge>\n </td>\n <td>\n <div>{build.authorName ?? \"—\"}</div>\n <Meta as=\"div\">{build.authorEmail ?? \"\"}</Meta>\n </td>\n <td>\n <Meta as=\"span\">{new Date(build.createdAt).toLocaleString()}</Meta>\n </td>\n <td class=\"nowrap\">\n <HStack wrap={false}>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n href={`/projects/${project.slug}/builds/${build.id}`}\n >\n View\n </Button>\n {link ? (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n href={link}\n target=\"_blank\"\n rel=\"noreferrer\"\n >\n Open link\n </Button>\n ) : null}\n </HStack>\n </td>\n </tr>\n );\n })}\n </tbody>\n </table>\n </Table>\n )}\n </DocumentLayout>\n );\n}\n\n/** Label types overview page (architecture.md `GET /projects/:slug/labels`). */\nexport async function renderLabelsPage(slug: string): Promise<RenderedContent | null> {\n const projects = await new ProjectModel(getStore().db).list();\n const project = projects.find((item) => item.slug === slug);\n if (!project) {\n return null;\n }\n const labelTypes = await new LabelModel(getStore().db).listTypes(project.id);\n\n return (\n <DocumentLayout\n title={`Labels · ${project.name}`}\n nav={{ active: \"labels\", projectSlug: project.slug, projectName: project.name }}\n >\n <PageHeader\n title=\"Labels\"\n description=\"Typed labels attach values to builds and link out to external systems.\"\n />\n\n {labelTypes.length === 0 ? (\n <EmptyState\n title=\"No label types\"\n description=\"No label types are configured for this project.\"\n />\n ) : (\n <Table>\n <table>\n <thead>\n <tr>\n <th>Key</th>\n <th>Name</th>\n <th>Template</th>\n </tr>\n </thead>\n <tbody>\n {labelTypes.map((labelType): HtmlEscapedString | Promise<HtmlEscapedString> => (\n <tr key={labelType.id}>\n <td>\n <Badge tone=\"neutral\">{labelType.key}</Badge>\n </td>\n <td>{labelType.name}</td>\n <td class=\"truncate max-w-cell\">{labelType.linkTemplate ?? \"—\"}</td>\n </tr>\n ))}\n </tbody>\n </table>\n </Table>\n )}\n </DocumentLayout>\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { css } from "../ui/css.mjs";
|
|
6
|
+
import { Button } from "../ui/buttons.mjs";
|
|
7
|
+
import { Badge, EmptyState, Meta, statusTone } from "../ui/feedback.mjs";
|
|
8
|
+
import { SelectField } from "../ui/forms.mjs";
|
|
9
|
+
import { Card, CardSection, PageHeader, SectionTitle } from "../ui/layout.mjs";
|
|
10
|
+
import { HStack, VStack } from "../ui/stacks.mjs";
|
|
11
|
+
import { FilterInput } from "../ui/filter-input.mjs";
|
|
12
|
+
import { DocumentLayout } from "../ui/document.mjs";
|
|
13
|
+
import { diffPaneImg, snapshotCard, snapshotCardBody, snapshotCardHead, snapshotCardMeta, snapshotGrid } from "../ui/styles/review.mjs";
|
|
14
|
+
import { BuildModel, ProjectModel, SnapshotModel } from "@storyshelf/core/models";
|
|
15
|
+
import { storybookDir } from "@storyshelf/core/utils";
|
|
16
|
+
import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
17
|
+
import { createUrlBuilder } from "@storyshelf/core/urls";
|
|
18
|
+
import { posix } from "node:path";
|
|
19
|
+
//#region src/pages/library.tsx
|
|
20
|
+
/** Snapshot image with library height cap, extending the shared diff pane image. */
|
|
21
|
+
const snapshotImg = css`
|
|
22
|
+
/* snapshot-img */
|
|
23
|
+
${diffPaneImg}
|
|
24
|
+
max-height: 240px;
|
|
25
|
+
object-fit: contain;
|
|
26
|
+
`;
|
|
27
|
+
/** Library page: gallery grouped by title from latest build on selected branch. */
|
|
28
|
+
async function renderLibraryPage(slug, branch) {
|
|
29
|
+
const { db, storage } = getStore();
|
|
30
|
+
const project = await new ProjectModel(db).getBySlug(slug);
|
|
31
|
+
if (!project) return null;
|
|
32
|
+
const build = await getLibraryBuild(db, project, branch);
|
|
33
|
+
if (!build) return renderEmptyLibrary(project);
|
|
34
|
+
const snapshots = await new SnapshotModel(db).listByBuild(build.id);
|
|
35
|
+
if (snapshots.length === 0) return renderEmptySnapshots(project, build);
|
|
36
|
+
return renderLibraryGrid(project, build, snapshots, await distinctBranches(db, project.id), await docsEntriesByStory(storage, project.id, build.id, snapshots));
|
|
37
|
+
}
|
|
38
|
+
async function getLibraryBuild(db, project, branch) {
|
|
39
|
+
const buildModel = new BuildModel(db);
|
|
40
|
+
const trimmed = branch?.trim();
|
|
41
|
+
const target = trimmed === void 0 || trimmed === "" ? project.gitDefaultBranch : trimmed;
|
|
42
|
+
const byBranch = await buildModel.list(project.id, { branch: target });
|
|
43
|
+
if (byBranch[0]) return byBranch[0];
|
|
44
|
+
if (target !== project.gitDefaultBranch) {
|
|
45
|
+
const defaults = await buildModel.list(project.id, { branch: project.gitDefaultBranch });
|
|
46
|
+
if (defaults[0]) return defaults[0];
|
|
47
|
+
}
|
|
48
|
+
return (await buildModel.list(project.id))[0] ?? null;
|
|
49
|
+
}
|
|
50
|
+
async function distinctBranches(db, projectId) {
|
|
51
|
+
const buildsList = await new BuildModel(db).list(projectId);
|
|
52
|
+
return [...new Set(buildsList.map((b) => b.gitBranch))].toSorted((a, b) => a.localeCompare(b));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Map snapshot story ids to their component's docs entry id. Reads the build's
|
|
56
|
+
* Storybook index from storage and returns an empty map when statics are
|
|
57
|
+
* purged or the build has no docs entries, so the Library never links to a
|
|
58
|
+
* docs page that does not exist.
|
|
59
|
+
*/
|
|
60
|
+
async function docsEntriesByStory(storage, projectId, buildId, snapshots) {
|
|
61
|
+
const prefixes = await readDocsPrefixes(storage, projectId, buildId);
|
|
62
|
+
const byStory = /* @__PURE__ */ new Map();
|
|
63
|
+
for (const snap of snapshots) {
|
|
64
|
+
const docsId = docsEntryId(prefixes, snap.storyId);
|
|
65
|
+
if (docsId) byStory.set(snap.storyId, docsId);
|
|
66
|
+
}
|
|
67
|
+
return byStory;
|
|
68
|
+
}
|
|
69
|
+
async function readDocsPrefixes(storage, projectId, buildId) {
|
|
70
|
+
const candidates = await Promise.all(["index.json", "stories.json"].map((name) => readIndexEntries(storage, projectId, buildId, name)));
|
|
71
|
+
const prefixes = /* @__PURE__ */ new Set();
|
|
72
|
+
for (const entries of candidates) if (entries) {
|
|
73
|
+
for (const entry of Object.values(entries)) if (entry?.type === "docs" && typeof entry.id === "string" && entry.id.endsWith("--docs")) prefixes.add(entry.id.slice(0, -6));
|
|
74
|
+
return prefixes;
|
|
75
|
+
}
|
|
76
|
+
return prefixes;
|
|
77
|
+
}
|
|
78
|
+
async function readIndexEntries(storage, projectId, buildId, name) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = await storage.read(posix.join(storybookDir(projectId, buildId), name));
|
|
81
|
+
return JSON.parse(raw.toString("utf8")).entries ?? null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function docsEntryId(prefixes, storyId) {
|
|
87
|
+
for (const prefix of prefixes) if (storyId.startsWith(`${prefix}--`) || storyId === prefix) return `${prefix}--docs`;
|
|
88
|
+
}
|
|
89
|
+
function renderEmptyLibrary(project) {
|
|
90
|
+
return /* @__PURE__ */ jsxs(DocumentLayout, {
|
|
91
|
+
title: "Library",
|
|
92
|
+
nav: {
|
|
93
|
+
active: "library",
|
|
94
|
+
projectSlug: project.slug,
|
|
95
|
+
projectName: project.name
|
|
96
|
+
},
|
|
97
|
+
children: [/* @__PURE__ */ jsx(PageHeader, {
|
|
98
|
+
title: "Library",
|
|
99
|
+
description: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
100
|
+
"All stories/components from the latest build on ",
|
|
101
|
+
project.gitDefaultBranch,
|
|
102
|
+
"."
|
|
103
|
+
] })
|
|
104
|
+
}), /* @__PURE__ */ jsx(EmptyState, {
|
|
105
|
+
title: "No builds yet",
|
|
106
|
+
description: "Upload a build to populate the library."
|
|
107
|
+
})]
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function renderEmptySnapshots(project, build) {
|
|
111
|
+
const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
|
|
112
|
+
return /* @__PURE__ */ jsxs(DocumentLayout, {
|
|
113
|
+
title: "Library",
|
|
114
|
+
nav: {
|
|
115
|
+
active: "library",
|
|
116
|
+
projectSlug: project.slug,
|
|
117
|
+
projectName: project.name
|
|
118
|
+
},
|
|
119
|
+
children: [/* @__PURE__ */ jsx(PageHeader, {
|
|
120
|
+
title: "Library",
|
|
121
|
+
description: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
122
|
+
"Latest build ",
|
|
123
|
+
build.gitBranch,
|
|
124
|
+
" · ",
|
|
125
|
+
build.gitSha.slice(0, 7),
|
|
126
|
+
" · ",
|
|
127
|
+
build.status
|
|
128
|
+
] })
|
|
129
|
+
}), /* @__PURE__ */ jsx(EmptyState, {
|
|
130
|
+
title: "No stories in latest build",
|
|
131
|
+
description: "The latest build has no snapshots yet.",
|
|
132
|
+
action: /* @__PURE__ */ jsx(Button, {
|
|
133
|
+
variant: "secondary",
|
|
134
|
+
href: urls.build(project.slug, build.id),
|
|
135
|
+
children: "View build"
|
|
136
|
+
})
|
|
137
|
+
})]
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function groupByTitle(snapshots) {
|
|
141
|
+
const map = /* @__PURE__ */ new Map();
|
|
142
|
+
for (const snap of snapshots) {
|
|
143
|
+
const key = snap.storyTitle || "Untitled";
|
|
144
|
+
const list = map.get(key) ?? [];
|
|
145
|
+
list.push(snap);
|
|
146
|
+
map.set(key, list);
|
|
147
|
+
}
|
|
148
|
+
return map;
|
|
149
|
+
}
|
|
150
|
+
function distinctViewports(snapshots) {
|
|
151
|
+
return [...new Set(snapshots.map((s) => s.viewportName))].toSorted();
|
|
152
|
+
}
|
|
153
|
+
function renderLibraryGrid(project, build, snapshots, branches, docsByStory) {
|
|
154
|
+
const byTitle = groupByTitle(snapshots);
|
|
155
|
+
const viewportNames = distinctViewports(snapshots);
|
|
156
|
+
const multi = viewportNames.length > 1;
|
|
157
|
+
return /* @__PURE__ */ jsxs(DocumentLayout, {
|
|
158
|
+
title: "Library",
|
|
159
|
+
nav: {
|
|
160
|
+
active: "library",
|
|
161
|
+
projectSlug: project.slug,
|
|
162
|
+
projectName: project.name
|
|
163
|
+
},
|
|
164
|
+
children: [
|
|
165
|
+
renderLibraryHeader(project, build, snapshots.length, branches),
|
|
166
|
+
multi ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(Meta, {
|
|
167
|
+
as: "span",
|
|
168
|
+
children: ["Viewports: ", viewportNames.join(" · ")]
|
|
169
|
+
}) }) : null,
|
|
170
|
+
renderTitleList(byTitle, project, build, multi, docsByStory),
|
|
171
|
+
/* @__PURE__ */ jsx("div", {
|
|
172
|
+
"data-filter-empty": true,
|
|
173
|
+
hidden: true,
|
|
174
|
+
children: /* @__PURE__ */ jsx(Meta, {
|
|
175
|
+
center: true,
|
|
176
|
+
children: "No stories match your filter."
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
]
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function renderLibraryHeader(project, build, storyCount, branches) {
|
|
183
|
+
const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
|
|
184
|
+
return /* @__PURE__ */ jsx(PageHeader, {
|
|
185
|
+
title: "Library",
|
|
186
|
+
description: /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
187
|
+
storyCount,
|
|
188
|
+
" stories · Latest build",
|
|
189
|
+
" ",
|
|
190
|
+
/* @__PURE__ */ jsxs("a", {
|
|
191
|
+
href: urls.build(project.slug, build.id),
|
|
192
|
+
children: [
|
|
193
|
+
build.gitBranch,
|
|
194
|
+
" · ",
|
|
195
|
+
build.gitSha.slice(0, 7)
|
|
196
|
+
]
|
|
197
|
+
}),
|
|
198
|
+
" ",
|
|
199
|
+
"· ",
|
|
200
|
+
new Date(build.createdAt).toLocaleString(),
|
|
201
|
+
" · ",
|
|
202
|
+
build.status
|
|
203
|
+
] }),
|
|
204
|
+
actions: /* @__PURE__ */ jsxs(HStack, { children: [
|
|
205
|
+
/* @__PURE__ */ jsx(FilterInput, {
|
|
206
|
+
label: "Filter stories",
|
|
207
|
+
placeholder: "Filter stories…"
|
|
208
|
+
}),
|
|
209
|
+
/* @__PURE__ */ jsx(Button, {
|
|
210
|
+
variant: "ghost",
|
|
211
|
+
size: "sm",
|
|
212
|
+
icon: "external-link",
|
|
213
|
+
href: urls.short(build.id),
|
|
214
|
+
children: "View Storybook"
|
|
215
|
+
}),
|
|
216
|
+
renderBranchPicker(project, build, branches)
|
|
217
|
+
] })
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function renderBranchPicker(project, build, branches) {
|
|
221
|
+
return /* @__PURE__ */ jsx("form", {
|
|
222
|
+
method: "get",
|
|
223
|
+
action: `/projects/${project.slug}/library`,
|
|
224
|
+
children: /* @__PURE__ */ jsxs(HStack, { children: [/* @__PURE__ */ jsx(SelectField, {
|
|
225
|
+
label: "Branch",
|
|
226
|
+
name: "branch",
|
|
227
|
+
layout: "inline",
|
|
228
|
+
value: build.gitBranch,
|
|
229
|
+
options: branches.map((b) => ({
|
|
230
|
+
value: b,
|
|
231
|
+
label: b
|
|
232
|
+
}))
|
|
233
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
234
|
+
variant: "secondary",
|
|
235
|
+
type: "submit",
|
|
236
|
+
children: "View"
|
|
237
|
+
})] })
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function renderTitleList(byTitle, project, build, multi, docsByStory) {
|
|
241
|
+
const titles = [...byTitle.keys()].toSorted((a, b) => a.localeCompare(b));
|
|
242
|
+
return /* @__PURE__ */ jsx(VStack, { children: titles.map((title) => renderTitleGroup(title, byTitle.get(title) ?? [], project, build, multi, docsByStory)) });
|
|
243
|
+
}
|
|
244
|
+
function renderTitleGroup(title, group, project, build, hasMultipleViewports, docsByStory) {
|
|
245
|
+
const byViewport = groupByViewport(group);
|
|
246
|
+
const vNames = [...byViewport.keys()].toSorted();
|
|
247
|
+
return /* @__PURE__ */ jsxs(Card, {
|
|
248
|
+
padded: false,
|
|
249
|
+
"data-filter-item": true,
|
|
250
|
+
"data-filter-text": `${title} ${group.map((snap) => snap.storyName).join(" ")}`,
|
|
251
|
+
children: [/* @__PURE__ */ jsxs(CardSection, {
|
|
252
|
+
divider: true,
|
|
253
|
+
children: [/* @__PURE__ */ jsx(SectionTitle, { children: title }), /* @__PURE__ */ jsxs(Meta, {
|
|
254
|
+
as: "span",
|
|
255
|
+
children: [group.length, " stories"]
|
|
256
|
+
})]
|
|
257
|
+
}), /* @__PURE__ */ jsx(CardSection, { children: /* @__PURE__ */ jsx(VStack, { children: vNames.map((vName) => renderViewportGroup(vName, byViewport.get(vName) ?? [], project, build, hasMultipleViewports, docsByStory)) }) })]
|
|
258
|
+
}, title);
|
|
259
|
+
}
|
|
260
|
+
function groupByViewport(group) {
|
|
261
|
+
const map = /* @__PURE__ */ new Map();
|
|
262
|
+
for (const snap of group) {
|
|
263
|
+
const list = map.get(snap.viewportName) ?? [];
|
|
264
|
+
list.push(snap);
|
|
265
|
+
map.set(snap.viewportName, list);
|
|
266
|
+
}
|
|
267
|
+
return map;
|
|
268
|
+
}
|
|
269
|
+
function renderViewportGroup(vName, snaps, project, build, hasMultipleViewports, docsByStory) {
|
|
270
|
+
return /* @__PURE__ */ jsxs("div", { children: [hasMultipleViewports ? /* @__PURE__ */ jsx(SectionTitle, {
|
|
271
|
+
level: 3,
|
|
272
|
+
children: vName
|
|
273
|
+
}) : null, /* @__PURE__ */ jsx("div", {
|
|
274
|
+
class: snapshotGrid,
|
|
275
|
+
children: snaps.toSorted((a, b) => a.storyName.localeCompare(b.storyName)).map((snap) => renderSnapshotCard(snap, project, build, docsByStory))
|
|
276
|
+
})] }, vName);
|
|
277
|
+
}
|
|
278
|
+
function renderSnapshotCard(snap, project, build, docsByStory) {
|
|
279
|
+
const docsId = docsByStory.get(snap.storyId);
|
|
280
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
281
|
+
class: snapshotCard,
|
|
282
|
+
"data-filter-item": true,
|
|
283
|
+
"data-filter-text": `${snap.storyTitle} ${snap.storyName}`,
|
|
284
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
285
|
+
class: snapshotCardHead,
|
|
286
|
+
children: [/* @__PURE__ */ jsx("strong", { children: snap.storyName }), /* @__PURE__ */ jsxs("span", {
|
|
287
|
+
class: snapshotCardMeta,
|
|
288
|
+
children: [
|
|
289
|
+
/* @__PURE__ */ jsx(Badge, {
|
|
290
|
+
tone: statusTone(snap.status),
|
|
291
|
+
children: snap.status
|
|
292
|
+
}),
|
|
293
|
+
" ",
|
|
294
|
+
snap.viewportWidth,
|
|
295
|
+
"×",
|
|
296
|
+
snap.viewportHeight
|
|
297
|
+
]
|
|
298
|
+
})]
|
|
299
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
300
|
+
class: snapshotCardBody,
|
|
301
|
+
children: [/* @__PURE__ */ jsx("img", {
|
|
302
|
+
class: snapshotImg,
|
|
303
|
+
src: `/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/image`,
|
|
304
|
+
alt: `${snap.storyTitle} / ${snap.storyName}`,
|
|
305
|
+
loading: "lazy"
|
|
306
|
+
}), /* @__PURE__ */ jsx(HStack, { children: renderCardActions(snap, project, build, docsId) })]
|
|
307
|
+
})]
|
|
308
|
+
}, snap.id);
|
|
309
|
+
}
|
|
310
|
+
function renderCardActions(snap, project, build, docsId) {
|
|
311
|
+
const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
|
|
312
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
313
|
+
/* @__PURE__ */ jsx(Button, {
|
|
314
|
+
variant: "secondary",
|
|
315
|
+
size: "sm",
|
|
316
|
+
icon: "columns",
|
|
317
|
+
href: `${urls.buildDiff(project.slug, build.id)}?snapshot=${snap.id}`,
|
|
318
|
+
children: "Review"
|
|
319
|
+
}),
|
|
320
|
+
/* @__PURE__ */ jsx(Button, {
|
|
321
|
+
variant: "ghost",
|
|
322
|
+
size: "sm",
|
|
323
|
+
icon: "external-link",
|
|
324
|
+
href: `${urls.storybookBuild(project.slug, build.id)}/?storyId=${encodeURIComponent(snap.storyId)}`,
|
|
325
|
+
target: "_blank",
|
|
326
|
+
rel: "noopener",
|
|
327
|
+
children: "Preview"
|
|
328
|
+
}),
|
|
329
|
+
docsId ? /* @__PURE__ */ jsx(Button, {
|
|
330
|
+
variant: "ghost",
|
|
331
|
+
size: "sm",
|
|
332
|
+
icon: "book",
|
|
333
|
+
href: `/projects/${project.slug}/storybook/build/${build.id}/?storyId=${encodeURIComponent(docsId)}&viewMode=docs`,
|
|
334
|
+
target: "_blank",
|
|
335
|
+
rel: "noopener",
|
|
336
|
+
children: "Docs"
|
|
337
|
+
}) : null
|
|
338
|
+
] });
|
|
339
|
+
}
|
|
340
|
+
//#endregion
|
|
341
|
+
export { renderLibraryPage };
|
|
342
|
+
|
|
343
|
+
//# sourceMappingURL=library.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"library.mjs","names":[],"sources":["../../src/pages/library.tsx"],"sourcesContent":["// oxlint-disable typescript/promise-function-async -- Hono JSX may return Promise<HtmlEscapedString>\nimport { BuildModel } from \"@storyshelf/core/models\";\nimport { ProjectModel } from \"@storyshelf/core/models\";\nimport { SnapshotModel } from \"@storyshelf/core/models\";\nimport type { Build } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport type { Snapshot } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport { storybookDir } from \"@storyshelf/core/utils\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { posix } from \"node:path\";\nimport { getStore } from \"../store.ts\";\nimport {\n Badge,\n Button,\n Card,\n CardSection,\n EmptyState,\n FilterInput,\n HStack,\n Meta,\n PageHeader,\n SectionTitle,\n SelectField,\n VStack,\n statusTone,\n} from \"../ui/components.tsx\";\nimport { css } from \"../ui/css.ts\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport {\n diffPaneImg,\n snapshotCard,\n snapshotCardBody,\n snapshotCardHead,\n snapshotCardMeta,\n snapshotGrid,\n} from \"../ui/styles/review.ts\";\n\n/** Snapshot image with library height cap, extending the shared diff pane image. */\nconst snapshotImg = css`\n /* snapshot-img */\n ${diffPaneImg}\n max-height: 240px;\n object-fit: contain;\n`;\n\n/** Library page: gallery grouped by title from latest build on selected branch. */\nexport async function renderLibraryPage(\n slug: string,\n branch?: string,\n): Promise<RenderedContent | null> {\n const { db, storage } = getStore();\n const project = await new ProjectModel(db).getBySlug(slug);\n if (!project) return null;\n const build = await getLibraryBuild(db, project, branch);\n if (!build) return renderEmptyLibrary(project);\n const snapshots = await new SnapshotModel(db).listByBuild(build.id);\n if (snapshots.length === 0) return renderEmptySnapshots(project, build);\n const branches = await distinctBranches(db, project.id);\n const docsByStory = await docsEntriesByStory(storage, project.id, build.id, snapshots);\n return renderLibraryGrid(project, build, snapshots, branches, docsByStory);\n}\n\nasync function getLibraryBuild(\n db: ReturnType<typeof getStore>[\"db\"],\n project: Project,\n branch?: string,\n): Promise<Build | null> {\n const buildModel = new BuildModel(db);\n const trimmed = branch?.trim();\n const target = trimmed === undefined || trimmed === \"\" ? project.gitDefaultBranch : trimmed;\n const byBranch = await buildModel.list(project.id, { branch: target });\n if (byBranch[0]) return byBranch[0];\n if (target !== project.gitDefaultBranch) {\n const defaults = await buildModel.list(project.id, { branch: project.gitDefaultBranch });\n if (defaults[0]) return defaults[0];\n }\n const all = await buildModel.list(project.id);\n return all[0] ?? null;\n}\n\nasync function distinctBranches(\n db: ReturnType<typeof getStore>[\"db\"],\n projectId: string,\n): Promise<string[]> {\n const buildsList = await new BuildModel(db).list(projectId);\n return [...new Set(buildsList.map((b) => b.gitBranch))].toSorted((a, b) => a.localeCompare(b));\n}\n\n/** Storybook index entries (only the docs subset we need). */\ninterface LibraryIndexEntry {\n id?: string;\n type?: string;\n}\n\n/**\n * Map snapshot story ids to their component's docs entry id. Reads the build's\n * Storybook index from storage and returns an empty map when statics are\n * purged or the build has no docs entries, so the Library never links to a\n * docs page that does not exist.\n */\nasync function docsEntriesByStory(\n storage: ReturnType<typeof getStore>[\"storage\"],\n projectId: string,\n buildId: string,\n snapshots: Snapshot[],\n): Promise<Map<string, string>> {\n const prefixes = await readDocsPrefixes(storage, projectId, buildId);\n const byStory = new Map<string, string>();\n for (const snap of snapshots) {\n const docsId = docsEntryId(prefixes, snap.storyId);\n if (docsId) byStory.set(snap.storyId, docsId);\n }\n return byStory;\n}\n\nasync function readDocsPrefixes(\n storage: ReturnType<typeof getStore>[\"storage\"],\n projectId: string,\n buildId: string,\n): Promise<Set<string>> {\n const candidates = await Promise.all(\n [\"index.json\", \"stories.json\"].map((name) =>\n readIndexEntries(storage, projectId, buildId, name),\n ),\n );\n const prefixes = new Set<string>();\n for (const entries of candidates) {\n if (entries) {\n for (const entry of Object.values(entries)) {\n if (entry?.type === \"docs\" && typeof entry.id === \"string\" && entry.id.endsWith(\"--docs\")) {\n prefixes.add(entry.id.slice(0, -\"--docs\".length));\n }\n }\n return prefixes;\n }\n }\n return prefixes;\n}\n\nasync function readIndexEntries(\n storage: ReturnType<typeof getStore>[\"storage\"],\n projectId: string,\n buildId: string,\n name: string,\n): Promise<Record<string, LibraryIndexEntry> | null> {\n try {\n const raw = await storage.read(posix.join(storybookDir(projectId, buildId), name));\n const parsed = JSON.parse(raw.toString(\"utf8\")) as {\n entries?: Record<string, LibraryIndexEntry>;\n };\n return parsed.entries ?? null;\n } catch {\n return null;\n }\n}\n\nfunction docsEntryId(prefixes: Set<string>, storyId: string): string | undefined {\n for (const prefix of prefixes) {\n if (storyId.startsWith(`${prefix}--`) || storyId === prefix) {\n return `${prefix}--docs`;\n }\n }\n return undefined;\n}\n\nfunction renderEmptyLibrary(project: Project): RenderedContent {\n return (\n <DocumentLayout\n title=\"Library\"\n nav={{ active: \"library\", projectSlug: project.slug, projectName: project.name }}\n >\n <PageHeader\n title=\"Library\"\n description={\n <>All stories/components from the latest build on {project.gitDefaultBranch}.</>\n }\n />\n <EmptyState title=\"No builds yet\" description=\"Upload a build to populate the library.\" />\n </DocumentLayout>\n );\n}\n\nfunction renderEmptySnapshots(project: Project, build: Build): RenderedContent {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n return (\n <DocumentLayout\n title=\"Library\"\n nav={{ active: \"library\", projectSlug: project.slug, projectName: project.name }}\n >\n <PageHeader\n title=\"Library\"\n description={\n <>\n Latest build {build.gitBranch} · {build.gitSha.slice(0, 7)} · {build.status}\n </>\n }\n />\n <EmptyState\n title=\"No stories in latest build\"\n description=\"The latest build has no snapshots yet.\"\n action={\n <Button variant=\"secondary\" href={urls.build(project.slug, build.id)}>\n View build\n </Button>\n }\n />\n </DocumentLayout>\n );\n}\n\nfunction groupByTitle(snapshots: Snapshot[]): Map<string, Snapshot[]> {\n const map = new Map<string, Snapshot[]>();\n for (const snap of snapshots) {\n const key = snap.storyTitle || \"Untitled\";\n const list = map.get(key) ?? [];\n list.push(snap);\n map.set(key, list);\n }\n return map;\n}\n\nfunction distinctViewports(snapshots: Snapshot[]): string[] {\n return [...new Set(snapshots.map((s) => s.viewportName))].toSorted();\n}\n\nfunction renderLibraryGrid(\n project: Project,\n build: Build,\n snapshots: Snapshot[],\n branches: string[],\n docsByStory: Map<string, string>,\n): RenderedContent {\n const byTitle = groupByTitle(snapshots);\n const viewportNames = distinctViewports(snapshots);\n const multi = viewportNames.length > 1;\n return (\n <DocumentLayout\n title=\"Library\"\n nav={{ active: \"library\", projectSlug: project.slug, projectName: project.name }}\n >\n {renderLibraryHeader(project, build, snapshots.length, branches)}\n {multi ? (\n <Card>\n <Meta as=\"span\">Viewports: {viewportNames.join(\" · \")}</Meta>\n </Card>\n ) : null}\n {renderTitleList(byTitle, project, build, multi, docsByStory)}\n <div data-filter-empty hidden>\n <Meta center>No stories match your filter.</Meta>\n </div>\n </DocumentLayout>\n );\n}\n\nfunction renderLibraryHeader(\n project: Project,\n build: Build,\n storyCount: number,\n branches: string[],\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n return (\n <PageHeader\n title=\"Library\"\n description={\n <>\n {storyCount} stories · Latest build{\" \"}\n <a href={urls.build(project.slug, build.id)}>\n {build.gitBranch} · {build.gitSha.slice(0, 7)}\n </a>{\" \"}\n · {new Date(build.createdAt).toLocaleString()} · {build.status}\n </>\n }\n actions={\n <HStack>\n <FilterInput label=\"Filter stories\" placeholder=\"Filter stories…\" />\n <Button variant=\"ghost\" size=\"sm\" icon=\"external-link\" href={urls.short(build.id)}>\n View Storybook\n </Button>\n {renderBranchPicker(project, build, branches)}\n </HStack>\n }\n />\n );\n}\n\nfunction renderBranchPicker(\n project: Project,\n build: Build,\n branches: string[],\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <form method=\"get\" action={`/projects/${project.slug}/library`}>\n <HStack>\n <SelectField\n label=\"Branch\"\n name=\"branch\"\n layout=\"inline\"\n value={build.gitBranch}\n options={branches.map((b) => ({ value: b, label: b }))}\n />\n <Button variant=\"secondary\" type=\"submit\">\n View\n </Button>\n </HStack>\n </form>\n );\n}\n\nfunction renderTitleList(\n byTitle: Map<string, Snapshot[]>,\n project: Project,\n build: Build,\n multi: boolean,\n docsByStory: Map<string, string>,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const titles = [...byTitle.keys()].toSorted((a, b) => a.localeCompare(b));\n return (\n <VStack>\n {titles.map((title) =>\n renderTitleGroup(title, byTitle.get(title) ?? [], project, build, multi, docsByStory),\n )}\n </VStack>\n );\n}\n\nfunction renderTitleGroup(\n title: string,\n group: Snapshot[],\n project: Project,\n build: Build,\n hasMultipleViewports: boolean,\n docsByStory: Map<string, string>,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const byViewport = groupByViewport(group);\n const vNames = [...byViewport.keys()].toSorted();\n return (\n <Card\n key={title}\n padded={false}\n data-filter-item\n data-filter-text={`${title} ${group.map((snap) => snap.storyName).join(\" \")}`}\n >\n <CardSection divider>\n <SectionTitle>{title}</SectionTitle>\n <Meta as=\"span\">{group.length} stories</Meta>\n </CardSection>\n <CardSection>\n <VStack>\n {vNames.map((vName) =>\n renderViewportGroup(\n vName,\n byViewport.get(vName) ?? [],\n project,\n build,\n hasMultipleViewports,\n docsByStory,\n ),\n )}\n </VStack>\n </CardSection>\n </Card>\n );\n}\n\nfunction groupByViewport(group: Snapshot[]): Map<string, Snapshot[]> {\n const map = new Map<string, Snapshot[]>();\n for (const snap of group) {\n const list = map.get(snap.viewportName) ?? [];\n list.push(snap);\n map.set(snap.viewportName, list);\n }\n return map;\n}\n\nfunction renderViewportGroup(\n vName: string,\n snaps: Snapshot[],\n project: Project,\n build: Build,\n hasMultipleViewports: boolean,\n docsByStory: Map<string, string>,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n return (\n <div key={vName}>\n {hasMultipleViewports ? <SectionTitle level={3}>{vName}</SectionTitle> : null}\n <div class={snapshotGrid}>\n {snaps\n .toSorted((a, b) => a.storyName.localeCompare(b.storyName))\n .map((snap) => renderSnapshotCard(snap, project, build, docsByStory))}\n </div>\n </div>\n );\n}\n\nfunction renderSnapshotCard(\n snap: Snapshot,\n project: Project,\n build: Build,\n docsByStory: Map<string, string>,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const docsId = docsByStory.get(snap.storyId);\n return (\n <div\n key={snap.id}\n class={snapshotCard}\n data-filter-item\n data-filter-text={`${snap.storyTitle} ${snap.storyName}`}\n >\n <div class={snapshotCardHead}>\n <strong>{snap.storyName}</strong>\n <span class={snapshotCardMeta}>\n <Badge tone={statusTone(snap.status)}>{snap.status}</Badge> {snap.viewportWidth}×\n {snap.viewportHeight}\n </span>\n </div>\n <div class={snapshotCardBody}>\n <img\n class={snapshotImg}\n src={`/api/v1/projects/${project.slug}/builds/${build.id}/snapshots/${snap.id}/image`}\n alt={`${snap.storyTitle} / ${snap.storyName}`}\n loading=\"lazy\"\n />\n <HStack>{renderCardActions(snap, project, build, docsId)}</HStack>\n </div>\n </div>\n );\n}\n\nfunction renderCardActions(\n snap: Snapshot,\n project: Project,\n build: Build,\n docsId: string | undefined,\n): HtmlEscapedString | Promise<HtmlEscapedString> {\n const urls = createUrlBuilder(\"/\", getStore().config.publishedBaseDomain);\n return (\n <>\n <Button\n variant=\"secondary\"\n size=\"sm\"\n icon=\"columns\"\n href={`${urls.buildDiff(project.slug, build.id)}?snapshot=${snap.id}`}\n >\n Review\n </Button>\n <Button\n variant=\"ghost\"\n size=\"sm\"\n icon=\"external-link\"\n href={`${urls.storybookBuild(project.slug, build.id)}/?storyId=${encodeURIComponent(snap.storyId)}`}\n target=\"_blank\"\n rel=\"noopener\"\n >\n Preview\n </Button>\n {docsId ? (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n icon=\"book\"\n href={`/projects/${project.slug}/storybook/build/${build.id}/?storyId=${encodeURIComponent(docsId)}&viewMode=docs`}\n target=\"_blank\"\n rel=\"noopener\"\n >\n Docs\n </Button>\n ) : null}\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuCA,MAAM,cAAc,GAAG;;IAEnB,YAAY;;;;;AAMhB,eAAsB,kBACpB,MACA,QACiC;CACjC,MAAM,EAAE,IAAI,YAAY,SAAS;CACjC,MAAM,UAAU,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,UAAU,IAAI;CACzD,IAAI,CAAC,SAAS,OAAO;CACrB,MAAM,QAAQ,MAAM,gBAAgB,IAAI,SAAS,MAAM;CACvD,IAAI,CAAC,OAAO,OAAO,mBAAmB,OAAO;CAC7C,MAAM,YAAY,MAAM,IAAI,cAAc,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CAClE,IAAI,UAAU,WAAW,GAAG,OAAO,qBAAqB,SAAS,KAAK;CAGtE,OAAO,kBAAkB,SAAS,OAAO,WAAW,MAF7B,iBAAiB,IAAI,QAAQ,EAAE,GAEQ,MADpC,mBAAmB,SAAS,QAAQ,IAAI,MAAM,IAAI,SAAS,CACZ;AAC3E;AAEA,eAAe,gBACb,IACA,SACA,QACuB;CACvB,MAAM,aAAa,IAAI,WAAW,EAAE;CACpC,MAAM,UAAU,QAAQ,KAAK;CAC7B,MAAM,SAAS,YAAY,KAAA,KAAa,YAAY,KAAK,QAAQ,mBAAmB;CACpF,MAAM,WAAW,MAAM,WAAW,KAAK,QAAQ,IAAI,EAAE,QAAQ,OAAO,CAAC;CACrE,IAAI,SAAS,IAAI,OAAO,SAAS;CACjC,IAAI,WAAW,QAAQ,kBAAkB;EACvC,MAAM,WAAW,MAAM,WAAW,KAAK,QAAQ,IAAI,EAAE,QAAQ,QAAQ,iBAAiB,CAAC;EACvF,IAAI,SAAS,IAAI,OAAO,SAAS;CACnC;CAEA,QAAO,MADW,WAAW,KAAK,QAAQ,EAAE,EAAA,CACjC,MAAM;AACnB;AAEA,eAAe,iBACb,IACA,WACmB;CACnB,MAAM,aAAa,MAAM,IAAI,WAAW,EAAE,CAAC,CAAC,KAAK,SAAS;CAC1D,OAAO,CAAC,GAAG,IAAI,IAAI,WAAW,KAAK,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,UAAU,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;AAC/F;;;;;;;AAcA,eAAe,mBACb,SACA,WACA,SACA,WAC8B;CAC9B,MAAM,WAAW,MAAM,iBAAiB,SAAS,WAAW,OAAO;CACnE,MAAM,0BAAU,IAAI,IAAoB;CACxC,KAAK,MAAM,QAAQ,WAAW;EAC5B,MAAM,SAAS,YAAY,UAAU,KAAK,OAAO;EACjD,IAAI,QAAQ,QAAQ,IAAI,KAAK,SAAS,MAAM;CAC9C;CACA,OAAO;AACT;AAEA,eAAe,iBACb,SACA,WACA,SACsB;CACtB,MAAM,aAAa,MAAM,QAAQ,IAC/B,CAAC,cAAc,cAAc,CAAC,CAAC,KAAK,SAClC,iBAAiB,SAAS,WAAW,SAAS,IAAI,CACpD,CACF;CACA,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,WAAW,YACpB,IAAI,SAAS;EACX,KAAK,MAAM,SAAS,OAAO,OAAO,OAAO,GACvC,IAAI,OAAO,SAAS,UAAU,OAAO,MAAM,OAAO,YAAY,MAAM,GAAG,SAAS,QAAQ,GACtF,SAAS,IAAI,MAAM,GAAG,MAAM,GAAG,EAAgB,CAAC;EAGpD,OAAO;CACT;CAEF,OAAO;AACT;AAEA,eAAe,iBACb,SACA,WACA,SACA,MACmD;CACnD,IAAI;EACF,MAAM,MAAM,MAAM,QAAQ,KAAK,MAAM,KAAK,aAAa,WAAW,OAAO,GAAG,IAAI,CAAC;EAIjF,OAHe,KAAK,MAAM,IAAI,SAAS,MAAM,CAGjC,CAAC,CAAC,WAAW;CAC3B,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,YAAY,UAAuB,SAAqC;CAC/E,KAAK,MAAM,UAAU,UACnB,IAAI,QAAQ,WAAW,GAAG,OAAO,GAAG,KAAK,YAAY,QACnD,OAAO,GAAG,OAAO;AAIvB;AAEA,SAAS,mBAAmB,SAAmC;CAC7D,OACE,qBAAC,gBAAD;EACE,OAAM;EACN,KAAK;GAAE,QAAQ;GAAW,aAAa,QAAQ;GAAM,aAAa,QAAQ;EAAK;EAFjF,UAAA,CAIE,oBAAC,YAAD;GACE,OAAM;GACN,aACE,qBAAA,UAAA,EAAA,UAAA;IAAE;IAAiD,QAAQ;IAAiB;GAAG,EAAA,CAAA;EAElF,CAAA,GACD,oBAAC,YAAD;GAAY,OAAM;GAAgB,aAAY;EAA2C,CAAA,CAC3E;;AAEpB;AAEA,SAAS,qBAAqB,SAAkB,OAA+B;CAC7E,MAAM,OAAO,iBAAiB,KAAK,SAAS,CAAC,CAAC,OAAO,mBAAmB;CACxE,OACE,qBAAC,gBAAD;EACE,OAAM;EACN,KAAK;GAAE,QAAQ;GAAW,aAAa,QAAQ;GAAM,aAAa,QAAQ;EAAK;EAFjF,UAAA,CAIE,oBAAC,YAAD;GACE,OAAM;GACN,aACE,qBAAA,UAAA,EAAA,UAAA;IAAE;IACc,MAAM;IAAU;IAAI,MAAM,OAAO,MAAM,GAAG,CAAC;IAAE;IAAI,MAAM;GACrE,EAAA,CAAA;EAEL,CAAA,GACD,oBAAC,YAAD;GACE,OAAM;GACN,aAAY;GACZ,QACE,oBAAC,QAAD;IAAQ,SAAQ;IAAY,MAAM,KAAK,MAAM,QAAQ,MAAM,MAAM,EAAE;IAAG,UAAA;GAE9D,CAAA;EAEX,CAAA,CACa;;AAEpB;AAEA,SAAS,aAAa,WAAgD;CACpE,MAAM,sBAAM,IAAI,IAAwB;CACxC,KAAK,MAAM,QAAQ,WAAW;EAC5B,MAAM,MAAM,KAAK,cAAc;EAC/B,MAAM,OAAO,IAAI,IAAI,GAAG,KAAK,CAAC;EAC9B,KAAK,KAAK,IAAI;EACd,IAAI,IAAI,KAAK,IAAI;CACnB;CACA,OAAO;AACT;AAEA,SAAS,kBAAkB,WAAiC;CAC1D,OAAO,CAAC,GAAG,IAAI,IAAI,UAAU,KAAK,MAAM,EAAE,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;AACrE;AAEA,SAAS,kBACP,SACA,OACA,WACA,UACA,aACiB;CACjB,MAAM,UAAU,aAAa,SAAS;CACtC,MAAM,gBAAgB,kBAAkB,SAAS;CACjD,MAAM,QAAQ,cAAc,SAAS;CACrC,OACE,qBAAC,gBAAD;EACE,OAAM;EACN,KAAK;GAAE,QAAQ;GAAW,aAAa,QAAQ;GAAM,aAAa,QAAQ;EAAK;EAFjF,UAAA;GAIG,oBAAoB,SAAS,OAAO,UAAU,QAAQ,QAAQ;GAC9D,QACC,oBAAC,MAAD,EAAA,UACE,qBAAC,MAAD;IAAM,IAAG;IAAT,UAAA,CAAgB,eAAY,cAAc,KAAK,KAAK,CAAQ;GACxD,CAAA,EAAA,CAAA,IACJ;GACH,gBAAgB,SAAS,SAAS,OAAO,OAAO,WAAW;GAC5D,oBAAC,OAAD;IAAK,qBAAA;IAAkB,QAAA;IACrB,UAAA,oBAAC,MAAD;KAAM,QAAA;KAAO,UAAA;IAAmC,CAAA;GAC7C,CAAA;EACS;;AAEpB;AAEA,SAAS,oBACP,SACA,OACA,YACA,UACgD;CAChD,MAAM,OAAO,iBAAiB,KAAK,SAAS,CAAC,CAAC,OAAO,mBAAmB;CACxE,OACE,oBAAC,YAAD;EACE,OAAM;EACN,aACE,qBAAA,UAAA,EAAA,UAAA;GACG;GAAW;GAAwB;GACpC,qBAAC,KAAD;IAAG,MAAM,KAAK,MAAM,QAAQ,MAAM,MAAM,EAAE;IAA1C,UAAA;KACG,MAAM;KAAU;KAAI,MAAM,OAAO,MAAM,GAAG,CAAC;IAC3C;;GAAE;GAAI;GACN,IAAI,KAAK,MAAM,SAAS,CAAC,CAAC,eAAe;GAAE;GAAI,MAAM;EACxD,EAAA,CAAA;EAEJ,SACE,qBAAC,QAAD,EAAA,UAAA;GACE,oBAAC,aAAD;IAAa,OAAM;IAAiB,aAAY;GAAmB,CAAA;GACnE,oBAAC,QAAD;IAAQ,SAAQ;IAAQ,MAAK;IAAK,MAAK;IAAgB,MAAM,KAAK,MAAM,MAAM,EAAE;IAAG,UAAA;GAE3E,CAAA;GACP,mBAAmB,SAAS,OAAO,QAAQ;EACtC,EAAA,CAAA;CAEX,CAAA;AAEL;AAEA,SAAS,mBACP,SACA,OACA,UACgD;CAChD,OACE,oBAAC,QAAD;EAAM,QAAO;EAAM,QAAQ,aAAa,QAAQ,KAAK;EACnD,UAAA,qBAAC,QAAD,EAAA,UAAA,CACE,oBAAC,aAAD;GACE,OAAM;GACN,MAAK;GACL,QAAO;GACP,OAAO,MAAM;GACb,SAAS,SAAS,KAAK,OAAO;IAAE,OAAO;IAAG,OAAO;GAAE,EAAE;EACtD,CAAA,GACD,oBAAC,QAAD;GAAQ,SAAQ;GAAY,MAAK;GAAS,UAAA;EAElC,CAAA,CACF,EAAA,CAAA;CACJ,CAAA;AAEV;AAEA,SAAS,gBACP,SACA,SACA,OACA,OACA,aACgD;CAChD,MAAM,SAAS,CAAC,GAAG,QAAQ,KAAK,CAAC,CAAC,CAAC,UAAU,GAAG,MAAM,EAAE,cAAc,CAAC,CAAC;CACxE,OACE,oBAAC,QAAD,EAAA,UACG,OAAO,KAAK,UACX,iBAAiB,OAAO,QAAQ,IAAI,KAAK,KAAK,CAAC,GAAG,SAAS,OAAO,OAAO,WAAW,CACtF,EACM,CAAA;AAEZ;AAEA,SAAS,iBACP,OACA,OACA,SACA,OACA,sBACA,aACgD;CAChD,MAAM,aAAa,gBAAgB,KAAK;CACxC,MAAM,SAAS,CAAC,GAAG,WAAW,KAAK,CAAC,CAAC,CAAC,SAAS;CAC/C,OACE,qBAAC,MAAD;EAEE,QAAQ;EACR,oBAAA;EACA,oBAAkB,GAAG,MAAM,GAAG,MAAM,KAAK,SAAS,KAAK,SAAS,CAAC,CAAC,KAAK,GAAG;EAJ5E,UAAA,CAME,qBAAC,aAAD;GAAa,SAAA;GAAb,UAAA,CACE,oBAAC,cAAD,EAAA,UAAe,MAAoB,CAAA,GACnC,qBAAC,MAAD;IAAM,IAAG;IAAT,UAAA,CAAiB,MAAM,QAAO,UAAc;GACjC,CAAA,CAAA;EACb,CAAA,GAAA,oBAAC,aAAD,EAAA,UACE,oBAAC,QAAD,EAAA,UACG,OAAO,KAAK,UACX,oBACE,OACA,WAAW,IAAI,KAAK,KAAK,CAAC,GAC1B,SACA,OACA,sBACA,WACF,CACF,EACM,CAAA,EACG,CAAA,CACT;CAvBC,GAAA,KAuBD;AAEV;AAEA,SAAS,gBAAgB,OAA4C;CACnE,MAAM,sBAAM,IAAI,IAAwB;CACxC,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,OAAO,IAAI,IAAI,KAAK,YAAY,KAAK,CAAC;EAC5C,KAAK,KAAK,IAAI;EACd,IAAI,IAAI,KAAK,cAAc,IAAI;CACjC;CACA,OAAO;AACT;AAEA,SAAS,oBACP,OACA,OACA,SACA,OACA,sBACA,aACgD;CAChD,OACE,qBAAC,OAAD,EAAA,UAAA,CACG,uBAAuB,oBAAC,cAAD;EAAc,OAAO;EAAI,UAAA;CAAoB,CAAA,IAAI,MACzE,oBAAC,OAAD;EAAK,OAAO;EACT,UAAA,MACE,UAAU,GAAG,MAAM,EAAE,UAAU,cAAc,EAAE,SAAS,CAAC,CAAC,CAC1D,KAAK,SAAS,mBAAmB,MAAM,SAAS,OAAO,WAAW,CAAC;CACnE,CAAA,CACF,EAAA,GAPK,KAOL;AAET;AAEA,SAAS,mBACP,MACA,SACA,OACA,aACgD;CAChD,MAAM,SAAS,YAAY,IAAI,KAAK,OAAO;CAC3C,OACE,qBAAC,OAAD;EAEE,OAAO;EACP,oBAAA;EACA,oBAAkB,GAAG,KAAK,WAAW,GAAG,KAAK;EAJ/C,UAAA,CAME,qBAAC,OAAD;GAAK,OAAO;GAAZ,UAAA,CACE,oBAAC,UAAD,EAAA,UAAS,KAAK,UAAkB,CAAA,GAChC,qBAAC,QAAD;IAAM,OAAO;IAAb,UAAA;KACE,oBAAC,OAAD;MAAO,MAAM,WAAW,KAAK,MAAM;MAAI,UAAA,KAAK;KAAc,CAAA;KAAC;KAAE,KAAK;KAAc;KAC/E,KAAK;IACF;GACH,CAAA,CAAA;EACL,CAAA,GAAA,qBAAC,OAAD;GAAK,OAAO;GAAZ,UAAA,CACE,oBAAC,OAAD;IACE,OAAO;IACP,KAAK,oBAAoB,QAAQ,KAAK,UAAU,MAAM,GAAG,aAAa,KAAK,GAAG;IAC9E,KAAK,GAAG,KAAK,WAAW,KAAK,KAAK;IAClC,SAAQ;GACT,CAAA,GACD,oBAAC,QAAD,EAAA,UAAS,kBAAkB,MAAM,SAAS,OAAO,MAAM,EAAU,CAAA,CAC9D;EACF,CAAA,CAAA;CArBE,GAAA,KAAK,EAqBP;AAET;AAEA,SAAS,kBACP,MACA,SACA,OACA,QACgD;CAChD,MAAM,OAAO,iBAAiB,KAAK,SAAS,CAAC,CAAC,OAAO,mBAAmB;CACxE,OACE,qBAAA,UAAA,EAAA,UAAA;EACE,oBAAC,QAAD;GACE,SAAQ;GACR,MAAK;GACL,MAAK;GACL,MAAM,GAAG,KAAK,UAAU,QAAQ,MAAM,MAAM,EAAE,EAAE,YAAY,KAAK;GAClE,UAAA;EAEO,CAAA;EACR,oBAAC,QAAD;GACE,SAAQ;GACR,MAAK;GACL,MAAK;GACL,MAAM,GAAG,KAAK,eAAe,QAAQ,MAAM,MAAM,EAAE,EAAE,YAAY,mBAAmB,KAAK,OAAO;GAChG,QAAO;GACP,KAAI;GACL,UAAA;EAEO,CAAA;EACP,SACC,oBAAC,QAAD;GACE,SAAQ;GACR,MAAK;GACL,MAAK;GACL,MAAM,aAAa,QAAQ,KAAK,mBAAmB,MAAM,GAAG,YAAY,mBAAmB,MAAM,EAAE;GACnG,QAAO;GACP,KAAI;GACL,UAAA;EAEO,CAAA,IACN;CACJ,EAAA,CAAA;AAEN"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
import { getStore } from "../store.mjs";
|
|
5
|
+
import { Button } from "../ui/buttons.mjs";
|
|
6
|
+
import { Alert, Meta } from "../ui/feedback.mjs";
|
|
7
|
+
import { Field } from "../ui/forms.mjs";
|
|
8
|
+
import { Card, PageHeader } from "../ui/layout.mjs";
|
|
9
|
+
import { VStack } from "../ui/stacks.mjs";
|
|
10
|
+
import { DocumentLayout } from "../ui/document.mjs";
|
|
11
|
+
import { passkeyLoginScript } from "./passkey-ceremony.mjs";
|
|
12
|
+
import { jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
13
|
+
//#region src/pages/login.tsx
|
|
14
|
+
/** Resolve auth UI text with defaults. */
|
|
15
|
+
function authUi() {
|
|
16
|
+
try {
|
|
17
|
+
return getStore().ui.auth ?? {};
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function ssoLabel(template, label) {
|
|
23
|
+
const raw = template ?? "Sign in with {label}";
|
|
24
|
+
return raw.includes("{label}") ? raw.replaceAll("{label}", label) : `${raw} ${label}`;
|
|
25
|
+
}
|
|
26
|
+
/** Sign-in page with engine login widgets. */
|
|
27
|
+
function renderLoginPage(state = {}) {
|
|
28
|
+
const ui = authUi();
|
|
29
|
+
const title = ui.title ?? "Sign in";
|
|
30
|
+
const subtitle = ui.subtitle;
|
|
31
|
+
const passwordLabel = ui.passwordLabel ?? "Password";
|
|
32
|
+
const submitLabel = ui.submitLabel ?? "Sign in";
|
|
33
|
+
return /* @__PURE__ */ jsx(DocumentLayout, {
|
|
34
|
+
title,
|
|
35
|
+
layout: "bare",
|
|
36
|
+
children: /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsxs(VStack, {
|
|
37
|
+
gap: "lg",
|
|
38
|
+
children: [
|
|
39
|
+
/* @__PURE__ */ jsx(PageHeader, {
|
|
40
|
+
title,
|
|
41
|
+
description: subtitle
|
|
42
|
+
}),
|
|
43
|
+
state.error ? /* @__PURE__ */ jsx(Alert, {
|
|
44
|
+
tone: "danger",
|
|
45
|
+
title: "Could not sign in",
|
|
46
|
+
children: state.error
|
|
47
|
+
}) : null,
|
|
48
|
+
state.engineMethods?.some((method) => method.kind === "password") ? /* @__PURE__ */ jsx("form", {
|
|
49
|
+
method: "post",
|
|
50
|
+
action: "/auth/engine/login",
|
|
51
|
+
novalidate: true,
|
|
52
|
+
children: /* @__PURE__ */ jsxs(VStack, { children: [
|
|
53
|
+
/* @__PURE__ */ jsx(Field, {
|
|
54
|
+
label: "Email",
|
|
55
|
+
name: "email",
|
|
56
|
+
type: "email",
|
|
57
|
+
required: true,
|
|
58
|
+
value: state.email,
|
|
59
|
+
autocomplete: "email"
|
|
60
|
+
}),
|
|
61
|
+
/* @__PURE__ */ jsx(Field, {
|
|
62
|
+
label: passwordLabel,
|
|
63
|
+
name: "password",
|
|
64
|
+
type: "password",
|
|
65
|
+
required: true,
|
|
66
|
+
autocomplete: "current-password",
|
|
67
|
+
placeholder: ui.passwordPlaceholder
|
|
68
|
+
}),
|
|
69
|
+
/* @__PURE__ */ jsx(Button, {
|
|
70
|
+
variant: "primary",
|
|
71
|
+
type: "submit",
|
|
72
|
+
children: submitLabel
|
|
73
|
+
})
|
|
74
|
+
] })
|
|
75
|
+
}) : null,
|
|
76
|
+
state.engineMethods?.filter((method) => method.kind === "oauth" || method.kind === "sso").map((provider) => /* @__PURE__ */ jsx(Button, {
|
|
77
|
+
variant: "secondary",
|
|
78
|
+
href: provider.url,
|
|
79
|
+
children: ssoLabel(ui.ssoLabelTemplate, provider.label)
|
|
80
|
+
}, provider.id)),
|
|
81
|
+
state.engineMethods?.some((method) => method.kind === "passkey") ? /* @__PURE__ */ jsxs("div", {
|
|
82
|
+
class: "mt-1",
|
|
83
|
+
children: [/* @__PURE__ */ jsxs(VStack, { children: [
|
|
84
|
+
/* @__PURE__ */ jsx(Meta, {
|
|
85
|
+
center: true,
|
|
86
|
+
children: "or"
|
|
87
|
+
}),
|
|
88
|
+
/* @__PURE__ */ jsx(Button, {
|
|
89
|
+
variant: "secondary",
|
|
90
|
+
type: "button",
|
|
91
|
+
"data-passkey-login": "true",
|
|
92
|
+
children: "Sign in with a passkey"
|
|
93
|
+
}),
|
|
94
|
+
/* @__PURE__ */ jsx("p", {
|
|
95
|
+
class: "login__help",
|
|
96
|
+
"data-passkey-login-status": "true",
|
|
97
|
+
"aria-live": "polite",
|
|
98
|
+
children: /* @__PURE__ */ jsx(Meta, {})
|
|
99
|
+
})
|
|
100
|
+
] }), /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: passkeyLoginScript() } })]
|
|
101
|
+
}) : null,
|
|
102
|
+
ui.helpText ? /* @__PURE__ */ jsx("p", {
|
|
103
|
+
class: "login__help",
|
|
104
|
+
children: /* @__PURE__ */ jsx(Meta, { children: ui.helpText })
|
|
105
|
+
}) : null,
|
|
106
|
+
ui.footerText ? /* @__PURE__ */ jsx("p", {
|
|
107
|
+
class: "login__footer",
|
|
108
|
+
children: /* @__PURE__ */ jsx(Meta, { children: ui.footerText })
|
|
109
|
+
}) : null
|
|
110
|
+
]
|
|
111
|
+
}) })
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { renderLoginPage };
|
|
116
|
+
|
|
117
|
+
//# sourceMappingURL=login.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.mjs","names":[],"sources":["../../src/pages/login.tsx"],"sourcesContent":["import { getStore } from \"../store.ts\";\nimport { Alert, Button, Card, Field, Meta, PageHeader, VStack } from \"../ui/components.tsx\";\nimport { DocumentLayout, type RenderedContent } from \"../ui/document.tsx\";\nimport { passkeyLoginScript } from \"./passkey-ceremony.ts\";\n\n/** One engine login widget (descriptor-driven). */\nexport interface EngineMethodLink {\n kind: \"password\" | \"oauth\" | \"passkey\" | \"sso\";\n id: string;\n label: string;\n url: string;\n}\n\n/** Form state for the sign-in page (engine widgets and error message). */\nexport interface LoginPageState {\n error?: string;\n /** Engine login widgets (descriptor-driven). */\n engineMethods?: EngineMethodLink[];\n /** Pre-filled email for the password form (e.g. after failed login). */\n email?: string;\n}\n\n/** Resolve auth UI text with defaults. */\nfunction authUi(): NonNullable<import(\"@storyshelf/core/config\").UIConfig[\"auth\"]> {\n try {\n return getStore().ui.auth ?? {};\n } catch {\n return {};\n }\n}\n\nfunction ssoLabel(template: string | undefined, label: string): string {\n const raw = template ?? \"Sign in with {label}\";\n return raw.includes(\"{label}\") ? raw.replaceAll(\"{label}\", label) : `${raw} ${label}`;\n}\n\n/** Sign-in page with engine login widgets. */\n// oxlint-disable-next-line eslint/max-lines-per-function -- page composes a few sections, still one concern\nexport function renderLoginPage(state: LoginPageState = {}): RenderedContent {\n const ui = authUi();\n const title = ui.title ?? \"Sign in\";\n const subtitle = ui.subtitle;\n const passwordLabel = ui.passwordLabel ?? \"Password\";\n const submitLabel = ui.submitLabel ?? \"Sign in\";\n return (\n <DocumentLayout title={title} layout=\"bare\">\n <Card>\n <VStack gap=\"lg\">\n <PageHeader title={title} description={subtitle} />\n\n {state.error ? (\n <Alert tone=\"danger\" title=\"Could not sign in\">\n {state.error}\n </Alert>\n ) : null}\n\n {state.engineMethods?.some((method) => method.kind === \"password\") ? (\n <form method=\"post\" action=\"/auth/engine/login\" novalidate>\n <VStack>\n <Field\n label=\"Email\"\n name=\"email\"\n type=\"email\"\n required\n value={state.email}\n autocomplete=\"email\"\n />\n <Field\n label={passwordLabel}\n name=\"password\"\n type=\"password\"\n required\n autocomplete=\"current-password\"\n placeholder={ui.passwordPlaceholder}\n />\n <Button variant=\"primary\" type=\"submit\">\n {submitLabel}\n </Button>\n </VStack>\n </form>\n ) : null}\n\n {state.engineMethods\n ?.filter((method) => method.kind === \"oauth\" || method.kind === \"sso\")\n .map(\n // oxlint-disable-next-line typescript/promise-function-async -- JSX map is sync\n (provider) => (\n <Button variant=\"secondary\" href={provider.url} key={provider.id}>\n {ssoLabel(ui.ssoLabelTemplate, provider.label)}\n </Button>\n ),\n )}\n\n {state.engineMethods?.some((method) => method.kind === \"passkey\") ? (\n <div class=\"mt-1\">\n <VStack>\n <Meta center>or</Meta>\n <Button variant=\"secondary\" type=\"button\" data-passkey-login=\"true\">\n Sign in with a passkey\n </Button>\n <p class=\"login__help\" data-passkey-login-status=\"true\" aria-live=\"polite\">\n <Meta />\n </p>\n </VStack>\n <script dangerouslySetInnerHTML={{ __html: passkeyLoginScript() }} />\n </div>\n ) : null}\n\n {ui.helpText ? (\n <p class=\"login__help\">\n <Meta>{ui.helpText}</Meta>\n </p>\n ) : null}\n\n {ui.footerText ? (\n <p class=\"login__footer\">\n <Meta>{ui.footerText}</Meta>\n </p>\n ) : null}\n </VStack>\n </Card>\n </DocumentLayout>\n );\n}\n"],"mappings":";;;;;;;;;;;;;;AAuBA,SAAS,SAA0E;CACjF,IAAI;EACF,OAAO,SAAS,CAAC,CAAC,GAAG,QAAQ,CAAC;CAChC,QAAQ;EACN,OAAO,CAAC;CACV;AACF;AAEA,SAAS,SAAS,UAA8B,OAAuB;CACrE,MAAM,MAAM,YAAY;CACxB,OAAO,IAAI,SAAS,SAAS,IAAI,IAAI,WAAW,WAAW,KAAK,IAAI,GAAG,IAAI,GAAG;AAChF;;AAIA,SAAgB,gBAAgB,QAAwB,CAAC,GAAoB;CAC3E,MAAM,KAAK,OAAO;CAClB,MAAM,QAAQ,GAAG,SAAS;CAC1B,MAAM,WAAW,GAAG;CACpB,MAAM,gBAAgB,GAAG,iBAAiB;CAC1C,MAAM,cAAc,GAAG,eAAe;CACtC,OACE,oBAAC,gBAAD;EAAuB;EAAO,QAAO;EACnC,UAAA,oBAAC,MAAD,EAAA,UACE,qBAAC,QAAD;GAAQ,KAAI;GAAZ,UAAA;IACE,oBAAC,YAAD;KAAmB;KAAO,aAAa;IAAW,CAAA;IAEjD,MAAM,QACL,oBAAC,OAAD;KAAO,MAAK;KAAS,OAAM;KACxB,UAAA,MAAM;IACF,CAAA,IACL;IAEH,MAAM,eAAe,MAAM,WAAW,OAAO,SAAS,UAAU,IAC/D,oBAAC,QAAD;KAAM,QAAO;KAAO,QAAO;KAAqB,YAAA;KAC9C,UAAA,qBAAC,QAAD,EAAA,UAAA;MACE,oBAAC,OAAD;OACE,OAAM;OACN,MAAK;OACL,MAAK;OACL,UAAA;OACA,OAAO,MAAM;OACb,cAAa;MACd,CAAA;MACD,oBAAC,OAAD;OACE,OAAO;OACP,MAAK;OACL,MAAK;OACL,UAAA;OACA,cAAa;OACb,aAAa,GAAG;MACjB,CAAA;MACD,oBAAC,QAAD;OAAQ,SAAQ;OAAU,MAAK;OAC5B,UAAA;MACK,CAAA;KACF,EAAA,CAAA;IACJ,CAAA,IACJ;IAEH,MAAM,eACH,QAAQ,WAAW,OAAO,SAAS,WAAW,OAAO,SAAS,KAAK,CAAC,CACrE,KAEE,aACC,oBAAC,QAAD;KAAQ,SAAQ;KAAY,MAAM,SAAS;KACxC,UAAA,SAAS,GAAG,kBAAkB,SAAS,KAAK;IACvC,GAF6C,SAAS,EAEtD,CAEZ;IAED,MAAM,eAAe,MAAM,WAAW,OAAO,SAAS,SAAS,IAC9D,qBAAC,OAAD;KAAK,OAAM;KAAX,UAAA,CACE,qBAAC,QAAD,EAAA,UAAA;MACE,oBAAC,MAAD;OAAM,QAAA;OAAO,UAAA;MAAQ,CAAA;MACrB,oBAAC,QAAD;OAAQ,SAAQ;OAAY,MAAK;OAAS,sBAAmB;OAAO,UAAA;MAE5D,CAAA;MACR,oBAAC,KAAD;OAAG,OAAM;OAAc,6BAA0B;OAAO,aAAU;OAChE,UAAA,oBAAC,MAAD,CAAO,CAAA;MACN,CAAA;KACG,EAAA,CAAA,GACR,oBAAC,UAAD,EAAQ,yBAAyB,EAAE,QAAQ,mBAAmB,EAAE,EAAI,CAAA,CACjE;IACH,CAAA,IAAA;IAEH,GAAG,WACF,oBAAC,KAAD;KAAG,OAAM;KACP,UAAA,oBAAC,MAAD,EAAA,UAAO,GAAG,SAAe,CAAA;IACxB,CAAA,IACD;IAEH,GAAG,aACF,oBAAC,KAAD;KAAG,OAAM;KACP,UAAA,oBAAC,MAAD,EAAA,UAAO,GAAG,WAAiB,CAAA;IAC1B,CAAA,IACD;GACE;EACJ,CAAA,EAAA,CAAA;CACQ,CAAA;AAEpB"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import "node:path";
|
|
2
|
+
import "node:url";
|
|
3
|
+
import.meta.url;
|
|
4
|
+
//#region src/pages/passkey-ceremony.ts
|
|
5
|
+
/**
|
|
6
|
+
* Page-local WebAuthn ceremony scripts (vanilla JS, no dependencies).
|
|
7
|
+
*
|
|
8
|
+
* `login.tsx` (authenticate) and `profile.tsx` (register) inline these via
|
|
9
|
+
* `dangerouslySetInnerHTML`. Both feature-detect first: without
|
|
10
|
+
* `PublicKeyCredential` or a secure context the button disables and a note
|
|
11
|
+
* explains passkeys need HTTPS or localhost.
|
|
12
|
+
*/
|
|
13
|
+
const B64_HELPERS = `
|
|
14
|
+
function b64dec(s){s=s.replace(/-/g,'+').replace(/_/g,'/');while(s.length%4){s+='=';}var bin=atob(s);var b=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++){b[i]=bin.charCodeAt(i);}return b;}
|
|
15
|
+
function b64enc(buf){var bin='';var b=new Uint8Array(buf);for(var i=0;i<b.length;i++){bin+=String.fromCharCode(b[i]);}return btoa(bin).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');}
|
|
16
|
+
`.trim();
|
|
17
|
+
const SUPPORT_CHECK = `
|
|
18
|
+
if(!window.PublicKeyCredential||!window.isSecureContext){
|
|
19
|
+
btn.disabled=true;
|
|
20
|
+
say('Passkeys need HTTPS or localhost.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
`.trim();
|
|
24
|
+
/** Registration ceremony for the profile page (E7 flow, shared helpers). */
|
|
25
|
+
function passkeyRegisterScript() {
|
|
26
|
+
return `
|
|
27
|
+
(function(){
|
|
28
|
+
var btn=document.querySelector('[data-passkey-register]');
|
|
29
|
+
if(!btn){return;}
|
|
30
|
+
function say(msg){var el=document.querySelector('[data-passkey-status]');if(el){el.textContent=msg;}}
|
|
31
|
+
${B64_HELPERS}
|
|
32
|
+
${SUPPORT_CHECK}
|
|
33
|
+
btn.addEventListener('click',function(){
|
|
34
|
+
btn.disabled=true;
|
|
35
|
+
say('Waiting for your authenticator…');
|
|
36
|
+
var nameInput=document.querySelector('input[name="keyName"]');
|
|
37
|
+
var name=nameInput&&nameInput.value.trim()?nameInput.value.trim():undefined;
|
|
38
|
+
fetch('/api/auth/passkey/generate-register-options',{credentials:'same-origin',headers:{'accept':'application/json'}})
|
|
39
|
+
.then(function(r){if(!r.ok){throw new Error('options '+r.status);}return r.json();})
|
|
40
|
+
.then(function(o){
|
|
41
|
+
var pk={challenge:b64dec(o.challenge),rp:o.rp,user:{id:b64dec(o.user.id),name:o.user.name,displayName:o.user.displayName||o.user.name},pubKeyCredParams:o.pubKeyCredParams};
|
|
42
|
+
if(o.timeout){pk.timeout=o.timeout;}
|
|
43
|
+
if(o.excludeCredentials){pk.excludeCredentials=o.excludeCredentials.map(function(c){return{id:b64dec(c.id),type:c.type,transports:c.transports};});}
|
|
44
|
+
if(o.authenticatorSelection){pk.authenticatorSelection=o.authenticatorSelection;}
|
|
45
|
+
if(o.attestation){pk.attestation=o.attestation;}
|
|
46
|
+
return navigator.credentials.create({publicKey:pk});
|
|
47
|
+
})
|
|
48
|
+
.then(function(cred){
|
|
49
|
+
var payload={id:cred.id,rawId:b64enc(cred.rawId),type:cred.type,response:{clientDataJSON:b64enc(cred.response.clientDataJSON),attestationObject:b64enc(cred.response.attestationObject)}};
|
|
50
|
+
if(cred.response.getTransports){payload.response.transports=cred.response.getTransports();}
|
|
51
|
+
if(name){payload.name=name;}
|
|
52
|
+
return fetch('/api/auth/passkey/verify-registration',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({response:payload,name:name})});
|
|
53
|
+
})
|
|
54
|
+
.then(function(r){if(!r.ok){throw new Error('verify '+r.status);}window.location.reload();})
|
|
55
|
+
.catch(function(){say('Registration failed — try again.');btn.disabled=false;});
|
|
56
|
+
});
|
|
57
|
+
})();
|
|
58
|
+
`.trim();
|
|
59
|
+
}
|
|
60
|
+
/** Authentication ceremony for the login page (E8 flow). */
|
|
61
|
+
function passkeyLoginScript() {
|
|
62
|
+
return `
|
|
63
|
+
(function(){
|
|
64
|
+
var btn=document.querySelector('[data-passkey-login]');
|
|
65
|
+
if(!btn){return;}
|
|
66
|
+
function say(msg){var el=document.querySelector('[data-passkey-login-status]');if(el){el.textContent=msg;}}
|
|
67
|
+
${B64_HELPERS}
|
|
68
|
+
${SUPPORT_CHECK}
|
|
69
|
+
btn.addEventListener('click',function(){
|
|
70
|
+
btn.disabled=true;
|
|
71
|
+
say('Waiting for your authenticator…');
|
|
72
|
+
fetch('/api/auth/passkey/generate-authenticate-options',{credentials:'same-origin',headers:{'accept':'application/json'}})
|
|
73
|
+
.then(function(r){if(!r.ok){throw new Error('options '+r.status);}return r.json();})
|
|
74
|
+
.then(function(o){
|
|
75
|
+
var pk={challenge:b64dec(o.challenge)};
|
|
76
|
+
if(o.timeout){pk.timeout=o.timeout;}
|
|
77
|
+
if(o.rpId){pk.rpId=o.rpId;}
|
|
78
|
+
if(o.allowCredentials){pk.allowCredentials=o.allowCredentials.map(function(c){return{id:b64dec(c.id),type:c.type,transports:c.transports};});}
|
|
79
|
+
if(o.userVerification){pk.userVerification=o.userVerification;}
|
|
80
|
+
return navigator.credentials.get({publicKey:pk});
|
|
81
|
+
})
|
|
82
|
+
.then(function(cred){
|
|
83
|
+
var authenticatorResponse={authenticatorData:b64enc(cred.response.authenticatorData),clientDataJSON:b64enc(cred.response.clientDataJSON),signature:b64enc(cred.response.signature)};
|
|
84
|
+
if(cred.response.userHandle){authenticatorResponse.userHandle=b64enc(cred.response.userHandle);}
|
|
85
|
+
var payload={id:cred.id,rawId:b64enc(cred.rawId),type:cred.type,response:authenticatorResponse};
|
|
86
|
+
return fetch('/api/auth/passkey/verify-authentication',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({response:payload})});
|
|
87
|
+
})
|
|
88
|
+
.then(function(r){if(!r.ok){throw new Error('verify '+r.status);}window.location.href='/';})
|
|
89
|
+
.catch(function(){say('Passkey sign-in failed — try again.');btn.disabled=false;});
|
|
90
|
+
});
|
|
91
|
+
})();
|
|
92
|
+
`.trim();
|
|
93
|
+
}
|
|
94
|
+
//#endregion
|
|
95
|
+
export { passkeyLoginScript, passkeyRegisterScript };
|
|
96
|
+
|
|
97
|
+
//# sourceMappingURL=passkey-ceremony.mjs.map
|