release: v0.13.0 (#535)

Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

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>
This commit is contained in:
Ralksta
2026-08-25 10:08:12 +02:00
committed by GitHub
parent 10ec68f493
commit a813433891
73 changed files with 4697 additions and 453 deletions

View File

@@ -68,6 +68,20 @@ AUTH_SECRET=change-me-to-a-long-random-string
# Leave unset or empty to disable the admin UI entirely. # Leave unset or empty to disable the admin UI entirely.
# ADMIN_PASSWORD=your-secure-admin-password # ADMIN_PASSWORD=your-secure-admin-password
# ── Site URL ─────────────────────────────────────────────────────
# Used for sitemap.xml, the feed and structured data when `url:` is not
# set in settings.yaml. The settings file takes precedence, so the admin
# panel's field always does what it appears to do.
# SITE_URL=https://folio.example
# ── Update notice ────────────────────────────────────────────────
# The admin dashboard compares the running version against the latest
# GitHub release and shows the result in the status list. One request a
# day, cached, and it fails quietly when there is no network.
# Set to false to switch it off — the dashboard then only names the
# version it is running.
# UPDATE_CHECK=false
# ── Site password (optional) ───────────────────────────────────── # ── Site password (optional) ─────────────────────────────────────
# Puts the WHOLE public site behind one password. Overrides # Puts the WHOLE public site behind one password. Overrides
# `sitePassword` in settings.yaml, so it can be rotated without # `sitePassword` in settings.yaml, so it can be rotated without

3
.gitignore vendored
View File

@@ -49,6 +49,9 @@ Thumbs.db
.claude/ .claude/
# Agent instructions — local working notes, not part of the published project. # Agent instructions — local working notes, not part of the published project.
# Both are generated by next.config.ts on `next dev`, so an untracked one shows
# up as noise in `git status` after every start.
AGENTS.md
CLAUDE.md CLAUDE.md
.planning/ .planning/

View File

@@ -7,6 +7,166 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases). [GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.13.0] — 2026-08-25
### Added
- **Discoverability: site URL, `robots.txt`, `sitemap.xml` and JSON-LD**
([#472](https://github.com/ralksta/immich-folio/issues/472)). A portfolio that
cannot be found is a portfolio nobody sees, and there was no way to write any
of it because nothing knew the site's own address. `url:` in `settings.yaml`,
editable under Settings › SEO, with `SITE_URL` as a fallback. Without one the
sitemap stays empty and the JSON-LD is omitted rather than guessing a host: a
wrong absolute URL is worse than a missing one.
What may appear in a listing is decided by one pure function, so a sitemap
cannot become the leak nobody notices — it excludes protected and hidden
subpages, protected albums, journal drafts, and the case easiest to miss:
public albums sitting under a protected or hidden subpage, which are not
reachable and whose names would otherwise be published. A locked site yields
nothing at all. Paths are percent-encoded, so an album named in a non-Latin
script produces a valid URL rather than an IRI the sitemap protocol will not
accept.
- **Slideshow mode in the lightbox**
([#473](https://github.com/ralksta/immich-folio/issues/473)). `s` cycles off →
3s → 5s → 10s → off, so a gallery can run unattended at an exhibition, a fair
booth or on a second screen. No configuration and no button: the speeds live
in the key itself, and a permanent control in the corner of a photograph costs
every visitor something. Any deliberate move — arrow key, nav button, swipe —
stops it, since someone reaching for an arrow has taken over. Under
`prefers-reduced-motion` the photograph appears instead of fading and scaling
in, because a running slideshow is exactly the repeated motion that preference
is about.
- **Download the original, opt-in per album**
([#475](https://github.com/ralksta/immich-folio/issues/475)). `download: true`
on an album offers the file from the lightbox, on a button and on `d`. Off
everywhere by default and opt-in per album on purpose: a public portfolio must
not start handing out full-resolution originals because one client gallery
needed to.
The route re-checks rather than inheriting the image proxy's assumptions — the
album must be on the allowlist, must have opted in, must have its password
gate satisfied, and the asset must actually belong to it, which is what stops
one enabled album's URL being edited into a download of anything in the Immich
instance. The gate check follows the whole route to the album, so an album
with no password of its own, reachable only through a subpage that has one,
cannot be downloaded until that subpage is unlocked. Every refusal answers
404, so the response never reveals which check failed.
- **Copy a link to the photo on screen**
([#478](https://github.com/ralksta/immich-folio/issues/478)). Positional
permalinks already existed — the grid writes `#photo-N` and restores it on
load — but nothing told a visitor they could share one. Now there is a button,
and `c`.
The clipboard is not assumed: `navigator.clipboard` is undefined outside a
secure context, and a self-hosted portfolio reached over plain http on a LAN
is exactly that, so the link appears in a selected field instead of the button
appearing to do nothing. The link is positional, as `gallery.yaml.example`
documents — reordering an album moves where a shared link lands.
- **Previous / next album at the foot of an album**
([#483](https://github.com/ralksta/immich-folio/issues/483)). An album detail
page ended nowhere: a visitor reached the last photograph and had only the
back link, so the sequence the photographer arranged stopped being a sequence.
The order comes from whatever list the visitor just came through rather than
an ordering of its own — a "next" that disagreed with the grid they were
looking at a moment ago would be worse than no control at all. Neighbours do
not wrap around, so reaching the end is visible as the end.
- **The Immich asset description becomes image alt text**
([#484](https://github.com/ralksta/immich-folio/issues/484)). Grid and
lightbox images rendered `alt=""` throughout while the description was already
flowing to the client as an editorial caption. It is gated on the `caption`
EXIF group rather than served unconditionally: that switch exists because the
description is the one field that can hold private notes, and an alt attribute
publishes it to crawlers exactly as a visible caption would. An absent
description leaves `alt=""` — the correct markup for a decorative image,
rather than inventing filler text.
- **Location precision, per album or per subpage**
([#469](https://github.com/ralksta/immich-folio/issues/469)).
`location: exact | city | country | hidden`. The map is the only public GPS
surface, and a public album's marker sat at the mean of its photographs'
coordinates — for an album shot in one place, a garden, a studio, a client's
home, that mean _is_ that place. Nobody chose that; it followed from the
camera writing GPS.
Coordinates are quantised server-side against a fixed global grid — city to
0.05°, country to 1° — rather than jittered per request, which could be
averaged away by asking twice. The place _name_ is reduced with the position,
so a marker rounded to a 1° cell is not still labelled with the town. The
lightbox info panel follows the same setting: an album set to `hidden`, which
asks to be absent from the map entirely, no longer tells anyone who clicks
"Info" where the photograph was taken. Where one marker merges several albums,
the strictest setting among them governs the whole marker.
- **The admin panel says when a newer release exists**
([#496](https://github.com/ralksta/immich-folio/issues/496)). Self-hosted
software that never mentions a new release gets run for months on an old
version, and there have already been security releases. The running version
comes from `package.json`, the latest from the GitHub releases API, one
request a day, riding on the status call the dashboard already makes.
Discreet by design: a row in the status list, not a banner, which only turns
into a link when there is something newer. It fails quietly in every direction
— no network, a rate limit, a changed payload — and a tag that is not a
version is rejected rather than read as one, so a date-style release tag
cannot announce an update that does not exist.
- **A Help tab in the admin panel, starting with the image viewer.** The
lightbox's keys are deliberately unadvertised to visitors, but that also left
the site owner with no way to learn that the slideshow, the copy-link and the
download exist at all — and a feature nobody can find is not shipped. Help
lists the viewer's shortcuts, notes the ones that are conditionally absent,
and explains the two that need it. The labels are read from the same
dictionary the viewer uses, so what the help promises is what a visitor is
shown.
- **A subpage's album covers get their own grid**
([#523](https://github.com/ralksta/immich-folio/issues/523)). `coverGrid`
sizes the cover tiles and nothing else; `grid` keeps the
`global < subpage < album` precedence for the photos. Until now a subpage had
a single `grid` key that both consumers read, so a gap typed into the field
labelled **Album Cover Grid** silently retuned every photo grid on the page
and overrode Settings › Grid.
A `gallery.yaml` written before the split has no `coverGrid`, so the covers
fall back to `grid` and such a page renders exactly as it did. The page
builder seeds the new cover fields from the old value, so the drawer reflects
what the page really renders and the first save writes the two out separately.
### Fixed
- **Albums whose name is written in CJK are reachable again**
([#522](https://github.com/ralksta/immich-folio/issues/522)). The slug was
built by dropping every character outside `[a-z0-9]`, so a Chinese, Japanese,
Korean, Cyrillic or Greek name was reduced to nothing at all. The album's link
then pointed at `/`, clicking it went nowhere, and because no album lookup was
ever attempted there was nothing in the log to explain it. Slugs now keep any
letter or digit, whatever the script, and fold Latin diacritics as before
(`Café` still becomes `cafe`). An album whose name has no letters at all — one
written purely in emoji — falls back to its id, so it stays reachable and two
of them no longer collide.
Generating a usable slug was only half of it: Next hands a catch-all route
segment to the page still percent-encoded, so `/家族相册` arrived as
`%E5%AE%B6...` and matched no album even once the slug was right — the album
link rendered, and opening it produced an empty page. Incoming slugs are now
decoded once at the route boundary, and matched in both their composed and
decomposed Unicode form, so a slug typed or pasted into the address bar
resolves too.
- **The lightbox controls are one bar instead of five anchors.** Reported
against studio-modern: the link, favourite and info controls sat on top of the
photo counter, and pressing Info made its label grow into the favourite button
beside it. Both followed from the same cause — each control positioned itself
with a hard-coded offset, so a button that changed width walked into its
neighbour and a conditionally absent one left a hole. They now sit in one flex
row, which cannot collide with itself.
## [0.12.0] — 2026-08-23 ## [0.12.0] — 2026-08-23
### Added ### Added

View File

@@ -8,6 +8,9 @@ import Image from 'next/image';
import React from 'react'; import React from 'react';
import { getServerDictionary } from '@/lib/i18n/server'; import { getServerDictionary } from '@/lib/i18n/server';
import { albumMetaDetail } from '@/lib/albumMeta'; import { albumMetaDetail } from '@/lib/albumMeta';
import { AlbumNav } from '@/components/AlbumNav';
import { StructuredData } from '@/components/StructuredData';
import type { AlbumNavPair } from '@/lib/albumNav';
interface AlbumDetailViewProps { interface AlbumDetailViewProps {
album: ImmichAlbum; album: ImmichAlbum;
@@ -28,6 +31,10 @@ interface AlbumDetailViewProps {
proofing?: boolean; proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */ /** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean; allowMailto?: boolean;
/** Neighbouring albums in the surrounding list, for the foot navigation. */
nav?: AlbumNavPair;
/** JSON-LD for this album, or null when no site URL is configured (#472). */
structuredData?: Record<string, unknown> | null;
} }
export function AlbumDetailView({ export function AlbumDetailView({
@@ -45,12 +52,15 @@ export function AlbumDetailView({
showGear = true, showGear = true,
proofing, proofing,
allowMailto, allowMailto,
nav,
structuredData,
}: AlbumDetailViewProps) { }: AlbumDetailViewProps) {
const metaDetail = albumMetaDetail(album, showGear); const metaDetail = albumMetaDetail(album, showGear);
const t = getServerDictionary(); const t = getServerDictionary();
return ( return (
<> <>
{structuredData && <StructuredData data={structuredData} />}
{heroImageUrl && ( {heroImageUrl && (
<div className="album-hero"> <div className="album-hero">
<Image <Image
@@ -93,6 +103,7 @@ export function AlbumDetailView({
proofing={proofing} proofing={proofing}
allowMailto={allowMailto} allowMailto={allowMailto}
/> />
{nav && <AlbumNav {...nav} />}
</> </>
); );
} }

View File

@@ -15,6 +15,7 @@ import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext'; import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal'; import { ProofingModal } from '@/components/ProofingModal';
import { useDictionary } from '@/components/I18nProvider'; import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, buildPhotoHash } from '@/lib/photoHash';
export interface PhotoItem { export interface PhotoItem {
id: string; id: string;
@@ -31,6 +32,10 @@ export interface PhotoItem {
focalLength?: string; focalLength?: string;
/** Natural image aspect ratio (width/height) for masonry layout */ /** Natural image aspect ratio (width/height) for masonry layout */
aspectRatio?: number; aspectRatio?: number;
/** Immich asset description, used as alt text. Absent when captions are off. */
caption?: string;
/** Present only when the surrounding album offers its originals (#475). */
downloadUrl?: string;
} }
interface PhotoGridProps { interface PhotoGridProps {
@@ -54,19 +59,6 @@ interface PhotoGridProps {
allowMailto?: boolean; allowMailto?: boolean;
} }
/** Parse `#photo-N` from a hash string. Returns index or null. */
function parsePhotoHash(hash: string): number | null {
const match = hash.match(/^#photo-(\d+)$/);
if (!match) return null;
const idx = parseInt(match[1], 10) - 1; // 1-indexed in URL, 0-indexed internally
return idx >= 0 ? idx : null;
}
/** Build the hash string for a given 0-based index. */
function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
}
function PhotoGridInner({ function PhotoGridInner({
assets, assets,
layout = 'masonry', layout = 'masonry',
@@ -186,7 +178,7 @@ function PhotoGridInner({
> >
<Image <Image
src={asset.thumbUrl} src={asset.thumbUrl}
alt="" alt={asset.caption ?? ''}
fill fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw" sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'} loading={index < 6 ? 'eager' : 'lazy'}

View File

@@ -20,7 +20,9 @@ import {
exifUrl, exifUrl,
videoUrl, videoUrl,
assetPlaceholder, assetPlaceholder,
assetCaption,
assetExifSummary, assetExifSummary,
downloadUrl,
assetAspectRatio, assetAspectRatio,
} from '@/lib/urls'; } from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens'; import { encodeAssetId } from '@/lib/tokens';
@@ -28,6 +30,7 @@ import {
buildCoverGridVars, buildCoverGridVars,
getConfig, getConfig,
hasExifPanelContent, hasExifPanelContent,
normalizeSlug,
resolveProofing, resolveProofing,
type GridConfig, type GridConfig,
} from '@/lib/config'; } from '@/lib/config';
@@ -36,6 +39,9 @@ import { isAdminAuthenticated } from '@/lib/admin/auth';
import PasswordGate from '@/components/PasswordGate'; import PasswordGate from '@/components/PasswordGate';
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner'; import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
import { AlbumDetailView } from './AlbumDetailView'; import { AlbumDetailView } from './AlbumDetailView';
import { albumNeighbours } from '@/lib/albumNav';
import { albumStructuredData } from '@/lib/structuredData';
import { absoluteUrl } from '@/lib/siteUrl';
import { SubpageGridView } from './SubpageGridView'; import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView'; import { EssayView } from './EssayView';
import { parseEssayMarkdown } from '@/lib/essay'; import { parseEssayMarkdown } from '@/lib/essay';
@@ -51,7 +57,11 @@ interface PathPageProps {
} }
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> { export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
const { path } = await params; // Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
const { path: rawPath } = await params;
const path = rawPath?.map(normalizeSlug);
if (!path || path.length === 0) return {}; if (!path || path.length === 0) return {};
const slug = path[0]; const slug = path[0];
@@ -114,13 +124,23 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
/** /**
* `showExif` covers the hover overlay only, and that overlay carries camera, * `showExif` covers the hover overlay only, and that overlay carries camera,
* lens and focal length — so it follows the `camera` group, not the panel. * lens and focal length — so it follows the `camera` group, not the panel.
*
* `showCaption` follows the `caption` group and decides whether the Immich
* description becomes alt text; see `assetCaption`.
*/ */
function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] { function toPhotoItems(
assets: ImmichAsset[],
showExif: boolean,
showCaption: boolean,
/** The album offering downloads, or undefined when it does not. */
downloadAlbumId?: string,
): PhotoItem[] {
return assets return assets
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO') .filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
.map((a) => { .map((a) => {
const ph = assetPlaceholder(a); const ph = assetPlaceholder(a);
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined; const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
const caption = assetCaption(a, showCaption);
const isVideo = a.type === 'VIDEO'; const isVideo = a.type === 'VIDEO';
return { return {
id: encodeAssetId(a.id), id: encodeAssetId(a.id),
@@ -131,11 +151,39 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
exifUrl: exifUrl(a.id), exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}), ...(exif ?? {}),
...(caption ? { caption } : {}),
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
aspectRatio: assetAspectRatio(a), aspectRatio: assetAspectRatio(a),
}; };
}); });
} }
/**
* JSON-LD for an album page. Null unless a site URL is configured — structured
* data is a set of claims about absolute URLs, and there is nothing truthful to
* claim without one (#472).
*/
function structuredDataFor(
album: { albumName: string; description?: string; albumThumbnailAssetId?: string | null },
path: string,
photoCount: number,
) {
const config = getConfig();
const cover = album.albumThumbnailAssetId
? absoluteUrl(config.siteUrl, imageUrl(album.albumThumbnailAssetId, 'preview'))
: null;
return albumStructuredData({
siteUrl: config.siteUrl,
pageUrl: absoluteUrl(config.siteUrl, path),
albumName: album.albumName,
...(album.description ? { description: album.description } : {}),
coverUrl: cover,
...(config.legal.name?.trim() ? { creator: config.legal.name } : {}),
...(config.seo.license ? { license: config.seo.license } : {}),
photoCount,
});
}
/** /**
* Returns a PasswordGate element if the key (slug or ID) is protected and * Returns a PasswordGate element if the key (slug or ID) is protected and
* not yet authenticated, otherwise returns null. * not yet authenticated, otherwise returns null.
@@ -175,7 +223,11 @@ async function getAlbumHeroData(
} }
export default async function PathPage({ params, searchParams }: PathPageProps) { export default async function PathPage({ params, searchParams }: PathPageProps) {
const { path } = await params; // Next hands catch-all segments over percent-encoded, so a non-ASCII slug
// ("/家族相册") would never match a stored one. Decode once, here, and every
// comparison downstream works on the same form (#522).
const { path: rawPath } = await params;
const path = rawPath?.map(normalizeSlug);
const sParams = (await searchParams) || {}; const sParams = (await searchParams) || {};
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true'; const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
@@ -199,10 +251,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const resolveLayout = (overrides?: Partial<GridConfig>) => const resolveLayout = (overrides?: Partial<GridConfig>) =>
overrides?.layout ?? config.grid.layout; overrides?.layout ?? config.grid.layout;
// The album-cover grid on a subpage takes its column count from the same // The album-cover grid on a subpage is sized by its own `coverGrid`, which
// config as the photo grids, so "3 columns" in the admin means three columns // touches nothing but the cover tiles (#523). An unset column count still
// everywhere. `gap` deliberately does not follow the global setting — see // falls back to the global one; `gap` deliberately does not follow the global
// buildCoverGridVars() for why. // setting — see buildCoverGridVars() for why.
const buildCoverGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties => const buildCoverGridStyle = (overrides?: Partial<GridConfig>): React.CSSProperties =>
buildCoverGridVars(overrides, config.grid.columns) as React.CSSProperties; buildCoverGridVars(overrides, config.grid.columns) as React.CSSProperties;
@@ -254,7 +306,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const spGrid = subpageData?.subpage.grid; const spGrid = subpageData?.subpage.grid;
const subpageName = subpageData?.subpage.name ?? subpageSlug; const subpageName = subpageData?.subpage.name ?? subpageSlug;
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera); const images = toPhotoItems(
album.assets,
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
);
// Password gate for protected albums // Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName); const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -262,10 +319,16 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
const heroData = await getAlbumHeroData(album.id, config); const heroData = await getAlbumHeroData(album.id, config);
// Neighbours come from the subpage list the visitor just came through, in
// that list's own order, so "next" agrees with the grid they saw (#483).
const nav = subpageData ? albumNeighbours(subpageData.albums, album.id, `/${subpageSlug}`) : {};
return ( return (
<AlbumDetailView <AlbumDetailView
album={album} album={album}
images={images} images={images}
nav={nav}
structuredData={structuredDataFor(album, `/${subpageSlug}/${albumSlug}`, images.length)}
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))} layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))} gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
backLinkHref={`/${subpageSlug}`} backLinkHref={`/${subpageSlug}`}
@@ -324,7 +387,11 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)), albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
); );
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : [])); const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(allAssets, config.exif.onHover && config.exif.camera); const images = toPhotoItems(
allAssets,
config.exif.onHover && config.exif.camera,
config.exif.caption,
);
// Fallback structured essay if layout: 'essay' is set without custom markdown file // Fallback structured essay if layout: 'essay' is set without custom markdown file
if (!essayParsed) { if (!essayParsed) {
@@ -381,7 +448,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound(); notFound();
} }
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera); const images = toPhotoItems(
album.assets,
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
);
// Password gate for protected albums // Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName); const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -444,7 +516,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
albums={albumsWithHero} albums={albumsWithHero}
coverPlaceholders={coverPlaceholders} coverPlaceholders={coverPlaceholders}
sections={result.subpage.sections} sections={result.subpage.sections}
gridStyle={buildCoverGridStyle(result.subpage.grid)} gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})} {...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
/> />
); );
@@ -464,7 +536,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
notFound(); notFound();
} }
const images = toPhotoItems(album.assets, config.exif.onHover && config.exif.camera); const images = toPhotoItems(
album.assets,
config.exif.onHover && config.exif.camera,
config.exif.caption,
config.albumDownloads[album.id] ? album.id : undefined,
);
// Password gate for protected albums // Password gate for protected albums
const albumGate = await gateIfProtected(album.id, 'album', album.albumName); const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
@@ -480,6 +557,8 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
gridStyle={buildGridStyle(mergeAlbumGrid(album.id))} gridStyle={buildGridStyle(mergeAlbumGrid(album.id))}
backLinkHref="/" backLinkHref="/"
backLinkLabel={getServerDictionary().common.backToGallery} backLinkLabel={getServerDictionary().common.backToGallery}
nav={albumNeighbours(await immich.getStandaloneAlbums(forceFresh), album.id)}
structuredData={structuredDataFor(album, `/${slug}`, images.length)}
watermark={config.watermark} watermark={config.watermark}
showExifPanel={hasExifPanelContent(config.exif)} showExifPanel={hasExifPanelContent(config.exif)}
showGear={config.exif.camera} showGear={config.exif.camera}

View File

@@ -1928,10 +1928,83 @@
color: var(--admin-error, #ef4444); color: var(--admin-error, #ef4444);
} }
.status-val.update {
color: var(--admin-warning, #f59e0b);
}
.status-val.unknown { .status-val.unknown {
color: var(--admin-text-secondary); color: var(--admin-text-secondary);
} }
/* ── Help section ──────────────────────────────────────────── */
.help-kbd {
display: inline-block;
min-width: 1.6rem;
padding: 2px 6px;
border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.15));
border-bottom-width: 2px;
border-radius: 4px;
background: var(--admin-bg-elevated, rgba(255, 255, 255, 0.06));
color: var(--admin-text);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
line-height: 1.4;
text-align: center;
}
.help-shortcuts {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 4px 0 0;
}
.help-shortcuts-row {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: 12px;
align-items: baseline;
padding: 10px 4px;
border-bottom: 1px solid var(--admin-border-subtle, rgba(255, 255, 255, 0.06));
}
.help-shortcuts-row:last-child {
border-bottom: none;
}
.help-shortcuts-keys {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 0;
}
.help-shortcuts-label {
margin: 0;
color: var(--admin-text);
font-size: 0.85rem;
}
.help-shortcuts-note {
display: block;
margin-top: 2px;
color: var(--admin-text-secondary);
font-size: 0.78rem;
}
.help-action {
padding: 12px 4px 4px;
}
@media (max-width: 600px) {
.help-shortcuts-row {
grid-template-columns: 1fr;
gap: 6px;
}
}
/* ── Search Bar ────────────────────────────────────────────── */ /* ── Search Bar ────────────────────────────────────────────── */
.builder-search-container { .builder-search-container {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;

View File

@@ -19,6 +19,7 @@ const TABS = [
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ }, { label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ }, { label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ }, { label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
]; ];
export default function AdminDashboard({ onLogout, children }: Props) { export default function AdminDashboard({ onLogout, children }: Props) {
@@ -229,6 +230,28 @@ export default function AdminDashboard({ onLogout, children }: Props) {
<span className="status-label">In-Memory Cache</span> <span className="status-label">In-Memory Cache</span>
<span className="status-val">{status?.cache?.size ?? 0} items</span> <span className="status-val">{status?.cache?.size ?? 0} items</span>
</div> </div>
{/*
Discreet on purpose: a row in the status list, not a
banner. When the check is off or could not run, the row
still names the running version and says nothing about
updates (#496).
*/}
<div className="status-item">
<span className="status-label">Version</span>
{status?.update?.updateAvailable ? (
<a
className="status-val update"
href="https://github.com/ralksta/immich-folio/releases/latest"
target="_blank"
rel="noopener noreferrer"
title={`Version ${status.update.latest} is available`}
>
{status.update.current} → {status.update.latest}
</a>
) : (
<span className="status-val">{status?.update?.current ?? '—'}</span>
)}
</div>
</div> </div>
<div className="status-dropdown-footer"> <div className="status-dropdown-footer">
<button <button

View File

@@ -0,0 +1,92 @@
'use client';
import { useState } from 'react';
import DoctorModal from './DoctorModal';
import * as Icons from './Icons';
import { en } from '@/lib/i18n/locales/en';
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
/**
* The admin help section.
*
* It exists because parts of the portfolio have no control of their own. The
* lightbox is the clearest case: its keys are deliberately unadvertised to
* visitors, which also left the site owner with no way to learn they exist
* (#473). This is where the owner finds out what their portfolio can do.
*
* Labels come from the English dictionary rather than being retyped, so what
* this page promises is what a visitor is actually shown. The admin panel is
* English-only, hence `en` directly.
*/
export default function HelpView() {
const [showDoctor, setShowDoctor] = useState(false);
return (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconFileText size={18} /> Help
</h3>
<p className="settings-section-sub">
What your portfolio can do that has no button of its own.
</p>
</div>
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
<Icons.IconFilm size={13} />
Image viewer
</span>
<span className="settings-group-desc">
Keys a visitor can press in the lightbox. There is deliberately no toolbar for these — a
permanent control over a photograph costs every visitor something — but visitors can
call up the same list with <kbd className="help-kbd">?</kbd> or{' '}
<kbd className="help-kbd">H</kbd>.
</span>
</div>
<dl className="help-shortcuts">
{LIGHTBOX_SHORTCUTS.map((shortcut) => (
<div key={shortcut.labelKey} className="help-shortcuts-row">
<dt className="help-shortcuts-keys">
{shortcutDisplayKeys(shortcut).map((key) => (
<kbd key={key} className="help-kbd">
{key}
</kbd>
))}
</dt>
<dd className="help-shortcuts-label">
{en.lightbox[shortcut.labelKey]}
{shortcut.note && <span className="help-shortcuts-note">{shortcut.note}</span>}
</dd>
</div>
))}
</dl>
</div>
<div className="settings-group">
<div className="settings-group-head">
<span className="settings-group-title">
<Icons.IconShieldCheck size={13} />
Diagnostics
</span>
<span className="settings-group-desc">
Checks the things that fail silently — a wrong proxy hop count, a deleted album ID, a
plaintext password, an album Immich does not consider shared. It also sits behind the
status badge in the top bar, which is where it belongs when the question is whether
anything needs attention right now.
</span>
</div>
<div className="help-action">
<button type="button" className="admin-btn" onClick={() => setShowDoctor(true)}>
<Icons.IconShieldCheck size={14} /> Run diagnostics
</button>
</div>
</div>
<DoctorModal isOpen={showDoctor} onClose={() => setShowDoctor(false)} />
</div>
);
}

View File

@@ -35,6 +35,10 @@ import {
COVER_GRID_COLUMNS_MAX, COVER_GRID_COLUMNS_MAX,
COVER_GRID_COLUMNS_MIN, COVER_GRID_COLUMNS_MIN,
COVER_GRID_GAP_MAX, COVER_GRID_GAP_MAX,
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
slugify,
type AlbumEntryObject, type AlbumEntryObject,
} from '@/lib/config/schema'; } from '@/lib/config/schema';
import { import {
@@ -124,7 +128,24 @@ interface Subpage {
essayFile?: string; essayFile?: string;
sections?: Section[]; sections?: Section[];
albums: AlbumEntry[]; albums: AlbumEntry[];
/** Photo grid for the albums on this page (and the page layout style). */
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** The album-cover tiles on this page only (#523). */
coverGrid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
}
/**
* The cover grid a subpage renders with when its gallery.yaml predates #523.
*
* Back then one `grid` key sized both the covers and the photos, so the values
* a user typed into "Album Cover Grid" live there. Seeding the new field from
* them means the drawer shows what the page actually renders, and the first
* save writes the split out explicitly. Only the two keys the cover grid reads
* are carried over — `layout` and `aspectRatio` never reached the covers.
*/
function seedCoverGrid(grid: Subpage['grid']): Subpage['coverGrid'] {
if (!grid) return undefined;
return normalizeSubpageGrid({ columns: grid.columns, gap: grid.gap });
} }
/** /**
@@ -553,6 +574,8 @@ export default function PageBuilder() {
})) }))
: undefined, : undefined,
grid: sp.grid as Subpage['grid'], grid: sp.grid as Subpage['grid'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
})); }));
} else if (raw.subpages && typeof raw.subpages === 'object') { } else if (raw.subpages && typeof raw.subpages === 'object') {
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => { subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
@@ -574,6 +597,8 @@ export default function PageBuilder() {
), ),
sections: undefined, sections: undefined,
grid: sp.grid as Subpage['grid'], grid: sp.grid as Subpage['grid'],
coverGrid:
(sp.coverGrid as Subpage['coverGrid']) ?? seedCoverGrid(sp.grid as Subpage['grid']),
}; };
}); });
} }
@@ -610,6 +635,7 @@ export default function PageBuilder() {
if (sp.essayText) entry.essayText = sp.essayText; if (sp.essayText) entry.essayText = sp.essayText;
if (sp.essayFile) entry.essayFile = sp.essayFile; if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid; if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.sections && sp.sections.length > 0) { if (sp.sections && sp.sections.length > 0) {
entry.sections = sp.sections.map((sec) => { entry.sections = sp.sections.map((sec) => {
@@ -910,13 +936,6 @@ export default function PageBuilder() {
return null; return null;
} }
function slugify(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
// ── Render ─────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
@@ -1487,14 +1506,14 @@ export default function PageBuilder() {
type="number" type="number"
min={COVER_GRID_COLUMNS_MIN} min={COVER_GRID_COLUMNS_MIN}
max={COVER_GRID_COLUMNS_MAX} max={COVER_GRID_COLUMNS_MAX}
value={sp.grid?.columns ?? ''} value={sp.coverGrid?.columns ?? ''}
placeholder="Columns (site default)" placeholder="Columns (site default)"
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
const columns = raw === '' ? undefined : Number(raw); const columns = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, { updateSubpage(spIndex, {
grid: normalizeSubpageGrid({ coverGrid: normalizeSubpageGrid({
...(sp.grid || {}), ...(sp.coverGrid || {}),
columns: columns:
columns != null && columns != null &&
columns >= COVER_GRID_COLUMNS_MIN && columns >= COVER_GRID_COLUMNS_MIN &&
@@ -1515,14 +1534,14 @@ export default function PageBuilder() {
type="number" type="number"
min={0} min={0}
max={COVER_GRID_GAP_MAX} max={COVER_GRID_GAP_MAX}
value={sp.grid?.gap ?? ''} value={sp.coverGrid?.gap ?? ''}
placeholder="Gap in px (theme default)" placeholder="Gap in px (theme default)"
onChange={(e) => { onChange={(e) => {
const raw = e.target.value; const raw = e.target.value;
const gap = raw === '' ? undefined : Number(raw); const gap = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, { updateSubpage(spIndex, {
grid: normalizeSubpageGrid({ coverGrid: normalizeSubpageGrid({
...(sp.grid || {}), ...(sp.coverGrid || {}),
gap: gap:
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
? gap ? gap
@@ -1539,10 +1558,82 @@ export default function PageBuilder() {
/> />
</div> </div>
<p className="admin-field-hint"> <p className="admin-field-hint">
Overrides how the album covers are tiled on this page. Leave the Overrides how the album covers are tiled on this page — the
column count empty to follow the site-wide grid setting, and the photos inside the albums are not affected. Leave the column
gap empty to keep the theme&apos;s own spacing. Tablet widths count empty to follow the site-wide grid setting, and the gap
show at most two covers per row. empty to keep the theme&apos;s own spacing. Tablet widths show
at most two covers per row.
</p>
</div>
)}
{/* Photo grid — the albums opened from this page.
Separate from the cover grid above: one field
used to drive both, so a cover gap retuned
every photo grid on the page (#523). */}
{sp.essayText == null && (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Photo Grid (Albums On This Page)</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={PHOTO_GRID_COLUMNS_MIN}
max={PHOTO_GRID_COLUMNS_MAX}
value={sp.grid?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => {
const raw = e.target.value;
const columns = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
columns:
columns != null &&
columns >= PHOTO_GRID_COLUMNS_MIN &&
columns <= PHOTO_GRID_COLUMNS_MAX
? columns
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
<input
type="number"
min={0}
max={PHOTO_GRID_GAP_MAX}
value={sp.grid?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => {
const raw = e.target.value;
const gap = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
gap:
gap != null && gap >= 0 && gap <= PHOTO_GRID_GAP_MAX
? gap
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
</div>
<p className="admin-field-hint">
How the photos inside every album on this page are laid out.
Leave both empty to follow Settings &rsaquo; Grid; a single
album can still override this in gallery.yaml.
</p> </p>
</div> </div>
)} )}

View File

@@ -8,12 +8,20 @@ import SaveBar from './SaveBar';
// Direct import from the theme module, not from '@/lib/config': the config // Direct import from the theme module, not from '@/lib/config': the config
// index pulls in `fs` and cannot be bundled into a client component. // index pulls in `fs` and cannot be bundled into a client component.
import { DEFAULT_PRESET } from '@/lib/config/theme'; import { DEFAULT_PRESET } from '@/lib/config/theme';
import { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema'; import {
PHOTO_GRID_COLUMNS_MAX,
PHOTO_GRID_COLUMNS_MIN,
PHOTO_GRID_GAP_MAX,
resolveExifDisplay,
resolveWatermarkOpacity,
} from '@/lib/config/schema';
import { SUPPORTED_LOCALES } from '@/lib/i18n'; import { SUPPORTED_LOCALES } from '@/lib/i18n';
interface Settings { interface Settings {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
/** Absolute site URL for sitemap, feed and structured data (#472). */
url?: string;
lang?: string; lang?: string;
sitePassword?: string; sitePassword?: string;
mode?: 'light' | 'dark' | 'auto'; mode?: 'light' | 'dark' | 'auto';
@@ -306,6 +314,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
? (section as string) ? (section as string)
: 'general'; : 'general';
const [settings, setSettings] = useState<Settings>({}); const [settings, setSettings] = useState<Settings>({});
/** Resolved site URL and its origin, so the panel can name SITE_URL (#472). */
const [siteUrlInfo, setSiteUrlInfo] = useState<{
effective: string | null;
source: 'env' | 'settings' | 'none';
} | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Collapsed by default: the four metadata switches are a detail of one // Collapsed by default: the four metadata switches are a detail of one
// decision, and showing them permanently is what made the section a wall (#510). // decision, and showing them permanently is what made the section a wall (#510).
@@ -418,8 +431,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
try { try {
const res = await fetch('/api/admin/settings'); const res = await fetch('/api/admin/settings');
if (res.ok) { if (res.ok) {
const { settings: data } = await res.json(); const { settings: data, siteUrl } = await res.json();
setSettings(data || {}); setSettings(data || {});
setSiteUrlInfo(siteUrl ?? null);
} }
} catch (err) { } catch (err) {
console.error('Failed to load settings:', err); console.error('Failed to load settings:', err);
@@ -1520,11 +1534,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<div className="admin-field-row"> <div className="admin-field-row">
<div className="admin-field"> <div className="admin-field">
<label>Columns (1 - 6)</label> <label>
Columns ({PHOTO_GRID_COLUMNS_MIN} - {PHOTO_GRID_COLUMNS_MAX})
</label>
<input <input
type="number" type="number"
min={1} min={PHOTO_GRID_COLUMNS_MIN}
max={6} max={PHOTO_GRID_COLUMNS_MAX}
value={settings.grid?.columns ?? 3} value={settings.grid?.columns ?? 3}
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)} onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
/> />
@@ -1534,7 +1550,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<input <input
type="number" type="number"
min={0} min={0}
max={48} max={PHOTO_GRID_GAP_MAX}
value={settings.grid?.gap ?? 12} value={settings.grid?.gap ?? 12}
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)} onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
/> />
@@ -1786,6 +1802,28 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
</div> </div>
</div> </div>
<div className="admin-field">
<label>Site URL</label>
<input
value={settings.url || ''}
onChange={(e) => update('url', e.target.value)}
placeholder="https://folio.example"
/>
<p style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '4px' }}>
Needed for <code>sitemap.xml</code>, the feed and structured data — those are
generated without a request, so the address cannot be derived from it. Leave empty
and the sitemap stays empty rather than guessing.
{siteUrlInfo?.source === 'env' && (
<>
<br />
Currently supplied by the <code>SITE_URL</code> environment variable (
<code>{siteUrlInfo.effective}</code>). A value entered here takes precedence
once saved.
</>
)}
</p>
</div>
<div className="admin-field"> <div className="admin-field">
<label>SEO Meta Title</label> <label>SEO Meta Title</label>
<input <input

5
app/admin/help/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import HelpView from '../components/HelpView';
export default function AdminHelpPage() {
return <HelpView />;
}

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth'; import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service'; import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
import { invalidateConfigCache } from '@/lib/config'; import { invalidateConfigCache, getConfigOrNull } from '@/lib/config';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import type { SettingsYaml } from '@/lib/config/schema'; import type { SettingsYaml } from '@/lib/config/schema';
@@ -16,7 +16,16 @@ export async function GET() {
} }
const settings = await readSettingsYaml(); const settings = await readSettingsYaml();
return NextResponse.json({ settings: settings || {} }); const config = getConfigOrNull();
return NextResponse.json({
settings: settings || {},
// The resolved value and its origin, so the panel can say when what it
// shows came from SITE_URL rather than from the field itself (#472).
siteUrl: {
effective: config?.siteUrl ?? null,
source: config?.siteUrlSource ?? 'none',
},
});
} }
/** PUT: Write settings.yaml config. */ /** PUT: Write settings.yaml config. */

View File

@@ -4,6 +4,7 @@ import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { cache } from '@/lib/cache'; import { cache } from '@/lib/cache';
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service'; import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
import { getUpdateStatus } from '@/lib/updateCheck';
export async function GET() { export async function GET() {
if (!isAdminEnabled()) { if (!isAdminEnabled()) {
@@ -68,6 +69,10 @@ export async function GET() {
const config = getConfig(); const config = getConfig();
// Answered from a day-long cache and never throws, so a blocked or offline
// instance still gets the rest of its status.
const update = await getUpdateStatus();
return NextResponse.json({ return NextResponse.json({
immich: { immich: {
status: immichOk ? 'connected' : 'disconnected', status: immichOk ? 'connected' : 'disconnected',
@@ -89,5 +94,6 @@ export async function GET() {
count: backupCount, count: backupCount,
lastBackup, lastBackup,
}, },
update,
}); });
} }

View File

@@ -0,0 +1,130 @@
/**
* Download route for original files (#475).
*
* Deliberately separate from the image proxy. That route treats the opaque
* asset token as the capability: holding one means you saw the page it was
* rendered on, and it serves previews. Originals are the deliverable a client
* pays for, so this route re-checks everything rather than inheriting that
* assumption:
*
* - the album must be on the allowlist,
* - the album must have opted in with `download: true`,
* - every password gate on a route to it must be satisfied — the album's own
* and, where that is the only way to it, the subpage's, and
* - the asset must actually belong to that album.
*
* The last one is what stops one enabled album's URL being edited into a
* download of any asset in the Immich instance.
*/
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { immich, ImmichUnavailableError } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { isAlbumReachable, siteLockResponse } from '@/lib/auth';
import { contentDisposition } from '@/lib/downloadName';
export const dynamic = 'force-dynamic';
/**
* Far below the image limit. A gallery page fires dozens of image requests;
* a download is one deliberate click, and each one is a full-size file off
* the Immich server.
*/
const DOWNLOAD_RPM = 30;
/** One 404 for every refusal, so the response never says which check failed. */
const notFound = () =>
NextResponse.json(
{ error: 'Not found' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
);
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ album: string; id: string }> },
) {
const ip = getClientIp(request);
const rl = checkRateLimit(`download:${ip}`, DOWNLOAD_RPM);
if (!rl.success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(retryAfterSeconds(rl.resetAt)),
'Cache-Control': 'no-store',
},
},
);
}
const locked = siteLockResponse(request);
if (locked) return locked;
const { album: albumToken, id: assetToken } = await params;
const albumId = decodeAssetId(albumToken);
const assetId = decodeAssetId(assetToken);
if (!albumId || !assetId) return notFound();
const config = getConfig();
// On the allowlist, and opted in.
if (!config.albums.includes(albumId)) return notFound();
if (!config.albumDownloads[albumId]) return notFound();
// Every gate on every route to the album, not just its own: an album with no
// password of its own, reachable only through a subpage that has one, was
// downloadable without that subpage ever being unlocked. The page checks
// this before rendering; a route handler is reached without passing through
// the page at all.
const cookieStore = await cookies();
const getCookie = (name: string) => cookieStore.get(name)?.value;
if (!isAlbumReachable(albumId, getCookie)) return notFound();
let album;
try {
album = await immich.getAlbum(albumId);
} catch (error) {
if (error instanceof ImmichUnavailableError) {
return NextResponse.json(
{ error: 'Immich is currently unavailable' },
{ status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } },
);
}
throw error;
}
if (!album) return notFound();
// The asset has to be in the album that authorised the download.
const asset = album.assets.find((candidate) => candidate.id === assetId);
if (!asset) return notFound();
let result;
try {
result = await immich.streamAsset(assetId, 'original');
} catch (error) {
if (error instanceof ImmichUnavailableError) {
return NextResponse.json(
{ error: 'Immich is currently unavailable' },
{ status: 503, headers: { 'Retry-After': '30', 'Cache-Control': 'no-store' } },
);
}
throw error;
}
if (!result) return notFound();
const headers: Record<string, string> = {
'Content-Type': result.contentType || 'application/octet-stream',
'Content-Disposition': contentDisposition(asset.originalFileName),
// Private: a download is authorised per visitor, so a shared cache must
// not hand the file to the next one.
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
};
if (result.contentLength) headers['Content-Length'] = result.contentLength;
return new NextResponse(result.stream, { headers });
}

View File

@@ -11,6 +11,8 @@ import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
import { siteLockResponse } from '@/lib/auth'; import { siteLockResponse } from '@/lib/auth';
import { assetLocationPrecision } from '@/lib/assetLocation';
import { placeLabel } from '@/lib/mapPrecision';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const config = getConfig(); const config = getConfig();
@@ -73,6 +75,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const exif = asset.exifInfo; const exif = asset.exifInfo;
const caption = show.caption ? exif.description?.trim() || undefined : undefined; const caption = show.caption ? exif.description?.trim() || undefined : undefined;
const place = show.location
? placeLabel(await assetLocationPrecision(assetId), {
city: exif.city ?? '',
country: exif.country ?? '',
})
: { city: '', country: '' };
const payload = { const payload = {
...(show.camera ...(show.camera
? { ? {
@@ -89,7 +98,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
iso: exif.iso, iso: exif.iso,
} }
: {}), : {}),
...(show.location ? { city: exif.city, country: exif.country } : {}), // Two gates, not one. `exifDisplay.location` decides whether this site
// publishes places at all; the album's `location:` decides how precisely
// this photograph may be placed — the same setting the map obeys, which
// used to stop at the map (#469).
// Emitted only when there is something to say. An empty string is not
// null, so keeping the keys would defeat the "no data" check below and
// render the blank panel it exists to prevent.
...(place.city ? { city: place.city } : {}),
...(place.country ? { country: place.country } : {}),
...(caption ? { description: caption } : {}), ...(caption ? { description: caption } : {}),
}; };

View File

@@ -13,7 +13,7 @@ vi.mock('@/lib/config', () => ({
})); }));
vi.mock('@/lib/immich', () => ({ vi.mock('@/lib/immich', () => ({
immich: { getAssetInfo: vi.fn() }, immich: { getAssetInfo: vi.fn(), getAlbum: vi.fn() },
ImmichUnavailableError: class ImmichUnavailableError extends Error {}, ImmichUnavailableError: class ImmichUnavailableError extends Error {},
})); }));
@@ -36,6 +36,7 @@ import { NextRequest } from 'next/server';
const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>; const mockConfig = getConfig as unknown as ReturnType<typeof vi.fn>;
const mockAssetInfo = immich.getAssetInfo as unknown as ReturnType<typeof vi.fn>; const mockAssetInfo = immich.getAssetInfo as unknown as ReturnType<typeof vi.fn>;
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
const EXIF_ASSET = { const EXIF_ASSET = {
exifInfo: { exifInfo: {
@@ -64,6 +65,9 @@ function configWith(raw?: ExifDisplayYaml, exifOnHover?: boolean) {
exifOnHover: exifOnHover !== false, exifOnHover: exifOnHover !== false,
exif: resolveExifDisplay(raw, exifOnHover), exif: resolveExifDisplay(raw, exifOnHover),
rateLimitRpm: 120, rateLimitRpm: 120,
// No album restricts its location, so the place comes through as the
// asset carries it — the shape getConfig() always builds (#469).
albumLocationPrecision: {},
}; };
} }
@@ -152,3 +156,40 @@ describe('GET /api/exif/[id] captions', () => {
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
}); });
/**
* `location:` used to govern the map and nothing else, so an album asking to
* be absent from it still named its city here (#469).
*/
describe('GET /api/exif/[id] album location precision', () => {
/** The album that carries the asset the mocked token decodes to. */
const carrying = { id: 'album-a', assets: [{ id: 'asset-uuid' }] };
async function respond(precision: Record<string, string>) {
mockConfig.mockReturnValue({ ...configWith(), albumLocationPrecision: precision });
mockAssetInfo.mockResolvedValue(EXIF_ASSET);
mockGetAlbum.mockResolvedValue(carrying);
const res = await GET(new NextRequest('http://localhost/api/exif/token'), {
params: Promise.resolve({ id: 'token' }),
});
return res.json();
}
it('names the city when nothing restricts it', async () => {
expect(await respond({})).toMatchObject({ city: 'Vienna', country: 'Austria' });
});
it('drops the city but keeps the country at country precision', async () => {
const body = await respond({ 'album-a': 'country' });
expect(body.country).toBe('Austria');
expect(body).not.toHaveProperty('city');
});
it('names no place at all when the album is hidden', async () => {
const body = await respond({ 'album-a': 'hidden' });
expect(body).not.toHaveProperty('city');
expect(body).not.toHaveProperty('country');
// The rest of the panel is untouched.
expect(body.model).toBe('X-T5');
});
});

View File

@@ -10,6 +10,12 @@ import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls'; import { imageUrl } from '@/lib/urls';
import { isAuthenticated, siteLockResponse } from '@/lib/auth'; import { isAuthenticated, siteLockResponse } from '@/lib/auth';
import { getMapData } from '@/lib/mapService'; import { getMapData } from '@/lib/mapService';
import {
applyPrecision,
placeLabel,
strictestPrecision,
type LocationPrecision,
} from '@/lib/mapPrecision';
import { ImmichUnavailableError } from '@/lib/immich'; import { ImmichUnavailableError } from '@/lib/immich';
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit'; import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
@@ -88,11 +94,17 @@ export async function GET(request: NextRequest) {
return result; return result;
}; };
const precisionOf = (albumId: string): LocationPrecision =>
config.albumLocationPrecision[albumId] ?? 'exact';
const publicLocations = locations const publicLocations = locations
.map((loc) => { .map((loc) => {
const allowedAlbums = loc.albums.filter( const allowedAlbums = loc.albums
(a) => (!a.subpageSlug || isSubpageAllowed(a.subpageSlug)) && isAlbumAllowed(a.id), .filter((a) => (!a.subpageSlug || isSubpageAllowed(a.subpageSlug)) && isAlbumAllowed(a.id))
); // `location: hidden` drops the album from the map entirely, before it
// can contribute. Withholding only its coordinates would still let the
// photo count and the cover name a place it asked to be absent from.
.filter((a) => precisionOf(a.id) !== 'hidden');
if (allowedAlbums.length === 0) return null; if (allowedAlbums.length === 0) return null;
@@ -102,11 +114,31 @@ export async function GET(request: NextRequest) {
const latSum = allowedAlbums.reduce((sum, a) => sum + a.latSum, 0); const latSum = allowedAlbums.reduce((sum, a) => sum + a.latSum, 0);
const lngSum = allowedAlbums.reduce((sum, a) => sum + a.lngSum, 0); const lngSum = allowedAlbums.reduce((sum, a) => sum + a.lngSum, 0);
/*
* One marker merges several albums, so the most cautious of them governs
* the whole marker. Splitting it instead would itself announce that one
* album is set more carefully — which is the thing being hidden.
*
* Quantised here, server-side: coordinates are never sent exact for the
* client to round.
*/
const level = strictestPrecision(allowedAlbums.map((a) => precisionOf(a.id)));
const position = applyPrecision(
{ lat: latSum / photoCount, lng: lngSum / photoCount },
level,
);
if (!position) return null;
// The name has to follow the coordinates down. Rounding the position to
// a 100 km cell while still calling the marker "Chorin" left the label
// more precise than the position it was meant to blur.
const place = placeLabel(level, { city: loc.city, country: loc.country });
return { return {
city: loc.city, city: place.city,
country: loc.country, country: place.country,
lat: latSum / photoCount, lat: position.lat,
lng: lngSum / photoCount, lng: position.lng,
photoCount, photoCount,
coverUrl: imageUrl(allowedAlbums[0].coverAssetId, 'thumbnail'), coverUrl: imageUrl(allowedAlbums[0].coverAssetId, 'thumbnail'),
albums: allowedAlbums.map((a) => ({ albums: allowedAlbums.map((a) => ({

View File

@@ -1017,6 +1017,79 @@
height: 16px; height: 16px;
} }
/* ── Album navigation (previous / next) ── */
.album-nav {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: 16px;
max-width: var(--max-width);
margin: 64px auto 96px;
padding: 24px 24px 0;
border-top: 1px solid var(--border-subtle);
}
.album-nav__link {
display: inline-flex;
align-items: center;
gap: 12px;
max-width: 45%;
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.album-nav__link:hover {
color: var(--text-primary);
}
.album-nav__link--next {
margin-left: auto;
text-align: right;
}
.album-nav__link svg {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.album-nav__text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.album-nav__kicker {
font-family: var(--font-caption);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.album-nav__name {
font-family: var(--font-serif);
font-size: 1.05rem;
color: inherit;
/* Long album names must not push the pair apart. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 600px) {
.album-nav {
margin: 48px auto 64px;
padding: 20px 16px 0;
}
.album-nav__name {
font-size: 0.95rem;
}
}
.album-header__title { .album-header__title {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 2.5rem; font-size: 2.5rem;

View File

@@ -11,6 +11,7 @@ import {
exifUrl, exifUrl,
assetPlaceholder, assetPlaceholder,
assetAspectRatio, assetAspectRatio,
assetCaption,
assetExifSummary, assetExifSummary,
} from '@/lib/urls'; } from '@/lib/urls';
import { encodeAssetId } from '@/lib/tokens'; import { encodeAssetId } from '@/lib/tokens';
@@ -177,6 +178,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
config.exif.onHover && config.exif.camera && a.type === 'IMAGE' config.exif.onHover && config.exif.camera && a.type === 'IMAGE'
? assetExifSummary(a) ? assetExifSummary(a)
: undefined; : undefined;
const caption = assetCaption(a, config.exif.caption);
const isVideo = a.type === 'VIDEO'; const isVideo = a.type === 'VIDEO';
return { return {
id: encodeAssetId(a.id), id: encodeAssetId(a.id),
@@ -186,6 +188,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
exifUrl: exifUrl(a.id), exifUrl: exifUrl(a.id),
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}), ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
...(exif ?? {}), ...(exif ?? {}),
...(caption ? { caption } : {}),
aspectRatio: assetAspectRatio(a), aspectRatio: assetAspectRatio(a),
}; };
}); });

View File

@@ -47,6 +47,10 @@ export async function generateMetadata(): Promise<Metadata> {
}; };
return { return {
// Without this, Next resolves Open Graph and canonical URLs against
// localhost. Absent when nobody configured a site URL, which leaves the
// previous behaviour rather than inventing a host.
...(config.siteUrl ? { metadataBase: new URL(config.siteUrl) } : {}),
title: { title: {
default: siteTitle, default: siteTitle,
template: config.seo.titleTemplate, template: config.seo.titleTemplate,

41
app/robots.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { MetadataRoute } from 'next';
import { getConfigOrNull } from '@/lib/config';
import { isSiteLocked } from '@/lib/auth';
import { absoluteUrl } from '@/lib/siteUrl';
/**
* robots.txt (#472).
*
* Reflects the same decision `generateMetadata()` already makes for the robots
* meta tag, so the two cannot disagree: a site behind a password, or one that
* asked not to be indexed, is closed to crawlers here as well.
*/
export const dynamic = 'force-dynamic';
export default function robots(): MetadataRoute.Robots {
const config = getConfigOrNull();
// Nothing configured yet — the setup screen is not worth crawling.
if (!config) {
return { rules: { userAgent: '*', disallow: '/' } };
}
if (isSiteLocked() || config.seo.noIndex) {
return { rules: { userAgent: '*', disallow: '/' } };
}
const sitemap = absoluteUrl(config.siteUrl, '/sitemap.xml');
return {
rules: {
userAgent: '*',
allow: '/',
// The image proxy, the admin panel and the installer have nothing to
// offer a crawler and plenty to cost the Immich server behind them.
disallow: ['/api/', '/admin', '/install'],
},
// Omitted rather than guessed when no site URL is configured: robots.txt
// requires an absolute sitemap URL.
...(sitemap ? { sitemap } : {}),
};
}

32
app/sitemap.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { MetadataRoute } from 'next';
import { getConfigOrNull } from '@/lib/config';
import { buildSiteShape } from '@/lib/siteShape';
import { publicPaths } from '@/lib/publicPages';
import { absoluteUrl } from '@/lib/siteUrl';
/**
* sitemap.xml (#472).
*
* What goes in is decided by `publicPaths()`, the same function the feed uses,
* because a sitemap lists exactly what access control otherwise hides and a
* second implementation of that judgement would eventually disagree with the
* first.
*/
export const dynamic = 'force-dynamic';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const config = getConfigOrNull();
// A sitemap needs absolute URLs and this route has no request context to
// derive a host from. Without a configured site URL there is nothing valid
// to emit, and an empty sitemap is better than a wrong one.
if (!config?.siteUrl) return [];
const shape = await buildSiteShape().catch(() => null);
if (!shape) return [];
return publicPaths(shape).flatMap((path) => {
const url = absoluteUrl(config.siteUrl, path);
return url ? [{ url, changeFrequency: 'weekly' as const }] : [];
});
}

66
components/AlbumNav.tsx Normal file
View File

@@ -0,0 +1,66 @@
/**
* AlbumNav — the "← previous album / next album →" pair at the foot of an
* album detail page (#483).
*
* Renders nothing when there is no neighbour in either direction, so a lone
* album keeps its quiet ending.
*/
import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
import type { AlbumNavPair } from '@/lib/albumNav';
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 AlbumNav({ prev, next }: AlbumNavPair) {
const t = getServerDictionary();
if (!prev && !next) return null;
return (
<nav className="album-nav" aria-label={t.common.albumNavAria}>
{prev ? (
<Link
href={prev.href}
className="album-nav__link album-nav__link--prev"
aria-label={t.common.prevAlbumAria(prev.name)}
>
<Chevron direction="prev" />
<span className="album-nav__text">
<span className="album-nav__kicker">{t.common.prevAlbum}</span>
<span className="album-nav__name">{prev.name}</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.nextAlbumAria(next.name)}
>
<span className="album-nav__text">
<span className="album-nav__kicker">{t.common.nextAlbum}</span>
<span className="album-nav__name">{next.name}</span>
</span>
<Chevron direction="next" />
</Link>
)}
</nav>
);
}

View File

@@ -65,6 +65,23 @@
transform: scale(1); transform: scale(1);
} }
/*
* The photograph enters with a fade and a slight scale. Under a slideshow
* that repeats every few seconds, which is exactly the motion this preference
* is about — so it simply appears instead. It still fades in from nothing, so
* a half-loaded image is never shown.
*/
@media (prefers-reduced-motion: reduce) {
.image {
transform: none;
transition: none;
}
.imageLoaded {
transform: none;
}
}
.close { .close {
position: fixed; position: fixed;
top: 20px; top: 20px;
@@ -127,11 +144,51 @@
height: 20px; height: 20px;
} }
.counter { /* ── Bottom bar ───────────────────────────────────── */
/*
* One row rather than five separately anchored controls.
*
* Each button used to carry its own fixed offset, which collided in two ways:
* the info button widens when its label becomes "Hide info" and grew into the
* favourite button beside it, and the left-hand controls ran into the centred
* counter once the viewport was narrow enough. A flex row cannot collide with
* itself, and a control that is conditionally absent now leaves no hole.
*/
.bottomBar {
position: fixed; position: fixed;
bottom: 24px; left: 0;
left: 50%; right: 0;
transform: translateX(-50%); bottom: 0;
z-index: 1001;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
/* The bar spans the viewport, so it must not swallow clicks on the photo. */
pointer-events: none;
}
.bottomBarGroup {
display: flex;
align-items: center;
gap: 8px;
/* Equal flex on both sides is what keeps the counter centred. */
flex: 1 1 0;
min-width: 0;
pointer-events: auto;
}
.bottomBarLeft {
justify-content: flex-start;
}
.bottomBarRight {
justify-content: flex-end;
}
.counter {
flex: 0 0 auto;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
font-weight: 300; font-weight: 300;
@@ -140,9 +197,6 @@
/* ── EXIF Info Panel ──────────────────────────────── */ /* ── EXIF Info Panel ──────────────────────────────── */
.infoToggle { .infoToggle {
position: fixed;
bottom: 24px;
right: 24px;
/* Icon and label sit on one line — the buttons carried an emoji before, which /* Icon and label sit on one line — the buttons carried an emoji before, which
needed no alignment of its own. */ needed no alignment of its own. */
display: inline-flex; display: inline-flex;
@@ -169,9 +223,71 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Proofing favourite button — sits left of the info toggle. */ /* Anchors are not buttons; strip the underline so it matches its neighbours. */
.favToggle { .downloadToggle {
right: 6.5rem; text-decoration: none;
}
/* Shown only when the clipboard API is unavailable. */
.copyManual {
position: fixed;
bottom: 76px;
left: 24px;
z-index: 1001;
display: flex;
flex-direction: column;
gap: 6px;
max-width: min(28rem, calc(100vw - 48px));
padding: 12px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
}
.copyManualLabel {
font-family: var(--font-sans);
font-size: 0.75rem;
color: var(--text-muted);
}
.copyManualInput {
width: 100%;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.8rem;
}
@media (max-width: 600px) {
/*
* Four controls, a counter and the close button do not fit on one line at
* phone width. Wrapping is the honest answer: the bottom edge already
* belongs to the EXIF strip in some presets, so there is nowhere to move to.
*/
.bottomBar {
padding: 12px 16px;
gap: 8px;
flex-wrap: wrap;
}
.bottomBarGroup {
flex: 0 1 auto;
}
/* Must not grow — a stretched counter pushes the row into the close button. */
.counter {
flex: 0 0 auto;
}
.copyManual {
left: 16px;
bottom: 66px;
}
} }
.exifPanel { .exifPanel {
@@ -187,7 +303,7 @@
--lb-panel-muted: var(--text-muted); --lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle); --lb-panel-border: var(--border-subtle);
position: fixed; position: fixed;
bottom: 60px; bottom: 76px;
right: 24px; right: 24px;
z-index: 1001; z-index: 1001;
background: rgba(20, 20, 20, 0.92); background: rgba(20, 20, 20, 0.92);
@@ -277,7 +393,7 @@
--lb-panel-muted: var(--text-muted); --lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle); --lb-panel-border: var(--border-subtle);
position: fixed; position: fixed;
bottom: 62px; bottom: 76px;
left: 24px; left: 24px;
z-index: 1001; z-index: 1001;
animation: exif-slide-up var(--transition-normal) ease forwards; animation: exif-slide-up var(--transition-normal) ease forwards;
@@ -463,11 +579,22 @@
} }
/* Counter → top left, monospace */ /* Counter → top left, monospace */
:global([data-preset='studio-modern']) .counter { /*
top: 20px; * This preset puts the viewer's chrome along the top edge. That is now said
* once, on the bar, instead of by each control: when every button carried its
* own offset, a button that changed width walked into its neighbour.
*/
:global([data-preset='studio-modern']) .bottomBar {
top: 0;
bottom: auto; bottom: auto;
left: 32px; /* The close button owns the top-right corner, so the bar stops short of it.
transform: none; That is the 76px the individual controls used to reserve by hand. */
padding: 20px 76px 20px 32px;
}
/* Counter first, so it keeps the left edge it had in this preset. */
:global([data-preset='studio-modern']) .counter {
order: -1;
font-family: var(--font-caption); font-family: var(--font-caption);
font-size: 0.6875rem; font-size: 0.6875rem;
letter-spacing: 0.15em; letter-spacing: 0.15em;
@@ -520,9 +647,6 @@
/* Info + favourite toggles move up into the topbar */ /* Info + favourite toggles move up into the topbar */
:global([data-preset='studio-modern']) .infoToggle { :global([data-preset='studio-modern']) .infoToggle {
top: 20px;
bottom: auto;
right: 76px;
background: none; background: none;
backdrop-filter: none; backdrop-filter: none;
-webkit-backdrop-filter: none; -webkit-backdrop-filter: none;
@@ -542,10 +666,6 @@
color: var(--accent); color: var(--accent);
} }
:global([data-preset='studio-modern']) .favToggle {
right: 176px;
}
/* The EXIF strip owns the bottom edge in this preset, so the panel opens from /* The EXIF strip owns the bottom edge in this preset, so the panel opens from
the top instead. */ the top instead. */
:global([data-preset='studio-modern']) .shortcutsPanel { :global([data-preset='studio-modern']) .shortcutsPanel {
@@ -638,12 +758,9 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
/* No room next to the info toggle — park it in the opposite corner. */ :global([data-preset='studio-modern']) .bottomBar {
:global([data-preset='studio-modern']) .favToggle { /* Same reserved corner as on desktop: the close button sits there. */
top: auto; padding: 16px 76px 16px 16px;
bottom: 76px;
right: auto;
left: 16px;
} }
:global([data-preset='studio-modern']) .navPrev { :global([data-preset='studio-modern']) .navPrev {

View File

@@ -26,6 +26,13 @@ import { useDictionary } from './I18nProvider';
// client component cannot import. // client component cannot import.
import { resolveWatermarkOpacity } from '@/lib/config/schema'; import { resolveWatermarkOpacity } from '@/lib/config/schema';
import { formatCamera } from '@/lib/exif'; import { formatCamera } from '@/lib/exif';
import { buildPhotoPermalink } from '@/lib/photoHash';
import { nextSlideshowSpeed, type SlideshowSpeed } from '@/lib/slideshow';
import {
LIGHTBOX_SHORTCUTS,
lightboxActionFor,
shortcutDisplayKeys,
} from '@/lib/lightboxShortcuts';
export interface LightboxWatermark { export interface LightboxWatermark {
enabled?: boolean; enabled?: boolean;
@@ -64,6 +71,20 @@ export function Lightbox({
const [canFullscreen, setCanFullscreen] = useState(false); const [canFullscreen, setCanFullscreen] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif(); const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
/**
* 'manual' is the honest outcome when the clipboard is unavailable — which is
* not a rarity here: `navigator.clipboard` is undefined outside a secure
* context, and a self-hosted portfolio reached over plain http on a LAN is
* exactly that. The link is then shown for the visitor to copy by hand
* rather than the button appearing to do nothing.
*/
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'manual'>('idle');
/**
* Slideshow interval in seconds, or null for off. `s` cycles through the
* three speeds and back to off, so the feature needs no configuration and no
* control of its own — in keeping with how the viewer treats its other keys.
*/
const [slideshowSeconds, setSlideshowSeconds] = useState<SlideshowSpeed>(null);
const overlayRef = useRef<HTMLDivElement>(null); const overlayRef = useRef<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null); const closeBtnRef = useRef<HTMLButtonElement>(null);
@@ -107,6 +128,81 @@ export function Lightbox({
setShowShortcuts((open) => !open); setShowShortcuts((open) => !open);
}, []); }, []);
/**
* 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).
*
* 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.
*/
const permalink = useCallback(() => {
if (typeof window === 'undefined') return '';
return buildPhotoPermalink(window.location, currentIndex);
}, [currentIndex]);
const handleCopyLink = useCallback(() => {
const url = permalink();
if (!navigator.clipboard) {
setCopyState('manual');
return;
}
navigator.clipboard.writeText(url).then(
() => setCopyState('copied'),
() => setCopyState('manual'),
);
}, [permalink]);
/**
* Auto-advance, so a gallery can run unattended at an exhibition, a fair
* booth or on a second screen (#473).
*
* `onNext` already wraps at the end of the album, so the sequence loops on
* its own with nothing further to arrange.
*/
const cycleSlideshow = useCallback(() => {
setSlideshowSeconds((current) => nextSlideshowSpeed(current));
}, []);
useEffect(() => {
if (slideshowSeconds === null) return;
const timer = setInterval(onNext, slideshowSeconds * 1000);
return () => clearInterval(timer);
}, [slideshowSeconds, onNext]);
/*
* Any deliberate move through the album stops the slideshow. Someone
* reaching for an arrow has taken over; leaving the timer running would
* yank the photo away from under them a second later.
*
* The timer above keeps calling the raw `onNext`, so it does not stop
* itself.
*/
const manualNext = useCallback(() => {
setSlideshowSeconds(null);
onNext();
}, [onNext]);
const manualPrev = useCallback(() => {
setSlideshowSeconds(null);
onPrev();
}, [onPrev]);
// A confirmation must not outlive the photo it was about.
useEffect(() => {
setCopyState('idle');
}, [currentIndex]);
useEffect(() => {
if (copyState !== 'copied') return;
const timer = setTimeout(() => setCopyState('idle'), 2000);
return () => clearTimeout(timer);
}, [copyState]);
/* /*
* Real fullscreen. The overlay already covers the viewport, but the browser's * Real fullscreen. The overlay already covers the viewport, but the browser's
* own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly * own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly
@@ -171,8 +267,8 @@ export function Lightbox({
}, [currentIndex, assets]); }, [currentIndex, assets]);
const { handleTouchStart, handleTouchEnd } = useSwipe({ const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: onNext, onSwipeLeft: manualNext,
onSwipeRight: onPrev, onSwipeRight: manualPrev,
}); });
/* /*
@@ -183,8 +279,29 @@ export function Lightbox({
*/ */
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) { /*
case 'Escape': * Bare keys only. The manual copy fallback puts a real text field on
* screen, and a visitor pressing Cmd/Ctrl+C in it means the browser's
* copy, not this viewer's — swallowing it would break the one gesture
* that field exists for. Esc stays available so the viewer can always
* be left.
*/
const target = e.target as HTMLElement | null;
const inTextField = target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
if (e.key !== 'Escape' && (e.metaKey || e.ctrlKey || e.altKey || inTextField)) return;
/*
* Which keys exist is the catalogue's business, not this handler's — see
* lib/lightboxShortcuts.ts. A key it does not list never gets here, and
* the switch below is exhaustive over LightboxAction, so an action added
* there without a branch here fails to compile rather than silently
* doing nothing.
*/
const action = lightboxActionFor(e.key);
if (action === null) return;
switch (action) {
case 'close':
// Innermost layer first: Esc dismisses the shortcut list, then leaves // Innermost layer first: Esc dismisses the shortcut list, then leaves
// fullscreen, and only closes the viewer once nothing is stacked on // fullscreen, and only closes the viewer once nothing is stacked on
// top of it. Most browsers swallow this Esc to exit fullscreen // top of it. Most browsers swallow this Esc to exit fullscreen
@@ -195,29 +312,41 @@ export function Lightbox({
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}); else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else onClose(); else onClose();
break; break;
case 'ArrowRight': case 'next':
onNext(); manualNext();
break; break;
case 'ArrowLeft': case 'prev':
onPrev(); manualPrev();
break; break;
case 'i': case 'info':
case 'I':
if (showExifToggle) handleExifToggle(); if (showExifToggle) handleExifToggle();
break; break;
// `?` is matched on the produced character, not the physical key: case 'slideshow':
// Shift+/ on a US layout, Shift+ß on a German one. `h` is the escape e.preventDefault();
// hatch for layouts where `?` is awkward — and the one key someone cycleSlideshow();
// guesses without having been told. break;
case '?': case 'download':
case 'h': if (current?.downloadUrl) {
case 'H': e.preventDefault();
window.location.href = current.downloadUrl;
}
break;
case 'copyLink':
e.preventDefault();
handleCopyLink();
break;
case 'shortcutList':
toggleShortcuts(); toggleShortcuts();
break; break;
case 'f': case 'fullscreen':
case 'F':
if (canFullscreen) toggleFullscreen(); if (canFullscreen) toggleFullscreen();
break; break;
default: {
// Exhaustiveness guard: this only type-checks while every
// LightboxAction has a branch above.
const unhandled: never = action;
void unhandled;
}
} }
}; };
@@ -230,10 +359,13 @@ export function Lightbox({
}; };
}, [ }, [
canFullscreen, canFullscreen,
current,
cycleSlideshow,
handleCopyLink,
handleExifToggle, handleExifToggle,
onClose, onClose,
onNext, manualNext,
onPrev, manualPrev,
showExifToggle, showExifToggle,
showShortcuts, showShortcuts,
toggleFullscreen, toggleFullscreen,
@@ -251,26 +383,27 @@ export function Lightbox({
); );
/* /*
* The advertised shortcuts, in the order they are worth learning. This is the * The advertised shortcuts, resolved from the shared catalogue.
* list the `?` panel renders — a key added to the handler above belongs here *
* too, or it stays as undiscoverable as the whole set was before. * The set of keys lives in lib/lightboxShortcuts.ts because the admin help
* renders the same list; only the two labels that depend on current state
* are decided here.
*/ */
const shortcutRows = [ const shortcutRows = LIGHTBOX_SHORTCUTS.filter((shortcut) => {
{ keys: ['←', '→'], label: t.lightbox.shortcutNavigate }, if (shortcut.availability === 'exifPanel') return showExifToggle;
// Journal entries hide the EXIF toggle, so `i` does nothing there. if (shortcut.availability === 'fullscreen') return canFullscreen;
...(showExifToggle ? [{ keys: ['I'], label: t.lightbox.shortcutInfo }] : []), if (shortcut.availability === 'download') return Boolean(current?.downloadUrl);
// Absent where the browser has no element fullscreen to give (iPhone). return true;
...(canFullscreen }).map((shortcut) => {
? [ let label: string = t.lightbox[shortcut.labelKey];
{ if (shortcut.labelKey === 'shortcutFullscreen' && isFullscreen) {
keys: ['F'], label = t.lightbox.shortcutExitFullscreen;
label: isFullscreen ? t.lightbox.shortcutExitFullscreen : t.lightbox.shortcutFullscreen, }
}, if (shortcut.labelKey === 'shortcutSlideshow' && slideshowSeconds !== null) {
] label = t.lightbox.shortcutSlideshowRunning(slideshowSeconds);
: []), }
{ keys: ['?', 'H'], label: t.lightbox.shortcutList }, return { keys: shortcutDisplayKeys(shortcut), label };
{ keys: ['Esc'], label: t.lightbox.shortcutClose }, });
];
const lightboxJsx = ( const lightboxJsx = (
<div <div
@@ -308,7 +441,7 @@ export function Lightbox({
{/* Previous button */} {/* Previous button */}
<button <button
className={`${styles.nav} ${styles.navPrev}`} className={`${styles.nav} ${styles.navPrev}`}
onClick={onPrev} onClick={manualPrev}
aria-label={t.lightbox.previous} aria-label={t.lightbox.previous}
title={t.lightbox.previousTitle} title={t.lightbox.previousTitle}
> >
@@ -354,7 +487,7 @@ export function Lightbox({
<img <img
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`} className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={current.previewUrl} src={current.previewUrl}
alt="" alt={current.caption ?? ''}
draggable={false} draggable={false}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)} onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
@@ -380,7 +513,7 @@ export function Lightbox({
{/* Next button */} {/* Next button */}
<button <button
className={`${styles.nav} ${styles.navNext}`} className={`${styles.nav} ${styles.navNext}`}
onClick={onNext} onClick={manualNext}
aria-label={t.lightbox.next} aria-label={t.lightbox.next}
title={t.lightbox.nextTitle} title={t.lightbox.nextTitle}
> >
@@ -397,45 +530,142 @@ export function Lightbox({
</svg> </svg>
</button> </button>
{/* Counter */} {/*
<div className={styles.counter} aria-live="polite" aria-atomic="true"> Slideshow state, announced but not drawn: a badge over a photograph
<span className="sr-only"> would be paid for by every visitor, and the shortcut list already
Photo {currentIndex + 1} of {assets.length} carries the speed for anyone who wants to see it.
</span> */}
<span aria-hidden="true"> <p className="sr-only" role="status">
{currentIndex + 1} / {assets.length} {slideshowSeconds === null
</span> ? t.lightbox.slideshowStopped
: t.lightbox.slideshowRunning(slideshowSeconds)}
</p>
{/*
One bottom bar rather than five separately anchored controls.
They used to carry hard-coded offsets, which broke in two ways: the
info button grows when its label becomes "Hide info" and walked into
the favourite button, and the left-hand controls ran into the centred
counter on a narrow viewport. A flex row cannot collide with itself.
The bar itself ignores pointer events so it does not swallow clicks
on the photograph behind it; each group takes them back.
*/}
<div className={styles.bottomBar}>
<div className={`${styles.bottomBarGroup} ${styles.bottomBarLeft}`}>
{/* Copy link — bottom left, opposite the info toggle */}
<button
className={styles.infoToggle}
onClick={handleCopyLink}
aria-label={t.lightbox.copyLink}
title={t.lightbox.copyLinkTitle}
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{copyState === 'copied' ? t.lightbox.copied : t.lightbox.copyLinkShort}
</button>
{/* Download the original — only when the album offers it (#475) */}
{current?.downloadUrl && (
<a
className={`${styles.infoToggle} ${styles.downloadToggle}`}
href={current.downloadUrl}
download
aria-label={t.lightbox.download}
title={t.lightbox.downloadTitle}
>
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t.lightbox.downloadShort}
</a>
)}
</div>
{/* Counter */}
<div className={styles.counter} aria-live="polite" aria-atomic="true">
<span className="sr-only">
Photo {currentIndex + 1} of {assets.length}
</span>
<span aria-hidden="true">
{currentIndex + 1} / {assets.length}
</span>
</div>
<div className={`${styles.bottomBarGroup} ${styles.bottomBarRight}`}>
{/* Proofing favorite button */}
{proofing && current && (
<button
className={styles.infoToggle}
style={{
color: isFav ? '#ff4d4f' : 'inherit',
fontWeight: isFav ? 600 : 400,
}}
onClick={() => proofing.toggleFavorite(current.id)}
aria-label={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
title={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
>
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" />
{isFav ? t.proofing.saved : t.proofing.favorite}
</button>
)}
{/* EXIF toggle */}
{showExifToggle && (
<button
className={styles.infoToggle}
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label={t.lightbox.toggleInfo}
title={t.lightbox.toggleInfoTitle}
>
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
</button>
)}
</div>
</div> </div>
{/* Proofing favorite button */} {/* Clipboard unavailable — show the link rather than fail quietly */}
{proofing && current && ( {copyState === 'manual' && (
<button <div className={styles.copyManual} role="status">
className={`${styles.infoToggle} ${styles.favToggle}`} <label className={styles.copyManualLabel} htmlFor="lightbox-permalink">
style={{ {t.lightbox.copyManual}
color: isFav ? '#ff4d4f' : 'inherit', </label>
fontWeight: isFav ? 600 : 400, <input
}} id="lightbox-permalink"
onClick={() => proofing.toggleFavorite(current.id)} className={styles.copyManualInput}
aria-label={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites} type="text"
title={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites} readOnly
> autoFocus
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" /> value={permalink()}
{isFav ? t.proofing.saved : t.proofing.favorite} onFocus={(e) => e.currentTarget.select()}
</button> />
)} </div>
{/* EXIF toggle */}
{showExifToggle && (
<button
className={styles.infoToggle}
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label={t.lightbox.toggleInfo}
title={t.lightbox.toggleInfoTitle}
>
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
</button>
)} )}
{/* EXIF panel */} {/* EXIF panel */}

View File

@@ -103,12 +103,20 @@ export function MapView() {
) )
.join(''); .join('');
/*
* An album set to `location: country` has no city name to show — the
* API withholds it rather than naming a village inside a 100 km cell
* (#469). The country then carries the heading on its own.
*/
const heading = loc.city || loc.country;
const subheading = loc.city ? loc.country : '';
const popupHtml = ` const popupHtml = `
<div class="map-popup"> <div class="map-popup">
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(loc.city)}" class="map-popup__cover" loading="lazy" /> <img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" />
<div class="map-popup__body"> <div class="map-popup__body">
<h3 class="map-popup__city">${escapeHtml(loc.city)}</h3> <h3 class="map-popup__city">${escapeHtml(heading)}</h3>
<p class="map-popup__country">${escapeHtml(loc.country)}</p> ${subheading ? `<p class="map-popup__country">${escapeHtml(subheading)}</p>` : ''}
<p class="map-popup__count">${escapeHtml(t.common.photos(loc.photoCount))}</p> <p class="map-popup__count">${escapeHtml(t.common.photos(loc.photoCount))}</p>
<div class="map-popup__albums">${albumLinks}</div> <div class="map-popup__albums">${albumLinks}</div>
</div> </div>

View File

@@ -0,0 +1,34 @@
/**
* A JSON-LD block (#472).
*
* `proxy.ts` sets a CSP with a per-request nonce and no `unsafe-inline`
* fallback, so an inline script without the nonce is blocked — silently, which
* is the failure mode worth guarding against: the markup looks right in the
* source and the crawler never sees it.
*/
import { headers } from 'next/headers';
/**
* Serialise for embedding in a <script> element.
*
* `</script>` inside a string value would end the element early and let the
* rest be parsed as markup, so the angle brackets are escaped. JSON parsers
* read `\u003c` as `<`, which leaves the data unchanged.
*/
function serialise(data: unknown): string {
return JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
}
export async function StructuredData({ data }: { data: unknown }) {
if (!data) return null;
const nonce = (await headers()).get('x-nonce') ?? undefined;
return (
<script
type="application/ld+json"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: serialise(data) }}
/>
);
}

View File

@@ -56,9 +56,13 @@ subpages:
subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers
password: "your-password" # Optional protection password: "your-password" # Optional protection
# hidden: true # EXPERIMENTAL: reachable by direct link, but not shown in navigation # hidden: true # EXPERIMENTAL: reachable by direct link, but not shown in navigation
# grid: # Optional per-page grid override (wins over settings.yaml) # coverGrid: # Optional: the album covers on this page (nothing else)
# columns: 3 # how many album covers per row on this page # columns: 3 # how many album covers per row on this page
# gap: 8 # spacing between the covers, in px — omit to keep the theme's own # gap: 8 # spacing between the covers, in px — omit to keep the theme's own
# grid: # Optional: the photos inside the albums (wins over settings.yaml)
# columns: 4
# gap: 6 # omit to keep the theme's own photo spacing
# layout: "masonry"
albums: albums:
- "album-uuid-5" - "album-uuid-5"
- "album-uuid-6": - "album-uuid-6":
@@ -69,6 +73,32 @@ subpages:
# layout: "justified" # layout: "justified"
# columns: 2 # columns: 2
# coverPosition: "50% 25%" # EXPERIMENTAL: focal point for the cover crop ("top", "center bottom", "50% 25%") # coverPosition: "50% 25%" # EXPERIMENTAL: focal point for the cover crop ("top", "center bottom", "50% 25%")
#
# download: true
# Offer the original file from the lightbox for this album.
# Off everywhere by default, and opt-in per album on purpose: a
# public portfolio should not start handing out full-resolution
# originals because one client gallery needed to.
# The download route checks the allowlist, this switch, the
# album's password and that the asset really belongs to the
# album, so an edited URL cannot reach anything else.
#
# location: city
# How precisely this album's photographs may be placed — on the
# map, and in the info panel of the viewer, which names the city
# and country of each photo.
# exact (default) the mean of the photographs' own coordinates
# city rounded to 0.05° — roughly 5 km, city still named
# country rounded to 1° — roughly 100 km, only the country named
# hidden no marker on the map, and no place in the info panel
# Worth setting for an album shot in one place — a garden, a
# studio, a client's home, a fragile site — where the mean of the
# coordinates is that place, and the marker stands on the property.
# May also be set once on a subpage, where it applies to every
# album on it unless the album says otherwise.
# Where one marker merges several albums, the most cautious of
# them governs the whole marker. A photograph that is in several
# albums likewise follows the most cautious of them.
# ── Subpage with Sections ──────────────────────────────────────── # ── Subpage with Sections ────────────────────────────────────────
# Use sections to group albums by location, theme, or any other logic. # Use sections to group albums by location, theme, or any other logic.

View File

@@ -19,6 +19,9 @@ lang: "en"
# titleTemplate: "%s | My Portfolio" # Template for subpages (%s is replaced with subpage/album title) # titleTemplate: "%s | My Portfolio" # Template for subpages (%s is replaced with subpage/album title)
# noIndex: false # Set to true to hide from search engines # noIndex: false # Set to true to hide from search engines
# noFollow: false # Set to true to prevent search engines from following links # noFollow: false # Set to true to prevent search engines from following links
# # Licence your photographs are published under, emitted as structured
# # data on album pages. A URL or a short identifier.
# license: "https://creativecommons.org/licenses/by-nc-nd/4.0/"
# ── Site password (optional) ───────────────────────────────────── # ── Site password (optional) ─────────────────────────────────────
# Puts the WHOLE public site behind one password — useful while a portfolio # Puts the WHOLE public site behind one password — useful while a portfolio
@@ -77,6 +80,14 @@ theme:
# heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic, cover (EXPERIMENTAL) # heroStyle: "split" # split, fullbleed, minimal, stacked, typographic, mosaic, cover (EXPERIMENTAL)
# Note: "mosaic" needs at least 3 hero images in gallery.yaml (optimal: 6) # Note: "mosaic" needs at least 3 hero images in gallery.yaml (optimal: 6)
# ── Site URL ─────────────────────────────────────────────────────
# The address this portfolio is reachable at, e.g. https://folio.example
# Needed for sitemap.xml, the feed and structured data: those are built
# without a request, so the host cannot be derived from one. Left empty,
# the sitemap stays empty rather than guessing a wrong address.
# The SITE_URL environment variable is used when this is not set.
# url: "https://folio.example"
# ── Grid configuration ─────────────────────────────────────────── # ── Grid configuration ───────────────────────────────────────────
grid: grid:
columns: 3 columns: 3

View File

@@ -72,8 +72,9 @@ Group albums under custom URL paths (e.g. `/japan`, `/wedding-smith`).
- **Name** — navigation label; the URL slug is derived from it - **Name** — navigation label; the URL slug is derived from it
- **Title / Subtitle** — page heading and subline - **Title / Subtitle** — page heading and subline
- **Password** — protect the whole subpage - **Password** — protect the whole subpage
- **Grid override** — layout settings for the photos on this page - **Page Layout Style** — the layout the photos on this page are shown in (masonry, uniform, showcase, …), or essay mode
- **Album Cover Grid** — how the album covers are tiled: columns (empty = the site-wide grid setting) and gap in px (empty = the theme's own cover spacing). Hidden in essay mode - **Album Cover Grid** — how the album covers are tiled: columns (empty = the site-wide grid setting) and gap in px (empty = the theme's own cover spacing). Hidden in essay mode
- **Photo Grid (Albums On This Page)** — columns and gap for the photos inside those albums; empty follows _Settings › Grid_. Separate from the cover grid: one field used to size both until [#523](https://github.com/ralksta/immich-folio/issues/523)
- **Enabled** — take the page offline without deleting it - **Enabled** — take the page offline without deleting it
- **Hidden** _(experimental)_ — unlisted: reachable by direct link, absent from the navigation - **Hidden** _(experimental)_ — unlisted: reachable by direct link, absent from the navigation
- **Essay** — turn the page into a photo essay, with a block editor for the text - **Essay** — turn the page into a photo essay, with a block editor for the text

View File

@@ -110,20 +110,21 @@ subpages:
### Subpage Options ### Subpage Options
| Key | Type | Description | | Key | Type | Description |
| ----------- | ------- | -------------------------------------------------------------------------------------- | | ----------- | ------- | ------------------------------------------------------------------------------------------------ |
| `name` | string | Navigation label; the URL slug is derived from it | | `name` | string | Navigation label; the URL slug is derived from it |
| `title` | string | Page heading, defaults to `name` | | `title` | string | Page heading, defaults to `name` |
| `subtitle` | string | Subline under the heading | | `subtitle` | string | Subline under the heading |
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) | | `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) | | `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
| `sections` | list | Named groups of albums — see [Sections](#sections) | | `sections` | list | Named groups of albums — see [Sections](#sections) |
| `grid` | object | Grid override for this page, photos and album covers — see [Grid Layout](#grid-layout) | | `grid` | object | Grid override for the photos on this page — see [Grid Layout](#grid-layout) |
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page | | `coverGrid` | object | Grid override for the album covers on this page — see [Album covers](#album-covers-on-a-subpage) |
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) | | `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
| `essayText` | string | Essay Markdown inline; written by the admin block editor | | `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
| `enabled` | boolean | `false` takes the page offline without deleting it | | `essayText` | string | Essay Markdown inline; written by the admin block editor |
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation | | `enabled` | boolean | `false` takes the page offline without deleting it |
| `hidden` | boolean | **Experimental.** Reachable by direct link, but hidden from the navigation |
```yaml ```yaml
subpages: subpages:
@@ -383,34 +384,48 @@ Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
### Album covers on a subpage ### Album covers on a subpage
A subpage with more than one album shows a grid of album covers rather than A subpage with more than one album shows a grid of album covers rather than
photos. That grid follows the same `columns` value — set it globally in photos. That grid has its own key, `coverGrid`, which sizes the cover tiles and
`settings.yaml` or per page in `gallery.yaml`, and the covers are tiled the same nothing else. `grid` next to it stays what it always was: the photos inside
way the photos are. Valid range is 1–6; below 1024px viewport width at most two those albums.
covers share a row, and below 640px they stack.
An unset `columns` falls back to the site-wide `grid.columns`, so a page that
states nothing tiles its covers the way it tiles its photos. Valid range is 1–6;
below 1024px viewport width at most two covers share a row, and below 640px they
stack.
`gap` is the deliberate exception: it does **not** cascade from the global `gap` is the deliberate exception: it does **not** cascade from the global
setting to the covers. Every theme preset picks its own cover spacing as part of setting to the covers. Every theme preset picks its own cover spacing as part of
its look — 1px in `monograph`, 2px in `minimal` and `editorial`, 20px in its look — 1px in `monograph`, 2px in `minimal` and `editorial`, 20px in
`studio-modern` — and letting the site-wide photo gap through would flatten all `studio-modern` — and letting the site-wide photo gap through would flatten all
of them into one look. Only an explicit `gap` on the subpage overrides the of them into one look. Only an explicit `coverGrid.gap` overrides the preset:
preset:
```yaml ```yaml
# gallery.yaml — album covers on this page only # gallery.yaml — the two grids of one page, set independently
subpages: subpages:
- name: Editorial - name: Editorial
grid: coverGrid:
columns: 3 # three covers per row (site-wide default otherwise) columns: 3 # three covers per row (site-wide default otherwise)
gap: 24 # overrides the theme's own cover spacing gap: 24 # overrides the theme's own cover spacing
grid:
columns: 4 # the photos inside each album
gap: 6
albums: albums:
- '33333333-3333-3333-3333-333333333333' - '33333333-3333-3333-3333-333333333333'
- '44444444-4444-4444-4444-444444444444' - '44444444-4444-4444-4444-444444444444'
``` ```
Both fields are editable per page in the admin panel under Both are editable per page in the admin panel, under **Pages › _subpage_ ›
**Pages › _subpage_ › Album Cover Grid**; left empty they follow the site-wide Album Cover Grid** and **Photo Grid**; left empty they follow the site-wide
setting and the theme respectively. setting and the theme respectively.
> [!NOTE]
> The covers and the photos shared a single `grid` key until
> [#523](https://github.com/ralksta/immich-folio/issues/523), where a gap typed
> into the admin's cover field turned out to retune every photo grid on the page
> as well. A `gallery.yaml` that predates the split has no `coverGrid`, so the
> covers fall back to `grid` and the page renders exactly as before; the first
> save from the admin panel writes the two out separately.
## Site Behaviour ## Site Behaviour
Feature toggles in `settings.yaml`: Feature toggles in `settings.yaml`:

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { albumNeighbours } from '@/lib/albumNav';
const album = (id: string, slug = id, albumName = id.toUpperCase()) => ({ id, slug, albumName });
const three = [album('a'), album('b'), album('c')];
describe('albumNeighbours', () => {
it('offers both neighbours in the middle of a list', () => {
expect(albumNeighbours(three, 'b')).toEqual({
prev: { href: '/a', name: 'A' },
next: { href: '/c', name: 'C' },
});
});
it('omits the previous link on the first album', () => {
expect(albumNeighbours(three, 'a')).toEqual({ next: { href: '/b', name: 'B' } });
});
it('omits the next link on the last album — no wrap-around', () => {
expect(albumNeighbours(three, 'c')).toEqual({ prev: { href: '/b', name: 'B' } });
});
it('prefixes hrefs with the subpage path when there is one', () => {
expect(albumNeighbours(three, 'b', '/travel')).toEqual({
prev: { href: '/travel/a', name: 'A' },
next: { href: '/travel/c', name: 'C' },
});
});
it('returns nothing for a single-album list', () => {
expect(albumNeighbours([album('a')], 'a')).toEqual({});
});
it('returns nothing for an empty list', () => {
expect(albumNeighbours([], 'a')).toEqual({});
});
it('returns nothing when the album is not in the list', () => {
expect(albumNeighbours(three, 'zzz')).toEqual({});
});
it('follows list order, not slug or name order', () => {
const shuffled = [album('c'), album('a'), album('b')];
expect(albumNeighbours(shuffled, 'a')).toEqual({
prev: { href: '/c', name: 'C' },
next: { href: '/b', name: 'B' },
});
});
it('uses the slug for the href and the display name for the label', () => {
const list = [album('id-1', 'harbour-at-dawn', 'Harbour at Dawn'), album('id-2')];
expect(albumNeighbours(list, 'id-2').prev).toEqual({
href: '/harbour-at-dawn',
name: 'Harbour at Dawn',
});
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { LocationPrecision } from '@/lib/mapPrecision';
/**
* The per-album `location:` setting governed the map and nothing else, so an
* album asking to be absent from it still had its city named in the lightbox
* info panel. These pin the join: an asset inherits the setting of the albums
* that carry it.
*/
const albumLocationPrecision: Record<string, LocationPrecision> = {};
vi.mock('@/lib/config', async () => {
const actual = await vi.importActual<typeof import('@/lib/config')>('@/lib/config');
return {
...actual,
getConfig: () => ({ albumLocationPrecision }),
};
});
const getAlbum = vi.fn();
vi.mock('@/lib/immich', () => ({
immich: { getAlbum: (id: string) => getAlbum(id) },
}));
import { assetLocationPrecision } from '@/lib/assetLocation';
const ASSET = 'asset-1';
const OTHER = 'asset-2';
/** An album whose assets are just ids. */
const album = (id: string, assetIds: string[]) => ({
id,
assets: assetIds.map((a) => ({ id: a })),
});
beforeEach(() => {
for (const key of Object.keys(albumLocationPrecision)) delete albumLocationPrecision[key];
getAlbum.mockReset();
});
describe('assetLocationPrecision', () => {
it('is exact when no album restricts anything, without asking Immich at all', async () => {
await expect(assetLocationPrecision(ASSET)).resolves.toBe('exact');
// The common case must not cost a single album fetch.
expect(getAlbum).not.toHaveBeenCalled();
});
it('takes the setting of an album that carries the asset', async () => {
albumLocationPrecision['album-a'] = 'country';
getAlbum.mockResolvedValue(album('album-a', [ASSET]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('country');
});
it('ignores an album that restricts but does not carry the asset', async () => {
albumLocationPrecision['album-a'] = 'hidden';
getAlbum.mockResolvedValue(album('album-a', [OTHER]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('exact');
});
// Same rule as a merged map marker: the cautious setting governs.
it('takes the strictest when several albums carry the asset', async () => {
albumLocationPrecision['album-a'] = 'city';
albumLocationPrecision['album-b'] = 'hidden';
getAlbum.mockImplementation(async (id: string) => album(id, [ASSET]));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
it('only asks about albums that actually restrict something', async () => {
albumLocationPrecision['album-a'] = 'exact';
albumLocationPrecision['album-b'] = 'city';
getAlbum.mockImplementation(async (id: string) => album(id, [ASSET]));
await assetLocationPrecision(ASSET);
expect(getAlbum).toHaveBeenCalledTimes(1);
expect(getAlbum).toHaveBeenCalledWith('album-b');
});
// Failing open would publish the position the setting exists to withhold.
it('withholds the place when an album cannot be read', async () => {
albumLocationPrecision['album-a'] = 'city';
getAlbum.mockRejectedValue(new Error('Immich is down'));
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
it('withholds it when the album is gone from the allowlist', async () => {
albumLocationPrecision['album-a'] = 'city';
getAlbum.mockResolvedValue(null);
await expect(assetLocationPrecision(ASSET)).resolves.toBe('hidden');
});
});

View File

@@ -6,11 +6,17 @@ vi.mock('@/lib/config', () => ({
getConfig: () => ({ getConfig: () => ({
immich: { apiKey: 'test-api-key-for-auth-tests' }, immich: { apiKey: 'test-api-key-for-auth-tests' },
authSecret: 'test-auth-secret-32-chars-long-min', authSecret: 'test-auth-secret-32-chars-long-min',
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
subpages: [ subpages: [
{ {
name: 'Private', name: 'Private',
slug: 'private', slug: 'private',
albumIds: ['00000000-0000-0000-0000-000000000001'], albumIds: [
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000004',
'00000000-0000-0000-0000-000000000005',
],
password: 'secret123', password: 'secret123',
}, },
{ {
@@ -32,7 +38,13 @@ vi.mock('@/lib/config', () => ({
}), }),
})); }));
import { isProtected, authenticate, isAuthenticated, findSubpageBySlug } from '@/lib/auth'; import {
isProtected,
authenticate,
isAuthenticated,
isAlbumReachable,
findSubpageBySlug,
} from '@/lib/auth';
describe('findSubpageBySlug', () => { describe('findSubpageBySlug', () => {
it('returns the subpage config for a known slug', () => { it('returns the subpage config for a known slug', () => {
@@ -197,3 +209,52 @@ describe('token expiry', () => {
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000); expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
}); });
}); });
/**
* A route handler is reached without ever passing through the page that would
* have asked for the subpage password, so it has to ask the same question
* itself (#475).
*/
describe('isAlbumReachable', () => {
const IN_PRIVATE = '00000000-0000-0000-0000-000000000001';
const IN_PUBLIC = '00000000-0000-0000-0000-000000000002';
const ALSO_STANDALONE = '00000000-0000-0000-0000-000000000004';
const OWN_PASSWORD = '00000000-0000-0000-0000-000000000005';
const none = () => undefined;
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
const header = (await authenticate(key, password, type))!;
const token = header.split('=')[1].split(';')[0];
const name = header.split('=')[0];
return (asked: string) => (asked === name ? token : undefined);
}
it('allows an album in a subpage that asks for nothing', () => {
expect(isAlbumReachable(IN_PUBLIC, none)).toBe(true);
});
it('refuses an album whose only route is a protected subpage', () => {
expect(isAlbumReachable(IN_PRIVATE, none)).toBe(false);
});
it('allows it once that subpage has been unlocked', async () => {
const getCookie = await cookieFor('private', 'secret123');
expect(isAlbumReachable(IN_PRIVATE, getCookie)).toBe(true);
});
// Listing an album on the home page publishes it, whatever else it is in.
it('allows a standalone album even when a protected subpage also lists it', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
});
it('allows that one once its own password is given too', async () => {
const subpage = await cookieFor('private', 'secret123');
const album = await cookieFor(OWN_PASSWORD, 'albumpass', 'album');
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
});
});

View File

@@ -12,7 +12,13 @@ vi.mock('@/lib/env', () => ({
}, },
})); }));
import { slugify, buildSubpageGrid, sanitizeNavLinks } from '@/lib/config'; import {
slugify,
albumSlug,
normalizeSlug,
buildSubpageGrid,
sanitizeNavLinks,
} from '@/lib/config';
// EXPERIMENTAL: external navigation links — rendered as <a href> in the // EXPERIMENTAL: external navigation links — rendered as <a href> in the
// header, so only http(s) URLs may survive sanitisation. // header, so only http(s) URLs may survive sanitisation.
@@ -81,6 +87,64 @@ describe('slugify', () => {
it('preserves numbers', () => { it('preserves numbers', () => {
expect(slugify('Album 2024')).toBe('album-2024'); expect(slugify('Album 2024')).toBe('album-2024');
}); });
// #522: stripping every non-ASCII character left CJK names with an empty
// slug, so their links pointed at "/" and clicking an album did nothing.
it('preserves CJK characters', () => {
expect(slugify('家族相册')).toBe('家族相册');
expect(slugify('写真')).toBe('写真');
expect(slugify('旅行 2024')).toBe('旅行-2024');
});
it('preserves Hangul in composed (NFC) form', () => {
const slug = slugify('가족 앨범');
expect(slug).toBe('가족-앨범');
expect(slug).toBe(slug.normalize('NFC'));
});
it('preserves other non-Latin scripts', () => {
expect(slugify('Москва 2024')).toBe('москва-2024');
});
it('still returns empty when nothing letter-like remains', () => {
expect(slugify('🎉🎉')).toBe('');
});
});
describe('albumSlug', () => {
it('uses the slugified name when it yields something', () => {
expect(albumSlug('Kloster Chorin', 'abc-123')).toBe('kloster-chorin');
expect(albumSlug('家族相册', 'abc-123')).toBe('家族相册');
});
// Without a fallback two emoji-only albums would both slugify to '' and
// collide on the same (unreachable) URL.
it('falls back to the album id when the name slugifies to nothing', () => {
expect(albumSlug('🎉', 'abc-123')).toBe('abc-123');
expect(albumSlug('', 'abc-123')).toBe('abc-123');
});
});
describe('normalizeSlug', () => {
// Next hands catch-all route segments over percent-encoded, so this is the
// half of #522 that fixing slugify() alone did not cure.
it('percent-decodes an encoded slug', () => {
expect(normalizeSlug('%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C')).toBe('家族相册');
});
it('leaves an already decoded slug alone', () => {
expect(normalizeSlug('家族相册')).toBe('家族相册');
expect(normalizeSlug('kloster-chorin')).toBe('kloster-chorin');
});
it('composes decomposed (NFD) input', () => {
expect(normalizeSlug('가족-앨범'.normalize('NFD'))).toBe('가족-앨범');
});
it('survives a malformed percent sequence instead of throwing', () => {
expect(() => normalizeSlug('%E5%AE')).not.toThrow();
expect(normalizeSlug('100%-real')).toBe('100%-real');
});
}); });
describe('buildSubpageGrid', () => { describe('buildSubpageGrid', () => {

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery } from '@/lib/config';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
const B = '22222222-2222-2222-2222-222222222222';
describe('deriveGallery — location precision (#469)', () => {
it('leaves an album without a setting absent, meaning exact', () => {
const gallery: GalleryYaml = { albums: [A] };
expect(deriveGallery(gallery).albumLocationPrecision[A]).toBeUndefined();
});
it('reads a per-album setting', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'city' } }] };
expect(deriveGallery(gallery).albumLocationPrecision[A]).toBe('city');
});
it('applies a subpage setting to every album on it', () => {
const gallery: GalleryYaml = {
subpages: [{ name: 'Gardens', location: 'country', albums: [A, B] }],
};
const derived = deriveGallery(gallery);
expect(derived.albumLocationPrecision[A]).toBe('country');
expect(derived.albumLocationPrecision[B]).toBe('country');
});
it("lets an album state its own, overriding the subpage's", () => {
const gallery: GalleryYaml = {
subpages: [
{ name: 'Gardens', location: 'city', albums: [A, { [B]: { location: 'hidden' } }] },
],
};
const derived = deriveGallery(gallery);
expect(derived.albumLocationPrecision[A]).toBe('city');
expect(derived.albumLocationPrecision[B]).toBe('hidden');
});
/**
* A typo in a privacy setting must not fall back to `exact` — that would
* publish the position it was meant to withhold.
*/
it('throws on an unknown album value rather than defaulting', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'приблизительно' } }] };
expect(() => deriveGallery(gallery)).toThrow(/unknown location/i);
});
it('throws on an unknown subpage value', () => {
const gallery: GalleryYaml = {
subpages: [{ name: 'Gardens', location: 'vague', albums: [A] }],
};
expect(() => deriveGallery(gallery)).toThrow(/unknown location/i);
});
it('names the valid values in the error, so the log line is enough to fix it', () => {
const gallery: GalleryYaml = { albums: [{ [A]: { location: 'nope' } }] };
expect(() => deriveGallery(gallery)).toThrow(/exact.*city.*country.*hidden/);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { safeDownloadName, contentDisposition } from '@/lib/downloadName';
const LF = String.fromCharCode(10);
const CRLF = String.fromCharCode(13, 10);
const NUL = String.fromCharCode(0);
const QUOTE = String.fromCharCode(34);
/** True if the string holds any control character. */
const hasControlChar = (value: string) =>
Array.from(value).some((character) => {
const code = character.charCodeAt(0);
return code < 32 || code === 127;
});
describe('safeDownloadName', () => {
it('passes an ordinary camera filename through', () => {
expect(safeDownloadName('DSCF1234.JPG')).toBe('DSCF1234.JPG');
});
it('keeps non-ASCII letters, which are a legitimate part of a name', () => {
expect(safeDownloadName('Fr\u00fchling am Meer.jpg')).toBe('Fr\u00fchling am Meer.jpg');
});
/** The header injection this exists to prevent. */
it.each([
['a newline', `a${LF}X-Evil: 1.jpg`],
['a CRLF', `a${CRLF}X-Evil: 1.jpg`],
['a null byte', `a${NUL}b.jpg`],
])('strips %s', (_label, raw) => {
expect(hasControlChar(safeDownloadName(raw))).toBe(false);
});
it('neutralises path separators and traversal', () => {
expect(safeDownloadName('../../etc/passwd')).not.toContain('/');
expect(safeDownloadName('..\\..\\secret.jpg')).not.toContain('\\');
expect(safeDownloadName('../evil.jpg').startsWith('.')).toBe(false);
});
it('removes the quotes and semicolons that delimit header parameters', () => {
expect(safeDownloadName(`a${QUOTE};b.jpg`)).toBe('ab.jpg');
});
it.each([
['undefined', undefined],
['empty', ''],
['whitespace', ' '],
['only dots', '...'],
])('falls back to a generic name for %s', (_label, raw) => {
expect(safeDownloadName(raw)).toBe('photo');
});
it('never returns an empty name', () => {
for (const raw of ['///', QUOTE.repeat(3), '..', ' . ', NUL]) {
expect(safeDownloadName(raw).length).toBeGreaterThan(0);
}
});
it('bounds the length', () => {
expect(safeDownloadName('x'.repeat(5000)).length).toBeLessThanOrEqual(200);
});
});
describe('contentDisposition', () => {
it('marks the response as an attachment', () => {
expect(contentDisposition('DSCF1234.JPG')).toMatch(/^attachment; /);
});
it('carries both a plain and a percent-encoded filename', () => {
const header = contentDisposition('Fr\u00fchling.jpg');
expect(header).toContain('filename="Fr_hling.jpg"');
expect(header).toContain("filename*=UTF-8''Fr%C3%BChling.jpg");
});
it('never emits a control character, whatever it is given', () => {
expect(hasControlChar(contentDisposition(`a${CRLF}X-Evil: 1.jpg`))).toBe(false);
});
it('never emits an unbalanced quote', () => {
const header = contentDisposition(`a${QUOTE}b.jpg`);
expect((header.match(/"/g) ?? []).length % 2).toBe(0);
});
it('survives a missing name', () => {
expect(contentDisposition(undefined)).toContain('filename="photo"');
});
});

View File

@@ -82,9 +82,16 @@ describe('dictionary parity', () => {
}); });
it('leaves no English string untranslated', () => { it('leaves no English string untranslated', () => {
// Proper nouns and units read the same in both languages; `coverAria` is // Proper nouns, loanwords and units read the same in both languages;
// pure interpolation with no words of its own. // `coverAria` is pure interpolation with no words of its own.
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']); const shared = new Set([
'nav.journal',
'journal.title',
'lightbox.iso',
'lightbox.copyLinkShort',
'lightbox.downloadShort',
'subpage.coverAria',
]);
const identical = enLeaves const identical = enLeaves
.filter(([path]) => !shared.has(path)) .filter(([path]) => !shared.has(path))

View File

@@ -381,6 +381,63 @@ describe('ImmichClient', () => {
}); });
}); });
// #522: a CJK album name slugified to '', so its link was "/" and the album
// was unreachable — no URL segment can ever be the empty string.
describe('getAlbumBySlug() with non-Latin names', () => {
function mockList(albumName: string) {
const json = (body: unknown) => ({
ok: true,
headers: { get: () => 'application/json' },
json: async () => body,
});
mockFetch.mockImplementation(async (url: string) => {
if (url.includes('/search/metadata')) {
return json({ assets: { items: [], nextPage: null, total: 0 } });
}
if (/\/albums\/album-2/.test(url)) {
return json({ id: 'album-2', albumName, assetCount: 0, assets: [], order: 'desc' });
}
return json([{ id: 'album-2', albumName, assetCount: 0 }]);
});
}
it('gives a CJK album a non-empty slug and resolves it', async () => {
mockList('家族相册');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('家族相册');
const album = await immich.getAlbumBySlug('家族相册');
expect(album?.id).toBe('album-2');
});
it('resolves a Hangul slug that arrives decomposed (NFD) from the URL', async () => {
mockList('가족 앨범');
await immich.getAlbums();
const album = await immich.getAlbumBySlug('가족-앨범'.normalize('NFD'));
expect(album?.id).toBe('album-2');
});
it('resolves a slug that arrives percent-encoded from the route', async () => {
mockList('家族相册');
await immich.getAlbums();
const encoded = encodeURIComponent('家族相册');
expect(encoded).not.toBe('家族相册');
const album = await immich.getAlbumBySlug(encoded);
expect(album?.id).toBe('album-2');
});
it('falls back to the album id when the name has no letters at all', async () => {
mockList('🎉');
const albums = await immich.getAlbums();
expect(albums[0].slug).toBe('album-2');
const album = await immich.getAlbumBySlug('album-2');
expect(album?.id).toBe('album-2');
});
});
describe('getStandaloneAlbums()', () => { describe('getStandaloneAlbums()', () => {
it('orders albums by gallery.yaml order, not Immich API order', async () => { it('orders albums by gallery.yaml order, not Immich API order', async () => {
// The Immich API returns albums in its own (creation/update) order; // The Immich API returns albums in its own (creation/update) order;

View File

@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
LIGHTBOX_SHORTCUTS,
lightboxActionFor,
shortcutDisplayKeys,
shortcutKeyLabel,
} from '@/lib/lightboxShortcuts';
import { en } from '@/lib/i18n/locales/en';
import { de } from '@/lib/i18n/locales/de';
describe('LIGHTBOX_SHORTCUTS', () => {
it('is not empty', () => {
expect(LIGHTBOX_SHORTCUTS.length).toBeGreaterThan(0);
});
/**
* The whole point of the shared catalogue: the `?` overlay and the admin
* help render this list, so a label that does not resolve would leave one of
* them blank.
*/
it.each(['en', 'de'])('every label resolves in %s', (locale) => {
const dictionary = locale === 'en' ? en.lightbox : de.lightbox;
for (const shortcut of LIGHTBOX_SHORTCUTS) {
const label = dictionary[shortcut.labelKey];
expect(typeof label, shortcut.labelKey).toBe('string');
expect((label as string).length).toBeGreaterThan(0);
}
});
it('gives every shortcut at least one key', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
expect(shortcut.bindings.length, shortcut.labelKey).toBeGreaterThan(0);
for (const binding of shortcut.bindings) {
expect(binding.eventKeys.length, binding.display).toBeGreaterThan(0);
}
}
});
it('binds no key twice, which would make the list contradict itself', () => {
const keys = LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.map((binding) => binding.display),
);
expect(new Set(keys).size).toBe(keys.length);
});
/**
* Two rows claiming the same physical key would make the viewer's behaviour
* depend on the order of this list, and the help would describe one of them
* wrongly.
*/
it('binds no KeyboardEvent.key to two actions', () => {
const eventKeys = LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.flatMap((binding) => [...binding.eventKeys]),
);
expect(new Set(eventKeys).size).toBe(eventKeys.length);
});
it('lists each label once', () => {
const labels = LIGHTBOX_SHORTCUTS.map((shortcut) => shortcut.labelKey);
expect(new Set(labels).size).toBe(labels.length);
});
it('documents the conditional shortcuts, since the reader cannot see them fail', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
if (shortcut.availability !== 'always') {
expect(shortcut.note, shortcut.labelKey).toBeTruthy();
}
}
});
});
/**
* The catalogue is the only route from a keypress to an action (#473), so
* these cover the join the viewer relies on.
*/
describe('lightboxActionFor', () => {
it('resolves every declared key to its action', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
for (const binding of shortcut.bindings) {
for (const key of binding.eventKeys) {
expect(lightboxActionFor(key), key).toBe(binding.action);
}
}
}
});
it('resolves the keys a visitor actually presses', () => {
expect(lightboxActionFor('ArrowLeft')).toBe('prev');
expect(lightboxActionFor('ArrowRight')).toBe('next');
expect(lightboxActionFor('Escape')).toBe('close');
expect(lightboxActionFor('?')).toBe('shortcutList');
expect(lightboxActionFor('h')).toBe('shortcutList');
expect(lightboxActionFor('H')).toBe('shortcutList');
});
it('takes both cases of every letter key, since Shift must not matter', () => {
for (const [lower, upper] of [
['i', 'I'],
['f', 'F'],
['s', 'S'],
['c', 'C'],
['d', 'D'],
]) {
expect(lightboxActionFor(lower), lower).not.toBeNull();
expect(lightboxActionFor(upper), upper).toBe(lightboxActionFor(lower));
}
});
it('returns null for a key the viewer does not bind', () => {
for (const key of ['a', 'Enter', 'Tab', ' ', 'ArrowUp', 'F5']) {
expect(lightboxActionFor(key), key).toBeNull();
}
});
});
describe('shortcutKeyLabel', () => {
it('renders the arrow names as arrows', () => {
expect(shortcutKeyLabel('ARROW_LEFT')).toBe(String.fromCharCode(0x2190));
expect(shortcutKeyLabel('ARROW_RIGHT')).toBe(String.fromCharCode(0x2192));
});
it('passes ordinary keys through', () => {
expect(shortcutKeyLabel('S')).toBe('S');
expect(shortcutKeyLabel('Esc')).toBe('Esc');
expect(shortcutKeyLabel('?')).toBe('?');
});
it('leaves no placeholder name unrendered', () => {
for (const shortcut of LIGHTBOX_SHORTCUTS) {
for (const key of shortcutDisplayKeys(shortcut)) {
expect(key).not.toMatch(/^ARROW_/);
}
}
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import {
placeLabel,
isLocationPrecision,
strictestPrecision,
quantiseCoordinate,
applyPrecision,
LOCATION_PRECISIONS,
type LocationPrecision,
} from '@/lib/mapPrecision';
describe('isLocationPrecision', () => {
it.each(LOCATION_PRECISIONS)('accepts %s', (level) => {
expect(isLocationPrecision(level)).toBe(true);
});
it.each(['', 'EXACT', 'town', 'precise', 'none'])('rejects %s', (value) => {
expect(isLocationPrecision(value)).toBe(false);
});
});
describe('strictestPrecision', () => {
it.each([
[['exact', 'city'], 'city'],
[['city', 'country'], 'country'],
[['exact', 'hidden'], 'hidden'],
[['country', 'city', 'exact'], 'country'],
[['exact'], 'exact'],
[['exact', 'exact'], 'exact'],
] as [LocationPrecision[], LocationPrecision][])('%s → %s', (levels, expected) => {
expect(strictestPrecision(levels)).toBe(expected);
});
it('is order-independent', () => {
expect(strictestPrecision(['country', 'exact'])).toBe(strictestPrecision(['exact', 'country']));
});
/** Nothing contributed, so there is nothing to place. */
it('treats an empty list as hidden', () => {
expect(strictestPrecision([])).toBe('hidden');
});
});
describe('quantiseCoordinate', () => {
it('leaves an exact coordinate untouched', () => {
expect(quantiseCoordinate(52.516272, 'exact')).toBe(52.516272);
});
it('snaps to 0.05° for city', () => {
expect(quantiseCoordinate(52.516272, 'city')).toBe(52.5);
expect(quantiseCoordinate(13.377704, 'city')).toBe(13.4);
});
it('snaps to 1° for country', () => {
expect(quantiseCoordinate(52.516272, 'country')).toBe(53);
expect(quantiseCoordinate(13.377704, 'country')).toBe(13);
});
it('leaves no floating-point tail', () => {
for (const value of [52.9123, 13.0001, -3.3333, 0.049]) {
const quantised = quantiseCoordinate(value, 'city');
expect(String(quantised)).toMatch(/^-?\d+(\.\d{1,2})?$/);
}
});
it('works south and west of zero', () => {
expect(quantiseCoordinate(-33.918861, 'city')).toBe(-33.9);
expect(quantiseCoordinate(-118.24368, 'country')).toBe(-118);
});
/**
* The property that matters: the output is a fixed grid, so asking twice
* cannot average back to the true position.
*/
it('is stable across repeated calls', () => {
const value = 47.376887;
const answers = new Set(Array.from({ length: 50 }, () => quantiseCoordinate(value, 'city')));
expect(answers.size).toBe(1);
});
it('places nearby points on the same grid node, so one album does not stand out', () => {
const garden = [52.5161, 52.5169, 52.5152].map((v) => quantiseCoordinate(v, 'city'));
expect(new Set(garden).size).toBe(1);
});
it('moves a coordinate far enough to stop naming a property', () => {
const exact = 52.516272;
expect(Math.abs(quantiseCoordinate(exact, 'city') - exact)).toBeGreaterThan(0.01);
});
});
describe('applyPrecision', () => {
const berlin = { lat: 52.516272, lng: 13.377704 };
it('quantises both axes', () => {
expect(applyPrecision(berlin, 'city')).toEqual({ lat: 52.5, lng: 13.4 });
});
it('passes an exact position through', () => {
expect(applyPrecision(berlin, 'exact')).toEqual(berlin);
});
/** Not {0,0} — the caller must drop the marker, not place it in the Atlantic. */
it('returns null for hidden', () => {
expect(applyPrecision(berlin, 'hidden')).toBeNull();
});
});
/**
* Quantising the coordinates while still naming the city left the label more
* precise than the position it was meant to blur: a 1° grid cell is ~100 km,
* and "Kloster Chorin" is a village.
*/
describe('placeLabel', () => {
const place = { city: 'Chorin', country: 'Germany' };
it('keeps the city at exact', () => {
expect(placeLabel('exact', place)).toEqual({ city: 'Chorin', country: 'Germany' });
});
it('keeps the city at city precision, which is roughly what a city name says', () => {
expect(placeLabel('city', place)).toEqual({ city: 'Chorin', country: 'Germany' });
});
it('drops the city at country precision', () => {
expect(placeLabel('country', place)).toEqual({ city: '', country: 'Germany' });
});
it('drops both when the marker is hidden, so a caller cannot leak one by accident', () => {
expect(placeLabel('hidden', place)).toEqual({ city: '', country: '' });
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { parsePhotoHash, buildPhotoHash, buildPhotoPermalink } from '@/lib/photoHash';
describe('buildPhotoHash', () => {
it('presents the index 1-indexed', () => {
expect(buildPhotoHash(0)).toBe('#photo-1');
expect(buildPhotoHash(41)).toBe('#photo-42');
});
});
describe('parsePhotoHash', () => {
it('round-trips with buildPhotoHash', () => {
for (const index of [0, 1, 7, 250]) {
expect(parsePhotoHash(buildPhotoHash(index))).toBe(index);
}
});
it.each([
['no hash', ''],
['bare hash', '#'],
['another anchor', '#gallery'],
['missing number', '#photo-'],
['not a number', '#photo-abc'],
['negative', '#photo--3'],
['trailing junk', '#photo-3x'],
['leading junk', '#x-photo-3'],
])('rejects %s', (_label, hash) => {
expect(parsePhotoHash(hash)).toBeNull();
});
it('rejects #photo-0, which has no 0-based index', () => {
expect(parsePhotoHash('#photo-0')).toBeNull();
});
});
describe('buildPhotoPermalink', () => {
const location = {
origin: 'https://folio.example',
pathname: '/travel/iceland',
search: '',
};
it('builds an absolute link to the photo', () => {
expect(buildPhotoPermalink(location, 2)).toBe('https://folio.example/travel/iceland#photo-3');
});
it('preserves an existing query string', () => {
expect(buildPhotoPermalink({ ...location, search: '?preview=true' }, 0)).toBe(
'https://folio.example/travel/iceland?preview=true#photo-1',
);
});
it('works at the site root', () => {
expect(buildPhotoPermalink({ ...location, pathname: '/' }, 0)).toBe(
'https://folio.example/#photo-1',
);
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import { publicPaths, type SiteShape } from '@/lib/publicPages';
const site = (over: Partial<SiteShape> = {}): SiteShape => ({
siteLocked: false,
subpages: [],
standaloneAlbums: [],
journal: [],
isAlbumProtected: () => false,
aboutEnabled: false,
mapEnabled: false,
journalEnabled: false,
...over,
});
const openSubpage = (slug: string, albums: { id: string; slug: string }[] = []) => ({
slug,
enabled: true,
hidden: false,
isProtected: false,
albums,
});
describe('publicPaths', () => {
it('always lists the home page', () => {
expect(publicPaths(site())).toEqual(['/']);
});
it('lists the static pages that are switched on', () => {
const paths = publicPaths(site({ aboutEnabled: true, mapEnabled: true, journalEnabled: true }));
expect(paths).toEqual(['/', '/about', '/map', '/journal']);
});
it('lists a subpage and its albums', () => {
const paths = publicPaths(
site({ subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])] }),
);
expect(paths).toEqual(['/', '/travel', '/travel/iceland']);
});
it('lists standalone albums at the root', () => {
expect(publicPaths(site({ standaloneAlbums: [{ id: 'a', slug: 'portraits' }] }))).toEqual([
'/',
'/portraits',
]);
});
// ── Exclusions ────────────────────────────────────────────────
it('yields nothing at all for a locked site', () => {
const paths = publicPaths(
site({
siteLocked: true,
aboutEnabled: true,
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),
);
expect(paths).toEqual([]);
});
it('omits a disabled subpage', () => {
const sp = { ...openSubpage('travel', [{ id: 'a', slug: 'iceland' }]), enabled: false };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a hidden subpage — a listing is the opposite of a direct link', () => {
const sp = { ...openSubpage('travel', [{ id: 'a', slug: 'iceland' }]), hidden: true };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a password-protected subpage', () => {
const sp = { ...openSubpage('clients', [{ id: 'a', slug: 'wedding' }]), isProtected: true };
expect(publicPaths(site({ subpages: [sp] }))).toEqual(['/']);
});
it('omits a protected album inside an open subpage', () => {
const paths = publicPaths(
site({
subpages: [
openSubpage('travel', [
{ id: 'open', slug: 'iceland' },
{ id: 'locked', slug: 'private' },
]),
],
isAlbumProtected: (id) => id === 'locked',
}),
);
expect(paths).toEqual(['/', '/travel', '/travel/iceland']);
});
it('omits a protected standalone album', () => {
const paths = publicPaths(
site({
standaloneAlbums: [
{ id: 'open', slug: 'portraits' },
{ id: 'locked', slug: 'clients' },
],
isAlbumProtected: (id) => id === 'locked',
}),
);
expect(paths).toEqual(['/', '/portraits']);
});
/**
* Inheritance — the case the issue calls out. The album itself has no
* password, but it can only be reached through a subpage that does.
*/
it('omits a public album that sits under a protected subpage', () => {
const sp = { ...openSubpage('clients', [{ id: 'open', slug: 'wedding' }]), isProtected: true };
const paths = publicPaths(site({ subpages: [sp] }));
expect(paths).not.toContain('/clients/wedding');
expect(paths).toEqual(['/']);
});
it('omits a public album under a hidden subpage for the same reason', () => {
const sp = { ...openSubpage('secret', [{ id: 'open', slug: 'wedding' }]), hidden: true };
expect(publicPaths(site({ subpages: [sp] }))).not.toContain('/secret/wedding');
});
it('omits journal drafts', () => {
const paths = publicPaths(
site({
journalEnabled: true,
journal: [
{ slug: 'published', draft: false },
{ slug: 'wip', draft: true },
],
}),
);
expect(paths).toEqual(['/', '/journal', '/journal/published']);
});
it('omits journal entries entirely when the journal is off', () => {
const paths = publicPaths(
site({ journalEnabled: false, journal: [{ slug: 'published', draft: false }] }),
);
expect(paths).toEqual(['/']);
});
// ── Shape ─────────────────────────────────────────────────────
it('emits no duplicates when a subpage and an album share a slug', () => {
const paths = publicPaths(
site({
subpages: [openSubpage('travel')],
standaloneAlbums: [{ id: 'a', slug: 'travel' }],
}),
);
expect(paths).toEqual([...new Set(paths)]);
});
it('emits only absolute-looking, single-slash paths', () => {
const paths = publicPaths(
site({
aboutEnabled: true,
journalEnabled: true,
journal: [{ slug: 'story', draft: false }],
subpages: [openSubpage('travel', [{ id: 'a', slug: 'iceland' }])],
standaloneAlbums: [{ id: 'b', slug: 'portraits' }],
}),
);
for (const p of paths) {
expect(p.startsWith('/')).toBe(true);
expect(p).not.toMatch(/\/\//);
}
});
});

View File

@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { normaliseSiteUrl, resolveSiteUrl, absoluteUrl } from '@/lib/siteUrl';
describe('normaliseSiteUrl', () => {
it('keeps a plain https origin', () => {
expect(normaliseSiteUrl('https://folio.example')).toBe('https://folio.example');
});
it('drops the trailing slash so callers can join with /', () => {
expect(normaliseSiteUrl('https://folio.example/')).toBe('https://folio.example');
expect(normaliseSiteUrl('https://folio.example/gallery//')).toBe(
'https://folio.example/gallery',
);
});
it('keeps a sub-path, since a portfolio may live under one', () => {
expect(normaliseSiteUrl('https://example.com/folio')).toBe('https://example.com/folio');
});
it('drops query and fragment', () => {
expect(normaliseSiteUrl('https://folio.example/?utm=1#top')).toBe('https://folio.example');
});
it('trims surrounding whitespace', () => {
expect(normaliseSiteUrl(' https://folio.example ')).toBe('https://folio.example');
});
it('allows http, for a LAN instance behind no TLS', () => {
expect(normaliseSiteUrl('http://192.168.1.10:7211')).toBe('http://192.168.1.10:7211');
});
/**
* A wrong absolute URL is worse than none: it would be emitted into the
* sitemap, the feed and the JSON-LD block.
*/
it.each([
['undefined', undefined],
['null', null],
['empty', ''],
['whitespace', ' '],
['a bare host', 'folio.example'],
['a path only', '/gallery'],
['javascript:', 'javascript:alert(1)'],
['data:', 'data:text/html,x'],
['ftp', 'ftp://folio.example'],
['nonsense', 'not a url'],
])('rejects %s', (_label, raw) => {
expect(normaliseSiteUrl(raw)).toBeNull();
});
});
describe('resolveSiteUrl', () => {
/**
* settings.yaml wins, matching how `settings.title ?? env.SITE_TITLE`
* already resolves. The admin field must never save and do nothing.
*/
it('prefers settings.yaml over the environment', () => {
expect(resolveSiteUrl('https://from-yaml.example', 'https://from-env.example')).toEqual({
url: 'https://from-yaml.example',
source: 'settings',
});
});
it('falls back to the environment when the file says nothing', () => {
expect(resolveSiteUrl(undefined, 'https://from-env.example')).toEqual({
url: 'https://from-env.example',
source: 'env',
});
});
/** An unusable value in the file must not shadow a good one in the env. */
it('ignores an invalid settings value and uses the environment', () => {
expect(resolveSiteUrl('not a url', 'https://from-env.example')).toEqual({
url: 'https://from-env.example',
source: 'env',
});
});
it('reports none when neither is set', () => {
expect(resolveSiteUrl(undefined, undefined)).toEqual({ url: null, source: 'none' });
});
});
describe('absoluteUrl', () => {
it('joins a path', () => {
expect(absoluteUrl('https://folio.example', '/travel')).toBe('https://folio.example/travel');
});
it('adds the missing leading slash', () => {
expect(absoluteUrl('https://folio.example', 'travel')).toBe('https://folio.example/travel');
});
it('never emits a double slash', () => {
expect(absoluteUrl('https://folio.example', '/travel/')).toBe('https://folio.example/travel');
});
it('keeps the root as a single slash', () => {
expect(absoluteUrl('https://folio.example', '/')).toBe('https://folio.example/');
});
it('works under a sub-path', () => {
expect(absoluteUrl('https://example.com/folio', '/travel')).toBe(
'https://example.com/folio/travel',
);
});
/** A relative sitemap entry is invalid and a relative feed link points at the reader. */
it('returns null without a site URL', () => {
expect(absoluteUrl(null, '/travel')).toBeNull();
});
});
/**
* A sitemap, a canonical link and a JSON-LD block all want a URI, not an IRI.
* Non-Latin album slugs only became possible with #522, so this surfaced with
* them.
*/
describe('absoluteUrl encoding', () => {
it('percent-encodes a non-ASCII path', () => {
expect(absoluteUrl('https://example.com', '/家族相册')).toBe(
'https://example.com/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
it('leaves an ASCII path exactly as it was', () => {
expect(absoluteUrl('https://example.com', '/kloster-chorin')).toBe(
'https://example.com/kloster-chorin',
);
});
it('keeps a query string intact', () => {
expect(absoluteUrl('https://example.com', '/api/image/abc?size=preview')).toBe(
'https://example.com/api/image/abc?size=preview',
);
});
// A portfolio may live under a sub-path, which normaliseSiteUrl keeps.
it('preserves a sub-path in the site URL', () => {
expect(absoluteUrl('https://example.com/folio', '/家族相册')).toBe(
'https://example.com/folio/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
// Encoding blindly would turn a '%' into '%25' and corrupt the URL.
it('does not encode an already-encoded path a second time', () => {
expect(absoluteUrl('https://example.com', '/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C')).toBe(
'https://example.com/%E5%AE%B6%E6%97%8F%E7%9B%B8%E5%86%8C',
);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { nextSlideshowSpeed, SLIDESHOW_SPEEDS, type SlideshowSpeed } from '@/lib/slideshow';
describe('nextSlideshowSpeed', () => {
it('cycles off → 3s → 5s → 10s → off', () => {
expect(nextSlideshowSpeed(null)).toBe(3);
expect(nextSlideshowSpeed(3)).toBe(5);
expect(nextSlideshowSpeed(5)).toBe(10);
expect(nextSlideshowSpeed(10)).toBeNull();
});
it('returns to off after one full cycle, so the key always lets go', () => {
let speed: SlideshowSpeed = null;
for (let i = 0; i < SLIDESHOW_SPEEDS.length; i++) speed = nextSlideshowSpeed(speed);
expect(speed).toBeNull();
});
it('never yields a speed outside the advertised set', () => {
let speed: SlideshowSpeed = null;
for (let i = 0; i < 20; i++) {
speed = nextSlideshowSpeed(speed);
expect(SLIDESHOW_SPEEDS).toContain(speed);
}
});
it('re-enters the cycle from an unrecognised value rather than sticking', () => {
expect(nextSlideshowSpeed(7 as SlideshowSpeed)).toBe(3);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { albumStructuredData } from '@/lib/structuredData';
const base = {
siteUrl: 'https://folio.example',
pageUrl: 'https://folio.example/travel/iceland',
albumName: 'Iceland',
};
describe('albumStructuredData', () => {
it('describes the album as an ImageGallery', () => {
const data = albumStructuredData(base)!;
expect(data['@context']).toBe('https://schema.org');
expect(data['@type']).toBe('ImageGallery');
expect(data.name).toBe('Iceland');
expect(data.url).toBe('https://folio.example/travel/iceland');
});
/**
* Structured data is a set of claims about absolute URLs. Without one there
* is nothing truthful to claim, so nothing is emitted.
*/
it.each([
['no site URL', { ...base, siteUrl: null }],
['no page URL', { ...base, pageUrl: null }],
['a blank album name', { ...base, albumName: ' ' }],
])('emits nothing for %s', (_label, input) => {
expect(albumStructuredData(input)).toBeNull();
});
it('omits optional fields rather than emitting empty ones', () => {
const data = albumStructuredData(base)!;
for (const key of ['description', 'creator', 'license', 'image', 'numberOfItems']) {
expect(data).not.toHaveProperty(key);
}
});
it('carries creator and license when configured', () => {
const data = albumStructuredData({
...base,
creator: 'Jane Doe',
license: 'https://creativecommons.org/licenses/by-nc/4.0/',
})!;
expect(data.creator).toEqual({ '@type': 'Person', name: 'Jane Doe' });
expect(data.license).toBe('https://creativecommons.org/licenses/by-nc/4.0/');
});
it('repeats creator and license on the cover image', () => {
const data = albumStructuredData({
...base,
coverUrl: 'https://folio.example/api/image/tok',
creator: 'Jane Doe',
license: 'CC BY-NC 4.0',
})!;
expect(data.image).toEqual({
'@type': 'ImageObject',
contentUrl: 'https://folio.example/api/image/tok',
creator: { '@type': 'Person', name: 'Jane Doe' },
license: 'CC BY-NC 4.0',
});
});
it('treats blank creator and license as absent', () => {
const data = albumStructuredData({ ...base, creator: ' ', license: ' ' })!;
expect(data).not.toHaveProperty('creator');
expect(data).not.toHaveProperty('license');
});
it('trims the values it does emit', () => {
const data = albumStructuredData({
...base,
albumName: ' Iceland ',
description: ' Winter light. ',
creator: ' Jane Doe ',
})!;
expect(data.name).toBe('Iceland');
expect(data.description).toBe('Winter light.');
expect(data.creator).toEqual({ '@type': 'Person', name: 'Jane Doe' });
});
it('reports the photo count when given', () => {
expect(albumStructuredData({ ...base, photoCount: 42 })!.numberOfItems).toBe(42);
expect(albumStructuredData({ ...base, photoCount: 0 })!.numberOfItems).toBe(0);
});
it('survives JSON serialisation, which is how it is emitted', () => {
const data = albumStructuredData({ ...base, creator: 'Jane Doe', photoCount: 3 })!;
expect(() => JSON.parse(JSON.stringify(data))).not.toThrow();
});
});

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from 'vitest';
vi.mock('@/lib/env', () => ({
env: {
IMMICH_API_URL: 'http://localhost:2283',
IMMICH_API_KEY: 'test-key',
SITE_TITLE: 'Test Gallery',
SITE_SUBTITLE: '',
CACHE_TTL: 300,
IMMICH_TIMEOUT_MS: 15000,
RATE_LIMIT_RPM: 120,
TRUSTED_PROXY_HOPS: 0,
},
}));
vi.mock('@/lib/config/parser', () => ({
loadYaml: vi.fn(),
clearYamlCache: vi.fn(),
validateUuid: (id: string) => id,
}));
import { deriveGallery } from '@/lib/config';
import type { GalleryYaml } from '@/lib/config/schema';
const A = '11111111-1111-1111-1111-111111111111';
function subpage(entry: Record<string, unknown>) {
const gallery = deriveGallery({
subpages: [{ name: 'Travel', albums: [A], ...entry }],
} as unknown as GalleryYaml);
return gallery.subpages[0];
}
/**
* The album covers and the photos inside those albums used to share a single
* `grid` key, so a gap typed into the admin's "Album Cover Grid" retuned every
* photo grid on the page (#523).
*/
describe('subpage grid vs. coverGrid', () => {
it('keeps the two independent once both are set', () => {
const sp = subpage({ grid: { columns: 4, gap: 6 }, coverGrid: { columns: 2, gap: 24 } });
expect(sp.grid).toEqual({ columns: 4, gap: 6 });
expect(sp.coverGrid).toEqual({ columns: 2, gap: 24 });
});
it('leaves the photo grid alone when only the covers are configured', () => {
const sp = subpage({ coverGrid: { columns: 2, gap: 24 } });
expect(sp.grid).toBeUndefined();
expect(sp.coverGrid).toEqual({ columns: 2, gap: 24 });
});
// A gallery.yaml written before the split has no `coverGrid` at all, and its
// covers were sized by `grid`. Falling back to it keeps that page rendering
// exactly as it did.
it('falls back to the photo grid when no coverGrid was written', () => {
const sp = subpage({ grid: { columns: 4, gap: 6, layout: 'uniform' } });
expect(sp.coverGrid).toEqual({ columns: 4, gap: 6, layout: 'uniform' });
});
it('reports no cover override when the page states neither', () => {
expect(subpage({}).coverGrid).toBeUndefined();
});
it('reads coverGrid from the map form of subpages too', () => {
const gallery = deriveGallery({
subpages: {
Travel: { albums: [A], grid: { gap: 6 }, coverGrid: { columns: 2 } },
},
} as unknown as GalleryYaml);
expect(gallery.subpages[0].grid).toEqual({ gap: 6 });
expect(gallery.subpages[0].coverGrid).toEqual({ columns: 2 });
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { compareVersions, isNewerVersion, parseLatestTag } from '@/lib/updateCheck';
describe('compareVersions', () => {
it.each([
['0.12.0', '0.12.0', 0],
['0.12.1', '0.12.0', 1],
['0.12.0', '0.12.1', -1],
['1.0.0', '0.99.99', 1],
['0.13.0', '0.9.0', 1],
])('%s vs %s', (a, b, expected) => {
expect(Math.sign(compareVersions(a, b))).toBe(expected);
});
/** 10 must not sort before 9, which is what a string compare would do. */
it('compares numerically, not lexically', () => {
expect(compareVersions('0.10.0', '0.9.0')).toBeGreaterThan(0);
expect(compareVersions('0.2.0', '0.10.0')).toBeLessThan(0);
});
it('tolerates the leading v the releases API uses', () => {
expect(compareVersions('v0.12.0', '0.12.0')).toBe(0);
expect(compareVersions('V0.13.0', '0.12.0')).toBeGreaterThan(0);
});
it('treats missing segments as zero', () => {
expect(compareVersions('1', '1.0.0')).toBe(0);
expect(compareVersions('1.1', '1.0.9')).toBeGreaterThan(0);
});
it('compares a pre-release on its numbers alone', () => {
expect(compareVersions('0.13.0-rc.1', '0.12.0')).toBeGreaterThan(0);
expect(compareVersions('0.12.0-rc.1', '0.12.0')).toBe(0);
});
it('does not crash on rubbish', () => {
expect(() => compareVersions('', 'x.y.z')).not.toThrow();
expect(compareVersions('x.y.z', '')).toBe(0);
});
});
describe('isNewerVersion', () => {
it('is true only for a strictly newer release', () => {
expect(isNewerVersion('0.13.0', '0.12.0')).toBe(true);
expect(isNewerVersion('0.12.0', '0.12.0')).toBe(false);
expect(isNewerVersion('0.11.0', '0.12.0')).toBe(false);
});
/** A downgraded tag must never be advertised as an update. */
it('does not nag when running ahead of the latest release', () => {
expect(isNewerVersion('v0.12.0', '0.13.0-dev')).toBe(false);
});
});
describe('parseLatestTag', () => {
it('reads tag_name', () => {
expect(parseLatestTag({ tag_name: 'v0.13.0' })).toBe('v0.13.0');
});
it('trims surrounding whitespace', () => {
expect(parseLatestTag({ tag_name: ' v0.13.0 ' })).toBe('v0.13.0');
});
it.each([
['null', null],
['a string', 'v0.13.0'],
['an array', []],
['an error object served with 200', { message: 'API rate limit exceeded' }],
['a non-string tag', { tag_name: 42 }],
['a tag that is not a version', { tag_name: 'nightly' }],
// The check only guarded the *start* of the tag, so anything beginning
// with a digit slipped through and was then compared as a version. A
// date-style tag reads as version 2024 and leaves every instance showing
// an update that does not exist, permanently.
['a date-style tag', { tag_name: '2024-01-05' }],
['a tag with a digit and then rubbish', { tag_name: '1abc' }],
])('returns null for %s', (_label, payload) => {
expect(parseLatestTag(payload)).toBeNull();
});
it('still accepts the shapes the project actually tags', () => {
expect(parseLatestTag({ tag_name: 'v0.13.0' })).toBe('v0.13.0');
expect(parseLatestTag({ tag_name: '0.13.0' })).toBe('0.13.0');
expect(parseLatestTag({ tag_name: 'v1.0' })).toBe('v1.0');
expect(parseLatestTag({ tag_name: 'v1.2.3-rc1' })).toBe('v1.2.3-rc1');
// Build metadata is ignorable for ordering, so rejecting it would mean
// never mentioning a release that is genuinely newer.
expect(parseLatestTag({ tag_name: 'v1.2.3+build.7' })).toBe('v1.2.3+build.7');
});
});

View File

@@ -17,6 +17,7 @@ import {
assetPlaceholder, assetPlaceholder,
assetExifSummary, assetExifSummary,
assetAspectRatio, assetAspectRatio,
assetCaption,
} from '@/lib/urls'; } from '@/lib/urls';
describe('imageUrl', () => { describe('imageUrl', () => {
@@ -168,3 +169,58 @@ describe('assetAspectRatio', () => {
).toBeUndefined(); ).toBeUndefined();
}); });
}); });
describe('assetCaption', () => {
/** An asset carrying nothing but the description under test. */
const withDescription = (description: string | null) => ({
exifInfo: {
make: null,
model: null,
lensModel: null,
focalLength: null,
fNumber: null,
exposureTime: null,
iso: null,
exifImageWidth: null,
exifImageHeight: null,
latitude: null,
longitude: null,
city: null,
state: null,
country: null,
dateTimeOriginal: null,
description,
},
});
it('returns the description when captions are shown', () => {
expect(assetCaption(withDescription('A gull over the harbour'), true)).toBe(
'A gull over the harbour',
);
});
it('trims surrounding whitespace', () => {
expect(assetCaption(withDescription(' Low tide\n'), true)).toBe('Low tide');
});
it.each([
['null', null],
['empty', ''],
['whitespace only', ' \n\t '],
])('returns undefined for a %s description, leaving alt=""', (_label, value) => {
expect(assetCaption(withDescription(value), true)).toBeUndefined();
});
it('returns undefined when the asset has no exifInfo at all', () => {
expect(assetCaption({ exifInfo: undefined }, true)).toBeUndefined();
});
/**
* The point of #506: the description is the one field that can hold private
* notes. Alt text publishes it as surely as a visible caption does, so the
* caption switch has to gate it too.
*/
it('withholds the description when captions are switched off', () => {
expect(assetCaption(withDescription('Client hates this crop'), false)).toBeUndefined();
});
});

65
lib/albumNav.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Sequential album navigation — the "previous / next album" pair at the foot of
* an album detail page, so a visitor drifting through the portfolio is not
* dropped at a dead end (#483).
*
* The order deliberately comes from whatever list the visitor just came
* through — the surrounding subpage, or the standalone album list — rather than
* from a separate ordering of its own. A "next" that disagreed with the grid
* the visitor was looking at a moment ago would be worse than no control.
*
* Neighbours are not wrapped around: the first album has no previous and the
* last has no next. Wrapping would guarantee a link in every position, but it
* also sends someone who reaches the end of a sequence back to its start with
* no sign that they have been round.
*/
/** The fields of an album this module needs. Structural, so `ImmichAlbum` fits. */
export interface AlbumNavCandidate {
id: string;
slug: string;
albumName: string;
}
export interface AlbumNavLink {
href: string;
name: string;
}
export interface AlbumNavPair {
prev?: AlbumNavLink;
next?: AlbumNavLink;
}
/**
* The neighbours of `currentId` within `siblings`, in that list's own order.
*
* `basePath` is the prefix the album slugs hang off: `''` for standalone albums
* (`/album-slug`) or `/subpage-slug` for albums inside a subpage.
*
* Returns an empty pair when the album is not in the list, or when it is the
* only one — both cases mean there is nothing to offer, and the caller renders
* nothing.
*/
export function albumNeighbours(
siblings: readonly AlbumNavCandidate[],
currentId: string,
basePath = '',
): AlbumNavPair {
const index = siblings.findIndex((a) => a.id === currentId);
if (index === -1 || siblings.length < 2) return {};
const href = (album: AlbumNavCandidate) => `${basePath}/${album.slug}`;
const link = (album: AlbumNavCandidate): AlbumNavLink => ({
href: href(album),
name: album.albumName,
});
const previous = index > 0 ? siblings[index - 1] : undefined;
const following = index < siblings.length - 1 ? siblings[index + 1] : undefined;
return {
...(previous ? { prev: link(previous) } : {}),
...(following ? { next: link(following) } : {}),
};
}

58
lib/assetLocation.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* The location precision that applies to a single photograph (#469).
*
* `location:` was written for the map, and the map was the only surface that
* honoured it. The lightbox info panel names the city and country of every
* photo from its own EXIF, gated on the global `exifDisplay` group — so an
* album set to `location: hidden`, which asks to be absent from the map
* entirely, still told anyone who clicked "Info" exactly where it was taken.
* Two settings with almost the same name disagreeing about the same fact.
*
* An asset has no album in its own right: the info request carries only an
* asset token. So the question is turned around — of the albums the operator
* deliberately restricted, which ones carry this asset?
*
* That keeps the common case free. Nobody using `location:` means nothing to
* check and no album fetched; the cost is bounded by how many albums were
* restricted, not by how many exist.
*/
import { getConfig } from './config';
import { immich } from './immich';
import { strictestPrecision, type LocationPrecision } from './mapPrecision';
/**
* The strictest `location:` among the albums carrying this asset, or `exact`
* when none of them restricts anything.
*
* An album that cannot be read counts as `hidden`. Membership is then unknown,
* and guessing that the asset is not in it would publish the position the
* setting exists to withhold — so this fails closed, at the cost of the info
* panel dropping its location line site-wide until the album is readable
* again. A configured album that Immich no longer has is already a warning in
* the config doctor.
*/
export async function assetLocationPrecision(assetId: string): Promise<LocationPrecision> {
const restricted = Object.entries(getConfig().albumLocationPrecision).filter(
([, level]) => level !== 'exact',
);
if (restricted.length === 0) return 'exact';
const levels = await Promise.all(
restricted.map(async ([albumId, level]): Promise<LocationPrecision | null> => {
try {
const album = await immich.getAlbum(albumId);
if (!album) return 'hidden';
return album.assets.some((asset) => asset.id === assetId) ? level : null;
} catch {
return 'hidden';
}
}),
);
const applicable = levels.filter((level): level is LocationPrecision => level !== null);
// strictestPrecision() reads an empty list as `hidden` — correct for a map
// marker nothing contributed to, wrong here, where it means no restricted
// album carries this photograph.
return applicable.length === 0 ? 'exact' : strictestPrecision(applicable);
}

View File

@@ -8,6 +8,9 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { getConfig, SubpageConfig } from './config'; import { getConfig, SubpageConfig } from './config';
// From the pure schema module, not the config barrel: tests that stub out
// '@/lib/config' wholesale would otherwise lose this helper.
import { normalizeSlug } from './config/schema';
import { verifyScrypt, generateScryptHash, isScryptHash } from './password'; import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
const TOKEN_EXPIRY_HOURS = 24; const TOKEN_EXPIRY_HOURS = 24;
@@ -92,11 +95,43 @@ function cookieName(key: string, type: ProtectedType): string {
*/ */
export const SITE_AUTH_COOKIE = 'lb_site_auth'; export const SITE_AUTH_COOKIE = 'lb_site_auth';
/**
* Whether this visitor may have the album at all — every gate on every route
* to it, not just the album's own (#475).
*
* A page checks the subpage's password before it renders anything; a route
* handler is reached without ever passing through that page, so it has to ask
* the same question itself. Checking only the album's own password let an
* album that carries none through, even when the sole way to it was a subpage
* that does.
*
* An album listed on the home page is published there whatever else lists it,
* so it stays reachable. Where the only routes are subpages, one of them has
* to be open — which mirrors what the visitor could actually have clicked.
*/
export function isAlbumReachable(
albumId: string,
getCookie: (name: string) => string | undefined,
): boolean {
if (!isAuthenticated(albumId, getCookie, 'album')) return false;
const config = getConfig();
if (config.standaloneAlbums?.includes(albumId)) return true;
const routes = config.subpages.filter(
(sp) => sp.enabled !== false && sp.albumIds.includes(albumId),
);
if (routes.length === 0) return true;
return routes.some((sp) => isAuthenticated(sp.slug, getCookie, 'subpage'));
}
/** /**
* Find the SubpageConfig for a given slug. * Find the SubpageConfig for a given slug.
*/ */
export function findSubpageBySlug(slug: string): SubpageConfig | undefined { export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
return getConfig().subpages.find((sp) => sp.slug === slug); const wanted = normalizeSlug(slug);
return getConfig().subpages.find((sp) => sp.slug === wanted);
} }
/** /**
@@ -108,7 +143,8 @@ function findPassword(key: string, type: ProtectedType): string | undefined {
return config.sitePassword || undefined; return config.sitePassword || undefined;
} }
if (type === 'subpage') { if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.password; const wanted = normalizeSlug(key);
return config.subpages.find((sp) => sp.slug === wanted)?.password;
} }
if (type === 'album') { if (type === 'album') {
return config.albumPasswords[key]; return config.albumPasswords[key];

View File

@@ -2,10 +2,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { env } from '../env'; import { env } from '../env';
import { resolveAuthSecret } from '../secret'; import { resolveAuthSecret } from '../secret';
import { resolveSiteUrl } from '../siteUrl';
import { getInstallCredentials } from '../install'; import { getInstallCredentials } from '../install';
import { loadYaml, clearYamlCache, validateUuid } from './parser'; import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme'; import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort'; import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import { LOCATION_PRECISIONS, isLocationPrecision, type LocationPrecision } from '../mapPrecision';
import { import {
slugify, slugify,
resolveExifDisplay, resolveExifDisplay,
@@ -56,27 +58,54 @@ export function getConfigOrNull(): AppConfig | null {
} }
} }
/** Converts raw YAML grid overrides into a typed partial GridConfig. */ interface RawGridOverrides {
export function buildSubpageGrid(raw?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
aspectRatio?: string; aspectRatio?: string;
layout?: string; layout?: string;
}): { grid: Partial<GridConfig> } | Record<string, never> { }
if (!raw) return {};
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
function parseGridOverrides(raw: RawGridOverrides): Partial<GridConfig> {
return { return {
grid: { ...(raw.columns != null ? { columns: raw.columns } : {}),
...(raw.columns != null ? { columns: raw.columns } : {}), ...(raw.gap != null ? { gap: raw.gap } : {}),
...(raw.gap != null ? { gap: raw.gap } : {}), ...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}), ...(raw.layout != null
...(raw.layout != null ? {
? { layout: (VALID_LAYOUTS.includes(raw.layout)
layout: (VALID_LAYOUTS.includes(raw.layout) ? raw.layout
? raw.layout : 'masonry') as GridConfig['layout'],
: 'masonry') as GridConfig['layout'], }
} : {}),
: {}), };
}, }
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
export function buildSubpageGrid(
raw?: RawGridOverrides,
): { grid: Partial<GridConfig> } | Record<string, never> {
if (!raw) return {};
return { grid: parseGridOverrides(raw) };
}
/**
* The two grids a subpage can override, as one spread-able object.
*
* `grid` sizes the photos inside its albums, `coverGrid` the album-cover tiles
* on the page itself. They were a single key until #523, where the admin field
* labelled "Album Cover Grid" turned out to retune every photo grid on the page
* as well. A gallery.yaml written before the split has no `coverGrid`, so the
* covers fall back to `grid` and such a page renders exactly as it did.
*/
function buildSubpageGrids(
grid?: RawGridOverrides,
coverGrid?: RawGridOverrides,
): { grid?: Partial<GridConfig>; coverGrid?: Partial<GridConfig> } {
const resolved = coverGrid ?? grid;
return {
...buildSubpageGrid(grid),
...(resolved ? { coverGrid: parseGridOverrides(resolved) } : {}),
}; };
} }
@@ -139,6 +168,8 @@ export interface GalleryDerivation {
albumManualOrders: Record<string, string[]>; albumManualOrders: Record<string, string[]>;
albumGrids: Record<string, Partial<GridConfig>>; albumGrids: Record<string, Partial<GridConfig>>;
albumCoverPositions: Record<string, string>; albumCoverPositions: Record<string, string>;
albumDownloads: Record<string, boolean>;
albumLocationPrecision: Record<string, LocationPrecision>;
} }
/** /**
@@ -162,18 +193,24 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumManualOrders: Record<string, string[]> = {}; const albumManualOrders: Record<string, string[]> = {};
const albumGrids: Record<string, Partial<GridConfig>> = {}; const albumGrids: Record<string, Partial<GridConfig>> = {};
const albumCoverPositions: Record<string, string> = {}; const albumCoverPositions: Record<string, string> = {};
const albumDownloads: Record<string, boolean> = {};
const albumLocationPrecision: Record<string, LocationPrecision> = {};
/** Returns null for an entry whose album ID is not a UUID; callers drop it. */ /** Returns null for an entry whose album ID is not a UUID; callers drop it. */
function processAlbumEntry( function processAlbumEntry(
entry: string | Record<string, string | AlbumEntryObject>, entry: string | Record<string, string | AlbumEntryObject>,
context: string, context: string,
inheritedLocation?: LocationPrecision,
): string | null { ): string | null {
if (typeof entry === 'string') { if (typeof entry === 'string') {
if (inheritedLocation) albumLocationPrecision[entry] = inheritedLocation;
return validateUuid(entry, context); return validateUuid(entry, context);
} }
const [uuid, value] = Object.entries(entry)[0]; const [uuid, value] = Object.entries(entry)[0];
const validatedUuid = validateUuid(uuid, context); const validatedUuid = validateUuid(uuid, context);
if (!validatedUuid) return null; if (!validatedUuid) return null;
// The subpage's setting applies unless the album states its own below.
if (inheritedLocation) albumLocationPrecision[validatedUuid] = inheritedLocation;
if (typeof value === 'string') { if (typeof value === 'string') {
albumOverrides[validatedUuid] = value; albumOverrides[validatedUuid] = value;
@@ -246,6 +283,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
} }
albumCoverPositions[validatedUuid] = pos; albumCoverPositions[validatedUuid] = pos;
} }
// Opt-in per album, never global: a public portfolio must not start
// handing out full-resolution originals because one client gallery
// needed to (#475).
if (value.download === true) albumDownloads[validatedUuid] = true;
const location = parseLocation(value.location, `${context}: album ${validatedUuid} location`);
if (location) albumLocationPrecision[validatedUuid] = location;
} }
return validatedUuid; return validatedUuid;
} }
@@ -254,12 +298,29 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
function processAlbumEntries( function processAlbumEntries(
entries: Array<string | Record<string, string | AlbumEntryObject>>, entries: Array<string | Record<string, string | AlbumEntryObject>>,
context: string, context: string,
inheritedLocation?: LocationPrecision,
): string[] { ): string[] {
return entries return entries
.map((entry) => processAlbumEntry(entry, context)) .map((entry) => processAlbumEntry(entry, context, inheritedLocation))
.filter((id): id is string => id !== null); .filter((id): id is string => id !== null);
} }
/**
* Narrow a raw `location:` value, or throw. Rejected rather than defaulted
* for the same reason as `sort`: a typo in a privacy setting that silently
* fell back to `exact` would publish the position it was meant to withhold.
*/
function parseLocation(raw: string | undefined, context: string): LocationPrecision | undefined {
if (raw == null) return undefined;
if (!isLocationPrecision(raw)) {
throw new Error(
`${context}: unknown location "${raw}". ` +
`Valid values are: ${LOCATION_PRECISIONS.join(', ')}.`,
);
}
return raw;
}
const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums'); const standaloneAlbumIds = processAlbumEntries(gallery.albums ?? [], 'gallery.yaml albums');
let subpages: SubpageConfig[] = []; let subpages: SubpageConfig[] = [];
@@ -279,19 +340,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
title: sec.title, title: sec.title,
slug: slugify(sec.title), slug: slugify(sec.title),
description: sec.description, description: sec.description,
albumIds: processAlbumEntries(sec.albums, `subpage "${sp.name}" section "${sec.title}"`), albumIds: processAlbumEntries(
sec.albums,
`subpage "${sp.name}" section "${sec.title}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
),
})); }));
// flat union of all section albums // flat union of all section albums
albumIds = sections!.flatMap((sec) => sec.albumIds); albumIds = sections!.flatMap((sec) => sec.albumIds);
// Also include any top-level albums (outside sections) // Also include any top-level albums (outside sections)
const topLevel = processAlbumEntries(sp.albums ?? [], `subpage "${sp.name}"`); const topLevel = processAlbumEntries(
sp.albums ?? [],
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
);
albumIds = [...topLevel, ...albumIds]; albumIds = [...topLevel, ...albumIds];
} else { } else {
const albums = sp.albums ?? []; const albums = sp.albums ?? [];
if (albums.length === 0) { if (albums.length === 0) {
throw new Error(`Subpage "${sp.name}" must have albums or sections`); throw new Error(`Subpage "${sp.name}" must have albums or sections`);
} }
albumIds = processAlbumEntries(albums, `subpage "${sp.name}"`); albumIds = processAlbumEntries(
albums,
`subpage "${sp.name}"`,
parseLocation(sp.location, `subpage "${sp.name}" location`),
);
} }
return { return {
@@ -307,7 +380,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
essayText: sp.essayText, essayText: sp.essayText,
enabled: sp.enabled !== false, enabled: sp.enabled !== false,
hidden: sp.hidden === true, hidden: sp.hidden === true,
...buildSubpageGrid(sp.grid), ...buildSubpageGrids(sp.grid, sp.coverGrid),
}; };
}); });
} else if (gallery.subpages) { } else if (gallery.subpages) {
@@ -327,14 +400,18 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
slug: slugify(name), slug: slugify(name),
title: sp.title, title: sp.title,
subtitle: sp.subtitle, subtitle: sp.subtitle,
albumIds: processAlbumEntries(albumEntries, `subpage "${name}"`), albumIds: processAlbumEntries(
albumEntries,
`subpage "${name}"`,
parseLocation(sp.location, `subpage "${name}" location`),
),
password: sp.password, password: sp.password,
proofing: sp.proofing, proofing: sp.proofing,
essayFile: sp.essayFile, essayFile: sp.essayFile,
essayText: sp.essayText, essayText: sp.essayText,
enabled: sp.enabled !== false, enabled: sp.enabled !== false,
hidden: sp.hidden === true, hidden: sp.hidden === true,
...buildSubpageGrid(sp.grid), ...buildSubpageGrids(sp.grid, sp.coverGrid),
}; };
}); });
} }
@@ -354,6 +431,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumManualOrders, albumManualOrders,
albumGrids, albumGrids,
albumCoverPositions, albumCoverPositions,
albumDownloads,
albumLocationPrecision,
}; };
} }
@@ -408,10 +487,13 @@ export function getConfig(): AppConfig {
title: 'Setup Required', title: 'Setup Required',
description: 'Please configure Immich Folio', description: 'Please configure Immich Folio',
titleTemplate: '%s | Setup Required', titleTemplate: '%s | Setup Required',
license: '',
noIndex: true, noIndex: true,
noFollow: true, noFollow: true,
}, },
heroImages: [], heroImages: [],
siteUrl: null,
siteUrlSource: 'none',
colorMode: 'dark', colorMode: 'dark',
exifOnHover: true, exifOnHover: true,
exif: resolveExifDisplay(), exif: resolveExifDisplay(),
@@ -434,6 +516,8 @@ export function getConfig(): AppConfig {
albumManualOrders: {}, albumManualOrders: {},
albumGrids: {}, albumGrids: {},
albumCoverPositions: {}, albumCoverPositions: {},
albumDownloads: {},
albumLocationPrecision: {},
navLinks: [], navLinks: [],
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000,
@@ -460,6 +544,8 @@ export function getConfig(): AppConfig {
albumManualOrders, albumManualOrders,
albumGrids, albumGrids,
albumCoverPositions, albumCoverPositions,
albumDownloads,
albumLocationPrecision,
} = deriveGallery(gallery); } = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery'; const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
@@ -486,12 +572,17 @@ export function getConfig(): AppConfig {
titleTemplate: settings.seo?.titleTemplate || `%s | ${siteSeoTitle}`, titleTemplate: settings.seo?.titleTemplate || `%s | ${siteSeoTitle}`,
noIndex: settings.seo?.noIndex === true, noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true, noFollow: settings.seo?.noFollow === true,
license: settings.seo?.license?.trim() || '',
}, },
heroImages: gallery.hero heroImages: gallery.hero
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero]) ? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
.map((id) => validateUuid(id, 'gallery.yaml hero')) .map((id) => validateUuid(id, 'gallery.yaml hero'))
.filter((id): id is string => id !== null) .filter((id): id is string => id !== null)
: [], : [],
...(() => {
const site = resolveSiteUrl(settings.url, env.SITE_URL);
return { siteUrl: site.url, siteUrlSource: site.source };
})(),
colorMode: resolveColorMode(settings.mode), colorMode: resolveColorMode(settings.mode),
exifOnHover: settings.exifOnHover !== false, exifOnHover: settings.exifOnHover !== false,
exif: resolveExifDisplay(settings.exif, settings.exifOnHover), exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
@@ -547,6 +638,8 @@ export function getConfig(): AppConfig {
albumManualOrders, albumManualOrders,
albumGrids, albumGrids,
albumCoverPositions, albumCoverPositions,
albumDownloads,
albumLocationPrecision,
navLinks: sanitizeNavLinks(settings.navLinks), navLinks: sanitizeNavLinks(settings.navLinks),
cacheTtl: env.CACHE_TTL * 1000, cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000, staleMaxAge: env.STALE_MAX_AGE * 1000,

View File

@@ -1,4 +1,5 @@
import type { AlbumSortMode } from '../albumSort'; import type { AlbumSortMode } from '../albumSort';
import type { LocationPrecision } from '../mapPrecision';
export interface SubpageSectionConfig { export interface SubpageSectionConfig {
title: string; title: string;
@@ -15,7 +16,13 @@ export interface SubpageConfig {
albumIds: string[]; // flat list (all albums, incl. from sections) albumIds: string[]; // flat list (all albums, incl. from sections)
sections?: SubpageSectionConfig[]; sections?: SubpageSectionConfig[];
password?: string; password?: string;
/** Photo grid for the albums on this page: global < subpage < album. */
grid?: Partial<GridConfig>; grid?: Partial<GridConfig>;
/**
* The album-cover tiles on this page. Falls back to `grid` when unset, which
* is what a pre-#523 gallery.yaml renders with (#523).
*/
coverGrid?: Partial<GridConfig>;
proofing?: boolean; proofing?: boolean;
essayFile?: string; essayFile?: string;
essayText?: string; essayText?: string;
@@ -29,12 +36,16 @@ export interface SubpageObjectValue {
subtitle?: string; subtitle?: string;
password?: string; password?: string;
grid?: Partial<GridConfig>; grid?: Partial<GridConfig>;
/** Album-cover tiles only; falls back to `grid` when unset (#523). */
coverGrid?: Partial<GridConfig>;
proofing?: boolean; proofing?: boolean;
essayFile?: string; essayFile?: string;
essayText?: string; essayText?: string;
enabled?: boolean; enabled?: boolean;
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */ /** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
hidden?: boolean; hidden?: boolean;
/** Map precision for every album here that does not set its own (#469). */
location?: string;
// Both album lists reuse AlbumEntryObject rather than inlining the shape: // Both album lists reuse AlbumEntryObject rather than inlining the shape:
// the inline copies had already drifted (they never gained `heroImage`), and // the inline copies had already drifted (they never gained `heroImage`), and
// this path is live for map-style subpages. // this path is live for map-style subpages.
@@ -91,6 +102,14 @@ export interface GridConfig {
'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified'; 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay' | 'justified';
} }
/**
* Bounds for the photo grid, shared by the site-wide and per-subpage inputs so
* the two forms cannot offer different ranges for the same setting.
*/
export const PHOTO_GRID_COLUMNS_MIN = 1;
export const PHOTO_GRID_COLUMNS_MAX = 6;
export const PHOTO_GRID_GAP_MAX = 48;
/** /**
* Bounds for the album-cover grid on a subpage. Shared with the admin inputs so * Bounds for the album-cover grid on a subpage. Shared with the admin inputs so
* the form and the renderer cannot drift apart. * the form and the renderer cannot drift apart.
@@ -105,12 +124,16 @@ export const COVER_GRID_TABLET_COLUMNS_MAX = 2;
/** /**
* The CSS custom properties that size the album-cover grid on a subpage. * The CSS custom properties that size the album-cover grid on a subpage.
* *
* `columns` follows the same config as the photo grids (global `settings.yaml` * The overrides come from the subpage's own `coverGrid`, which sizes the cover
* < per-subpage override), so "3 columns" in the admin means three columns * tiles and nothing else — it used to be the same `grid` key the photos read,
* everywhere. `gap` deliberately does not: each theme preset picks the cover * so a cover gap silently retuned every photo grid on the page (#523).
* spacing as part of its look (1px monograph, 20px studio-modern), so the *
* variable is only emitted when a subpage sets `gap` explicitly — otherwise the * `columns` still falls back to the global `settings.yaml` count, so a page
* preset's own `--subpage-gap` stands. * that states nothing tiles its covers like its photos. `gap` deliberately does
* not: each theme preset picks the cover spacing as part of its look (1px
* monograph, 20px studio-modern), so the variable is only emitted when a
* subpage sets `gap` explicitly — otherwise the preset's own `--subpage-gap`
* stands.
* *
* `--subpage-columns-tablet` is computed here rather than in CSS because * `--subpage-columns-tablet` is computed here rather than in CSS because
* `repeat()` does not reliably accept a `min()` expression. * `repeat()` does not reliably accept a `min()` expression.
@@ -158,7 +181,16 @@ export interface AppConfig {
titleTemplate: string; titleTemplate: string;
noIndex: boolean; noIndex: boolean;
noFollow: boolean; noFollow: boolean;
/** Emitted as `license` in JSON-LD when set. */
license: string;
}; };
/**
* The site's own absolute URL, or null when nobody configured one. Sitemap,
* feed and JSON-LD need it and have no request context to derive it from.
*/
siteUrl: string | null;
/** Where `siteUrl` came from, so the admin panel can name it (#472). */
siteUrlSource: 'env' | 'settings' | 'none';
heroImages: string[]; heroImages: string[];
/** Colour mode a first-time visitor lands on; their own choice overrides it. */ /** Colour mode a first-time visitor lands on; their own choice overrides it. */
colorMode: ColorMode; colorMode: ColorMode;
@@ -198,6 +230,10 @@ export interface AppConfig {
albumGrids: Record<string, Partial<GridConfig>>; albumGrids: Record<string, Partial<GridConfig>>;
/** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */ /** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */
albumCoverPositions: Record<string, string>; albumCoverPositions: Record<string, string>;
/** Albums whose originals may be downloaded. Absent means no (#475). */
albumDownloads: Record<string, boolean>;
/** Per-album map precision. Absent means `exact` (#469). */
albumLocationPrecision: Record<string, LocationPrecision>;
/** EXPERIMENTAL: external links appended to the header navigation. */ /** EXPERIMENTAL: external links appended to the header navigation. */
navLinks: NavLinkConfig[]; navLinks: NavLinkConfig[];
cacheTtl: number; cacheTtl: number;
@@ -258,6 +294,14 @@ export interface AlbumEntryObject {
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */ /** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
coverPosition?: string; coverPosition?: string;
/** Offer the original file for download from the lightbox (#475). Off by default. */
download?: boolean;
/**
* How precisely this album's photographs may be placed on the map:
* `exact` (default), `city`, `country` or `hidden`. Narrowed to
* `LocationPrecision` in deriveGallery(), where a typo has to be rejected.
*/
location?: string;
} }
export interface GalleryYaml { export interface GalleryYaml {
@@ -281,18 +325,29 @@ export interface GalleryYaml {
essayText?: string; essayText?: string;
enabled?: boolean; enabled?: boolean;
hidden?: boolean; hidden?: boolean;
/** Map precision inherited by every album here (#469). */
location?: string;
grid?: { grid?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
aspectRatio?: string; aspectRatio?: string;
layout?: string; layout?: string;
}; };
/** Album covers only; falls back to `grid` when unset (#523). */
coverGrid?: {
columns?: number;
gap?: number;
aspectRatio?: string;
layout?: string;
};
}>; }>;
} }
export interface SettingsYaml { export interface SettingsYaml {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
/** Absolute site URL, e.g. https://folio.example. `SITE_URL` overrides it. */
url?: string;
lang?: string; lang?: string;
seo?: { seo?: {
title?: string; title?: string;
@@ -300,6 +355,8 @@ export interface SettingsYaml {
titleTemplate?: string; titleTemplate?: string;
noIndex?: boolean; noIndex?: boolean;
noFollow?: boolean; noFollow?: boolean;
/** Licence URL or identifier for structured data, e.g. a CC link (#472). */
license?: string;
}; };
/** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */ /** 'dark' (default), 'light' or 'auto' — the visitor's starting colour mode. */
mode?: ColorMode; mode?: ColorMode;
@@ -450,11 +507,60 @@ export function resolveWatermarkOpacity(raw?: number): number {
return Math.min(1, Math.max(0, fraction)); return Math.min(1, Math.max(0, fraction));
} }
/**
* URL slug for a display name.
*
* NFD + combining-mark removal folds Latin diacritics ("Café" -> "cafe"), but
* everything else keeps its letters: the separator class is \p{L}\p{N}, not
* [a-z0-9]. The ASCII-only version stripped CJK, Hangul and Cyrillic names down
* to an empty slug, which turned their links into "/" (#522). NFC at the end
* recomposes what NFD split — Hangul syllables decompose into jamo, and only
* the composed form matches what a browser sends back in the URL.
*
* Can still return '' for a name made purely of symbols or emoji; callers that
* need a routable slug must supply a fallback (see albumSlug).
*/
export function slugify(name: string): string { export function slugify(name: string): string {
return name return name
.toLowerCase() .toLowerCase()
.normalize('NFD') .normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') .replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-') .replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, ''); .replace(/^-+|-+$/g, '')
.normalize('NFC');
}
/**
* Routable slug for an album. Falls back to the album id when the name has no
* letters or digits at all, so an emoji-only album is still reachable — and so
* two of them do not collide on the same empty slug.
*/
export function albumSlug(name: string, id: string): string {
return slugify(name) || id;
}
/**
* Normalize a slug that came in from a URL before comparing it to a stored one.
*
* Two things can differ even when the slug is "the same":
*
* - Percent-encoding. A non-ASCII path segment reaches the route handler
* still encoded ("%E5%AE%B6..."), so comparing it raw against the stored
* slug never matches — the half of #522 that survived fixing slugify().
* - Unicode form. Slugs are stored NFC; a non-Latin slug typed or pasted on
* macOS can arrive NFD, and the two are different strings that render the
* same.
*
* ASCII slugs are unaffected by either step. No stored slug can contain a '%',
* since slugify() drops it, so decoding cannot collide with a real slug.
*/
export function normalizeSlug(slug: string): string {
let decoded = slug;
try {
decoded = decodeURIComponent(slug);
} catch {
// Malformed percent sequence — compare what we were actually given rather
// than throwing a 500 on a hand-mangled URL.
}
return decoded.normalize('NFC');
} }

69
lib/downloadName.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Filenames for downloaded originals (#475).
*
* The name comes from Immich, which took it from the camera or from whatever
* the photographer typed - so it is untrusted input on its way into a
* `Content-Disposition` header. A newline in it would end the header and let
* the rest be read as headers of its own; a path separator would suggest a
* directory to the browser.
*/
/**
* Drop control characters, including the CR and LF that would end a header.
*
* Written as a code-point filter rather than a regular expression: a character
* class of escaped control codes is the kind of source line that gets mangled
* by an editor or a copy-paste and then silently stops matching.
*/
function stripControlChars(value: string): string {
return Array.from(value)
.filter((character) => {
const code = character.charCodeAt(0);
return code > 31 && code !== 127;
})
.join('');
}
/** True for printable ASCII, the range the pre-RFC-5987 filename is limited to. */
function isPrintableAscii(character: string): boolean {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126;
}
/**
* A filename safe to put in a `Content-Disposition` header.
*
* Strips anything that could break out of the header or out of the download
* directory, then falls back to a generic name if nothing usable is left - an
* empty filename would make the browser invent one from the URL, which is the
* opaque token.
*/
export function safeDownloadName(raw: string | undefined, fallback = 'photo'): string {
const cleaned = stripControlChars(raw ?? '')
// Directory separators and traversal.
.replace(/[/\\]/g, '_')
// Quotes and semicolons delimit the header's own parameters.
.replace(/["';]/g, '')
.replace(/^\.+/, '')
.trim();
if (!cleaned) return fallback;
// Long names are the browser's problem, but an unbounded one is ours.
return cleaned.slice(0, 200);
}
/**
* A full `Content-Disposition` value.
*
* Emits both `filename` and `filename*`: the plain parameter is stripped to
* ASCII for clients that predate RFC 5987, and the starred one carries the
* real name percent-encoded for everything since.
*/
export function contentDisposition(name: string | undefined): string {
const safe = safeDownloadName(name);
const ascii =
Array.from(safe)
.map((character) => (isPrintableAscii(character) ? character : '_'))
.join('') || 'photo';
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(safe)}`;
}

View File

@@ -19,6 +19,10 @@ export interface Env {
ADMIN_PASSWORD?: string; ADMIN_PASSWORD?: string;
SITE_PASSWORD?: string; SITE_PASSWORD?: string;
INSTALL_CONTENT_DIR?: string; INSTALL_CONTENT_DIR?: string;
/** Whether the admin dashboard may ask GitHub for the latest release. */
UPDATE_CHECK: boolean;
/** Absolute site URL for sitemap, feed and JSON-LD. Overrides settings.yaml. */
SITE_URL?: string;
} }
function parseEnv(): Env { function parseEnv(): Env {
@@ -111,6 +115,11 @@ function parseEnv(): Env {
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined, ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
SITE_PASSWORD: process.env.SITE_PASSWORD || undefined, SITE_PASSWORD: process.env.SITE_PASSWORD || undefined,
INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined, INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined,
// On unless switched off: an instance that never learns about a security
// release is the worse default. Only `false` disables it, so a typo does
// not silently turn the check off.
UPDATE_CHECK: process.env.UPDATE_CHECK !== 'false',
SITE_URL: process.env.SITE_URL || undefined,
}; };
} }

View File

@@ -29,6 +29,11 @@ export const de: Dictionary = {
photos: (n: number) => plural(n, 'Foto', 'Fotos'), photos: (n: number) => plural(n, 'Foto', 'Fotos'),
albums: (n: number) => plural(n, 'Album', 'Alben'), albums: (n: number) => plural(n, 'Album', 'Alben'),
collections: (n: number) => plural(n, 'Sammlung', 'Sammlungen'), collections: (n: number) => plural(n, 'Sammlung', 'Sammlungen'),
prevAlbum: 'Vorheriges Album',
nextAlbum: 'Nächstes Album',
prevAlbumAria: (name: string) => `Vorheriges Album: ${name}`,
nextAlbumAria: (name: string) => `Nächstes Album: ${name}`,
albumNavAria: 'Albumnavigation',
loadingGallery: 'Galerie wird geladen', loadingGallery: 'Galerie wird geladen',
loadingPhotos: 'Fotos werden geladen', loadingPhotos: 'Fotos werden geladen',
}, },
@@ -123,11 +128,25 @@ export const de: Dictionary = {
iso: 'ISO', iso: 'ISO',
location: 'Ort', location: 'Ort',
noExif: 'Keine EXIF-Daten vorhanden', noExif: 'Keine EXIF-Daten vorhanden',
copyLink: 'Link zu diesem Foto kopieren',
copyLinkTitle: 'Link zu diesem Foto kopieren (c)',
copyLinkShort: 'Link',
copied: 'Kopiert',
copyManual: 'Diesen Link kopieren',
shortcuts: 'Tastaturkürzel', shortcuts: 'Tastaturkürzel',
shortcutNavigate: 'Vorheriges / nächstes Foto', shortcutNavigate: 'Vorheriges / nächstes Foto',
shortcutInfo: 'Fotoinfos', shortcutInfo: 'Fotoinfos',
shortcutFullscreen: 'Vollbild', shortcutFullscreen: 'Vollbild',
shortcutExitFullscreen: 'Vollbild verlassen', shortcutExitFullscreen: 'Vollbild verlassen',
download: 'Originaldatei herunterladen',
downloadTitle: 'Originaldatei herunterladen (d)',
downloadShort: 'Original',
shortcutDownload: 'Original herunterladen',
shortcutSlideshow: 'Diaschau',
shortcutSlideshowRunning: (seconds: number) => `Diaschau — alle ${seconds}s`,
slideshowStopped: 'Diaschau angehalten',
slideshowRunning: (seconds: number) => `Diaschau läuft, wechselt alle ${seconds} Sekunden`,
shortcutCopyLink: 'Link zum Foto kopieren',
shortcutList: 'Diese Liste', shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen', shortcutClose: 'Ansicht schließen',
}, },

View File

@@ -29,6 +29,11 @@ export const en = {
photos: (n: number) => plural(n, 'photo', 'photos'), photos: (n: number) => plural(n, 'photo', 'photos'),
albums: (n: number) => plural(n, 'album', 'albums'), albums: (n: number) => plural(n, 'album', 'albums'),
collections: (n: number) => plural(n, 'collection', 'collections'), collections: (n: number) => plural(n, 'collection', 'collections'),
prevAlbum: 'Previous album',
nextAlbum: 'Next album',
prevAlbumAria: (name: string) => `Previous album: ${name}`,
nextAlbumAria: (name: string) => `Next album: ${name}`,
albumNavAria: 'Album navigation',
loadingGallery: 'Loading gallery', loadingGallery: 'Loading gallery',
loadingPhotos: 'Loading photos', loadingPhotos: 'Loading photos',
}, },
@@ -123,11 +128,25 @@ export const en = {
iso: 'ISO', iso: 'ISO',
location: 'Location', location: 'Location',
noExif: 'No EXIF data available', noExif: 'No EXIF data available',
copyLink: 'Copy link to this photo',
copyLinkTitle: 'Copy link to this photo (c)',
copyLinkShort: 'Link',
copied: 'Copied',
copyManual: 'Copy this link',
shortcuts: 'Keyboard shortcuts', shortcuts: 'Keyboard shortcuts',
shortcutNavigate: 'Previous / next photo', shortcutNavigate: 'Previous / next photo',
shortcutInfo: 'Photo info', shortcutInfo: 'Photo info',
shortcutFullscreen: 'Fullscreen', shortcutFullscreen: 'Fullscreen',
shortcutExitFullscreen: 'Leave fullscreen', shortcutExitFullscreen: 'Leave fullscreen',
download: 'Download the original file',
downloadTitle: 'Download the original file (d)',
downloadShort: 'Original',
shortcutDownload: 'Download the original',
shortcutSlideshow: 'Slideshow',
shortcutSlideshowRunning: (seconds: number) => `Slideshow — every ${seconds}s`,
slideshowStopped: 'Slideshow stopped',
slideshowRunning: (seconds: number) => `Slideshow running, advancing every ${seconds} seconds`,
shortcutCopyLink: 'Copy link to this photo',
shortcutList: 'This list', shortcutList: 'This list',
shortcutClose: 'Close the viewer', shortcutClose: 'Close the viewer',
}, },

View File

@@ -7,7 +7,7 @@
* requests go through our proxy route. * requests go through our proxy route.
*/ */
import { getConfig, slugify, type SubpageConfig } from './config'; import { getConfig, albumSlug, normalizeSlug, type SubpageConfig } from './config';
import { cache } from './cache'; import { cache } from './cache';
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort'; import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
@@ -423,7 +423,7 @@ class ImmichClient {
...album, ...album,
albumName: name, albumName: name,
description, description,
slug: slugify(name), slug: albumSlug(name, album.id),
}; };
}); });
@@ -530,9 +530,8 @@ class ImmichClient {
subpageSlug: string, subpageSlug: string,
forceFresh = false, forceFresh = false,
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> { ): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find( const wanted = normalizeSlug(subpageSlug);
(sp) => sp.slug === subpageSlug && sp.enabled !== false, const subpage = this.config.subpages.find((sp) => sp.slug === wanted && sp.enabled !== false);
);
if (!subpage) return null; if (!subpage) return null;
const allAlbums = await this.getAlbums(forceFresh); const allAlbums = await this.getAlbums(forceFresh);
@@ -700,7 +699,7 @@ class ImmichClient {
const description = this.config.albumDescriptions[album.id] ?? album.description ?? ''; const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
album.albumName = name; album.albumName = name;
album.description = description; album.description = description;
album.slug = slugify(name); album.slug = albumSlug(name, album.id);
this.cacheSet(cacheKey, album); this.cacheSet(cacheKey, album);
return album; return album;
@@ -728,13 +727,15 @@ class ImmichClient {
let searchSet = albums; let searchSet = albums;
if (subpageSlug) { if (subpageSlug) {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); const wantedSubpage = normalizeSlug(subpageSlug);
const subpage = this.config.subpages.find((sp) => sp.slug === wantedSubpage);
if (!subpage) return null; if (!subpage) return null;
const subpageIds = new Set(subpage.albumIds); const subpageIds = new Set(subpage.albumIds);
searchSet = albums.filter((a) => subpageIds.has(a.id)); searchSet = albums.filter((a) => subpageIds.has(a.id));
} }
const match = searchSet.find((a) => a.slug === slug); const wanted = normalizeSlug(slug);
const match = searchSet.find((a) => a.slug === wanted);
if (!match) return null; if (!match) return null;
return this.getAlbum(match.id, forceFresh); return this.getAlbum(match.id, forceFresh);
} }
@@ -743,7 +744,8 @@ class ImmichClient {
* Check if a slug corresponds to a subpage. * Check if a slug corresponds to a subpage.
*/ */
isSubpageSlug(slug: string): boolean { isSubpageSlug(slug: string): boolean {
return this.config.subpages.some((sp) => sp.slug === slug && sp.enabled !== false); const wanted = normalizeSlug(slug);
return this.config.subpages.some((sp) => sp.slug === wanted && sp.enabled !== false);
} }
/** /**

171
lib/lightboxShortcuts.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* The lightbox's keyboard shortcuts, in one place (#473).
*
* Three things read this list: the `?` overlay the visitor sees, the help
* section in the admin panel where the site owner finds out these keys exist
* at all, and — since this commit — the viewer's own key handler.
*
* That last one is the point. The catalogue used to describe the keys while
* the handler independently decided which ones it listened for, so a key added
* to the viewer and forgotten here stayed exactly as undiscoverable as the
* whole set was before, and nothing would have said so. Now a key reaches the
* handler only by being declared here, and `LightboxAction` is a closed union
* the handler switches over exhaustively: declaring an action the viewer does
* not implement is a type error, not a silent gap.
*
* The labels stay out of here: they live in the dictionaries, and the two
* dynamic ones (fullscreen flips, the slideshow names its speed) are resolved
* by the viewer, which is the only place that knows the current state.
*/
/** What has to be true for a shortcut to do anything. */
export type ShortcutAvailability =
/** Always present. */
| 'always'
/** Journal entries hide the EXIF toggle, so `i` does nothing there. */
| 'exifPanel'
/** Absent where the browser has no element fullscreen to give (iPhone). */
| 'fullscreen'
/** Only where the album offers its originals. */
| 'download';
/**
* What a key does. Closed on purpose: the viewer switches over this union
* exhaustively, so adding a member without handling it fails to compile.
*/
export type LightboxAction =
| 'close'
| 'prev'
| 'next'
| 'info'
| 'fullscreen'
| 'slideshow'
| 'copyLink'
| 'download'
| 'shortcutList';
export interface ShortcutBinding {
/**
* How the key is written in the two lists. Arrows are stored as names rather
* than glyphs so the list survives an editor that mangles them; both
* renderers ask for the display form via `shortcutKeyLabel()`.
*/
display: string;
/**
* The exact `KeyboardEvent.key` values that trigger it, case variants
* included. `?` is matched on the produced character, not the physical key:
* Shift+/ on a US layout, Shift+ß on a German one.
*/
eventKeys: readonly string[];
action: LightboxAction;
}
export interface LightboxShortcut {
bindings: readonly ShortcutBinding[];
/** Key into the `lightbox` dictionary section. */
labelKey:
| 'shortcutNavigate'
| 'shortcutInfo'
| 'shortcutFullscreen'
| 'shortcutSlideshow'
| 'shortcutCopyLink'
| 'shortcutDownload'
| 'shortcutList'
| 'shortcutClose';
availability: ShortcutAvailability;
/** Shown in the admin help to explain when the key is absent. */
note?: string;
}
/** In the order they are worth learning. */
export const LIGHTBOX_SHORTCUTS: readonly LightboxShortcut[] = [
{
// One row, two actions: the arrows are learnt together but do opposite
// things, which is why a binding carries the action rather than the row.
bindings: [
{ display: 'ARROW_LEFT', eventKeys: ['ArrowLeft'], action: 'prev' },
{ display: 'ARROW_RIGHT', eventKeys: ['ArrowRight'], action: 'next' },
],
labelKey: 'shortcutNavigate',
availability: 'always',
},
{
bindings: [{ display: 'I', eventKeys: ['i', 'I'], action: 'info' }],
labelKey: 'shortcutInfo',
availability: 'exifPanel',
note: 'Hidden where every EXIF group is switched off, and in journal entries.',
},
{
bindings: [{ display: 'F', eventKeys: ['f', 'F'], action: 'fullscreen' }],
labelKey: 'shortcutFullscreen',
availability: 'fullscreen',
note: 'Absent on browsers with no element fullscreen, such as Safari on iPhone.',
},
{
bindings: [{ display: 'S', eventKeys: ['s', 'S'], action: 'slideshow' }],
labelKey: 'shortcutSlideshow',
availability: 'always',
note: 'Cycles off, 3s, 5s, 10s and back to off. Any arrow or swipe stops it.',
},
{
bindings: [{ display: 'C', eventKeys: ['c', 'C'], action: 'copyLink' }],
labelKey: 'shortcutCopyLink',
availability: 'always',
note: 'Copies a link to the photo on screen. Positional: reordering the album moves where it lands.',
},
{
bindings: [{ display: 'D', eventKeys: ['d', 'D'], action: 'download' }],
labelKey: 'shortcutDownload',
availability: 'download',
note: 'Only for albums with download: true in gallery.yaml.',
},
{
// `h` is the escape hatch for layouts where `?` is awkward — and the one
// key someone guesses without having been told.
bindings: [
{ display: '?', eventKeys: ['?'], action: 'shortcutList' },
{ display: 'H', eventKeys: ['h', 'H'], action: 'shortcutList' },
],
labelKey: 'shortcutList',
availability: 'always',
},
{
bindings: [{ display: 'Esc', eventKeys: ['Escape'], action: 'close' }],
labelKey: 'shortcutClose',
availability: 'always',
},
];
/**
* Arrow names to glyphs. Ordinary keys pass through, so both renderers can map
* every display key through this without asking which kind it is.
*/
export function shortcutKeyLabel(key: string): string {
if (key === 'ARROW_LEFT') return String.fromCharCode(0x2190);
if (key === 'ARROW_RIGHT') return String.fromCharCode(0x2192);
return key;
}
/** The keys of one shortcut, ready to render. */
export function shortcutDisplayKeys(shortcut: LightboxShortcut): string[] {
return shortcut.bindings.map((binding) => shortcutKeyLabel(binding.display));
}
const ACTION_BY_EVENT_KEY: ReadonlyMap<string, LightboxAction> = new Map(
LIGHTBOX_SHORTCUTS.flatMap((shortcut) =>
shortcut.bindings.flatMap((binding) =>
binding.eventKeys.map((key) => [key, binding.action] as const),
),
),
);
/**
* What a `KeyboardEvent.key` should do, or null if the viewer does not bind it.
*
* This is the only route from a keypress to an action, so a key the catalogue
* does not list cannot reach the viewer, and one it does list cannot be
* silently missing from the help.
*/
export function lightboxActionFor(eventKey: string): LightboxAction | null {
return ACTION_BY_EVENT_KEY.get(eventKey) ?? null;
}

107
lib/mapPrecision.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* How precisely an album's photographs may be placed on the map (#469).
*
* The map is the only public GPS surface: the EXIF route does not expose
* coordinates at all, and password-protected albums are dropped before
* aggregation. What was left was public albums, whose marker sits at the mean
* of the actual coordinates — and for an album shot in one place (a garden, a
* studio, a client's home, a fragile site) that mean *is* that place. The
* marker is named after the city and stands on the property. Nobody chose
* that; it follows from the camera writing GPS.
*
* Coordinates are quantised rather than snapped to city centres, so the
* project takes on no geodata dependency.
*/
export const LOCATION_PRECISIONS = ['exact', 'city', 'country', 'hidden'] as const;
export type LocationPrecision = (typeof LOCATION_PRECISIONS)[number];
export function isLocationPrecision(value: string): value is LocationPrecision {
return (LOCATION_PRECISIONS as readonly string[]).includes(value);
}
/**
* Grid each level snaps to. `decimals` trims the floating-point tail that
* dividing and re-multiplying leaves behind — without it 0.05° rounding emits
* values like 52.900000000000006, which would be a needlessly precise-looking
* number for a deliberately imprecise position.
*/
const GRID: Record<Exclude<LocationPrecision, 'hidden'>, { step: number; decimals: number }> = {
exact: { step: 0, decimals: 6 },
city: { step: 0.05, decimals: 2 }, // ≈ 5 km
country: { step: 1, decimals: 0 }, // ≈ 100 km
};
/** Strictness order — later wins when albums disagree. */
const RANK: Record<LocationPrecision, number> = {
exact: 0,
city: 1,
country: 2,
hidden: 3,
};
/**
* The strictest level among the albums contributing to one marker.
*
* One marker merges several albums, so the cautious setting has to govern the
* whole marker. Splitting the marker instead would itself reveal that one of
* its albums is set more cautiously — which is the thing being hidden.
*
* An empty list is `hidden`: nothing contributed, so there is nothing to show.
*/
export function strictestPrecision(levels: readonly LocationPrecision[]): LocationPrecision {
if (levels.length === 0) return 'hidden';
return levels.reduce((worst, level) => (RANK[level] > RANK[worst] ? level : worst), 'exact');
}
/**
* Snap a coordinate to the level's grid.
*
* A fixed global grid, not a random offset per request: jitter could be
* averaged away by asking repeatedly, which would hand back the true position
* to anyone patient enough to ask twice.
*/
export function quantiseCoordinate(value: number, level: LocationPrecision): number {
if (level === 'hidden') return 0;
const { step, decimals } = GRID[level];
if (step === 0) return value;
return Number((Math.round(value / step) * step).toFixed(decimals));
}
/**
* Apply a precision level to a position. Returns null for `hidden`, which the
* caller must treat as "this marker does not exist" rather than "this marker
* is at 0,0".
*/
export function applyPrecision(
position: { lat: number; lng: number },
level: LocationPrecision,
): { lat: number; lng: number } | null {
if (level === 'hidden') return null;
return {
lat: quantiseCoordinate(position.lat, level),
lng: quantiseCoordinate(position.lng, level),
};
}
/**
* The place name a marker may carry at this precision.
*
* Quantising the coordinates is only half of withholding a position: the
* marker is also *named*, and a city name is far more precise than the 1° grid
* `country` snaps to. At `country` the name is reduced to the country, which
* is what the coordinates already say. `city` keeps it — a 0.05° cell is
* roughly what naming a city discloses anyway.
*
* `hidden` returns neither, so a caller that forgets to drop the marker
* entirely cannot leak the place through its label.
*/
export function placeLabel(
level: LocationPrecision,
place: { city: string; country: string },
): { city: string; country: string } {
if (level === 'hidden') return { city: '', country: '' };
if (level === 'country') return { city: '', country: place.country };
return { city: place.city, country: place.country };
}

36
lib/photoHash.ts Normal file
View File

@@ -0,0 +1,36 @@
/**
* The `#photo-N` permalink shared by the photo grid and the lightbox.
*
* The grid writes and restores it; the lightbox offers a button that copies it
* (#478). The format lives here so the two cannot drift apart.
*
* The link is positional, as `gallery.yaml.example` documents: it names a
* place in the album, not an asset, so reordering an album moves where a
* shared link lands.
*/
/** Parse `#photo-N`. Returns a 0-based index, or null if it is not one. */
export function parsePhotoHash(hash: string): number | null {
const match = hash.match(/^#photo-(\d+)$/);
if (!match) return null;
const index = parseInt(match[1], 10) - 1; // 1-indexed in the URL
return index >= 0 ? index : null;
}
/** The hash for a 0-based index, 1-indexed for the reader. */
export function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`;
}
/**
* An absolute permalink to one photo.
*
* Takes the location parts rather than reading `window`, so it is pure and the
* caller does not depend on the grid's hash-sync effect having run yet.
*/
export function buildPhotoPermalink(
location: { origin: string; pathname: string; search: string },
index: number,
): string {
return `${location.origin}${location.pathname}${location.search}${buildPhotoHash(index)}`;
}

96
lib/publicPages.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* Which pages a crawler — or a feed reader — may be told about (#472, #471).
*
* This is the part that matters. A sitemap and a feed collect exactly what
* access control otherwise hides: titles, cover images, publication dates. It
* is a leak surface nobody notices, because it never shows up in the browser.
*
* The rules are kept here, as a pure function over a description of the site,
* so both surfaces answer the same question the same way and every exclusion
* can be stated as a test.
*/
export interface PublicAlbum {
id: string;
slug: string;
}
export interface PublicSubpage {
slug: string;
/** `enabled: false` in gallery.yaml — the subpage does not exist publicly. */
enabled: boolean;
/** `hidden: true` — reachable by direct link only, which a listing is not. */
hidden: boolean;
/** The subpage carries a password. */
isProtected: boolean;
albums: PublicAlbum[];
}
export interface PublicJournalEntry {
slug: string;
draft: boolean;
}
export interface SiteShape {
/** A global site password is on and no one is past it. */
siteLocked: boolean;
subpages: PublicSubpage[];
/** Albums that are not part of any subpage. */
standaloneAlbums: PublicAlbum[];
journal: PublicJournalEntry[];
/** Whether an album carries its own password. Keyed by Immich album id. */
isAlbumProtected: (albumId: string) => boolean;
aboutEnabled: boolean;
mapEnabled: boolean;
journalEnabled: boolean;
}
/**
* Every path that may appear in a public listing, in a stable order.
*
* A locked site yields nothing at all: the gate covers the whole site, so
* naming its pages would describe exactly what the password withholds.
*/
export function publicPaths(site: SiteShape): string[] {
if (site.siteLocked) return [];
const paths: string[] = ['/'];
if (site.aboutEnabled) paths.push('/about');
if (site.mapEnabled) paths.push('/map');
if (site.journalEnabled) paths.push('/journal');
for (const subpage of site.subpages) {
// A subpage that is off, unlisted, or behind a password is not a public
// page — and neither is anything underneath it. Inheritance is the case
// that is easy to miss: a public album under a protected subpage is not
// reachable, so listing it would advertise a URL that only asks for a
// password, and name the album while doing it.
if (!subpage.enabled || subpage.hidden || subpage.isProtected) continue;
paths.push(`/${subpage.slug}`);
for (const album of subpage.albums) {
if (site.isAlbumProtected(album.id)) continue;
paths.push(`/${subpage.slug}/${album.slug}`);
}
}
for (const album of site.standaloneAlbums) {
if (site.isAlbumProtected(album.id)) continue;
paths.push(`/${album.slug}`);
}
if (site.journalEnabled) {
for (const entry of site.journal) {
// Drafts stay visible to the logged-in admin and absent for everyone
// else; a listing is "everyone else".
if (entry.draft) continue;
paths.push(`/journal/${entry.slug}`);
}
}
// A slug collision between a subpage and a standalone album would otherwise
// emit the same URL twice.
return [...new Set(paths)];
}

50
lib/siteShape.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Build the description of the site that `publicPaths()` reasons about (#472).
*
* Kept apart from the pure rules so the exclusions stay testable without a
* running Immich, and so the sitemap and the feed cannot answer the visibility
* question differently.
*/
import { getConfig } from './config';
import { immich } from './immich';
import { isProtected, isSiteLocked } from './auth';
import { listJournalEntries } from './admin/journal-service';
import type { SiteShape, PublicAlbum } from './publicPages';
export async function buildSiteShape(): Promise<SiteShape> {
const config = getConfig();
// Allowlisted albums only — getAlbums() already drops everything else, so an
// album that was never published cannot reach a listing through here.
const albums = await immich.getAlbums();
const byId = new Map(albums.map((album) => [album.id, album]));
const resolve = (ids: string[]): PublicAlbum[] =>
ids
.map((id) => byId.get(id))
.filter((album): album is NonNullable<typeof album> => Boolean(album))
.map((album) => ({ id: album.id, slug: album.slug }));
const journal = await listJournalEntries().catch(() => []);
return {
siteLocked: isSiteLocked(),
subpages: config.subpages.map((subpage) => ({
slug: subpage.slug,
enabled: subpage.enabled !== false,
hidden: subpage.hidden === true,
isProtected: isProtected(subpage.slug, 'subpage'),
albums: resolve(subpage.albumIds),
})),
standaloneAlbums: resolve(config.standaloneAlbums),
journal: journal.map((entry) => ({
slug: entry.slug,
draft: entry.frontmatter.draft === true,
})),
isAlbumProtected: (albumId) => isProtected(albumId, 'album'),
aboutEnabled: config.aboutEnabled,
mapEnabled: config.map,
// There is no switch for the journal; it exists when it has entries.
journalEnabled: journal.length > 0,
};
}

94
lib/siteUrl.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* The site's own absolute URL (#472).
*
* Sitemaps and feeds need absolute URLs, and `app/sitemap.ts` runs without a
* request context — so the host cannot be read off a header the way the rest of
* the app does it. It has to be configured.
*
* Two sources: `url:` in settings.yaml, editable in the admin panel, and
* `SITE_URL` in the environment as a fallback for anyone who would rather
* configure it there and never open the panel.
*
* settings.yaml wins. #472 proposed the opposite, on the grounds that
* `SITE_TITLE`/`SITE_SUBTITLE` already work that way — but they do not:
* lib/config/index.ts resolves them as `settings.title ?? env.SITE_TITLE`, so
* the file already takes precedence. Following that convention means the admin
* field always does what it appears to do, which is what the issue actually
* wanted; the alternative was to introduce a field that silently has no effect
* and then build a mechanism to explain it.
*/
/**
* Normalise a configured site URL, or return null if it cannot be used.
*
* Rejects anything that is not absolute http(s): a bare host, a `javascript:`
* URL or a typo would otherwise be emitted into a sitemap, a feed and a
* JSON-LD block, where a wrong value is worse than a missing one.
*
* Keeps a path (a portfolio may live under a sub-path) but drops the trailing
* slash, query and fragment, so callers can join with `/` without producing
* `//` or carrying a stray `?`.
*/
export function normaliseSiteUrl(raw: string | undefined | null): string | null {
const trimmed = (raw ?? '').trim();
if (!trimmed) return null;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
if (!parsed.hostname) return null;
const path = parsed.pathname.replace(/\/+$/, '');
return `${parsed.origin}${path}`;
}
/**
* The effective site URL, and where it came from. The origin is reported so
* the admin panel can tell the operator that the value it shows came from the
* environment rather than from the field they are looking at.
*/
export function resolveSiteUrl(
settingsUrl: string | undefined,
envUrl: string | undefined,
): { url: string | null; source: 'env' | 'settings' | 'none' } {
const fromSettings = normaliseSiteUrl(settingsUrl);
if (fromSettings) return { url: fromSettings, source: 'settings' };
const fromEnv = normaliseSiteUrl(envUrl);
if (fromEnv) return { url: fromEnv, source: 'env' };
return { url: null, source: 'none' };
}
/**
* Join the site URL with a path, for a sitemap entry or a feed link.
*
* Returns null without a site URL: a relative link in a sitemap is invalid and
* a relative link in a feed points at the reader, so there is nothing sensible
* to fall back to.
*/
export function absoluteUrl(siteUrl: string | null, path: string): string | null {
if (!siteUrl) return null;
const raw = path.startsWith('/') ? path : `/${path}`;
const suffix = raw === '/' ? '/' : raw.replace(/\/+$/, '');
return `${siteUrl}${encodePathBytes(suffix)}`;
}
/**
* Percent-encode exactly what a URI may not carry: anything outside printable
* ASCII. Album slugs keep their own script since #522, so a path can now hold
* characters a sitemap, a canonical link or a JSON-LD block must not.
*
* Deliberately not `encodeURI()`, which would also re-encode the `%` of an
* already-encoded path and corrupt it, and deliberately not `new URL(path,
* siteUrl)`, which would drop the sub-path a portfolio may be published under
* — the one thing `normaliseSiteUrl()` goes out of its way to keep.
*/
function encodePathBytes(path: string): string {
return path.replace(/[^\x20-\x7E]/g, (character) => encodeURIComponent(character));
}

24
lib/slideshow.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Slideshow speeds for the lightbox (#473).
*
* Three speeds and off, cycled by one key, so a gallery can be left running at
* an exhibition, a fair booth or on a second screen without the viewer growing
* a control of its own.
*/
/** Seconds between advances, slowest last. `null` is off. */
export const SLIDESHOW_SPEEDS = [null, 3, 5, 10] as const;
export type SlideshowSpeed = (typeof SLIDESHOW_SPEEDS)[number];
/**
* The next speed in the cycle: off → 3s → 5s → 10s → off.
*
* An unrecognised value returns the first speed, so the cycle can always be
* re-entered rather than sticking.
*/
export function nextSlideshowSpeed(current: SlideshowSpeed): SlideshowSpeed {
const position = SLIDESHOW_SPEEDS.indexOf(current);
if (position === -1) return SLIDESHOW_SPEEDS[1];
return SLIDESHOW_SPEEDS[(position + 1) % SLIDESHOW_SPEEDS.length];
}

60
lib/structuredData.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* The JSON-LD payload for an album page (#472).
*
* `ImageGallery` with an `ImageObject` for the cover is the annotation that
* pays off for a photography portfolio: it is what a search result can show,
* and it is where `creator` and `license` belong.
*
* Built as a pure function so the shape can be asserted without rendering, and
* so the "no site URL, no structured data" rule is stated once.
*/
export interface AlbumStructuredDataInput {
siteUrl: string | null;
/** Absolute URL of the album page. */
pageUrl: string | null;
albumName: string;
description?: string;
/** Absolute URL of the cover image, if there is one. */
coverUrl?: string | null;
/** The photographer, for `creator`. */
creator?: string;
/** A licence URL or short identifier, e.g. a Creative Commons link. */
license?: string;
photoCount?: number;
}
export function albumStructuredData(
input: AlbumStructuredDataInput,
): Record<string, unknown> | null {
// Structured data is a set of claims about URLs. Without an absolute site URL
// there is nothing truthful to claim, so nothing is emitted.
if (!input.siteUrl || !input.pageUrl) return null;
if (!input.albumName.trim()) return null;
const creator = input.creator?.trim()
? { '@type': 'Person', name: input.creator.trim() }
: undefined;
const license = input.license?.trim() || undefined;
return {
'@context': 'https://schema.org',
'@type': 'ImageGallery',
name: input.albumName.trim(),
url: input.pageUrl,
...(input.description?.trim() ? { description: input.description.trim() } : {}),
...(typeof input.photoCount === 'number' ? { numberOfItems: input.photoCount } : {}),
...(creator ? { creator } : {}),
...(license ? { license } : {}),
...(input.coverUrl
? {
image: {
'@type': 'ImageObject',
contentUrl: input.coverUrl,
...(creator ? { creator } : {}),
...(license ? { license } : {}),
},
}
: {}),
};
}

134
lib/updateCheck.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Update notice for the admin dashboard (#496).
*
* Self-hosted software that never mentions a new release gets run for months
* on an old version, which matters here because there have already been
* security releases.
*
* The check is a single call to the GitHub releases API, answered from a
* day-long cache, and switchable off with `UPDATE_CHECK=false`. It fails
* quietly in every direction: no network, a rate limit, a malformed answer or
* an offline instance must never cost the dashboard its other information.
*/
import { env } from './env';
import pkg from '../package.json';
/** The version this instance is running. */
export const CURRENT_VERSION: string = pkg.version;
const RELEASES_URL = 'https://api.github.com/repos/ralksta/immich-folio/releases/latest';
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 5000;
export interface UpdateStatus {
current: string;
latest: string | null;
/** Null when the check is off, or nothing is known yet. */
updateAvailable: boolean | null;
}
/**
* Compare two dot-separated versions numerically.
*
* Returns a negative number when `a` is older, positive when newer, 0 when
* equal. A leading `v` is tolerated because that is how the releases API spells
* a tag. Anything after a `-` is dropped: a pre-release is compared on its
* numbers alone, which is enough to decide whether to mention it.
*/
export function compareVersions(a: string, b: string): number {
const parts = (version: string) =>
version
.trim()
.replace(/^v/i, '')
.split('+')[0]
.split('-')[0]
.split('.')
.map((part) => Number.parseInt(part, 10) || 0);
const left = parts(a);
const right = parts(b);
const length = Math.max(left.length, right.length);
for (let i = 0; i < length; i++) {
const difference = (left[i] ?? 0) - (right[i] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
/** Whether `latest` is worth telling the operator about. */
export function isNewerVersion(latest: string, current: string): boolean {
return compareVersions(latest, current) > 0;
}
/**
* Pull the tag out of a releases API payload.
*
* Written against `unknown` because this is a third-party response: a shape
* that changed, or an error object served with a 200, must yield null rather
* than throw inside the dashboard.
*/
export function parseLatestTag(payload: unknown): string | null {
if (typeof payload !== 'object' || payload === null) return null;
const tag = (payload as { tag_name?: unknown }).tag_name;
if (typeof tag !== 'string') return null;
const trimmed = tag.trim();
// A tag that is not a version cannot be compared, and guessing would be
// worse than saying nothing. Anchored at both ends: guarding only the start
// let anything beginning with a digit through, and a date-style tag then
// read as version 2024 and left every instance showing an update that does
// not exist. A pre-release suffix is allowed but may not itself contain a
// '-', which is what keeps "2024-01-05" out; build metadata is allowed and
// ignored when ordering, as semver says.
return /^v?\d+(\.\d+)*(-[0-9A-Za-z.]+)?(\+[0-9A-Za-z.]+)?$/.test(trimmed) ? trimmed : null;
}
let cached: { latest: string | null; at: number } | null = null;
/** Drop the memoised answer. Exposed for tests. */
export function resetUpdateCache(): void {
cached = null;
}
/**
* The latest published version, or null if it is not known.
*
* Cached for a day whether the answer was a version or a failure, so a
* dashboard left open does not hammer the API and an outage is not retried on
* every page load.
*/
async function fetchLatestVersion(): Promise<string | null> {
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.latest;
let latest: string | null = null;
try {
const response = await fetch(RELEASES_URL, {
headers: { Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
cache: 'no-store',
});
if (response.ok) latest = parseLatestTag(await response.json());
} catch {
// Offline, blocked, rate-limited or timed out. The dashboard simply does
// not mention updates this time.
latest = null;
}
cached = { latest, at: Date.now() };
return latest;
}
/** The update status for the admin dashboard. Never throws. */
export async function getUpdateStatus(): Promise<UpdateStatus> {
if (!env.UPDATE_CHECK) {
return { current: CURRENT_VERSION, latest: null, updateAvailable: null };
}
const latest = await fetchLatestVersion();
return {
current: CURRENT_VERSION,
latest,
updateAvailable: latest === null ? null : isNewerVersion(latest, CURRENT_VERSION),
};
}

View File

@@ -96,6 +96,37 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
return { camera, lens, focalLength }; return { camera, lens, focalLength };
} }
/**
* The download URL for an original file (#475).
*
* Carries the album as well as the asset: the route authorises the download
* against the album that offered it, and checks the asset really belongs to
* that album.
*/
export function downloadUrl(albumId: string, assetId: string): string {
return `/api/download/${encodeAssetId(albumId)}/${encodeAssetId(assetId)}`;
}
/**
* The Immich asset description, trimmed, for use as image alt text.
*
* Gated on the `caption` EXIF group rather than served unconditionally. That
* switch exists because the description is the one field that can hold private
* notes (#506), and an alt attribute publishes it to crawlers and view-source
* exactly as a visible caption would. A site that has switched captions off has
* said it does not want that text on the page.
*
* Returns undefined when there is nothing to say, which leaves `alt=""` — the
* correct markup for a decorative image, and better than inventing filler.
*/
export function assetCaption(
asset: Pick<ImmichAsset, 'exifInfo'>,
showCaption: boolean,
): string | undefined {
if (!showCaption) return undefined;
return asset.exifInfo?.description?.trim() || undefined;
}
/** /**
* Compute the natural aspect ratio (width / height) from EXIF dimensions. * Compute the natural aspect ratio (width / height) from EXIF dimensions.
* Returns undefined if dimensions are not available. * Returns undefined if dimensions are not available.

108
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich-folio", "name": "immich-folio",
"version": "0.12.0", "version": "0.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-folio", "name": "immich-folio",
"version": "0.12.0", "version": "0.13.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -14,7 +14,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"js-yaml": "^4.3.1", "js-yaml": "^4.3.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "16.3.0", "next": "16.3.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
@@ -28,7 +28,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6", "prettier": "^3.9.6",
@@ -1623,15 +1623,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz",
"integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", "integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.1.tgz",
"integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==", "integrity": "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1640,9 +1640,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz",
"integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", "integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1656,9 +1656,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz",
"integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", "integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1672,9 +1672,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz",
"integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", "integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1691,9 +1691,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz",
"integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", "integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1710,9 +1710,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz",
"integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", "integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1729,9 +1729,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz",
"integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", "integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1748,9 +1748,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz",
"integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", "integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1764,9 +1764,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz",
"integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", "integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2221,9 +2221,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
@@ -4054,13 +4054,13 @@
} }
}, },
"node_modules/eslint-config-next": { "node_modules/eslint-config-next": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz", "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.1.tgz",
"integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==", "integrity": "sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "16.3.0", "@next/eslint-plugin-next": "16.3.1",
"eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2", "eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -5835,13 +5835,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz",
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.3.0", "@next/env": "16.3.1",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.5.23", "postcss": "8.5.23",
@@ -5854,14 +5854,14 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-arm64": "16.3.1",
"@next/swc-darwin-x64": "16.3.0", "@next/swc-darwin-x64": "16.3.1",
"@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.1",
"@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.1",
"@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.1",
"@next/swc-linux-x64-musl": "16.3.0", "@next/swc-linux-x64-musl": "16.3.1",
"@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.1",
"@next/swc-win32-x64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.1",
"sharp": "^0.35.3" "sharp": "^0.35.3"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-folio", "name": "immich-folio",
"version": "0.12.0", "version": "0.13.0",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -20,7 +20,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"js-yaml": "^4.3.1", "js-yaml": "^4.3.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "16.3.0", "next": "16.3.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8", "react-dom": "19.2.8",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
@@ -34,7 +34,7 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"prettier": "^3.9.6", "prettier": "^3.9.6",

301
pnpm-lock.yaml generated
View File

@@ -24,8 +24,8 @@ importers:
specifier: ^1.9.4 specifier: ^1.9.4
version: 1.9.4 version: 1.9.4
next: next:
specifier: 16.3.0 specifier: 16.3.1
version: 16.3.0(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) version: 16.3.1(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react: react:
specifier: 19.2.8 specifier: 19.2.8
version: 19.2.8 version: 19.2.8
@@ -61,8 +61,8 @@ importers:
specifier: ^9 specifier: ^9
version: 9.39.5 version: 9.39.5
eslint-config-next: eslint-config-next:
specifier: 16.3.0 specifier: 16.3.1
version: 16.3.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) version: 16.3.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)
eslint-config-prettier: eslint-config-prettier:
specifier: ^10.1.8 specifier: ^10.1.8
version: 10.1.8(eslint@9.39.5) version: 10.1.8(eslint@9.39.5)
@@ -601,67 +601,67 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@napi-rs/wasm-runtime@1.2.2': '@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
peerDependencies: peerDependencies:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
'@next/env@16.3.0': '@next/env@16.3.1':
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==} resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==}
'@next/eslint-plugin-next@16.3.0': '@next/eslint-plugin-next@16.3.1':
resolution: {integrity: sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==} resolution: {integrity: sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==}
'@next/swc-darwin-arm64@16.3.0': '@next/swc-darwin-arm64@16.3.1':
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==} resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@16.3.0': '@next/swc-darwin-x64@16.3.1':
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==} resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@16.3.0': '@next/swc-linux-arm64-gnu@16.3.1':
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==} resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-arm64-musl@16.3.0': '@next/swc-linux-arm64-musl@16.3.1':
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==} resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-linux-x64-gnu@16.3.0': '@next/swc-linux-x64-gnu@16.3.1':
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==} resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-x64-musl@16.3.0': '@next/swc-linux-x64-musl@16.3.1':
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==} resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-win32-arm64-msvc@16.3.0': '@next/swc-win32-arm64-msvc@16.3.1':
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==} resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@16.3.0': '@next/swc-win32-x64-msvc@16.3.1':
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==} resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -835,8 +835,8 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.23':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tybys/wasm-util@0.10.3': '@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -879,63 +879,63 @@ packages:
'@types/react@19.2.18': '@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
'@typescript-eslint/eslint-plugin@8.66.0': '@typescript-eslint/eslint-plugin@8.67.0':
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.66.0 '@typescript-eslint/parser': ^8.67.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.66.0': '@typescript-eslint/parser@8.67.0':
resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.66.0': '@typescript-eslint/project-service@8.67.0':
resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.66.0': '@typescript-eslint/scope-manager@8.67.0':
resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.66.0': '@typescript-eslint/tsconfig-utils@8.67.0':
resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.66.0': '@typescript-eslint/type-utils@8.67.0':
resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.66.0': '@typescript-eslint/types@8.67.0':
resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.66.0': '@typescript-eslint/typescript-estree@8.67.0':
resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.66.0': '@typescript-eslint/utils@8.67.0':
resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.66.0': '@typescript-eslint/visitor-keys@8.67.0':
resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@unrs/resolver-binding-android-arm-eabi@1.12.2': '@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -1185,8 +1185,8 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
baseline-browser-mapping@2.11.13: baseline-browser-mapping@2.11.16:
resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} resolution: {integrity: sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -1311,8 +1311,8 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
electron-to-chromium@1.5.403: electron-to-chromium@1.5.412:
resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} resolution: {integrity: sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==}
emoji-regex@9.2.2: emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1369,8 +1369,8 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
eslint-config-next@16.3.0: eslint-config-next@16.3.1:
resolution: {integrity: sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==} resolution: {integrity: sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==}
peerDependencies: peerDependencies:
eslint: '>=9.0.0' eslint: '>=9.0.0'
typescript: '>=3.3.1' typescript: '>=3.3.1'
@@ -1482,6 +1482,7 @@ packages:
eslint@9.39.5: eslint@9.39.5:
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true hasBin: true
peerDependencies: peerDependencies:
jiti: '*' jiti: '*'
@@ -1607,8 +1608,8 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
get-tsconfig@4.14.1: get-tsconfig@4.14.3:
resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
glob-parent@5.1.2: glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
@@ -1935,8 +1936,8 @@ packages:
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next@16.3.0: next@16.3.1:
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==} resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -2340,8 +2341,8 @@ packages:
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typescript-eslint@8.66.0: typescript-eslint@8.67.0:
resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -2362,8 +2363,8 @@ packages:
unrs-resolver@1.12.2: unrs-resolver@1.12.2:
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
update-browserslist-db@1.3.0: update-browserslist-db@1.3.1:
resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
@@ -2925,44 +2926,44 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0 '@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.3 '@tybys/wasm-util': 0.10.3
optional: true optional: true
'@next/env@16.3.0': {} '@next/env@16.3.1': {}
'@next/eslint-plugin-next@16.3.0(eslint@9.39.5)': '@next/eslint-plugin-next@16.3.1(eslint@9.39.5)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5)
fast-glob: 3.3.1 fast-glob: 3.3.1
transitivePeerDependencies: transitivePeerDependencies:
- eslint - eslint
'@next/swc-darwin-arm64@16.3.0': '@next/swc-darwin-arm64@16.3.1':
optional: true optional: true
'@next/swc-darwin-x64@16.3.0': '@next/swc-darwin-x64@16.3.1':
optional: true optional: true
'@next/swc-linux-arm64-gnu@16.3.0': '@next/swc-linux-arm64-gnu@16.3.1':
optional: true optional: true
'@next/swc-linux-arm64-musl@16.3.0': '@next/swc-linux-arm64-musl@16.3.1':
optional: true optional: true
'@next/swc-linux-x64-gnu@16.3.0': '@next/swc-linux-x64-gnu@16.3.1':
optional: true optional: true
'@next/swc-linux-x64-musl@16.3.0': '@next/swc-linux-x64-musl@16.3.1':
optional: true optional: true
'@next/swc-win32-arm64-msvc@16.3.0': '@next/swc-win32-arm64-msvc@16.3.1':
optional: true optional: true
'@next/swc-win32-x64-msvc@16.3.0': '@next/swc-win32-x64-msvc@16.3.1':
optional: true optional: true
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@@ -3064,7 +3065,7 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.23':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -3108,14 +3109,14 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/parser': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.66.0 '@typescript-eslint/visitor-keys': 8.67.0
eslint: 9.39.5 eslint: 9.39.5
ignore: 7.0.6 ignore: 7.0.6
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -3124,41 +3125,41 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3)': '@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.66.0 '@typescript-eslint/visitor-keys': 8.67.0
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.5 eslint: 9.39.5
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.66.0(typescript@5.9.3)': '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
debug: 4.4.3 debug: 4.4.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.66.0': '@typescript-eslint/scope-manager@8.67.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/visitor-keys': 8.66.0 '@typescript-eslint/visitor-keys': 8.67.0
'@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.66.0(eslint@9.39.5)(typescript@5.9.3)': '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.5 eslint: 9.39.5
ts-api-utils: 2.5.0(typescript@5.9.3) ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -3166,14 +3167,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/types@8.66.0': {} '@typescript-eslint/types@8.67.0': {}
'@typescript-eslint/typescript-estree@8.66.0(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.66.0(typescript@5.9.3) '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/visitor-keys': 8.66.0 '@typescript-eslint/visitor-keys': 8.67.0
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.6 minimatch: 10.2.6
semver: 7.8.5 semver: 7.8.5
@@ -3183,20 +3184,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.66.0(eslint@9.39.5)(typescript@5.9.3)': '@typescript-eslint/utils@8.67.0(eslint@9.39.5)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
'@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/scope-manager': 8.67.0
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
eslint: 9.39.5 eslint: 9.39.5
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/visitor-keys@8.66.0': '@typescript-eslint/visitor-keys@8.67.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.66.0 '@typescript-eslint/types': 8.67.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@unrs/resolver-binding-android-arm-eabi@1.12.2': '@unrs/resolver-binding-android-arm-eabi@1.12.2':
@@ -3257,7 +3258,7 @@ snapshots:
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0 '@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true optional: true
'@unrs/resolver-binding-win32-arm64-msvc@1.12.2': '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
@@ -3434,7 +3435,7 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.13: {} baseline-browser-mapping@2.11.16: {}
brace-expansion@1.1.18: brace-expansion@1.1.18:
dependencies: dependencies:
@@ -3451,11 +3452,11 @@ snapshots:
browserslist@4.28.8: browserslist@4.28.8:
dependencies: dependencies:
baseline-browser-mapping: 2.11.13 baseline-browser-mapping: 2.11.16
caniuse-lite: 1.0.30001809 caniuse-lite: 1.0.30001809
electron-to-chromium: 1.5.403 electron-to-chromium: 1.5.412
node-releases: 2.0.53 node-releases: 2.0.53
update-browserslist-db: 1.3.0(browserslist@4.28.8) update-browserslist-db: 1.3.1(browserslist@4.28.8)
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
dependencies: dependencies:
@@ -3560,7 +3561,7 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
electron-to-chromium@1.5.403: {} electron-to-chromium@1.5.412: {}
emoji-regex@9.2.2: {} emoji-regex@9.2.2: {}
@@ -3710,18 +3711,18 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-next@16.3.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3): eslint-config-next@16.3.1(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3):
dependencies: dependencies:
'@next/eslint-plugin-next': 16.3.0(eslint@9.39.5) '@next/eslint-plugin-next': 16.3.1(eslint@9.39.5)
eslint: 9.39.5 eslint: 9.39.5
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5)
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5)
eslint-plugin-react: 7.37.5(eslint@9.39.5) eslint-plugin-react: 7.37.5(eslint@9.39.5)
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5)
globals: 16.4.0 globals: 16.4.0
typescript-eslint: 8.66.0(eslint@9.39.5)(typescript@5.9.3) typescript-eslint: 8.67.0(eslint@9.39.5)(typescript@5.9.3)
optionalDependencies: optionalDependencies:
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -3742,33 +3743,33 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.5 eslint: 9.39.5
get-tsconfig: 4.14.1 get-tsconfig: 4.14.3
is-bun-module: 2.0.0 is-bun-module: 2.0.0
stable-hash: 0.0.5 stable-hash: 0.0.5
tinyglobby: 0.2.17 tinyglobby: 0.2.17
unrs-resolver: 1.12.2 unrs-resolver: 1.12.2
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.14.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5): eslint-module-utils@2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/parser': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
eslint: 9.39.5 eslint: 9.39.5
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -3779,7 +3780,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.5 eslint: 9.39.5
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5) eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5)
hasown: 2.0.4 hasown: 2.0.4
is-core-module: 2.16.2 is-core-module: 2.16.2
is-glob: 4.0.3 is-glob: 4.0.3
@@ -3791,7 +3792,7 @@ snapshots:
string.prototype.trimend: 1.0.10 string.prototype.trimend: 1.0.10
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/parser': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
transitivePeerDependencies: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
@@ -4034,7 +4035,7 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
get-tsconfig@4.14.1: get-tsconfig@4.14.3:
dependencies: dependencies:
resolve-pkg-maps: 1.0.0 resolve-pkg-maps: 1.0.0
@@ -4347,25 +4348,25 @@ snapshots:
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
next@16.3.0(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): next@16.3.1(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies: dependencies:
'@next/env': 16.3.0 '@next/env': 16.3.1
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.23
baseline-browser-mapping: 2.11.13 baseline-browser-mapping: 2.11.16
caniuse-lite: 1.0.30001809 caniuse-lite: 1.0.30001809
postcss: 8.5.23 postcss: 8.5.23
react: 19.2.8 react: 19.2.8
react-dom: 19.2.8(react@19.2.8) react-dom: 19.2.8(react@19.2.8)
styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.3.0 '@next/swc-darwin-arm64': 16.3.1
'@next/swc-darwin-x64': 16.3.0 '@next/swc-darwin-x64': 16.3.1
'@next/swc-linux-arm64-gnu': 16.3.0 '@next/swc-linux-arm64-gnu': 16.3.1
'@next/swc-linux-arm64-musl': 16.3.0 '@next/swc-linux-arm64-musl': 16.3.1
'@next/swc-linux-x64-gnu': 16.3.0 '@next/swc-linux-x64-gnu': 16.3.1
'@next/swc-linux-x64-musl': 16.3.0 '@next/swc-linux-x64-musl': 16.3.1
'@next/swc-win32-arm64-msvc': 16.3.0 '@next/swc-win32-arm64-msvc': 16.3.1
'@next/swc-win32-x64-msvc': 16.3.0 '@next/swc-win32-x64-msvc': 16.3.1
'@playwright/test': 1.62.1 '@playwright/test': 1.62.1
sharp: 0.35.3(@types/node@20.19.43) sharp: 0.35.3(@types/node@20.19.43)
transitivePeerDependencies: transitivePeerDependencies:
@@ -4863,12 +4864,12 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typescript-eslint@8.66.0(eslint@9.39.5)(typescript@5.9.3): typescript-eslint@8.67.0(eslint@9.39.5)(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/parser': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.9.3) '@typescript-eslint/utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
eslint: 9.39.5 eslint: 9.39.5
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -4912,7 +4913,7 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
'@unrs/resolver-binding-win32-x64-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
update-browserslist-db@1.3.0(browserslist@4.28.8): update-browserslist-db@1.3.1(browserslist@4.28.8):
dependencies: dependencies:
browserslist: 4.28.8 browserslist: 4.28.8
escalade: 3.2.0 escalade: 3.2.0