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',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
label: {
|
||||
de: 'Bildunterschrift'
|
||||
},
|
||||
type: 'text'
|
||||
},
|
||||
],
|
||||
minRows: 3,
|
||||
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 { Gallery } from './Gallery'
|
||||
import { Gallery, GalleryItem } from './Gallery'
|
||||
import chris from "./../../assets/christophorus.jpeg"
|
||||
import forest from "./../../assets/forest.jpeg"
|
||||
import map from "./../../assets/map.jpg"
|
||||
|
||||
const meta: Meta<typeof Gallery> = {
|
||||
component: Gallery,
|
||||
|
|
@ -9,27 +11,52 @@ const meta: Meta<typeof Gallery> = {
|
|||
type Story = StoryObj<typeof Gallery>;
|
||||
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 = {
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
thumbnail: chris,
|
||||
image: chris,
|
||||
alt: "hallo"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
thumbnail: chris,
|
||||
image: chris,
|
||||
alt: "hallo"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
thumbnail: chris,
|
||||
image: chris,
|
||||
alt: "hallo"
|
||||
}
|
||||
]
|
||||
{ id: "1", thumbnail: chris, image: chris, alt: "Heiliger Christophorus" },
|
||||
remote("2", 1015, 600, 400, "Fluss im Tal"),
|
||||
{ id: "3", thumbnail: forest, image: forest, alt: "Wald" },
|
||||
remote("4", 1018, 500, 500, "Berglandschaft"),
|
||||
{ id: "5", thumbnail: map, image: map, alt: "Karte der Pfarrei" },
|
||||
remote("6", 1039, 700, 400, "Wasserfall"),
|
||||
remote("7", 164, 400, 600, "Stillleben"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
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 Image, { StaticImageData } from 'next/image'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { AutoScroll } from '@/components/Gallery/AutoScroll'
|
||||
import { Fullscreen } from '@/components/Gallery/Fullscreen'
|
||||
import {
|
||||
CSSProperties,
|
||||
MouseEvent as ReactMouseEvent,
|
||||
Ref,
|
||||
TouchEvent as ReactTouchEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
type ImageType = {
|
||||
src: string,
|
||||
width: number,
|
||||
height: number
|
||||
} | StaticImageData
|
||||
type ImageType =
|
||||
| {
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
| StaticImageData
|
||||
|
||||
export type GalleryItem = {
|
||||
id: string;
|
||||
thumbnail: ImageType ;
|
||||
image: ImageType;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
type GalleryItemProps = {
|
||||
thumbnail: ImageType,
|
||||
alt: string,
|
||||
onClick?: () => void
|
||||
id: string
|
||||
thumbnail: ImageType
|
||||
image: ImageType
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
type GalleryProps = {
|
||||
items: GalleryItem[];
|
||||
items: GalleryItem[]
|
||||
}
|
||||
|
||||
const GalleryItem = ({ thumbnail, alt, onClick }: GalleryItemProps) => {
|
||||
return (
|
||||
<Image
|
||||
onClick={onClick}
|
||||
className={styles.item}
|
||||
src={thumbnail.src}
|
||||
height={thumbnail.height}
|
||||
width={thumbnail.width}
|
||||
alt={alt}
|
||||
unoptimized={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/** Horizontal gap between thumbnails — keep in sync with `$gap` in styles.module.scss. */
|
||||
const GAP = 30
|
||||
/** Autoscroll speed in pixels per second. */
|
||||
const SPEED = 30
|
||||
/** Pointer travel (px) past which a press counts as a drag, not a click. */
|
||||
const DRAG_THRESHOLD = 5
|
||||
/** Minimum swipe distance (px) to trigger navigation / close in the lightbox. */
|
||||
const SWIPE_THRESHOLD = 50
|
||||
/** Fade duration (ms) — keep in sync with the animations in styles.module.scss. */
|
||||
const FADE_MS = 100
|
||||
|
||||
export const Gallery = ({ items }: GalleryProps) => {
|
||||
const [displayFullscreen, setDisplayFullscreen] = useState(false)
|
||||
const [idx, setIdx] = useState(0)
|
||||
const [isScrolling, setIsScrolling] = useState(true)
|
||||
const displayImage = useCallback((n: number) => {
|
||||
setIdx(n);
|
||||
setDisplayFullscreen(true);
|
||||
setIsScrolling(false)
|
||||
}, [setDisplayFullscreen, setIdx, setIsScrolling]);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
||||
// Render a second copy of the strip only once we know it should loop (client, motion-ok, overflowing).
|
||||
const [loop, setLoop] = useState(false)
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIdx((idx + 1) % items.length)
|
||||
}, [idx, setIdx, items])
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const firstGroupRef = useRef<HTMLDivElement>(null)
|
||||
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) {
|
||||
return null;
|
||||
// Keep the autoscroll loop paused while the lightbox is open.
|
||||
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 (
|
||||
<>
|
||||
<AutoScroll
|
||||
isScrolling={isScrolling}
|
||||
onTouch={() => setIsScrolling(false)}
|
||||
<div
|
||||
className={styles.wrapper}
|
||||
ref={wrapperRef}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
{items.map((item, index) =>
|
||||
<GalleryItem
|
||||
onClick={() => displayImage(index)}
|
||||
key={item.id}
|
||||
thumbnail={item.thumbnail}
|
||||
alt={item.alt}
|
||||
/>)}
|
||||
<div className={styles.track}>
|
||||
{renderGroup(0, firstGroupRef)}
|
||||
{loop && renderGroup(1, undefined, true)}
|
||||
</div>
|
||||
</AutoScroll>
|
||||
|
||||
<Fullscreen
|
||||
display={displayFullscreen}
|
||||
image={image}
|
||||
closeClicked={() => setDisplayFullscreen(false)}
|
||||
nextClicked={next}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
item={items[lightboxIndex]}
|
||||
index={lightboxIndex}
|
||||
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";
|
||||
|
||||
.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;
|
||||
gap: 30px;
|
||||
flex-wrap: nowrap;
|
||||
gap: $gap;
|
||||
justify-content: center;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
gap: $gap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
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_20260605_095112 from './20260605_095112';
|
||||
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 = [
|
||||
{
|
||||
|
|
@ -286,6 +287,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260605_113107_occurrence_end_time.up,
|
||||
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: {
|
||||
photo: string | Media;
|
||||
caption?: string | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
id?: string | null;
|
||||
|
|
@ -798,6 +799,7 @@ export interface Group {
|
|||
| {
|
||||
items: {
|
||||
photo: string | Media;
|
||||
caption?: string | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
id?: string | null;
|
||||
|
|
@ -1031,6 +1033,7 @@ export interface Blog {
|
|||
| {
|
||||
items: {
|
||||
photo: string | Media;
|
||||
caption?: string | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
id?: string | null;
|
||||
|
|
@ -1831,6 +1834,7 @@ export interface BlogSelect<T extends boolean = true> {
|
|||
| T
|
||||
| {
|
||||
photo?: T;
|
||||
caption?: T;
|
||||
id?: T;
|
||||
};
|
||||
id?: T;
|
||||
|
|
@ -2015,6 +2019,7 @@ export interface GroupSelect<T extends boolean = true> {
|
|||
| T
|
||||
| {
|
||||
photo?: T;
|
||||
caption?: T;
|
||||
id?: T;
|
||||
};
|
||||
id?: T;
|
||||
|
|
@ -2174,6 +2179,7 @@ export interface PagesSelect<T extends boolean = true> {
|
|||
| T
|
||||
| {
|
||||
photo?: T;
|
||||
caption?: T;
|
||||
id?: T;
|
||||
};
|
||||
id?: T;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { StaticImageData } from 'next/image'
|
|||
|
||||
type Items = {
|
||||
photo: string | Media;
|
||||
caption?: string | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
|
||||
|
|
@ -20,7 +21,8 @@ export const transformGallery = (items: Items) => {
|
|||
alt: item.photo.alt,
|
||||
id: item.id,
|
||||
thumbnail,
|
||||
image
|
||||
image,
|
||||
caption: item.caption ?? undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue