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:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -1,23 +1,31 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { focusableIn } from '@/lib/focusTrap';
import { usePathname } from 'next/navigation';
import { useDictionary } from './I18nProvider';
/** Must match the `min-width: 641px` query in globals.css. */
const PHONE_QUERY = '(max-width: 640px)';
/**
* MobileNav — hamburger button + dropdown panel for the header links.
*
* `children` — Home, subpages, standalone albums, About, Map — is the exact
* same server-rendered markup the desktop header already used. It is never
* duplicated: `.header__nav-links` is `display: contents` above the mobile
* breakpoint, so on desktop this component is invisible and the links sit
* directly in `.header__nav`'s flex row exactly as before. Below the
* breakpoint the same markup becomes the dropdown panel's content, toggled
* by the `data-open` attribute rather than by mounting/unmounting (#590).
* duplicated: `.header__nav-links` is `display: contents` while the links fit
* in one row, so on desktop this component is invisible and the links sit
* directly in `.header__nav`'s flex row exactly as before. Otherwise the same
* markup becomes the dropdown panel's content, toggled by the `data-open`
* attribute rather than by mounting/unmounting (#590).
*
* The header used to scroll sideways with the scrollbar hidden on narrow
* viewports, so a visitor with more subpages than fit the width had no sign
* that any were missing.
* "Otherwise" is two conditions. Phones always get the menu, and the CSS says
* so on its own, before hydration. Wider viewports get it when the row is
* wider than the header — measured here, because it depends on how many
* subpages the site has and on the preset's type, and published as
* `data-collapsed`. The header used to scroll sideways with the scrollbar
* hidden instead, which on a tablet left About, Map and the theme toggle
* off-screen with no sign they existed.
*/
export function MobileNav({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
@@ -37,10 +45,61 @@ export function MobileNav({ children }: { children: React.ReactNode }) {
setOpen(false);
}
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
// The width the inline row needs. It can only be read while the row is laid
// out, so once collapsed this last reading is what a wider header is
// compared against; expanding re-measures, and collapses again if the
// reading was stale (a late web font, a different active link).
const neededRef = useRef(0);
// A layout effect, so a row that does not fit is swapped for the button
// before the browser paints it.
useLayoutEffect(() => {
const nav = buttonRef.current?.parentElement;
if (!nav) return;
const phone = typeof window.matchMedia === 'function' ? window.matchMedia(PHONE_QUERY) : null;
const measure = () => {
const isPhone = phone?.matches ?? false;
if (!collapsed && !isPhone) neededRef.current = nav.scrollWidth;
const next = isPhone || neededRef.current > nav.clientWidth;
if (next === collapsed) return;
setCollapsed(next);
// A panel left open would otherwise keep its backdrop and the page's
// scroll lock once the links are back in the row.
if (!next) setOpen(false);
};
measure();
const resize = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
resize?.observe(nav);
phone?.addEventListener('change', measure);
let active = true;
document.fonts?.ready.then(() => {
if (active) measure();
});
return () => {
active = false;
resize?.disconnect();
phone?.removeEventListener('change', measure);
};
}, [collapsed, pathname]);
useEffect(() => {
if (!open) return;
// Focus moves into the panel when it opens (#696), so a keyboard or
// screen-reader visitor lands on the first link rather than behind it.
// Only when the panel is actually the dropdown — while the links sit in
// the row the button is hidden, so this never runs.
if (panelRef.current) focusableIn(panelRef.current)[0]?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
if (e.key === 'Escape') {
close();
// Esc returns focus to the button that opened the panel; otherwise
// it would be left on a link that is no longer visible.
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
@@ -53,8 +112,10 @@ export function MobileNav({ children }: { children: React.ReactNode }) {
return (
<>
<button
ref={buttonRef}
type="button"
className="header__menu-btn"
data-collapsed={collapsed}
aria-expanded={open}
aria-controls="header-nav-panel"
aria-label={open ? t.nav.closeMenu : t.nav.openMenu}
@@ -90,7 +151,13 @@ export function MobileNav({ children }: { children: React.ReactNode }) {
)}
</button>
{open && <div className="header__nav-backdrop" onClick={close} aria-hidden="true" />}
<div id="header-nav-panel" className="header__nav-links" data-open={open}>
<div
ref={panelRef}
id="header-nav-panel"
className="header__nav-links"
data-open={open}
data-collapsed={collapsed}
>
{children}
</div>
</>