release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
@@ -2,13 +2,17 @@
|
||||
* HeroCarousel — crossfade between hero images on a timer.
|
||||
*
|
||||
* If only one image is provided, renders a static image with no timer.
|
||||
* When multiple images are provided, crossfades between them every 6 seconds.
|
||||
* When multiple images are provided, crossfades between them every 6 seconds
|
||||
* — unless the visitor prefers reduced motion, or has paused it with the
|
||||
* pause control (WCAG 2.2.2: anything that moves on its own for more than
|
||||
* five seconds needs a way to stop it, #696).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useDictionary } from './I18nProvider';
|
||||
|
||||
interface HeroImage {
|
||||
src: string;
|
||||
@@ -19,23 +23,51 @@ interface HeroImage {
|
||||
|
||||
interface HeroCarouselProps {
|
||||
images: HeroImage[];
|
||||
/**
|
||||
* `sizes` for next/image. Most heroes are full-width; only the split hero's
|
||||
* image panel is half the viewport, and only above the mobile breakpoint.
|
||||
*/
|
||||
sizes?: string;
|
||||
}
|
||||
|
||||
const INTERVAL_MS = 6000;
|
||||
export const INTERVAL_MS = 6000;
|
||||
|
||||
export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
function subscribeReducedMotion(onChange: () => void) {
|
||||
const mql = window.matchMedia(REDUCED_MOTION);
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}
|
||||
|
||||
/** True when the visitor asked the OS for less motion. False during SSR. */
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribeReducedMotion,
|
||||
() => window.matchMedia(REDUCED_MOTION).matches,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroCarousel({ images, sizes = '100vw' }: HeroCarouselProps) {
|
||||
const t = useDictionary();
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
// null = the visitor has not touched the control; the default then follows
|
||||
// the motion preference. An explicit play overrides reduced motion.
|
||||
const [pausedByUser, setPausedByUser] = useState<boolean | null>(null);
|
||||
const paused = pausedByUser ?? reducedMotion;
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
if (images.length <= 1 || paused) return;
|
||||
|
||||
const timer = setInterval(advance, INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [images.length, advance]);
|
||||
}, [images.length, advance, paused]);
|
||||
|
||||
if (images.length === 0) {
|
||||
return <div className="hero__image-placeholder" />;
|
||||
@@ -50,7 +82,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
alt=""
|
||||
className="hero__image"
|
||||
fill
|
||||
sizes="50vw"
|
||||
sizes={sizes}
|
||||
priority
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
@@ -70,7 +102,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
alt=""
|
||||
className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`}
|
||||
fill
|
||||
sizes="50vw"
|
||||
sizes={sizes}
|
||||
priority={i === 0}
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
@@ -78,6 +110,25 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
/>
|
||||
))}
|
||||
<HeroExifChip text={images[currentIndex]?.exif} />
|
||||
<button
|
||||
type="button"
|
||||
className="hero__carousel-toggle"
|
||||
aria-label={paused ? t.home.playSlideshow : t.home.pauseSlideshow}
|
||||
title={paused ? t.home.playSlideshow : t.home.pauseSlideshow}
|
||||
data-paused={paused}
|
||||
onClick={() => setPausedByUser(!paused)}
|
||||
>
|
||||
{paused ? (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="5" width="4" height="14" />
|
||||
<rect x="14" y="5" width="4" height="14" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user