create-bearminion 1.5.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 (30) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +5 -3
  3. package/package.json +2 -2
  4. package/template/README.md +2 -2
  5. package/template/package.json +1 -1
  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/lib/content-model.ts +4 -1
  24. package/template/src/lib/migrations.ts +27 -0
  25. package/template/src/styles/global.css +161 -0
  26. package/template/src/components/sections/video/Video.astro +0 -78
  27. package/template/src/components/sections/video/Video.stories.ts +0 -17
  28. package/template/src/components/sections/video/VideoFrame.astro +0 -60
  29. package/template/src/components/sections/video/definition.ts +0 -58
  30. package/template/src/components/sections/video/wireframe.ts +0 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ## 1.5.0
4
20
 
5
21
  ### Patch 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.5.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",
@@ -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
 
@@ -19,7 +19,7 @@
19
19
  "@fontsource/jetbrains-mono": "^5.3.0",
20
20
  "@tailwindcss/vite": "^4.1.12",
21
21
  "astro": "^7.4.1",
22
- "bearminion": "^1.5.0",
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",
@@ -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,
@@ -13,6 +13,7 @@ interface Props {
13
13
  poster?: Image
14
14
  image?: Image
15
15
  label?: string
16
+ playback?: MediaProps['playback']
16
17
  /** `data-field` path for the image, so a click on the frame opens the image picker. */
17
18
  imageField: string
18
19
  /** Near the top of the page: the cover loads at once instead of lazily, since it is likely the largest thing on screen. */
@@ -20,7 +21,7 @@ interface Props {
20
21
  class?: string
21
22
  }
22
23
 
23
- const { kind, aspect, video, poster, image, label, imageField, eager = false, class: className } = Astro.props
24
+ const { kind, aspect, video, poster, image, label, playback = 'controls', imageField, eager = false, class: className } = Astro.props
24
25
  const ASPECT = { '16:9': 'aspect-video', '4:3': 'aspect-[4/3]', '21:9': 'aspect-[21/9]', '1:1': 'aspect-square' } as const
25
26
  const frame = cn(
26
27
  'relative w-full min-h-48 overflow-hidden rounded-[clamp(1rem,2cqi,1.5rem)] bg-surface shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]',
@@ -35,6 +36,9 @@ const coverSrcset = source ? await imageSrcset(poster) : undefined
35
36
  // The frame is at most 68rem wide and the full width of a phone.
36
37
  const coverSizes = '(min-width: 72rem) 68rem, 100vw'
37
38
  const name = label || 'Video'
39
+ // A video file on loop starts muted and without controls once the page script sees it on screen. Until then, and without
40
+ // JavaScript or with reduced motion, it keeps its controls so it can still be played.
41
+ const looping = source?.kind === 'file' && playback === 'loop'
38
42
  ---
39
43
 
40
44
  {
@@ -63,6 +67,10 @@ const name = label || 'Video'
63
67
  class="shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]"
64
68
  data-field={imageField}
65
69
  />
70
+ ) : looping && source?.kind === 'file' ? (
71
+ <div class={frame}>
72
+ <video class="absolute inset-0 size-full object-cover" src={source.src} poster={cover} preload="metadata" playsinline muted loop controls aria-label={name} data-video-loop />
73
+ </div>
66
74
  ) : (
67
75
  source && (
68
76
  <div
@@ -145,4 +153,24 @@ const name = label || 'Video'
145
153
  }
146
154
  bindMediaPlayers()
147
155
  document.addEventListener('bearminion:preview', bindMediaPlayers)
156
+
157
+ /** Looping videos play while at least a quarter of them is on screen, and not at all with reduced motion. */
158
+ function bindLoopingVideos() {
159
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
160
+ for (const video of document.querySelectorAll<HTMLVideoElement>('video[data-video-loop]')) {
161
+ if (video.dataset.bound === 'true') continue
162
+ video.dataset.bound = 'true'
163
+ if (reduced) continue
164
+ video.controls = false
165
+ new IntersectionObserver(
166
+ ([entry]) => {
167
+ if (entry?.isIntersecting) void video.play().catch(() => (video.controls = true))
168
+ else video.pause()
169
+ },
170
+ { threshold: 0.25 },
171
+ ).observe(video)
172
+ }
173
+ }
174
+ bindLoopingVideos()
175
+ document.addEventListener('bearminion:preview', bindLoopingVideos)
148
176
  </script>