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:
66
components/JournalNav.tsx
Normal file
66
components/JournalNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
98
components/MobileNav.tsx
Normal 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
47
components/NavLink.tsx
Normal 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}/`);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
105
components/__tests__/NavLink.test.tsx
Normal file
105
components/__tests__/NavLink.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user