feature: upgraded gallery

This commit is contained in:
Benno Tielen 2026-06-05 14:49:54 +02:00
parent 57a9a1c966
commit d1c05bfeb8
14 changed files with 26674 additions and 1405 deletions

View file

@ -20,6 +20,13 @@ export const GalleryBlock: Block = {
relationTo: 'media', relationTo: 'media',
required: true required: true
}, },
{
name: 'caption',
label: {
de: 'Bildunterschrift'
},
type: 'text'
},
], ],
minRows: 3, minRows: 3,
maxRows: 12 maxRows: 12

View file

@ -1,67 +0,0 @@
import styles from "./autoscroll.module.scss"
import { useEffect, useRef, useState } from 'react'
type AutoScrollProps = {
isScrolling: boolean
onTouch?: () => void
children: JSX.Element
}
/**
* This component autoscroll the content from left to right
* automatically
*
*/
export const AutoScroll = ({children, isScrolling, onTouch}: AutoScrollProps) => {
const [step, setStep] = useState(0)
const [direction, setDirection] = useState<'left' | 'right'>('right')
const wrapper = useRef<HTMLDivElement>(null)
useEffect(() => {
if(wrapper && wrapper.current) {
if (isScrolling) {
const scrollSpeed = 30; // 30px per second
const toScroll = wrapper.current.scrollWidth - window.innerWidth;
// nothing to scroll
if (toScroll <= 0) {
return;
}
const steps = toScroll/scrollSpeed * 1000
const t = setTimeout(() => {
let left = toScroll * step/steps
if(wrapper.current) {
wrapper.current.scrollTo({
left: left,
})
}
if(step >= steps) {
setDirection("left")
}
if(step <= 0) {
setDirection("right")
}
setStep(direction === "right" ? step + 1 : step - 1)
return () => clearTimeout(t);
}, 1);
}
}}, [wrapper, step, setStep, direction, setDirection, isScrolling])
return (
<div
className={styles.wrapper}
ref={wrapper}
onTouchStart={onTouch}
>
{children}
</div>
)
}

View file

@ -1,31 +0,0 @@
import styles from './fullscreen.module.scss'
import Image, { StaticImageData } from 'next/image'
type FullScreenProps = {
display: boolean
image: StaticImageData
alt?: string
closeClicked: () => void
nextClicked: () => void
}
export const Fullscreen = ({display, image, closeClicked, alt, nextClicked}: FullScreenProps) => {
if(display)
return (
<div className={styles.display}>
<div
className={styles.close}
onClick={closeClicked}
>x</div>
<Image
onClick={nextClicked}
className={styles.displayImage}
height={image.height}
width={image.width}
src={image.src}
alt={alt || ""}
unoptimized={true}
/>
</div>
)
}

View file

