create-bearminion 1.4.0 → 1.6.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -3
  3. package/package.json +3 -3
  4. package/template/README.md +3 -3
  5. package/template/package.json +4 -4
  6. package/template/src/components/atoms/Typography.astro +5 -2
  7. package/template/src/components/sections/SectionList.astro +6 -3
  8. package/template/src/components/sections/definitions.ts +4 -2
  9. package/template/src/components/sections/gallery/Gallery.astro +128 -0
  10. package/template/src/components/sections/gallery/Gallery.stories.ts +24 -0
  11. package/template/src/components/sections/gallery/definition.ts +59 -0
  12. package/template/src/components/sections/gallery/lightbox.ts +159 -0
  13. package/template/src/components/sections/gallery/wireframe.ts +12 -0
  14. package/template/src/components/sections/media/Media.astro +1 -0
  15. package/template/src/components/sections/media/MediaPlayer.astro +29 -1
  16. package/template/src/components/sections/media/definition.ts +6 -1
  17. package/template/src/components/sections/media/source.ts +3 -33
  18. package/template/src/components/sections/registry.ts +4 -2
  19. package/template/src/components/sections/text/Text.astro +43 -0
  20. package/template/src/components/sections/text/Text.stories.ts +17 -0
  21. package/template/src/components/sections/text/definition.ts +36 -0
  22. package/template/src/components/sections/text/wireframe.ts +12 -0
  23. package/template/src/components/seo/Seo.astro +11 -9
  24. package/template/src/lib/content-model.ts +4 -1
  25. package/template/src/lib/migrations.ts +27 -0
  26. package/template/src/styles/global.css +161 -0
  27. package/template/src/components/sections/video/Video.astro +0 -78
  28. package/template/src/components/sections/video/Video.stories.ts +0 -17
  29. package/template/src/components/sections/video/VideoFrame.astro +0 -60
  30. package/template/src/components/sections/video/definition.ts +0 -58
  31. package/template/src/components/sections/video/wireframe.ts +0 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # create-bearminion
2
2
 
3
+ ## 1.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - eec6f3d: New starter sections and editor features.
8
+
9
+ - **Gallery** section: grid, masonry and featured layouts, a keyboard-friendly lightbox, an optional box around each picture, and required alt text.
10
+ - **Text** section: long-form rich text for an About or policy page, with a narrow, readable column on by default. Rich-text fields can opt into **H2** and **H3** (`data-rich-headings`); `renderRichText`, `sanitizeRichHtml` and `stripPlainRich` take a `{ headings }` option.
11
+ - **Media** now covers video: the separate Video section is gone, and Media gains `playback` (`controls` or `loop`). Content `schemaVersion` 2 migrates existing Video sections to Media automatically.
12
+ - **Video uploads**: upload an mp4, webm, mov or ogv to `public/videos` from the editor (`videosDir`, `maxVideoBytes`, default 50 MB), pick an uploaded one, and see what a pasted link is or why it will not play. New API routes `GET`/`POST /videos` and `DELETE /videos/:filename`.
13
+ - `parseMediaSource` and `VIDEO_FILE` are exported from `bearminion`.
14
+
15
+ ### Patch Changes
16
+
17
+ - eec6f3d: Clearer package descriptions and READMEs: what BearMinion is, who it is for, and what `create-bearminion` refuses (a folder that is not new or empty).
18
+
19
+ ## 1.5.0
20
+
21
+ ### Patch Changes
22
+
23
+ - 8a428e7: Sitewide JSON-LD. `site.seo.jsonLd` is optional (up to 10 blocks, 50 KB, so no migration) and the default template emits its blocks on every page. A page's own `seo.jsonLd` still replaces only the generated `WebPage` block, and a block with the same `@id` is emitted once, the page's first. With the conventional ids `<url>/#organization` and `<url>/#website` in the site's blocks, the generated `WebPage` gets `isPartOf` and `publisher` as `@id` references. The Site tab has a Structured data group with an Edit JSON-LD dialog and a Generate button that fills an `Organization` and a `WebSite` from the site name, address and language, replacing blocks with the same `@id`. The logo is added only for a full https address. A warning shows while the site address is still `example.com`. `siteJsonLdIds`, `mergeJsonLd`, `hasJsonLdId`, `generateSiteJsonLd`, `upsertJsonLd` and `isPlaceholderSiteUrl` are exported; themes that emit JSON-LD should use `mergeJsonLd` (see the theme docs).
24
+
3
25
  ## 1.4.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- Create a website you edit by clicking on it. No database, no admin panel, no server.<br />
6
+ The BearMinion for the bare minimum. Create a website you edit by clicking on it: no database to patch at 3 AM, no admin panel, no server.<br />
7
7
  Built on <a href="https://astro.build">Astro</a>.
8
8
  </p>
9
9
 
@@ -50,11 +50,13 @@ Pass options after `--` with npm (`npm create bearminion@latest my-site -- --yes
50
50
  | `--no-git` | Skip creating a git repository |
51
51
  | `-y`, `--yes` | Ask nothing and use the defaults, for scripts and AI agents |
52
52
 
53
- The folder must be new or empty. Inside an existing git repository, the site is added as a folder and no new repository is made.
53
+ > **Don't poke the bear:** the folder must be new or empty (`.git` and `.DS_Store` are fine). It refuses anything else, and neither of us wants to untangle that.
54
+
55
+ Inside an existing git repository, the site is added as a folder and no new repository is made.
54
56
 
55
57
  ## What you get
56
58
 
57
- The [default BearMinion template](https://github.com/langehelp-software/bearminion/tree/main/templates/default): an Astro and Tailwind site with thirteen section types, the [`bearminion`](https://www.npmjs.com/package/bearminion) editor at the same version as this package, and content as JSON files you own and commit. The template ships inside this package, so it works offline and always matches its editor.
59
+ The [default BearMinion template](https://github.com/langehelp-software/bearminion/tree/main/templates/default): an Astro and Tailwind site with fourteen section types, the [`bearminion`](https://www.npmjs.com/package/bearminion) editor at the same version as this package, and content as JSON files you own and commit. The template ships inside this package, so it works offline and always matches its editor.
58
60
 
59
61
  ## Learn more
60
62
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-bearminion",
3
- "version": "1.4.0",
4
- "description": "Create a BearMinion site: an Astro website you edit by clicking on it.",
3
+ "version": "1.6.0",
4
+ "description": "Create a BearMinion site: an Astro website you edit by clicking on it. No database, no server.",
5
5
  "license": "MIT",
6
6
  "author": "Jakub Lange",
7
7
  "homepage": "https://bearminion.com",
@@ -46,7 +46,7 @@
46
46
  "@types/node": "^22.19.0",
47
47
  "tsdown": "0.23.1",
48
48
  "typescript": "~5.9.3",
49
- "vitest": "^5.0.4"
49
+ "vitest": "^5.0.5"
50
50
  },
51
51
  "scripts": {
52
52
  "build": "tsdown",
@@ -29,7 +29,7 @@ This template is yours to change: it is the theme. `bearminion` is the only runt
29
29
 
30
30
  Organisms are Header, Navigation, MobileMenu, and Footer. A modal, a dialog, and a cookie banner belong in this folder too. They are the chrome around every page (or a standalone widget), and they are fed by `content/site.json`.
31
31
 
32
- Sections are `hero`, `logoStrip`, `steps`, `features`, `textImage`, `stats`, `testimonials`, `pricing`, `video`, `media`, `faq`, `cta`, and `contact`. Each page entry is `{ id, anchor?, type, variant, tone, motion?, props }`. `anchor` is an optional lowercase slug, unique on the page, and it becomes the section element's `id`. `data-section-id` stays the section id. `[data-section-id]` sets `scroll-margin-top: 7rem` so a sticky or floating header does not cover the target. `html` uses `scroll-behavior: smooth`, and `prefers-reduced-motion: reduce` forces `scroll-behavior: auto`. Section components are built from molecules and atoms. A section may use an organism when it needs one, such as a modal. The section root carries `data-section-id`, `data-section-type`, `data-variant`, `data-tone`, and `data-motion`. Atoms accept a `class` and forward `data-*` attributes, including `data-field`, so the editor can find the text.
32
+ Sections are `hero`, `logoStrip`, `steps`, `gallery`, `features`, `text`, `textImage`, `stats`, `testimonials`, `pricing`, `media`, `faq`, `cta`, and `contact`. Each page entry is `{ id, anchor?, type, variant, tone, motion?, props }`. `anchor` is an optional lowercase slug, unique on the page, and it becomes the section element's `id`. `data-section-id` stays the section id. `[data-section-id]` sets `scroll-margin-top: 7rem` so a sticky or floating header does not cover the target. `html` uses `scroll-behavior: smooth`, and `prefers-reduced-motion: reduce` forces `scroll-behavior: auto`. Section components are built from molecules and atoms. A section may use an organism when it needs one, such as a modal. The section root carries `data-section-id`, `data-section-type`, `data-variant`, `data-tone`, and `data-motion`. Atoms accept a `class` and forward `data-*` attributes, including `data-field`, so the editor can find the text.
33
33
 
34
34
  Paragraph fields (`sub`, `intro`, `body`, `description`, `quote`, `note`, `thanks`, FAQ answers and Text + Image `points`) are markdown. The template renders bold, italic, inline `code` (a mono chip), links, and paragraphs through `bearminion/rich-text`, escapes every other character, and adds `rel="noopener noreferrer"` on `http` and `https` links.
35
35
 
@@ -40,7 +40,7 @@ src/components/
40
40
  organisms/ Header, Navigation, MobileMenu, Footer, plus site-definition.ts (header and footer layouts, limits)
41
41
  sections/ one directory per type, plus definitions.ts, registry.ts, shared.ts, SectionList.astro
42
42
  hero/ definition.ts (zod schema, defaults, options), wireframe.ts (picker thumbnail), Hero.astro, Hero.stories.ts
43
- logo-strip/ steps/ features/ text-image/ stats/ testimonials/ pricing/ video/ media/ faq/ cta/ contact/
43
+ logo-strip/ steps/ gallery/ features/ text/ text-image/ stats/ testimonials/ pricing/ media/ faq/ cta/ contact/
44
44
  seo/ Seo
45
45
  ```
46
46
 
@@ -184,7 +184,7 @@ The site's own images (`logo.png`, `favicon.png`, `og.png`, `hero.png`) live in
184
184
 
185
185
  ## SEO and sitemap
186
186
 
187
- `Seo.astro` reads the page `seo` object and the site defaults in `site.json`. The document title is `seo.titleTemplate` with `%s` replaced by the page SEO title. Description falls back to the site description. The canonical URL is `seo.canonical` when set, otherwise the page path joined to `site.url`. Robots is `noindex` or `index`, plus `nofollow` or `follow`. Open Graph, Twitter, and WebPage JSON-LD use the same title, description, and image. A page that sets `seo.jsonLd` gets those blocks instead of the generated WebPage block, one script tag per block. `site.analytics.head` and `site.analytics.bodyEnd` are emitted by `Base.astro` in production builds only, behind the [cookie banner](#cookie-banner) unless it is switched off.
187
+ `Seo.astro` reads the page `seo` object and the site defaults in `site.json`. The document title is `seo.titleTemplate` with `%s` replaced by the page SEO title. Description falls back to the site description. The canonical URL is `seo.canonical` when set, otherwise the page path joined to `site.url`. Robots is `noindex` or `index`, plus `nofollow` or `follow`. Open Graph, Twitter, and WebPage JSON-LD use the same title, description, and image. Every block in `site.seo.jsonLd` (usually an `Organization` and a `WebSite`) is emitted on every page, and the generated WebPage then points at them by `@id`. A page that sets `seo.jsonLd` gets those blocks instead of the generated WebPage block only, never instead of the site's. A block with the same `@id` is emitted once, the page's copy winning. One script tag per block. `site.analytics.head` and `site.analytics.bodyEnd` are emitted by `Base.astro` in production builds only, behind the [cookie banner](#cookie-banner) unless it is switched off.
188
188
 
189
189
  `astro.config.ts` sets `site` from `site.json` and adds `@astrojs/sitemap`. The filter keeps content pages whose `seo.noindex` is false. `content/pages/preview.json` is the sample noindex page: it is built, it emits `noindex`, and it is absent from the sitemap. `src/pages/robots.txt.ts` points crawlers at `sitemap-index.xml`.
190
190
 
@@ -18,8 +18,8 @@
18
18
  "@fontsource/instrument-serif": "^5.3.0",
19
19
  "@fontsource/jetbrains-mono": "^5.3.0",
20
20
  "@tailwindcss/vite": "^4.1.12",
21
- "astro": "^7.3.8",
22
- "bearminion": "^1.4.0",
21
+ "astro": "^7.4.1",
22
+ "bearminion": "^1.6.0",
23
23
  "class-variance-authority": "^0.7.1",
24
24
  "sharp": "^0.35.5",
25
25
  "tailwind-merge": "^3.7.0",
@@ -27,8 +27,8 @@
27
27
  "zod": "^4.6.7"
28
28
  },
29
29
  "devDependencies": {
30
- "@astrojs/check": "^0.9.5",
31
- "@storybook-astro/framework": "^1.12.0",
30
+ "@astrojs/check": "^0.9.11",
31
+ "@storybook-astro/framework": "^1.13.0",
32
32
  "@storybook/builder-vite": "^10.6.2",
33
33
  "@types/node": "^22.19.0",
34
34
  "storybook": "^10.6.2",
@@ -18,6 +18,8 @@ interface Props {
18
18
  id?: string
19
19
  /** Markdown subset (bold, italic, links, lists, paragraphs). Renders sanitized HTML. */
20
20
  markdown?: string
21
+ /** The markdown may hold `##` and `###` headings, and the editor offers them. */
22
+ richHeadings?: boolean
21
23
  'data-field'?: string
22
24
  }
23
25
 
@@ -30,10 +32,11 @@ const {
30
32
  align,
31
33
  class: className,
32
34
  markdown,
35
+ richHeadings = false,
33
36
  ...rest
34
37
  } = Astro.props
35
38
 
36
- const html = markdown !== undefined ? renderRichText(markdown) : undefined
39
+ const html = markdown !== undefined ? renderRichText(markdown, { headings: richHeadings }) : undefined
37
40
  // Rich text is `<p>`s and lists, which a `<p>`, `<span>` or heading cannot hold. A `blockquote`, `li` or `figcaption` can.
38
41
  const Tag = html !== undefined && PHRASING_ONLY.has(TagName) ? 'div' : TagName
39
42
  const classes = cn(typography({ size, color, weight, font, align }), className)
@@ -41,7 +44,7 @@ const classes = cn(typography({ size, color, weight, font, align }), className)
41
44
 
42
45
  {
43
46
  html !== undefined ? (
44
- <Tag class={classes} data-field-kind="rich" set:html={html} {...rest} />
47
+ <Tag class={classes} data-field-kind="rich" data-rich-headings={richHeadings ? 'true' : undefined} set:html={html} {...rest} />
45
48
  ) : (
46
49
  <TagName class={classes} {...rest}>
47
50
  <slot />
@@ -12,12 +12,13 @@ const { sections, motion = true } = Astro.props
12
12
  const Hero = sectionComponents.hero
13
13
  const LogoStrip = sectionComponents.logoStrip
14
14
  const Steps = sectionComponents.steps
15
+ const Gallery = sectionComponents.gallery
15
16
  const Features = sectionComponents.features
17
+ const Text = sectionComponents.text
16
18
  const TextImage = sectionComponents.textImage
17
19
  const Stats = sectionComponents.stats
18
20
  const Testimonials = sectionComponents.testimonials
19
21
  const Pricing = sectionComponents.pricing
20
- const Video = sectionComponents.video
21
22
  const Media = sectionComponents.media
22
23
  const Faq = sectionComponents.faq
23
24
  const Cta = sectionComponents.cta
@@ -33,8 +34,12 @@ const Contact = sectionComponents.contact
33
34
  return <LogoStrip section={section} index={index} motion={motion} />
34
35
  case 'steps':
35
36
  return <Steps section={section} index={index} motion={motion} />
37
+ case 'gallery':
38
+ return <Gallery section={section} index={index} motion={motion} />
36
39
  case 'features':
37
40
  return <Features section={section} index={index} motion={motion} />
41
+ case 'text':
42
+ return <Text section={section} index={index} motion={motion} />
38
43
  case 'textImage':
39
44
  return <TextImage section={section} index={index} motion={motion} />
40
45
  case 'stats':
@@ -43,8 +48,6 @@ const Contact = sectionComponents.contact
43
48
  return <Testimonials section={section} index={index} motion={motion} />
44
49
  case 'pricing':
45
50
  return <Pricing section={section} index={index} motion={motion} />
46
- case 'video':
47
- return <Video section={section} index={index} motion={motion} />
48
51
  case 'media':
49
52
  return <Media section={section} index={index} motion={motion} />
50
53
  case 'faq':
@@ -10,6 +10,7 @@ import { contact } from './contact/definition'
10
10
  import { cta } from './cta/definition'
11
11
  import { faq } from './faq/definition'
12
12
  import { features } from './features/definition'
13
+ import { gallery } from './gallery/definition'
13
14
  import { hero } from './hero/definition'
14
15
  import { logoStrip } from './logo-strip/definition'
15
16
  import { media } from './media/definition'
@@ -17,8 +18,8 @@ import { pricing } from './pricing/definition'
17
18
  import { stats } from './stats/definition'
18
19
  import { steps } from './steps/definition'
19
20
  import { testimonials } from './testimonials/definition'
21
+ import { text } from './text/definition'
20
22
  import { textImage } from './text-image/definition'
21
- import { video } from './video/definition'
22
23
 
23
24
  /**
24
25
  * Every section type of this site. Each one is defined in its own directory:
@@ -31,12 +32,13 @@ export const sectionDefinitions = {
31
32
  hero,
32
33
  logoStrip,
33
34
  steps,
35
+ gallery,
34
36
  features,
37
+ text,
35
38
  textImage,
36
39
  stats,
37
40
  testimonials,
38
41
  pricing,
39
- video,
40
42
  media,
41
43
  faq,
42
44
  cta,
@@ -0,0 +1,128 @@
1
+ ---
2
+ import type { Section } from '../definitions'
3
+ import Typography from '../../atoms/Typography.astro'
4
+ import MediaFrame from '../../molecules/MediaFrame.astro'
5
+ import SectionHeader from '../../molecules/SectionHeader.astro'
6
+ import SectionShell from '../../molecules/SectionShell.astro'
7
+ import { isRemote, resolveLocalAsset } from '../../../lib/assets'
8
+ import { imageUrl } from '../../../lib/image-url'
9
+ import { cn } from '../../../lib/cn'
10
+
11
+ interface Props {
12
+ section: Extract<Section, { type: 'gallery' }>
13
+ index: number
14
+ motion?: boolean
15
+ }
16
+
17
+ const { section, index, motion = true } = Astro.props
18
+ const props = section.props
19
+ const field = (path: string) => `sections.${index}.props.${path}`
20
+ const reveal = section.motion ?? motion
21
+ const images = props.images.filter((image) => !image.hidden)
22
+ const featured = section.variant === 'featured'
23
+ const masonry = section.variant === 'masonry'
24
+
25
+ // Static class names, so Tailwind sees them.
26
+ const gridColumns = {
27
+ '2': 'grid-cols-1 @min-[560px]:grid-cols-2',
28
+ '3': 'grid-cols-1 @min-[560px]:grid-cols-2 @min-[900px]:grid-cols-3',
29
+ '4': 'grid-cols-2 @min-[700px]:grid-cols-3 @min-[1000px]:grid-cols-4',
30
+ } as const
31
+ const masonryColumns = {
32
+ '2': 'columns-1 @min-[560px]:columns-2',
33
+ '3': 'columns-1 @min-[560px]:columns-2 @min-[900px]:columns-3',
34
+ '4': 'columns-2 @min-[700px]:columns-3 @min-[1000px]:columns-4',
35
+ } as const
36
+
37
+ /** The index of an image in the section's full list, so the editor's field paths stay right when some are hidden. */
38
+ const pathIndex = (image: (typeof images)[number]) => props.images.indexOf(image)
39
+ const aspect = (position: number) => {
40
+ if (featured && position === 0) return 'aspect-[4/3] @min-[900px]:aspect-auto @min-[900px]:h-full'
41
+ if (masonry) return position % 3 === 0 ? 'aspect-[3/4]' : position % 3 === 1 ? 'aspect-[4/3]' : 'aspect-square'
42
+ return 'aspect-[4/3]'
43
+ }
44
+ const tileClass = (position: number) =>
45
+ cn(featured && position === 0 && '@min-[900px]:col-span-2 @min-[900px]:row-span-2', masonry && 'mb-4 break-inside-avoid')
46
+
47
+ /** A picture the lightbox can show: a real file or address, not the placeholder. */
48
+ const viewable = (src: string) => src !== 'placeholder' && (isRemote(src) || Boolean(resolveLocalAsset(src)) || src.replace(/^\//, '').startsWith('src/assets/'))
49
+
50
+ // The large file each link points at. With JavaScript off the link opens it; with JavaScript on it is what the lightbox loads.
51
+ const larges = await Promise.all(images.map((image) => (props.lightbox && viewable(image.src) ? imageUrl(image) : undefined)))
52
+ const lightbox = props.lightbox && larges.some(Boolean)
53
+ ---
54
+
55
+ <SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
56
+ <div class="section-container flex flex-col gap-[clamp(2rem,4cqi,3.5rem)]">
57
+ <SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} size="h2-md" field={field} class="gap-5" />
58
+ {props.body && <Typography as="div" size="lead" color="muted" class="max-w-[40rem]" data-field={field('body')} markdown={props.body} />}
59
+ <ul
60
+ class={cn(
61
+ masonry ? masonryColumns[props.columns] : gridColumns[props.columns],
62
+ masonry ? 'gap-x-4' : cn('grid gap-4', featured && '@min-[900px]:auto-rows-fr'),
63
+ )}
64
+ >
65
+ {
66
+ images.map((image, position) => {
67
+ const frame = (
68
+ <MediaFrame
69
+ src={image.src}
70
+ alt={image.alt}
71
+ aspectClass={aspect(position)}
72
+ fit={masonry ? 'auto' : image.fit}
73
+ position={image.position}
74
+ class={cn(props.frame && 'border border-line bg-surface', larges[position] && 'transition-transform duration-300 hover:scale-[1.01] motion-reduce:transition-none')}
75
+ data-field={field(`images.${pathIndex(image)}.alt`)}
76
+ />
77
+ )
78
+ return (
79
+ <li class={tileClass(position)}>
80
+ {larges[position] ? (
81
+ <a
82
+ href={larges[position]}
83
+ class="block size-full cursor-zoom-in rounded-card-lg focus-visible:outline-offset-2"
84
+ data-gallery-open
85
+ data-alt={image.alt}
86
+ data-caption={image.caption}
87
+ aria-label={`View larger: ${image.alt}`}
88
+ >
89
+ {frame}
90
+ </a>
91
+ ) : (
92
+ frame
93
+ )}
94
+ </li>
95
+ )
96
+ })
97
+ }
98
+ </ul>
99
+ </div>
100
+ {
101
+ lightbox && (
102
+ <dialog class="bm-lightbox" aria-label={props.heading ? `${props.heading}: pictures` : 'Pictures'} data-gallery-dialog>
103
+ <div class="bm-lightbox-stage" data-gallery-stage>
104
+ <button type="button" class="bm-lightbox-close" aria-label="Close" data-gallery-close>
105
+ <svg viewBox="0 0 24 24" aria-hidden="true" class="bm-lightbox-icon"><path d="M6 6l12 12M18 6L6 18" /></svg>
106
+ </button>
107
+ <p class="bm-lightbox-counter" data-gallery-counter aria-hidden="true" />
108
+ <button type="button" class="bm-lightbox-nav bm-lightbox-prev" aria-label="Previous picture" data-gallery-step="-1">
109
+ <svg viewBox="0 0 24 24" aria-hidden="true" class="bm-lightbox-icon"><path d="M14.5 5.5L8 12l6.5 6.5" /></svg>
110
+ </button>
111
+ <figure class="bm-lightbox-figure" data-gallery-figure>
112
+ <img class="bm-lightbox-image" alt="" data-gallery-image decoding="async" />
113
+ <figcaption class="bm-lightbox-caption" data-gallery-caption hidden />
114
+ </figure>
115
+ <button type="button" class="bm-lightbox-nav bm-lightbox-next" aria-label="Next picture" data-gallery-step="1">
116
+ <svg viewBox="0 0 24 24" aria-hidden="true" class="bm-lightbox-icon"><path d="M9.5 5.5L16 12l-6.5 6.5" /></svg>
117
+ </button>
118
+ <p class="sr-only" role="status" aria-live="polite" data-gallery-status />
119
+ </div>
120
+ </dialog>
121
+ )
122
+ }
123
+ </SectionShell>
124
+
125
+ <script>
126
+ import { initGalleries } from './lightbox'
127
+ initGalleries()
128
+ </script>
@@ -0,0 +1,24 @@
1
+ import { sectionArgTypes } from '../../../stories/options'
2
+ import { gallery } from './definition'
3
+ import Gallery from './Gallery.astro'
4
+
5
+ /** The sample pages hold no Gallery section, so the stories start from its defaults. */
6
+ const section = { id: 'gallery', type: 'gallery', variant: 'grid', tone: 'base', props: gallery.defaults } as const
7
+ const captioned = {
8
+ ...gallery.defaults,
9
+ images: gallery.defaults.images.map((image, index) => ({ ...image, caption: `Caption for picture ${index + 1}` })),
10
+ }
11
+
12
+ export default {
13
+ title: 'Sections/Gallery',
14
+ component: Gallery,
15
+ parameters: { layout: 'fullscreen' },
16
+ argTypes: sectionArgTypes('gallery'),
17
+ }
18
+
19
+ export const Grid = { args: { section, index: 0 } }
20
+ export const Masonry = { args: { section: { ...section, variant: 'masonry', props: { ...gallery.defaults, columns: '4' } }, index: 0 } }
21
+ export const Featured = { args: { section: { ...section, variant: 'featured', tone: 'subtle', props: captioned }, index: 0 } }
22
+ export const TwoColumns = { args: { section: { ...section, props: { ...gallery.defaults, columns: '2' } }, index: 0 } }
23
+ export const Framed = { args: { section: { ...section, props: { ...gallery.defaults, frame: true } }, index: 0 } }
24
+ export const NoLightbox = { args: { section: { ...section, props: { ...gallery.defaults, lightbox: false } }, index: 0 } }
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod'
2
+ import { defineSection, imageSchema, richTextSchema, textSchema, withEmphasis } from 'bearminion'
3
+ import { accent, eyebrow, heading } from '../shared'
4
+ import { galleryWireframe } from './wireframe'
5
+
6
+ export const galleryVariants = ['grid', 'masonry', 'featured'] as const
7
+ export const galleryColumns = ['2', '3', '4'] as const
8
+
9
+ export const galleryCounts = { images: { recommended: '6 to 12', min: 1, max: 24 } } as const
10
+
11
+ /**
12
+ * A gallery picture needs a description: it is the only thing a screen reader or a failed load shows. The optional
13
+ * `caption` is for everyone else: it shows under the picture in the lightbox.
14
+ */
15
+ export const galleryImageSchema = imageSchema.extend({
16
+ alt: z.string().min(1, 'Describe the picture'),
17
+ caption: textSchema.optional(),
18
+ })
19
+
20
+ export const gallerySchema = withEmphasis({
21
+ eyebrow,
22
+ heading,
23
+ body: richTextSchema.optional(),
24
+ images: z.array(galleryImageSchema).min(galleryCounts.images.min).max(galleryCounts.images.max),
25
+ /** Pictures per row on a wide screen. Fewer on narrow ones. */
26
+ columns: z.enum(galleryColumns).default('3'),
27
+ /** Off: pictures stay where they are. On: a click opens the picture large, with arrows to move through the rest. */
28
+ lightbox: z.boolean().default(true),
29
+ /** On: each picture sits in a bordered box, so a picture that does not fill its frame still shows where the frame ends. */
30
+ frame: z.boolean().default(false),
31
+ })
32
+
33
+ export type GalleryProps = z.infer<typeof gallerySchema>
34
+
35
+ export const gallery = defineSection({
36
+ type: 'gallery',
37
+ label: 'Gallery',
38
+ description: 'A set of pictures. Click one to see it large, with captions, arrows and swipe.',
39
+ schema: gallerySchema,
40
+ defaults: {
41
+ eyebrow: 'Gallery',
42
+ heading: 'A few pictures say it faster.',
43
+ marks: accent('A few pictures say it faster.', 'faster.'),
44
+ images: [1, 2, 3, 4, 5, 6].map((n) => ({ src: 'placeholder', alt: `Picture ${n}` })),
45
+ columns: '3',
46
+ lightbox: true,
47
+ frame: false,
48
+ },
49
+ variants: galleryVariants,
50
+ defaultVariant: 'grid',
51
+ options: [
52
+ { name: 'columns', label: 'Pictures per row', type: 'enum', choices: galleryColumns, default: '3' },
53
+ { name: 'lightbox', label: 'Open pictures large on click', type: 'boolean', default: true },
54
+ { name: 'frame', label: 'Draw a box around each picture', type: 'boolean', default: false },
55
+ ],
56
+ optional: { props: ['eyebrow', 'heading', 'body'], items: { images: ['caption'] } },
57
+ counts: galleryCounts,
58
+ wireframe: galleryWireframe,
59
+ })
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The gallery lightbox. Each picture in the page is a link to its large file, so with JavaScript off a click simply
3
+ * opens the picture. With it on, a click opens a native <dialog> that shows one picture at a time. The large file is
4
+ * requested when the dialog opens (and the pictures next to it, so the arrows feel instant), never before.
5
+ */
6
+
7
+ /** The index after moving `step` places from `from` in a list of `count`, wrapping at both ends. */
8
+ export function stepIndex(from: number, step: number, count: number): number {
9
+ if (count <= 0) return 0
10
+ return (((from + step) % count) + count) % count
11
+ }
12
+
13
+ /** Which way a finger moved: a mostly horizontal drag of at least `threshold` px. Positive is toward the next picture. */
14
+ export function swipeStep(dx: number, dy: number, threshold = 48): -1 | 0 | 1 {
15
+ if (Math.abs(dx) < threshold || Math.abs(dx) < Math.abs(dy) * 1.25) return 0
16
+ return dx < 0 ? 1 : -1
17
+ }
18
+
19
+ interface Item {
20
+ src: string
21
+ alt: string
22
+ caption: string
23
+ opener: HTMLElement
24
+ }
25
+
26
+ function itemsOf(section: Element): Item[] {
27
+ return [...section.querySelectorAll<HTMLElement>('[data-gallery-open]')].map((opener) => ({
28
+ src: opener.getAttribute('href') ?? '',
29
+ alt: opener.dataset.alt ?? '',
30
+ caption: opener.dataset.caption ?? '',
31
+ opener,
32
+ }))
33
+ }
34
+
35
+ class Lightbox {
36
+ private items: Item[] = []
37
+ private index = 0
38
+ private touch: { x: number; y: number } | null = null
39
+ private readonly image: HTMLImageElement
40
+ private readonly caption: HTMLElement
41
+ private readonly counter: HTMLElement
42
+ private readonly status: HTMLElement
43
+ private readonly figure: HTMLElement
44
+
45
+ constructor(private readonly dialog: HTMLDialogElement) {
46
+ this.image = this.part<HTMLImageElement>('[data-gallery-image]')
47
+ this.caption = this.part('[data-gallery-caption]')
48
+ this.counter = this.part('[data-gallery-counter]')
49
+ this.status = this.part('[data-gallery-status]')
50
+ this.figure = this.part('[data-gallery-figure]')
51
+
52
+ dialog.addEventListener('click', (event) => {
53
+ const target = event.target as HTMLElement
54
+ if (target.closest('[data-gallery-close]')) return this.close()
55
+ const step = target.closest<HTMLElement>('[data-gallery-step]')
56
+ if (step) return this.go(Number(step.dataset.galleryStep))
57
+ // The backdrop and the empty space around the picture close it; the picture and its caption do not.
58
+ if (target === dialog || target === this.figure || target.hasAttribute('data-gallery-stage')) this.close()
59
+ })
60
+ dialog.addEventListener('keydown', (event) => {
61
+ if (event.key === 'ArrowLeft') this.go(-1)
62
+ else if (event.key === 'ArrowRight') this.go(1)
63
+ else if (event.key === 'Home') this.show(0)
64
+ else if (event.key === 'End') this.show(this.items.length - 1)
65
+ else return
66
+ event.preventDefault()
67
+ })
68
+ // The dialog's own Escape and the close button both end here, so focus always goes back to the picture opened.
69
+ dialog.addEventListener('close', () => this.items[this.index]?.opener.focus({ preventScroll: true }))
70
+ dialog.addEventListener('pointerdown', (event) => {
71
+ if (event.pointerType !== 'touch') return
72
+ this.touch = { x: event.clientX, y: event.clientY }
73
+ })
74
+ dialog.addEventListener('pointerup', (event) => {
75
+ if (!this.touch) return
76
+ const step = swipeStep(event.clientX - this.touch.x, event.clientY - this.touch.y)
77
+ this.touch = null
78
+ if (step !== 0) this.go(step)
79
+ })
80
+ dialog.addEventListener('pointercancel', () => {
81
+ this.touch = null
82
+ })
83
+ this.image.addEventListener('load', () => this.dialog.removeAttribute('data-loading'))
84
+ this.image.addEventListener('error', () => {
85
+ this.dialog.removeAttribute('data-loading')
86
+ this.status.textContent = 'This picture could not be loaded.'
87
+ })
88
+ }
89
+
90
+ private part<T extends HTMLElement = HTMLElement>(selector: string): T {
91
+ const node = this.dialog.querySelector<T>(selector)
92
+ if (!node) throw new Error(`Gallery lightbox is missing ${selector}`)
93
+ return node
94
+ }
95
+
96
+ open(section: Element, opener: Element) {
97
+ this.items = itemsOf(section)
98
+ const start = this.items.findIndex((item) => item.opener === opener)
99
+ this.dialog.toggleAttribute('data-single', this.items.length < 2)
100
+ this.show(Math.max(0, start))
101
+ if (!this.dialog.open) this.dialog.showModal()
102
+ this.dialog.querySelector<HTMLElement>('[data-gallery-close]')?.focus({ preventScroll: true })
103
+ }
104
+
105
+ private go(step: number) {
106
+ this.show(stepIndex(this.index, step, this.items.length))
107
+ }
108
+
109
+ private show(index: number) {
110
+ const item = this.items[index]
111
+ if (!item) return
112
+ this.index = index
113
+ this.status.textContent = ''
114
+ this.dialog.setAttribute('data-loading', '')
115
+ this.image.alt = item.alt
116
+ this.image.src = item.src
117
+ // A picture without a caption shows its description, so every picture in the lightbox says what it is.
118
+ const text = item.caption || item.alt
119
+ this.caption.textContent = text
120
+ this.caption.hidden = text === ''
121
+ this.counter.textContent = `${index + 1} / ${this.items.length}`
122
+ // Fetch the neighbours now, so the next arrow press shows its picture at once.
123
+ for (const near of [stepIndex(index, 1, this.items.length), stepIndex(index, -1, this.items.length)]) {
124
+ if (near !== index) new Image().src = this.items[near]?.src ?? ''
125
+ }
126
+ }
127
+
128
+ close() {
129
+ if (this.dialog.open) this.dialog.close()
130
+ }
131
+ }
132
+
133
+ const lightboxes = new WeakMap<HTMLDialogElement, Lightbox>()
134
+
135
+ /** One delegated listener, so sections added later by the editor's live preview work too. Safe to call twice. */
136
+ export function initGalleries(): void {
137
+ const flag = '__bearminionGallery'
138
+ const scope = window as unknown as Record<string, boolean | undefined>
139
+ if (scope[flag]) return
140
+ scope[flag] = true
141
+ document.addEventListener('click', (event) => {
142
+ const opener = (event.target as Element | null)?.closest<HTMLElement>('[data-gallery-open]')
143
+ if (!opener) return
144
+ // While the editor is on the page a click edits the picture. Either way the link itself must not navigate.
145
+ event.preventDefault()
146
+ // Preview mode shows the page as visitors see it, so the lightbox works there.
147
+ const root = document.documentElement
148
+ if (root.hasAttribute('data-bearminion-ready') && !root.hasAttribute('data-bearminion-preview')) return
149
+ const section = opener.closest('[data-section-id]')
150
+ const dialog = section?.querySelector<HTMLDialogElement>('dialog[data-gallery-dialog]')
151
+ if (!section || !dialog) return
152
+ let box = lightboxes.get(dialog)
153
+ if (!box) {
154
+ box = new Lightbox(dialog)
155
+ lightboxes.set(dialog, box)
156
+ }
157
+ box.open(section, opener)
158
+ })
159
+ }
@@ -0,0 +1,12 @@
1
+ import { defineWireframe } from 'bearminion'
2
+
3
+ /** Add section picker thumbnail: [left %, top %, width %, height %, kind]. */
4
+ export const galleryWireframe = defineWireframe([
5
+ [8, 10, 34, 8, 'title'],
6
+ [8, 24, 20, 12, 'figure'],
7
+ [32, 24, 20, 12, 'figure'],
8
+ [56, 24, 20, 12, 'figure'],
9
+ [8, 40, 20, 12, 'figure'],
10
+ [32, 40, 20, 12, 'figure'],
11
+ [56, 40, 20, 12, 'figure'],
12
+ ])
@@ -28,6 +28,7 @@ const player = {
28
28
  poster: props.poster,
29
29
  image: props.image,
30
30
  label: props.label,
31
+ playback: props.playback,
31
32
  imageField: field('image.alt'),
32
33
  // The first two sections are usually on screen at load, where a lazy cover would hold back the largest paint.
33
34
  eager: index <= 1,