feature: upgraded gallery
This commit is contained in:
parent
57a9a1c966
commit
d1c05bfeb8
14 changed files with 26674 additions and 1405 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
.wrapper {
|
|
||||||
overflow: scroll;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
26037
src/migrations/20260605_124103_gallery_caption.json
Normal file
26037
src/migrations/20260605_124103_gallery_caption.json
Normal file
File diff suppressed because it is too large
Load diff
27
src/migrations/20260605_124103_gallery_caption.ts
Normal file
27
src/migrations/20260605_124103_gallery_caption.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue