release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

66
components/JournalNav.tsx Normal file
View File

@@ -0,0 +1,66 @@
/**
* JournalNav — the "← previous entry / next entry →" pair at the foot of a
* journal entry, mirroring AlbumNav (#483) for the journal (#591).
*
* Renders nothing when there is no neighbour in either direction, so a lone
* entry keeps its quiet ending.
*/
import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
import type { JournalNavPair } from '@/lib/journalNav';
function Chevron({ direction }: { direction: 'prev' | 'next' }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points={direction === 'prev' ? '15 18 9 12 15 6' : '9 18 15 12 9 6'} />
</svg>
);
}
export function JournalNav({ prev, next }: JournalNavPair) {
const t = getServerDictionary();
if (!prev && !next) return null;
return (
<nav className="album-nav" aria-label={t.common.entryNavAria}>
{prev ? (
<Link
href={prev.href}
className="album-nav__link album-nav__link--prev"
aria-label={t.common.prevEntryAria(prev.title)}
>
<Chevron direction="prev" />
<span className="album-nav__text">
<span className="album-nav__kicker">{t.common.prevEntry}</span>
<span className="album-nav__name">{prev.title}</span>
</span>
</Link>
) : (
/* Holds the next link against the right edge when there is no previous. */
<span aria-hidden="true" />
)}
{next && (
<Link
href={next.href}
className="album-nav__link album-nav__link--next"
aria-label={t.common.nextEntryAria(next.title)}
>
<span className="album-nav__text">
<span className="album-nav__kicker">{t.common.nextEntry}</span>
<span className="album-nav__name">{next.title}</span>
</span>
<Chevron direction="next" />
</Link>
)}
</nav>
);
}

View File

@@ -131,19 +131,20 @@ export function Lightbox({
/**
* The deep link to the photo on screen.
*
* Built from the current index rather than read back from `location.hash`,
* so it does not depend on the grid's hash-sync effect having run yet. The
* `#photo-N` form is the permalink the grid already writes and restores;
* this only supplies the affordance to copy it (#478).
* Built from `current.id` — the asset's own stable token, the same one the
* grid already carries on every `PhotoItem` — rather than read back from
* `location.search`, so it does not depend on the grid's query-sync effect
* having run yet. This only supplies the affordance to copy it (#478); the
* `?photo=<assetId>` form itself is what the grid writes and restores.
*
* The link is positional, as `gallery.yaml.example` documents: reordering an
* album moves where a shared link lands. That is a property of the existing
* permalink, not of the button.
* Addressed by the photo, not a position: reordering the album, or
* deleting a different photo, no longer repoints an already-shared link
* (#588).
*/
const permalink = useCallback(() => {
if (typeof window === 'undefined') return '';
return buildPhotoPermalink(window.location, currentIndex);
}, [currentIndex]);
if (typeof window === 'undefined' || !current) return '';
return buildPhotoPermalink(window.location, current.id);
}, [current]);
const handleCopyLink = useCallback(() => {
const url = permalink();

98
components/MobileNav.tsx Normal file
View File

@@ -0,0 +1,98 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useDictionary } from './I18nProvider';
/**
* 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).
*
* 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.
*/
export function MobileNav({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const t = useDictionary();
const close = useCallback(() => setOpen(false), []);
// The root layout persists across navigations, so without this the panel
// would still be open on the page a visitor just tapped through to. Adjusted
// during render rather than in an effect, per React's guidance for state
// that only needs to change alongside a prop — it resolves in this render
// pass instead of triggering an extra one.
const [prevPathname, setPrevPathname] = useState(pathname);
if (pathname !== prevPathname) {
setPrevPathname(pathname);
setOpen(false);
}
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [open, close]);
return (
<>
<button
type="button"
className="header__menu-btn"
aria-expanded={open}
aria-controls="header-nav-panel"
aria-label={open ? t.nav.closeMenu : t.nav.openMenu}
onClick={() => setOpen((o) => !o)}
>
{open ? (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>
{open && <div className="header__nav-backdrop" onClick={close} aria-hidden="true" />}
<div id="header-nav-panel" className="header__nav-links" data-open={open}>
{children}
</div>
</>
);
}

47
components/NavLink.tsx Normal file
View File

@@ -0,0 +1,47 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
/**
* A header link that knows whether it is the page you are on.
*
* `.header__nav-link.active` has been styled in globals.css since the header
* was written, but nothing ever applied the class — the nav looked like it
* should tell you where you are, and did not. `aria-current` was missing with
* it, so screen-reader users had no equivalent either.
*
* Matching: exact for `/`, and prefix-with-separator for everything else, so an
* album at `/reisen/island` marks its subpage `/reisen` without `/reise` also
* matching `/reisen`. External links are rendered by the caller as plain
* anchors and never take part.
*/
export function NavLink({
href,
children,
className = 'header__nav-link',
}: {
href: string;
children: React.ReactNode;
className?: string;
}) {
const pathname = usePathname();
const active = isActivePath(pathname, href);
return (
<Link
href={href}
className={active ? `${className} active` : className}
aria-current={active ? 'page' : undefined}
>
{children}
</Link>
);
}
/** Exported for tests: whether `href` is the section `pathname` sits in. */
export function isActivePath(pathname: string | null, href: string): boolean {
if (!pathname) return false;
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(`${href}/`);
}

View File

@@ -3,7 +3,7 @@
* for all subpages and standalone albums in the header.
*/
import Link from 'next/link';
import { NavLink } from './NavLink';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { listJournalEntries } from '@/lib/admin/journal-service';
@@ -25,19 +25,15 @@ export async function SubpageNav() {
return (
<>
{subpages.map((sp) => (
<Link key={sp.slug} href={`/${sp.slug}`} className="header__nav-link">
<NavLink key={sp.slug} href={`/${sp.slug}`}>
{sp.name}
</Link>
</NavLink>
))}
{hasPublicJournal && (
<Link href="/journal" className="header__nav-link">
{t.nav.journal}
</Link>
)}
{hasPublicJournal && <NavLink href="/journal">{t.nav.journal}</NavLink>}
{standaloneAlbums.map((album) => (
<Link key={album.id} href={`/${album.slug}`} className="header__nav-link">
<NavLink key={album.id} href={`/${album.slug}`}>
{album.albumName}
</Link>
</NavLink>
))}
{navLinks.map((link) => (
<a

View File

@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { NavLink } from '../NavLink';
/**
* isActivePath is covered on its own in lib/__tests__/navActive.test.ts. What
* this adds is the part that was never verified: that the decision actually
* reaches the markup — the class the CSS has been waiting for since the header
* was written, and the aria-current that had no equivalent anywhere.
*
* The routes below are the ones the reference site really serves.
*/
const mockPathname = vi.hoisted(() => ({ value: '/' }));
vi.mock('next/navigation', () => ({
usePathname: () => mockPathname.value,
}));
afterEach(cleanup);
const at = (pathname: string) => {
mockPathname.value = pathname;
};
describe('NavLink', () => {
it('marks the current page in both the class and aria-current', () => {
at('/deutschland');
render(<NavLink href="/deutschland">Deutschland</NavLink>);
const link = screen.getByRole('link', { name: 'Deutschland' });
expect(link.className).toContain('active');
expect(link.getAttribute('aria-current')).toBe('page');
});
it('leaves a link that is not current untouched', () => {
at('/polen');
render(<NavLink href="/deutschland">Deutschland</NavLink>);
const link = screen.getByRole('link', { name: 'Deutschland' });
expect(link.className).not.toContain('active');
// Absent, not "false": aria-current="false" is a value screen readers
// announce differently from the attribute simply not being there.
expect(link.getAttribute('aria-current')).toBeNull();
});
it('keeps the base class alongside the active one, so the link still looks like a link', () => {
at('/map');
render(<NavLink href="/map">Karte</NavLink>);
expect(screen.getByRole('link', { name: 'Karte' }).className).toContain('header__nav-link');
});
it('marks the subpage while viewing one of its albums', () => {
at('/deutschland/kloster-chorin');
render(
<>
<NavLink href="/">Start</NavLink>
<NavLink href="/deutschland">Deutschland</NavLink>
<NavLink href="/polen">Polen</NavLink>
</>,
);
expect(screen.getByRole('link', { name: 'Deutschland' }).className).toContain('active');
expect(screen.getByRole('link', { name: 'Start' }).className).not.toContain('active');
expect(screen.getByRole('link', { name: 'Polen' }).className).not.toContain('active');
});
it('marks exactly one link across a full header', () => {
at('/deutschland/an-der-ostsee');
render(
<>
<NavLink href="/">Start</NavLink>
<NavLink href="/deutschland">Deutschland</NavLink>
<NavLink href="/polen">Polen</NavLink>
<NavLink href="/japan">Japan</NavLink>
<NavLink href="/south-korea">south-korea</NavLink>
<NavLink href="/about">Über mich</NavLink>
<NavLink href="/map">Karte</NavLink>
</>,
);
const marked = screen
.getAllByRole('link')
.filter((el) => el.getAttribute('aria-current') === 'page');
expect(marked).toHaveLength(1);
expect(marked[0].textContent).toBe('Deutschland');
});
it('marks nothing on a page outside the navigation', () => {
at('/impressum');
render(
<>
<NavLink href="/">Start</NavLink>
<NavLink href="/about">Über mich</NavLink>
</>,
);
expect(
screen.getAllByRole('link').filter((el) => el.getAttribute('aria-current') === 'page'),
).toHaveLength(0);
});
});