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.
- package/CHANGELOG.md +16 -0
- package/README.md +5 -3
- package/package.json +2 -2
- package/template/README.md +2 -2
- package/template/package.json +1 -1
- package/template/src/components/atoms/Typography.astro +5 -2
- package/template/src/components/sections/SectionList.astro +6 -3
- package/template/src/components/sections/definitions.ts +4 -2
- package/template/src/components/sections/gallery/Gallery.astro +128 -0
- package/template/src/components/sections/gallery/Gallery.stories.ts +24 -0
- package/template/src/components/sections/gallery/definition.ts +59 -0
- package/template/src/components/sections/gallery/lightbox.ts +159 -0
- package/template/src/components/sections/gallery/wireframe.ts +12 -0
- package/template/src/components/sections/media/Media.astro +1 -0
- package/template/src/components/sections/media/MediaPlayer.astro +29 -1
- package/template/src/components/sections/media/definition.ts +6 -1
- package/template/src/components/sections/media/source.ts +3 -33
- package/template/src/components/sections/registry.ts +4 -2
- package/template/src/components/sections/text/Text.astro +43 -0
- package/template/src/components/sections/text/Text.stories.ts +17 -0
- package/template/src/components/sections/text/definition.ts +36 -0
- package/template/src/components/sections/text/wireframe.ts +12 -0
- package/template/src/lib/content-model.ts +4 -1
- package/template/src/lib/migrations.ts +27 -0
- package/template/src/styles/global.css +161 -0
- package/template/src/components/sections/video/Video.astro +0 -78
- package/template/src/components/sections/video/Video.stories.ts +0 -17
- package/template/src/components/sections/video/VideoFrame.astro +0 -60
- package/template/src/components/sections/video/definition.ts +0 -58
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
"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",
|
package/template/README.md
CHANGED
|
@@ -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`, `
|
|
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/
|
|
43
|
+
logo-strip/ steps/ gallery/ features/ text/ text-image/ stats/ testimonials/ pricing/ media/ faq/ cta/ contact/
|
|
44
44
|
seo/ Seo
|
|
45
45
|
```
|
|
46
46
|
|
package/template/package.json
CHANGED
|
@@ -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>
|