@ -1,6 +1,8 @@
import { Meta, StoryObj } from '@storybook/nextjs-vite' import { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Gallery } from './Gallery' import { Gallery, GalleryItem } from './Gallery'
import chris from "./../../assets/christophorus.jpeg" import chris from "./../../assets/christophorus.jpeg"
import forest from "./../../assets/forest.jpeg"
import map from "./../../assets/map.jpg"
const meta: Meta<typeof Gallery> = { const meta: Meta<typeof Gallery> = {
component: Gallery, component: Gallery,
@ -9,27 +11,52 @@ const meta: Meta<typeof Gallery> = {
type Story = StoryObj<typeof Gallery>; type Story = StoryObj<typeof Gallery>;
export default meta export default meta
// Remote image with explicit dimensions (varied aspect ratios) so the strip
// shows genuinely different items. Picsum serves a stable image per `id`.
const remote = (
id: string,
picId: number,
width: number,
height: number,
alt: string,
caption?: string,
): GalleryItem => ({
id,
thumbnail: { src: `https://picsum.photos/id/${picId}/${width}/${height}`, width, height },
image: { src: `https://picsum.photos/id/${picId}/${width * 2}/${height * 2}`, width: width * 2, height: height * 2 },
alt,
caption,
})
export const Default: Story = { export const Default: Story = {
args: { args: {
items: [ items: [
{ { id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus" },
id: "1", remote("2", 1015, 600, 400, "Fluss im Tal"),
thumbnail: chris, { id: "3", thumbnail: forest, image: forest, alt: "Wald" },
image: chris, remote("4", 1018, 500, 500, "Berglandschaft"),
alt: "hallo" { id: "5", thumbnail: map, image: map, alt: "Karte der Pfarrei" },
}, remote("6", 1039, 700, 400, "Wasserfall"),
{ remote("7", 164, 400, 600, "Stillleben"),
id: "2", ],
thumbnail: chris,
image: chris,
alt: "hallo"
},
{
id: "3",
thumbnail: chris,
image: chris,
alt: "hallo"
}
]
}, },
} }
export const WithCaptions: Story = {
args: {
items: [
{ id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus", caption: "Fenster des Heiligen Christophorus, Hauptschiff" },
{ id: "2", thumbnail: forest, image: forest, alt: "Wald", caption: "Spaziergang durch den Pfarrwald" },
remote("3", 1043, 600, 400, "Innenraum", "Blick auf die Orgelempore"),
{ id: "4", thumbnail: map, image: map, alt: "Karte", caption: "Lage der Gemeinde" },
],
},
}
export const SingleItem: Story = {
args: {
items: [
{ id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus", caption: "Einzelnes Bild ohne Autoscroll" },
],
},
}

View file

@ -2,92 +2,398 @@
import styles from './styles.module.scss' import styles from './styles.module.scss'
import Image, { StaticImageData } from 'next/image' import Image, { StaticImageData } from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react' import {
import { AutoScroll } from '@/components/Gallery/AutoScroll' CSSProperties,
import { Fullscreen } from '@/components/Gallery/Fullscreen' MouseEvent as ReactMouseEvent,
Ref,
TouchEvent as ReactTouchEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
type ImageType = { type ImageType =
src: string, | {
width: number, src: string
height: number width: number
} | StaticImageData height: number
}
| StaticImageData
export type GalleryItem = { export type GalleryItem = {
id: string; id: string
thumbnail: ImageType ; thumbnail: ImageType
image: ImageType; image: ImageType
alt: string; alt: string
} caption?: string
type GalleryItemProps = {
thumbnail: ImageType,
alt: string,
onClick?: () => void
} }
type GalleryProps = { type GalleryProps = {
items: GalleryItem[]; items: GalleryItem[]
} }
const GalleryItem = ({ thumbnail, alt, onClick }: GalleryItemProps) => { /** Horizontal gap between thumbnails — keep in sync with `$gap` in styles.module.scss. */
return ( const GAP = 30
<Image /** Autoscroll speed in pixels per second. */
onClick={onClick} const SPEED = 30
className={styles.item} /** Pointer travel (px) past which a press counts as a drag, not a click. */
src={thumbnail.src} const DRAG_THRESHOLD = 5
height={thumbnail.height} /** Minimum swipe distance (px) to trigger navigation / close in the lightbox. */
width={thumbnail.width} const SWIPE_THRESHOLD = 50
alt={alt} /** Fade duration (ms) — keep in sync with the animations in styles.module.scss. */
unoptimized={true} const FADE_MS = 100
/>
)
}
export const Gallery = ({ items }: GalleryProps) => { export const Gallery = ({ items }: GalleryProps) => {
const [displayFullscreen, setDisplayFullscreen] = useState(false) const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [idx, setIdx] = useState(0) // Render a second copy of the strip only once we know it should loop (client, motion-ok, overflowing).
const [isScrolling, setIsScrolling] = useState(true) const [loop, setLoop] = useState(false)
const displayImage = useCallback((n: number) => {
setIdx(n);
setDisplayFullscreen(true);
setIsScrolling(false)
}, [setDisplayFullscreen, setIdx, setIsScrolling]);
const next = useCallback(() => { const wrapperRef = useRef<HTMLDivElement>(null)
setIdx((idx + 1) % items.length) const firstGroupRef = useRef<HTMLDivElement>(null)
}, [idx, setIdx, items]) const pausedRef = useRef(false)
const lightboxOpenRef = useRef(false)
const dragRef = useRef({ active: false, startX: 0, scrollStart: 0, moved: false })
const close = useCallback(() => setLightboxIndex(null), [])
const showNext = useCallback(
() => setLightboxIndex((i) => (i === null ? i : (i + 1) % items.length)),
[items.length],
)
const showPrev = useCallback(
() => setLightboxIndex((i) => (i === null ? i : (i - 1 + items.length) % items.length)),
[items.length],
)
if(items.length == 0) { // Keep the autoscroll loop paused while the lightbox is open.
return null; useEffect(() => {
lightboxOpenRef.current = lightboxIndex !== null
}, [lightboxIndex])
// Decide whether the strip should autoscroll/loop at all.
useEffect(() => {
const wrapper = wrapperRef.current
if (!wrapper || items.length < 2) return
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (prefersReduced) return
if (wrapper.scrollWidth <= wrapper.clientWidth) return
setLoop(true)
}, [items.length])
// Continuous, seamless autoscroll via requestAnimationFrame on the duplicated strip.
useEffect(() => {
if (!loop) return
const wrapper = wrapperRef.current
const firstGroup = firstGroupRef.current
if (!wrapper || !firstGroup) return
let raf = 0
let last: number | null = null
const step = (now: number) => {
if (last !== null && !pausedRef.current && !lightboxOpenRef.current) {
const dt = (now - last) / 1000
wrapper.scrollLeft += SPEED * dt
const period = firstGroup.offsetWidth + GAP
if (period > 0 && wrapper.scrollLeft >= period) {
wrapper.scrollLeft -= period
}
}
last = now
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [loop])
// --- Pointer drag-to-scroll (mouse). Touch uses native overflow scrolling. ---
const onMouseEnter = useCallback(() => {
pausedRef.current = true
}, [])
const onMouseLeave = useCallback(() => {
pausedRef.current = false
dragRef.current.active = false
}, [])
const onMouseDown = useCallback((e: ReactMouseEvent) => {
const wrapper = wrapperRef.current
if (!wrapper) return
dragRef.current = {
active: true,
startX: e.clientX,
scrollStart: wrapper.scrollLeft,
moved: false,
}
}, [])
const onMouseMove = useCallback((e: ReactMouseEvent) => {
const drag = dragRef.current
const wrapper = wrapperRef.current
if (!drag.active || !wrapper) return
const dx = e.clientX - drag.startX
if (Math.abs(dx) > DRAG_THRESHOLD) drag.moved = true
wrapper.scrollLeft = drag.scrollStart - dx
}, [])
const onMouseUp = useCallback(() => {
dragRef.current.active = false
}, [])
// Touch: pause autoscroll while a finger is down; native scrolling moves the strip.
const onTouchStart = useCallback(() => {
pausedRef.current = true
}, [])
const onTouchEnd = useCallback(() => {
pausedRef.current = false
}, [])
// Keyboard: pause autoscroll while a thumbnail is focused so it can be activated (WCAG 2.2.2).
const onFocus = useCallback(() => {
pausedRef.current = true
}, [])
const onBlur = useCallback(() => {
pausedRef.current = false
}, [])
const handleThumbClick = useCallback((index: number) => {
// Ignore the click that ends a drag.
if (dragRef.current.moved) return
setLightboxIndex(index)
}, [])
if (items.length === 0) {
return null
} }
const { image, alt } = items[idx] const renderGroup = (copy: number, ref?: Ref<HTMLDivElement>, hidden = false) => (
<div className={styles.group} ref={ref} aria-hidden={hidden || undefined}>
{items.map((item, index) => (
<Image
key={`${item.id}-${copy}`}
className={styles.item}
src={item.thumbnail.src}
width={item.thumbnail.width}
height={item.thumbnail.height}
alt={item.alt}
unoptimized
draggable={false}
tabIndex={hidden ? -1 : 0}
role="button"
aria-haspopup="dialog"
onClick={() => handleThumbClick(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleThumbClick(index)
}
}}
/>
))}
</div>
)
return ( return (
<> <>
<AutoScroll <div
isScrolling={isScrolling} className={styles.wrapper}
onTouch={() => setIsScrolling(false)} ref={wrapperRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onFocus={onFocus}
onBlur={onBlur}
> >
<div className={styles.container}> <div className={styles.track}>
{items.map((item, index) => {renderGroup(0, firstGroupRef)}
<GalleryItem {loop && renderGroup(1, undefined, true)}
onClick={() => displayImage(index)}
key={item.id}
thumbnail={item.thumbnail}
alt={item.alt}
/>)}
</div> </div>
</AutoScroll> </div>
<Fullscreen {lightboxIndex !== null && (
display={displayFullscreen} <Lightbox
image={image} item={items[lightboxIndex]}
closeClicked={() => setDisplayFullscreen(false)} index={lightboxIndex}
nextClicked={next} total={items.length}
hasMultiple={items.length > 1}
onClose={close}
onNext={showNext}
onPrev={showPrev}
/>
)}
</>
)
}
type LightboxProps = {
item: GalleryItem
index: number
total: number
hasMultiple: boolean
onClose: () => void
onNext: () => void
onPrev: () => void
}
// Visually hide content while keeping it available to assistive technology.
const srOnly: CSSProperties = {
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
border: 0,
}
const Lightbox = ({ item, index, total, hasMultiple, onClose, onNext, onPrev }: LightboxProps) => {
const dialogRef = useRef<HTMLDivElement>(null)
const touchRef = useRef({ x: 0, y: 0 })
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// Play the exit fade before the parent unmounts us.
const [closing, setClosing] = useState(false)
// Fade the whole lightbox out, then actually close once the animation ends.
const requestClose = useCallback(() => {
setClosing((alreadyClosing) => {
if (!alreadyClosing) closeTimer.current = setTimeout(onClose, FADE_MS)
return true
})
}, [onClose])
// Lock body scroll and restore focus on close.
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialogRef.current?.focus()
return () => {
if (closeTimer.current) clearTimeout(closeTimer.current)
document.body.style.overflow = previousOverflow
previouslyFocused?.focus?.()
}
}, [])
// Keyboard navigation + focus trap (keep Tab within the modal — WCAG 2.4.3).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
requestClose()
} else if (e.key === 'ArrowRight') {
onNext()
} else if (e.key === 'ArrowLeft') {
onPrev()
} else if (e.key === 'Tab') {
const dialog = dialogRef.current
if (!dialog) return
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>('button'))
if (focusables.length === 0) {
e.preventDefault()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement
if (e.shiftKey && (active === first || active === dialog)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [requestClose, onNext, onPrev])
const onTouchStart = (e: ReactTouchEvent) => {
const t = e.changedTouches[0]
touchRef.current = { x: t.clientX, y: t.clientY }
}
const onTouchEnd = (e: ReactTouchEvent) => {
const t = e.changedTouches[0]
const dx = t.clientX - touchRef.current.x
const dy = t.clientY - touchRef.current.y
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > SWIPE_THRESHOLD) {
if (dx < 0) onNext()
else onPrev()
} else if (dy > SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx)) {
requestClose()
}
}
const stop = (e: ReactMouseEvent) => e.stopPropagation()
return (
<div
className={`${styles.display} ${closing ? styles.closing : ''}`}
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Bildergalerie"
tabIndex={-1}
onClick={requestClose}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<div style={srOnly} aria-live="polite">
{`Bild ${index + 1} von ${total}: ${item.alt}`}
</div>
<button
type="button"
className={styles.close}
aria-label="Schließen"
onClick={(e) => {
stop(e)
requestClose()
}}
>
×
</button>
{hasMultiple && (
<button
type="button"
className={`${styles.nav} ${styles.prev}`}
aria-label="Vorheriges Bild"
onClick={(e) => {
stop(e)
onPrev()
}}
/>
)}
<Image
className={styles.displayImage}
src={item.image.src}
width={item.image.width}
height={item.image.height}
alt={item.alt}
unoptimized
onClick={stop}
/> />
</> {hasMultiple && (
<button
type="button"
className={`${styles.nav} ${styles.next}`}
aria-label="Nächstes Bild"
onClick={(e) => {
stop(e)
onNext()
}}
/>
)}
{item.caption && (
<div className={styles.captionBar} onClick={stop}>
{item.caption}
</div>
)}
</div>
) )
} }

View file

@ -1,4 +0,0 @@
.wrapper {
overflow: scroll;
scrollbar-width: none;
}

View file

@ -1,42 +0,0 @@
@import "template.scss";
.display {
position: fixed;
top: 0;
left: 0;
z-index: 99;
width: 100vw;
height: 100vh;
background-color: $overlay;
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
}
.displayImage {
display: block;
max-height: 90vh;
max-width: 90vw;
object-fit: contain;
}
.close {
color: $white;
font-weight: bold;
position: fixed;
top: 35px;
right: 35px;
background-color: $base-color;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.close:hover {
background-color: $shade1;
}

View file

@ -1,12 +1,176 @@
@import "template.scss"; @import "template.scss";
.container { $gap: 30px;
/* ---- Thumbnail strip ---- */
.wrapper {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
cursor: grab;
&::-webkit-scrollbar {
display: none;
}
&:active {
cursor: grabbing;
}
}
.track {
display: flex; display: flex;
gap: 30px; gap: $gap;
flex-wrap: nowrap;
justify-content: center; justify-content: center;
min-width: 100%;
width: max-content;
}
.group {
display: flex;
gap: $gap;
flex: none;
} }
.item { .item {
cursor: pointer; cursor: pointer;
} flex: none;
user-select: none;
-webkit-user-drag: none;
&:focus-visible {
outline: 3px solid $base-color;
outline-offset: 3px;
}
}
/* ---- Fullscreen lightbox ---- */
@keyframes lightboxFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes lightboxFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
.display {
position: fixed;
top: 0;
left: 0;
z-index: 99;
width: 100vw;
height: 100vh;
background-color: $overlay;
backdrop-filter: blur(8px);
display: flex;
justify-content: center;
align-items: center;
outline: none;
animation: lightboxFadeIn 200ms ease;
}
.closing {
animation: lightboxFadeOut 200ms ease forwards;
}
.displayImage {
display: block;
max-height: 90vh;
max-width: 90vw;
object-fit: contain;
user-select: none;
}
.close,
.nav {
color: $white;
font-weight: bold;
position: fixed;
background-color: $base-color;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 24px;
line-height: 1;
opacity: 0.6;
&:hover,
&:focus-visible {
background-color: $shade1;
opacity: 1;
}
&:focus-visible {
outline: 3px solid $white;
outline-offset: 2px;
}
}
.close {
top: 35px;
right: 35px;
}
.nav {
top: 50%;
transform: translateY(-50%);
font-size: 0;
// Chevron drawn with CSS borders so it stays perfectly centred (no font metrics).
&::before {
content: "";
display: block;
width: 9px;
height: 9px;
border: solid $white;
border-width: 0;
}
}
.prev {
left: 35px;
&::before {
border-width: 0 0 3px 3px;
transform: rotate(45deg);
margin-left: 3px; // optical centering of the rotated chevron
}
}
.next {
right: 35px;
&::before {
border-width: 3px 3px 0 0;
transform: rotate(45deg);
margin-right: 3px; // optical centering of the rotated chevron
}
}
.captionBar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24px 16px;
color: $white;
text-align: center;
font-size: 16px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.0));
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
}
@media (prefers-reduced-motion: reduce) {
.display,
.closing {
animation: none;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T12:41:03.305Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T12:41:03.609Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T12:41:03.673Z';
ALTER TABLE "blog_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "_blog_v_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "group_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "_group_v_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "pages_blocks_gallery_items" ADD COLUMN "caption" varchar;
ALTER TABLE "_pages_v_blocks_gallery_items" ADD COLUMN "caption" varchar;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "announcement" ALTER COLUMN "date" SET DEFAULT '2026-06-07T11:31:06.259Z';
ALTER TABLE "calendar" ALTER COLUMN "date" SET DEFAULT '2026-06-07T11:31:06.641Z';
ALTER TABLE "classifieds" ALTER COLUMN "until" SET DEFAULT '2026-07-05T11:31:06.720Z';
ALTER TABLE "blog_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "_blog_v_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "group_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "_group_v_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "pages_blocks_gallery_items" DROP COLUMN "caption";
ALTER TABLE "_pages_v_blocks_gallery_items" DROP COLUMN "caption";`)
}

View file

@ -46,6 +46,7 @@ import * as migration_20260429_121105_collapsible_map_with_text from './20260429
import * as migration_20260504_070356_next_prev_buttons from './20260504_070356_next_prev_buttons'; import * as migration_20260504_070356_next_prev_buttons from './20260504_070356_next_prev_buttons';
import * as migration_20260605_095112 from './20260605_095112'; import * as migration_20260605_095112 from './20260605_095112';
import * as migration_20260605_113107_occurrence_end_time from './20260605_113107_occurrence_end_time'; import * as migration_20260605_113107_occurrence_end_time from './20260605_113107_occurrence_end_time';
import * as migration_20260605_124103_gallery_caption from './20260605_124103_gallery_caption';
export const migrations = [ export const migrations = [
{ {
@ -286,6 +287,11 @@ export const migrations = [
{ {
up: migration_20260605_113107_occurrence_end_time.up, up: migration_20260605_113107_occurrence_end_time.up,
down: migration_20260605_113107_occurrence_end_time.down, down: migration_20260605_113107_occurrence_end_time.down,
name: '20260605_113107_occurrence_end_time' name: '20260605_113107_occurrence_end_time',
},
{
up: migration_20260605_124103_gallery_caption.up,
down: migration_20260605_124103_gallery_caption.down,
name: '20260605_124103_gallery_caption'
}, },
]; ];

View file

@ -505,6 +505,7 @@ export interface Page {
| { | {
items: { items: {
photo: string | Media; photo: string | Media;
caption?: string | null;
id?: string | null; id?: string | null;
}[]; }[];
id?: string | null; id?: string | null;
@ -798,6 +799,7 @@ export interface Group {
| { | {
items: { items: {
photo: string | Media; photo: string | Media;
caption?: string | null;
id?: string | null; id?: string | null;
}[]; }[];
id?: string | null; id?: string | null;
@ -1031,6 +1033,7 @@ export interface Blog {
| { | {
items: { items: {
photo: string | Media; photo: string | Media;
caption?: string | null;
id?: string | null; id?: string | null;
}[]; }[];
id?: string | null; id?: string | null;
@ -1831,6 +1834,7 @@ export interface BlogSelect<T extends boolean = true> {
| T | T
| { | {
photo?: T; photo?: T;
caption?: T;
id?: T; id?: T;
}; };
id?: T; id?: T;
@ -2015,6 +2019,7 @@ export interface GroupSelect<T extends boolean = true> {
| T | T
| { | {
photo?: T; photo?: T;
caption?: T;
id?: T; id?: T;
}; };
id?: T; id?: T;
@ -2174,6 +2179,7 @@ export interface PagesSelect<T extends boolean = true> {
| T | T
| { | {
photo?: T; photo?: T;
caption?: T;
id?: T; id?: T;
}; };
id?: T; id?: T;

View file

@ -4,6 +4,7 @@ import { StaticImageData } from 'next/image'
type Items = { type Items = {
photo: string | Media; photo: string | Media;
caption?: string | null;
id?: string | null; id?: string | null;
}[]; }[];
@ -20,7 +21,8 @@ export const transformGallery = (items: Items) => {
alt: item.photo.alt, alt: item.photo.alt,
id: item.id, id: item.id,
thumbnail, thumbnail,
image image,
caption: item.caption ?? undefined
}) })
} }
} }