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:
@@ -68,6 +68,20 @@ AUTH_SECRET=change-me-to-a-long-random-string
|
||||
# Leave unset or empty to disable the admin UI entirely.
|
||||
# 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) ─────────────────────────────────────
|
||||
# Puts the WHOLE public site behind one password. Overrides
|
||||
# `sitePassword` in settings.yaml, so it can be rotated without
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -49,6 +49,9 @@ Thumbs.db
|
||||
.claude/
|
||||
|
||||
# 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
|
||||
.planning/
|
||||
|
||||
|
||||
160
CHANGELOG.md
160
CHANGELOG.md
@@ -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
|
||||
[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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -8,6 +8,9 @@ import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { getServerDictionary } from '@/lib/i18n/server';
|
||||
import { albumMetaDetail } from '@/lib/albumMeta';
|
||||
import { AlbumNav } from '@/components/AlbumNav';
|
||||
import { StructuredData } from '@/components/StructuredData';
|
||||
import type { AlbumNavPair } from '@/lib/albumNav';
|
||||
|
||||
interface AlbumDetailViewProps {
|
||||
album: ImmichAlbum;
|
||||
@@ -28,6 +31,10 @@ interface AlbumDetailViewProps {
|
||||
proofing?: boolean;
|
||||
/** Offer the "send by email" button in the proofing modal. */
|
||||
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({
|
||||
@@ -45,12 +52,15 @@ export function AlbumDetailView({
|
||||
showGear = true,
|
||||
proofing,
|
||||
allowMailto,
|
||||
nav,
|
||||
structuredData,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album, showGear);
|
||||
const t = getServerDictionary();
|
||||
|
||||
return (
|
||||
<>
|
||||
{structuredData && <StructuredData data={structuredData} />}
|
||||
{heroImageUrl && (
|
||||
<div className="album-hero">
|
||||
<Image
|
||||
@@ -93,6 +103,7 @@ export function AlbumDetailView({
|
||||
proofing={proofing}
|
||||
allowMailto={allowMailto}
|
||||
/>
|
||||
{nav && <AlbumNav {...nav} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FadeIn } from '@/components/FadeIn';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
import { useDictionary } from '@/components/I18nProvider';
|
||||
import { parsePhotoHash, buildPhotoHash } from '@/lib/photoHash';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
@@ -31,6 +32,10 @@ export interface PhotoItem {
|
||||
focalLength?: string;
|
||||
/** Natural image aspect ratio (width/height) for masonry layout */
|
||||
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 {
|
||||
@@ -54,19 +59,6 @@ interface PhotoGridProps {
|
||||
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({
|
||||
assets,
|
||||
layout = 'masonry',
|
||||
@@ -186,7 +178,7 @@ function PhotoGridInner({
|
||||
>
|
||||
<Image
|
||||
src={asset.thumbUrl}
|
||||
alt=""
|
||||
alt={asset.caption ?? ''}
|
||||
fill
|
||||
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
||||
loading={index < 6 ? 'eager' : 'lazy'}
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
exifUrl,
|
||||
videoUrl,
|
||||
assetPlaceholder,
|
||||
assetCaption,
|
||||
assetExifSummary,
|
||||
downloadUrl,
|
||||
assetAspectRatio,
|
||||
} from '@/lib/urls';
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
buildCoverGridVars,
|
||||
getConfig,
|
||||
hasExifPanelContent,
|
||||
normalizeSlug,
|
||||
resolveProofing,
|
||||
type GridConfig,
|
||||
} from '@/lib/config';
|
||||
@@ -36,6 +39,9 @@ import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { albumNeighbours } from '@/lib/albumNav';
|
||||
import { albumStructuredData } from '@/lib/structuredData';
|
||||
import { absoluteUrl } from '@/lib/siteUrl';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
import { parseEssayMarkdown } from '@/lib/essay';
|
||||
@@ -51,7 +57,11 @@ interface PathPageProps {
|
||||
}
|
||||
|
||||
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 {};
|
||||
|
||||
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,
|
||||
* 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
|
||||
.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
|
||||
.map((a) => {
|
||||
const ph = assetPlaceholder(a);
|
||||
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||
const caption = assetCaption(a, showCaption);
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
id: encodeAssetId(a.id),
|
||||
@@ -131,11 +151,39 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
exifUrl: exifUrl(a.id),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||
...(exif ?? {}),
|
||||
...(caption ? { caption } : {}),
|
||||
...(downloadAlbumId ? { downloadUrl: downloadUrl(downloadAlbumId, a.id) } : {}),
|
||||
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
|
||||
* not yet authenticated, otherwise returns null.
|
||||
@@ -175,7 +223,11 @@ async function getAlbumHeroData(
|
||||
}
|
||||
|
||||
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 forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
|
||||
|
||||
@@ -199,10 +251,10 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
const resolveLayout = (overrides?: Partial<GridConfig>) =>
|
||||
overrides?.layout ?? config.grid.layout;
|
||||
|
||||
// The album-cover grid on a subpage takes its column count from the same
|
||||
// config as the photo grids, so "3 columns" in the admin means three columns
|
||||
// everywhere. `gap` deliberately does not follow the global setting — see
|
||||
// buildCoverGridVars() for why.
|
||||
// The album-cover grid on a subpage is sized by its own `coverGrid`, which
|
||||
// touches nothing but the cover tiles (#523). An unset column count still
|
||||
// falls back to the global one; `gap` deliberately does not follow the global
|
||||
// setting — see buildCoverGridVars() for why.
|
||||
const buildCoverGridStyle = (overrides?: Partial<GridConfig>): 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 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
|
||||
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);
|
||||
|
||||
// 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 (
|
||||
<AlbumDetailView
|
||||
album={album}
|
||||
images={images}
|
||||
nav={nav}
|
||||
structuredData={structuredDataFor(album, `/${subpageSlug}/${albumSlug}`, images.length)}
|
||||
layout={resolveLayout(mergeAlbumGrid(album.id, spGrid))}
|
||||
gridStyle={buildGridStyle(mergeAlbumGrid(album.id, spGrid))}
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
@@ -324,7 +387,11 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh)),
|
||||
);
|
||||
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
|
||||
if (!essayParsed) {
|
||||
@@ -381,7 +448,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
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
|
||||
const albumGate = await gateIfProtected(album.id, 'album', album.albumName);
|
||||
@@ -444,7 +516,7 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
albums={albumsWithHero}
|
||||
coverPlaceholders={coverPlaceholders}
|
||||
sections={result.subpage.sections}
|
||||
gridStyle={buildCoverGridStyle(result.subpage.grid)}
|
||||
gridStyle={buildCoverGridStyle(result.subpage.coverGrid)}
|
||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||
/>
|
||||
);
|
||||
@@ -464,7 +536,12 @@ export default async function PathPage({ params, searchParams }: PathPageProps)
|
||||
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
|
||||
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))}
|
||||
backLinkHref="/"
|
||||
backLinkLabel={getServerDictionary().common.backToGallery}
|
||||
nav={albumNeighbours(await immich.getStandaloneAlbums(forceFresh), album.id)}
|
||||
structuredData={structuredDataFor(album, `/${slug}`, images.length)}
|
||||
watermark={config.watermark}
|
||||
showExifPanel={hasExifPanelContent(config.exif)}
|
||||
showGear={config.exif.camera}
|
||||
|
||||
@@ -1928,10 +1928,83 @@
|
||||
color: var(--admin-error, #ef4444);
|
||||
}
|
||||
|
||||
.status-val.update {
|
||||
color: var(--admin-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.status-val.unknown {
|
||||
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 ────────────────────────────────────────────── */
|
||||
.builder-search-container {
|
||||
padding: 0.75rem 1.5rem;
|
||||
|
||||
@@ -19,6 +19,7 @@ const TABS = [
|
||||
{ label: 'Journal', href: '/admin/journal', match: /^\/admin\/journal/ },
|
||||
{ label: 'Settings', href: '/admin/settings', match: /^\/admin\/settings/ },
|
||||
{ label: 'Analytics', href: '/admin/analytics', match: /^\/admin\/analytics/ },
|
||||
{ label: 'Help', href: '/admin/help', match: /^\/admin\/help/ },
|
||||
];
|
||||
|
||||
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-val">{status?.cache?.size ?? 0} items</span>
|
||||
</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 className="status-dropdown-footer">
|
||||
<button
|
||||
|
||||
92
app/admin/components/HelpView.tsx
Normal file
92
app/admin/components/HelpView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
COVER_GRID_COLUMNS_MAX,
|
||||
COVER_GRID_COLUMNS_MIN,
|
||||
COVER_GRID_GAP_MAX,
|
||||
PHOTO_GRID_COLUMNS_MAX,
|
||||
PHOTO_GRID_COLUMNS_MIN,
|
||||
PHOTO_GRID_GAP_MAX,
|
||||
slugify,
|
||||
type AlbumEntryObject,
|
||||
} from '@/lib/config/schema';
|
||||
import {
|
||||
@@ -124,7 +128,24 @@ interface Subpage {
|
||||
essayFile?: string;
|
||||
sections?: Section[];
|
||||
albums: AlbumEntry[];
|
||||
/** Photo grid for the albums on this page (and the page layout style). */
|
||||
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,
|
||||
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') {
|
||||
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
|
||||
@@ -574,6 +597,8 @@ export default function PageBuilder() {
|
||||
),
|
||||
sections: undefined,
|
||||
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.essayFile) entry.essayFile = sp.essayFile;
|
||||
if (sp.grid) entry.grid = sp.grid;
|
||||
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
entry.sections = sp.sections.map((sec) => {
|
||||
@@ -910,13 +936,6 @@ export default function PageBuilder() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -1487,14 +1506,14 @@ export default function PageBuilder() {
|
||||
type="number"
|
||||
min={COVER_GRID_COLUMNS_MIN}
|
||||
max={COVER_GRID_COLUMNS_MAX}
|
||||
value={sp.grid?.columns ?? ''}
|
||||
value={sp.coverGrid?.columns ?? ''}
|
||||
placeholder="Columns (site default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const columns = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
coverGrid: normalizeSubpageGrid({
|
||||
...(sp.coverGrid || {}),
|
||||
columns:
|
||||
columns != null &&
|
||||
columns >= COVER_GRID_COLUMNS_MIN &&
|
||||
@@ -1515,14 +1534,14 @@ export default function PageBuilder() {
|
||||
type="number"
|
||||
min={0}
|
||||
max={COVER_GRID_GAP_MAX}
|
||||
value={sp.grid?.gap ?? ''}
|
||||
value={sp.coverGrid?.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 || {}),
|
||||
coverGrid: normalizeSubpageGrid({
|
||||
...(sp.coverGrid || {}),
|
||||
gap:
|
||||
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
|
||||
? gap
|
||||
@@ -1539,10 +1558,82 @@ export default function PageBuilder() {
|
||||
/>
|
||||
</div>
|
||||
<p className="admin-field-hint">
|
||||
Overrides how the album covers are tiled on this page. Leave the
|
||||
column count empty to follow the site-wide grid setting, and the
|
||||
gap empty to keep the theme's own spacing. Tablet widths
|
||||
show at most two covers per row.
|
||||
Overrides how the album covers are tiled on this page — the
|
||||
photos inside the albums are not affected. Leave the column
|
||||
count empty to follow the site-wide grid setting, and the gap
|
||||
empty to keep the theme'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 › Grid; a single
|
||||
album can still override this in gallery.yaml.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,12 +8,20 @@ import SaveBar from './SaveBar';
|
||||
// Direct import from the theme module, not from '@/lib/config': the config
|
||||
// index pulls in `fs` and cannot be bundled into a client component.
|
||||
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';
|
||||
|
||||
interface Settings {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** Absolute site URL for sitemap, feed and structured data (#472). */
|
||||
url?: string;
|
||||
lang?: string;
|
||||
sitePassword?: string;
|
||||
mode?: 'light' | 'dark' | 'auto';
|
||||
@@ -306,6 +314,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
? (section as string)
|
||||
: 'general';
|
||||
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);
|
||||
// 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).
|
||||
@@ -418,8 +431,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
try {
|
||||
const res = await fetch('/api/admin/settings');
|
||||
if (res.ok) {
|
||||
const { settings: data } = await res.json();
|
||||
const { settings: data, siteUrl } = await res.json();
|
||||
setSettings(data || {});
|
||||
setSiteUrlInfo(siteUrl ?? null);
|
||||
}
|
||||
} catch (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">
|
||||
<label>Columns (1 - 6)</label>
|
||||
<label>
|
||||
Columns ({PHOTO_GRID_COLUMNS_MIN} - {PHOTO_GRID_COLUMNS_MAX})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
min={PHOTO_GRID_COLUMNS_MIN}
|
||||
max={PHOTO_GRID_COLUMNS_MAX}
|
||||
value={settings.grid?.columns ?? 3}
|
||||
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||
/>
|
||||
@@ -1534,7 +1550,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={48}
|
||||
max={PHOTO_GRID_GAP_MAX}
|
||||
value={settings.grid?.gap ?? 12}
|
||||
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
@@ -1786,6 +1802,28 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</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">
|
||||
<label>SEO Meta Title</label>
|
||||
<input
|
||||
|
||||
5
app/admin/help/page.tsx
Normal file
5
app/admin/help/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import HelpView from '../components/HelpView';
|
||||
|
||||
export default function AdminHelpPage() {
|
||||
return <HelpView />;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
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 type { SettingsYaml } from '@/lib/config/schema';
|
||||
|
||||
@@ -16,7 +16,16 @@ export async function GET() {
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { immich } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { cache } from '@/lib/cache';
|
||||
import { listBackups, readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { getUpdateStatus } from '@/lib/updateCheck';
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
@@ -68,6 +69,10 @@ export async function GET() {
|
||||
|
||||
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({
|
||||
immich: {
|
||||
status: immichOk ? 'connected' : 'disconnected',
|
||||
@@ -89,5 +94,6 @@ export async function GET() {
|
||||
count: backupCount,
|
||||
lastBackup,
|
||||
},
|
||||
update,
|
||||
});
|
||||
}
|
||||
|
||||
130
app/api/download/[album]/[id]/route.ts
Normal file
130
app/api/download/[album]/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
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 }> }) {
|
||||
const config = getConfig();
|
||||
@@ -73,6 +75,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const exif = asset.exifInfo;
|
||||
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 = {
|
||||
...(show.camera
|
||||
? {
|
||||
@@ -89,7 +98,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/lib/config', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { getAssetInfo: vi.fn() },
|
||||
immich: { getAssetInfo: vi.fn(), getAlbum: vi.fn() },
|
||||
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 mockAssetInfo = immich.getAssetInfo as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockGetAlbum = immich.getAlbum as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const EXIF_ASSET = {
|
||||
exifInfo: {
|
||||
@@ -64,6 +65,9 @@ function configWith(raw?: ExifDisplayYaml, exifOnHover?: boolean) {
|
||||
exifOnHover: exifOnHover !== false,
|
||||
exif: resolveExifDisplay(raw, exifOnHover),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,12 @@ import { getConfig } from '@/lib/config';
|
||||
import { imageUrl } from '@/lib/urls';
|
||||
import { isAuthenticated, siteLockResponse } from '@/lib/auth';
|
||||
import { getMapData } from '@/lib/mapService';
|
||||
import {
|
||||
applyPrecision,
|
||||
placeLabel,
|
||||
strictestPrecision,
|
||||
type LocationPrecision,
|
||||
} from '@/lib/mapPrecision';
|
||||
import { ImmichUnavailableError } from '@/lib/immich';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
|
||||
@@ -88,11 +94,17 @@ export async function GET(request: NextRequest) {
|
||||
return result;
|
||||
};
|
||||
|
||||
const precisionOf = (albumId: string): LocationPrecision =>
|
||||
config.albumLocationPrecision[albumId] ?? 'exact';
|
||||
|
||||
const publicLocations = locations
|
||||
.map((loc) => {
|
||||
const allowedAlbums = loc.albums.filter(
|
||||
(a) => (!a.subpageSlug || isSubpageAllowed(a.subpageSlug)) && isAlbumAllowed(a.id),
|
||||
);
|
||||
const allowedAlbums = loc.albums
|
||||
.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;
|
||||
|
||||
@@ -102,11 +114,31 @@ export async function GET(request: NextRequest) {
|
||||
const latSum = allowedAlbums.reduce((sum, a) => sum + a.latSum, 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 {
|
||||
city: loc.city,
|
||||
country: loc.country,
|
||||
lat: latSum / photoCount,
|
||||
lng: lngSum / photoCount,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
lat: position.lat,
|
||||
lng: position.lng,
|
||||
photoCount,
|
||||
coverUrl: imageUrl(allowedAlbums[0].coverAssetId, 'thumbnail'),
|
||||
albums: allowedAlbums.map((a) => ({
|
||||
|
||||
@@ -1017,6 +1017,79 @@
|
||||
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 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2.5rem;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
exifUrl,
|
||||
assetPlaceholder,
|
||||
assetAspectRatio,
|
||||
assetCaption,
|
||||
assetExifSummary,
|
||||
} from '@/lib/urls';
|
||||
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'
|
||||
? assetExifSummary(a)
|
||||
: undefined;
|
||||
const caption = assetCaption(a, config.exif.caption);
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
id: encodeAssetId(a.id),
|
||||
@@ -186,6 +188,7 @@ export default async function JournalDetailPage({ params }: JournalDetailPagePro
|
||||
exifUrl: exifUrl(a.id),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
|
||||
...(exif ?? {}),
|
||||
...(caption ? { caption } : {}),
|
||||
aspectRatio: assetAspectRatio(a),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
|
||||
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: {
|
||||
default: siteTitle,
|
||||
template: config.seo.titleTemplate,
|
||||
|
||||
41
app/robots.ts
Normal file
41
app/robots.ts
Normal 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
32
app/sitemap.ts
Normal 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
66
components/AlbumNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,23 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
@@ -127,11 +144,51 @@
|
||||
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;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
left: 0;
|
||||
right: 0;
|
||||
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;
|
||||
color: var(--text-muted);
|
||||
font-weight: 300;
|
||||
@@ -140,9 +197,6 @@
|
||||
|
||||
/* ── EXIF Info Panel ──────────────────────────────── */
|
||||
.infoToggle {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
/* Icon and label sit on one line — the buttons carried an emoji before, which
|
||||
needed no alignment of its own. */
|
||||
display: inline-flex;
|
||||
@@ -169,9 +223,71 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Proofing favourite button — sits left of the info toggle. */
|
||||
.favToggle {
|
||||
right: 6.5rem;
|
||||
/* Anchors are not buttons; strip the underline so it matches its neighbours. */
|
||||
.downloadToggle {
|
||||
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 {
|
||||
@@ -187,7 +303,7 @@
|
||||
--lb-panel-muted: var(--text-muted);
|
||||
--lb-panel-border: var(--border-subtle);
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
bottom: 76px;
|
||||
right: 24px;
|
||||
z-index: 1001;
|
||||
background: rgba(20, 20, 20, 0.92);
|
||||
@@ -277,7 +393,7 @@
|
||||
--lb-panel-muted: var(--text-muted);
|
||||
--lb-panel-border: var(--border-subtle);
|
||||
position: fixed;
|
||||
bottom: 62px;
|
||||
bottom: 76px;
|
||||
left: 24px;
|
||||
z-index: 1001;
|
||||
animation: exif-slide-up var(--transition-normal) ease forwards;
|
||||
@@ -463,11 +579,22 @@
|
||||
}
|
||||
|
||||
/* 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;
|
||||
left: 32px;
|
||||
transform: none;
|
||||
/* The close button owns the top-right corner, so the bar stops short of it.
|
||||
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-size: 0.6875rem;
|
||||
letter-spacing: 0.15em;
|
||||
@@ -520,9 +647,6 @@
|
||||
|
||||
/* Info + favourite toggles move up into the topbar */
|
||||
:global([data-preset='studio-modern']) .infoToggle {
|
||||
top: 20px;
|
||||
bottom: auto;
|
||||
right: 76px;
|
||||
background: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
@@ -542,10 +666,6 @@
|
||||
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 top instead. */
|
||||
:global([data-preset='studio-modern']) .shortcutsPanel {
|
||||
@@ -638,12 +758,9 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
/* No room next to the info toggle — park it in the opposite corner. */
|
||||
:global([data-preset='studio-modern']) .favToggle {
|
||||
top: auto;
|
||||
bottom: 76px;
|
||||
right: auto;
|
||||
left: 16px;
|
||||
:global([data-preset='studio-modern']) .bottomBar {
|
||||
/* Same reserved corner as on desktop: the close button sits there. */
|
||||
padding: 16px 76px 16px 16px;
|
||||
}
|
||||
|
||||
:global([data-preset='studio-modern']) .navPrev {
|
||||
|
||||
@@ -26,6 +26,13 @@ import { useDictionary } from './I18nProvider';
|
||||
// client component cannot import.
|
||||
import { resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||
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 {
|
||||
enabled?: boolean;
|
||||
@@ -64,6 +71,20 @@ export function Lightbox({
|
||||
const [canFullscreen, setCanFullscreen] = useState(false);
|
||||
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
|
||||
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 closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -107,6 +128,81 @@ export function Lightbox({
|
||||
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
|
||||
* own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly
|
||||
@@ -171,8 +267,8 @@ export function Lightbox({
|
||||
}, [currentIndex, assets]);
|
||||
|
||||
const { handleTouchStart, handleTouchEnd } = useSwipe({
|
||||
onSwipeLeft: onNext,
|
||||
onSwipeRight: onPrev,
|
||||
onSwipeLeft: manualNext,
|
||||
onSwipeRight: manualPrev,
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -183,8 +279,29 @@ export function Lightbox({
|
||||
*/
|
||||
useEffect(() => {
|
||||
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
|
||||
// fullscreen, and only closes the viewer once nothing is stacked on
|
||||
// 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 onClose();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
onNext();
|
||||
case 'next':
|
||||
manualNext();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
onPrev();
|
||||
case 'prev':
|
||||
manualPrev();
|
||||
break;
|
||||
case 'i':
|
||||
case 'I':
|
||||
case 'info':
|
||||
if (showExifToggle) handleExifToggle();
|
||||
break;
|
||||
// `?` is matched on the produced character, not the physical key:
|
||||
// Shift+/ on a US layout, Shift+ß on a German one. `h` is the escape
|
||||
// hatch for layouts where `?` is awkward — and the one key someone
|
||||
// guesses without having been told.
|
||||
case '?':
|
||||
case 'h':
|
||||
case 'H':
|
||||
case 'slideshow':
|
||||
e.preventDefault();
|
||||
cycleSlideshow();
|
||||
break;
|
||||
case 'download':
|
||||
if (current?.downloadUrl) {
|
||||
e.preventDefault();
|
||||
window.location.href = current.downloadUrl;
|
||||
}
|
||||
break;
|
||||
case 'copyLink':
|
||||
e.preventDefault();
|
||||
handleCopyLink();
|
||||
break;
|
||||
case 'shortcutList':
|
||||
toggleShortcuts();
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
case 'fullscreen':
|
||||
if (canFullscreen) toggleFullscreen();
|
||||
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,
|
||||
current,
|
||||
cycleSlideshow,
|
||||
handleCopyLink,
|
||||
handleExifToggle,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrev,
|
||||
manualNext,
|
||||
manualPrev,
|
||||
showExifToggle,
|
||||
showShortcuts,
|
||||
toggleFullscreen,
|
||||
@@ -251,26 +383,27 @@ export function Lightbox({
|
||||
);
|
||||
|
||||
/*
|
||||
* The advertised shortcuts, in the order they are worth learning. This is the
|
||||
* 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 advertised shortcuts, resolved from the shared catalogue.
|
||||
*
|
||||
* 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 = [
|
||||
{ keys: ['←', '→'], label: t.lightbox.shortcutNavigate },
|
||||
// Journal entries hide the EXIF toggle, so `i` does nothing there.
|
||||
...(showExifToggle ? [{ keys: ['I'], label: t.lightbox.shortcutInfo }] : []),
|
||||
// Absent where the browser has no element fullscreen to give (iPhone).
|
||||
...(canFullscreen
|
||||
? [
|
||||
{
|
||||
keys: ['F'],
|
||||
label: isFullscreen ? t.lightbox.shortcutExitFullscreen : t.lightbox.shortcutFullscreen,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ keys: ['?', 'H'], label: t.lightbox.shortcutList },
|
||||
{ keys: ['Esc'], label: t.lightbox.shortcutClose },
|
||||
];
|
||||
const shortcutRows = LIGHTBOX_SHORTCUTS.filter((shortcut) => {
|
||||
if (shortcut.availability === 'exifPanel') return showExifToggle;
|
||||
if (shortcut.availability === 'fullscreen') return canFullscreen;
|
||||
if (shortcut.availability === 'download') return Boolean(current?.downloadUrl);
|
||||
return true;
|
||||
}).map((shortcut) => {
|
||||
let label: string = t.lightbox[shortcut.labelKey];
|
||||
if (shortcut.labelKey === 'shortcutFullscreen' && isFullscreen) {
|
||||
label = t.lightbox.shortcutExitFullscreen;
|
||||
}
|
||||
if (shortcut.labelKey === 'shortcutSlideshow' && slideshowSeconds !== null) {
|
||||
label = t.lightbox.shortcutSlideshowRunning(slideshowSeconds);
|
||||
}
|
||||
return { keys: shortcutDisplayKeys(shortcut), label };
|
||||
});
|
||||
|
||||
const lightboxJsx = (
|
||||
<div
|
||||
@@ -308,7 +441,7 @@ export function Lightbox({
|
||||
{/* Previous button */}
|
||||
<button
|
||||
className={`${styles.nav} ${styles.navPrev}`}
|
||||
onClick={onPrev}
|
||||
onClick={manualPrev}
|
||||
aria-label={t.lightbox.previous}
|
||||
title={t.lightbox.previousTitle}
|
||||
>
|
||||
@@ -354,7 +487,7 @@ export function Lightbox({
|
||||
<img
|
||||
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
|
||||
src={current.previewUrl}
|
||||
alt=""
|
||||
alt={current.caption ?? ''}
|
||||
draggable={false}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
|
||||
@@ -380,7 +513,7 @@ export function Lightbox({
|
||||
{/* Next button */}
|
||||
<button
|
||||
className={`${styles.nav} ${styles.navNext}`}
|
||||
onClick={onNext}
|
||||
onClick={manualNext}
|
||||
aria-label={t.lightbox.next}
|
||||
title={t.lightbox.nextTitle}
|
||||
>
|
||||
@@ -397,6 +530,82 @@ export function Lightbox({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Slideshow state, announced but not drawn: a badge over a photograph
|
||||
would be paid for by every visitor, and the shortcut list already
|
||||
carries the speed for anyone who wants to see it.
|
||||
*/}
|
||||
<p className="sr-only" role="status">
|
||||
{slideshowSeconds === null
|
||||
? 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">
|
||||
@@ -407,10 +616,11 @@ export function Lightbox({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.bottomBarGroup} ${styles.bottomBarRight}`}>
|
||||
{/* Proofing favorite button */}
|
||||
{proofing && current && (
|
||||
<button
|
||||
className={`${styles.infoToggle} ${styles.favToggle}`}
|
||||
className={styles.infoToggle}
|
||||
style={{
|
||||
color: isFav ? '#ff4d4f' : 'inherit',
|
||||
fontWeight: isFav ? 600 : 400,
|
||||
@@ -437,6 +647,26 @@ export function Lightbox({
|
||||
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clipboard unavailable — show the link rather than fail quietly */}
|
||||
{copyState === 'manual' && (
|
||||
<div className={styles.copyManual} role="status">
|
||||
<label className={styles.copyManualLabel} htmlFor="lightbox-permalink">
|
||||
{t.lightbox.copyManual}
|
||||
</label>
|
||||
<input
|
||||
id="lightbox-permalink"
|
||||
className={styles.copyManualInput}
|
||||
type="text"
|
||||
readOnly
|
||||
autoFocus
|
||||
value={permalink()}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXIF panel */}
|
||||
{showExif && (
|
||||
|
||||
@@ -103,12 +103,20 @@ export function MapView() {
|
||||
)
|
||||
.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 = `
|
||||
<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">
|
||||
<h3 class="map-popup__city">${escapeHtml(loc.city)}</h3>
|
||||
<p class="map-popup__country">${escapeHtml(loc.country)}</p>
|
||||
<h3 class="map-popup__city">${escapeHtml(heading)}</h3>
|
||||
${subheading ? `<p class="map-popup__country">${escapeHtml(subheading)}</p>` : ''}
|
||||
<p class="map-popup__count">${escapeHtml(t.common.photos(loc.photoCount))}</p>
|
||||
<div class="map-popup__albums">${albumLinks}</div>
|
||||
</div>
|
||||
|
||||
34
components/StructuredData.tsx
Normal file
34
components/StructuredData.tsx
Normal 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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -56,9 +56,13 @@ subpages:
|
||||
subtitle: "A collection of 2026 highlights in 35mm film." # Optional: Sub-text for headers
|
||||
password: "your-password" # Optional protection
|
||||
# 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
|
||||
# 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:
|
||||
- "album-uuid-5"
|
||||
- "album-uuid-6":
|
||||
@@ -69,6 +73,32 @@ subpages:
|
||||
# layout: "justified"
|
||||
# columns: 2
|
||||
# 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 ────────────────────────────────────────
|
||||
# Use sections to group albums by location, theme, or any other logic.
|
||||
|
||||
@@ -19,6 +19,9 @@ lang: "en"
|
||||
# titleTemplate: "%s | My Portfolio" # Template for subpages (%s is replaced with subpage/album title)
|
||||
# noIndex: false # Set to true to hide from search engines
|
||||
# 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) ─────────────────────────────────────
|
||||
# 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)
|
||||
# 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:
|
||||
columns: 3
|
||||
|
||||
@@ -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
|
||||
- **Title / Subtitle** — page heading and subline
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
|
||||
@@ -111,14 +111,15 @@ subpages:
|
||||
### Subpage Options
|
||||
|
||||
| Key | Type | Description |
|
||||
| ----------- | ------- | -------------------------------------------------------------------------------------- |
|
||||
| ----------- | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `name` | string | Navigation label; the URL slug is derived from it |
|
||||
| `title` | string | Page heading, defaults to `name` |
|
||||
| `subtitle` | string | Subline under the heading |
|
||||
| `password` | string | Gates the whole subpage — see [Password Protection](#password-protection) |
|
||||
| `albums` | list | Album UUIDs, optionally with [per-album options](#per-album-options) |
|
||||
| `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) |
|
||||
| `coverGrid` | object | Grid override for the album covers on this page — see [Album covers](#album-covers-on-a-subpage) |
|
||||
| `proofing` | boolean | Enables [client proofing](#client-proofing) on this page |
|
||||
| `essayFile` | string | Render a Markdown essay instead of a grid — see [Journal & Photo Essays](journal.md) |
|
||||
| `essayText` | string | Essay Markdown inline; written by the admin block editor |
|
||||
@@ -383,34 +384,48 @@ Available layouts: `masonry`, `uniform`, `showcase`, `filmstrip`,
|
||||
### Album covers on a subpage
|
||||
|
||||
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
|
||||
`settings.yaml` or per page in `gallery.yaml`, and the covers are tiled the same
|
||||
way the photos are. Valid range is 1–6; below 1024px viewport width at most two
|
||||
covers share a row, and below 640px they stack.
|
||||
photos. That grid has its own key, `coverGrid`, which sizes the cover tiles and
|
||||
nothing else. `grid` next to it stays what it always was: the photos inside
|
||||
those albums.
|
||||
|
||||
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
|
||||
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
|
||||
`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
|
||||
preset:
|
||||
of them into one look. Only an explicit `coverGrid.gap` overrides the preset:
|
||||
|
||||
```yaml
|
||||
# gallery.yaml — album covers on this page only
|
||||
# gallery.yaml — the two grids of one page, set independently
|
||||
subpages:
|
||||
- name: Editorial
|
||||
grid:
|
||||
coverGrid:
|
||||
columns: 3 # three covers per row (site-wide default otherwise)
|
||||
gap: 24 # overrides the theme's own cover spacing
|
||||
grid:
|
||||
columns: 4 # the photos inside each album
|
||||
gap: 6
|
||||
albums:
|
||||
- '33333333-3333-3333-3333-333333333333'
|
||||
- '44444444-4444-4444-4444-444444444444'
|
||||
```
|
||||
|
||||
Both fields are editable per page in the admin panel under
|
||||
**Pages › _subpage_ › Album Cover Grid**; left empty they follow the site-wide
|
||||
Both are editable per page in the admin panel, under **Pages › _subpage_ ›
|
||||
Album Cover Grid** and **Photo Grid**; left empty they follow the site-wide
|
||||
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
|
||||
|
||||
Feature toggles in `settings.yaml`:
|
||||
|
||||
58
lib/__tests__/albumNav.test.ts
Normal file
58
lib/__tests__/albumNav.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
89
lib/__tests__/assetLocation.test.ts
Normal file
89
lib/__tests__/assetLocation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,17 @@ vi.mock('@/lib/config', () => ({
|
||||
getConfig: () => ({
|
||||
immich: { apiKey: 'test-api-key-for-auth-tests' },
|
||||
authSecret: 'test-auth-secret-32-chars-long-min',
|
||||
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
|
||||
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
|
||||
subpages: [
|
||||
{
|
||||
name: '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',
|
||||
},
|
||||
{
|
||||
@@ -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', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// header, so only http(s) URLs may survive sanitisation.
|
||||
@@ -81,6 +87,64 @@ describe('slugify', () => {
|
||||
it('preserves numbers', () => {
|
||||
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', () => {
|
||||
|
||||
79
lib/__tests__/derive-location.test.ts
Normal file
79
lib/__tests__/derive-location.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
87
lib/__tests__/downloadName.test.ts
Normal file
87
lib/__tests__/downloadName.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
@@ -82,9 +82,16 @@ describe('dictionary parity', () => {
|
||||
});
|
||||
|
||||
it('leaves no English string untranslated', () => {
|
||||
// Proper nouns and units read the same in both languages; `coverAria` is
|
||||
// pure interpolation with no words of its own.
|
||||
const shared = new Set(['nav.journal', 'journal.title', 'lightbox.iso', 'subpage.coverAria']);
|
||||
// Proper nouns, loanwords and units read the same in both languages;
|
||||
// `coverAria` is pure interpolation with no words of its own.
|
||||
const shared = new Set([
|
||||
'nav.journal',
|
||||
'journal.title',
|
||||
'lightbox.iso',
|
||||
'lightbox.copyLinkShort',
|
||||
'lightbox.downloadShort',
|
||||
'subpage.coverAria',
|
||||
]);
|
||||
|
||||
const identical = enLeaves
|
||||
.filter(([path]) => !shared.has(path))
|
||||
|
||||
@@ -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()', () => {
|
||||
it('orders albums by gallery.yaml order, not Immich API order', async () => {
|
||||
// The Immich API returns albums in its own (creation/update) order;
|
||||
|
||||
135
lib/__tests__/lightboxShortcuts.test.ts
Normal file
135
lib/__tests__/lightboxShortcuts.test.ts
Normal 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_/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
132
lib/__tests__/mapPrecision.test.ts
Normal file
132
lib/__tests__/mapPrecision.test.ts
Normal 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: '' });
|
||||
});
|
||||
});
|
||||
58
lib/__tests__/photoHash.test.ts
Normal file
58
lib/__tests__/photoHash.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
168
lib/__tests__/publicPages.test.ts
Normal file
168
lib/__tests__/publicPages.test.ts
Normal 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(/\/\//);
|
||||
}
|
||||
});
|
||||
});
|
||||
150
lib/__tests__/siteUrl.test.ts
Normal file
150
lib/__tests__/siteUrl.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
29
lib/__tests__/slideshow.test.ts
Normal file
29
lib/__tests__/slideshow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
lib/__tests__/structuredData.test.ts
Normal file
90
lib/__tests__/structuredData.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
73
lib/__tests__/subpage-cover-grid.test.ts
Normal file
73
lib/__tests__/subpage-cover-grid.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
90
lib/__tests__/updateCheck.test.ts
Normal file
90
lib/__tests__/updateCheck.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
assetPlaceholder,
|
||||
assetExifSummary,
|
||||
assetAspectRatio,
|
||||
assetCaption,
|
||||
} from '@/lib/urls';
|
||||
|
||||
describe('imageUrl', () => {
|
||||
@@ -168,3 +169,58 @@ describe('assetAspectRatio', () => {
|
||||
).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
65
lib/albumNav.ts
Normal 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
58
lib/assetLocation.ts
Normal 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);
|
||||
}
|
||||
40
lib/auth.ts
40
lib/auth.ts
@@ -8,6 +8,9 @@
|
||||
|
||||
import crypto from 'crypto';
|
||||
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';
|
||||
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
@@ -92,11 +95,43 @@ function cookieName(key: string, type: ProtectedType): string {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
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') {
|
||||
return config.albumPasswords[key];
|
||||
|
||||
@@ -2,10 +2,12 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { env } from '../env';
|
||||
import { resolveAuthSecret } from '../secret';
|
||||
import { resolveSiteUrl } from '../siteUrl';
|
||||
import { getInstallCredentials } from '../install';
|
||||
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
||||
import { resolveTheme, VALID_LAYOUTS, DEFAULT_PRESET } from './theme';
|
||||
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
|
||||
import { LOCATION_PRECISIONS, isLocationPrecision, type LocationPrecision } from '../mapPrecision';
|
||||
import {
|
||||
slugify,
|
||||
resolveExifDisplay,
|
||||
@@ -56,16 +58,16 @@ export function getConfigOrNull(): AppConfig | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
||||
export function buildSubpageGrid(raw?: {
|
||||
interface RawGridOverrides {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: 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 {
|
||||
grid: {
|
||||
...(raw.columns != null ? { columns: raw.columns } : {}),
|
||||
...(raw.gap != null ? { gap: raw.gap } : {}),
|
||||
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
|
||||
@@ -76,7 +78,34 @@ export function buildSubpageGrid(raw?: {
|
||||
: '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[]>;
|
||||
albumGrids: Record<string, Partial<GridConfig>>;
|
||||
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 albumGrids: Record<string, Partial<GridConfig>> = {};
|
||||
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. */
|
||||
function processAlbumEntry(
|
||||
entry: string | Record<string, string | AlbumEntryObject>,
|
||||
context: string,
|
||||
inheritedLocation?: LocationPrecision,
|
||||
): string | null {
|
||||
if (typeof entry === 'string') {
|
||||
if (inheritedLocation) albumLocationPrecision[entry] = inheritedLocation;
|
||||
return validateUuid(entry, context);
|
||||
}
|
||||
const [uuid, value] = Object.entries(entry)[0];
|
||||
const validatedUuid = validateUuid(uuid, context);
|
||||
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') {
|
||||
albumOverrides[validatedUuid] = value;
|
||||
@@ -246,6 +283,13 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -254,12 +298,29 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
function processAlbumEntries(
|
||||
entries: Array<string | Record<string, string | AlbumEntryObject>>,
|
||||
context: string,
|
||||
inheritedLocation?: LocationPrecision,
|
||||
): string[] {
|
||||
return entries
|
||||
.map((entry) => processAlbumEntry(entry, context))
|
||||
.map((entry) => processAlbumEntry(entry, context, inheritedLocation))
|
||||
.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');
|
||||
|
||||
let subpages: SubpageConfig[] = [];
|
||||
@@ -279,19 +340,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
title: sec.title,
|
||||
slug: slugify(sec.title),
|
||||
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
|
||||
albumIds = sections!.flatMap((sec) => sec.albumIds);
|
||||
// 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];
|
||||
} else {
|
||||
const albums = sp.albums ?? [];
|
||||
if (albums.length === 0) {
|
||||
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 {
|
||||
@@ -307,7 +380,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
essayText: sp.essayText,
|
||||
enabled: sp.enabled !== false,
|
||||
hidden: sp.hidden === true,
|
||||
...buildSubpageGrid(sp.grid),
|
||||
...buildSubpageGrids(sp.grid, sp.coverGrid),
|
||||
};
|
||||
});
|
||||
} else if (gallery.subpages) {
|
||||
@@ -327,14 +400,18 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
slug: slugify(name),
|
||||
title: sp.title,
|
||||
subtitle: sp.subtitle,
|
||||
albumIds: processAlbumEntries(albumEntries, `subpage "${name}"`),
|
||||
albumIds: processAlbumEntries(
|
||||
albumEntries,
|
||||
`subpage "${name}"`,
|
||||
parseLocation(sp.location, `subpage "${name}" location`),
|
||||
),
|
||||
password: sp.password,
|
||||
proofing: sp.proofing,
|
||||
essayFile: sp.essayFile,
|
||||
essayText: sp.essayText,
|
||||
enabled: sp.enabled !== false,
|
||||
hidden: sp.hidden === true,
|
||||
...buildSubpageGrid(sp.grid),
|
||||
...buildSubpageGrids(sp.grid, sp.coverGrid),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -354,6 +431,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
albumManualOrders,
|
||||
albumGrids,
|
||||
albumCoverPositions,
|
||||
albumDownloads,
|
||||
albumLocationPrecision,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -408,10 +487,13 @@ export function getConfig(): AppConfig {
|
||||
title: 'Setup Required',
|
||||
description: 'Please configure Immich Folio',
|
||||
titleTemplate: '%s | Setup Required',
|
||||
license: '',
|
||||
noIndex: true,
|
||||
noFollow: true,
|
||||
},
|
||||
heroImages: [],
|
||||
siteUrl: null,
|
||||
siteUrlSource: 'none',
|
||||
colorMode: 'dark',
|
||||
exifOnHover: true,
|
||||
exif: resolveExifDisplay(),
|
||||
@@ -434,6 +516,8 @@ export function getConfig(): AppConfig {
|
||||
albumManualOrders: {},
|
||||
albumGrids: {},
|
||||
albumCoverPositions: {},
|
||||
albumDownloads: {},
|
||||
albumLocationPrecision: {},
|
||||
navLinks: [],
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
||||
@@ -460,6 +544,8 @@ export function getConfig(): AppConfig {
|
||||
albumManualOrders,
|
||||
albumGrids,
|
||||
albumCoverPositions,
|
||||
albumDownloads,
|
||||
albumLocationPrecision,
|
||||
} = deriveGallery(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}`,
|
||||
noIndex: settings.seo?.noIndex === true,
|
||||
noFollow: settings.seo?.noFollow === true,
|
||||
license: settings.seo?.license?.trim() || '',
|
||||
},
|
||||
heroImages: gallery.hero
|
||||
? (Array.isArray(gallery.hero) ? gallery.hero : [gallery.hero])
|
||||
.map((id) => validateUuid(id, 'gallery.yaml hero'))
|
||||
.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),
|
||||
exifOnHover: settings.exifOnHover !== false,
|
||||
exif: resolveExifDisplay(settings.exif, settings.exifOnHover),
|
||||
@@ -547,6 +638,8 @@ export function getConfig(): AppConfig {
|
||||
albumManualOrders,
|
||||
albumGrids,
|
||||
albumCoverPositions,
|
||||
albumDownloads,
|
||||
albumLocationPrecision,
|
||||
navLinks: sanitizeNavLinks(settings.navLinks),
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AlbumSortMode } from '../albumSort';
|
||||
import type { LocationPrecision } from '../mapPrecision';
|
||||
|
||||
export interface SubpageSectionConfig {
|
||||
title: string;
|
||||
@@ -15,7 +16,13 @@ export interface SubpageConfig {
|
||||
albumIds: string[]; // flat list (all albums, incl. from sections)
|
||||
sections?: SubpageSectionConfig[];
|
||||
password?: string;
|
||||
/** Photo grid for the albums on this page: global < subpage < album. */
|
||||
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;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
@@ -29,12 +36,16 @@ export interface SubpageObjectValue {
|
||||
subtitle?: string;
|
||||
password?: string;
|
||||
grid?: Partial<GridConfig>;
|
||||
/** Album-cover tiles only; falls back to `grid` when unset (#523). */
|
||||
coverGrid?: Partial<GridConfig>;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
/** EXPERIMENTAL: reachable by direct link, but not shown in navigation */
|
||||
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:
|
||||
// the inline copies had already drifted (they never gained `heroImage`), and
|
||||
// this path is live for map-style subpages.
|
||||
@@ -91,6 +102,14 @@ export interface GridConfig {
|
||||
'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
|
||||
* 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.
|
||||
*
|
||||
* `columns` follows the same config as the photo grids (global `settings.yaml`
|
||||
* < per-subpage override), so "3 columns" in the admin means three columns
|
||||
* everywhere. `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.
|
||||
* The overrides come from the subpage's own `coverGrid`, which sizes the cover
|
||||
* tiles and nothing else — it used to be the same `grid` key the photos read,
|
||||
* so a cover gap silently retuned every photo grid on the page (#523).
|
||||
*
|
||||
* `columns` still falls back to the global `settings.yaml` count, so a page
|
||||
* 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
|
||||
* `repeat()` does not reliably accept a `min()` expression.
|
||||
@@ -158,7 +181,16 @@ export interface AppConfig {
|
||||
titleTemplate: string;
|
||||
noIndex: 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[];
|
||||
/** Colour mode a first-time visitor lands on; their own choice overrides it. */
|
||||
colorMode: ColorMode;
|
||||
@@ -198,6 +230,10 @@ export interface AppConfig {
|
||||
albumGrids: Record<string, Partial<GridConfig>>;
|
||||
/** EXPERIMENTAL: per-album focal point for cover crops (CSS object-position). */
|
||||
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. */
|
||||
navLinks: NavLinkConfig[];
|
||||
cacheTtl: number;
|
||||
@@ -258,6 +294,14 @@ export interface AlbumEntryObject {
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
/** EXPERIMENTAL: focal point for the cover crop, e.g. "50% 25%" or "top" */
|
||||
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 {
|
||||
@@ -281,18 +325,29 @@ export interface GalleryYaml {
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
hidden?: boolean;
|
||||
/** Map precision inherited by every album here (#469). */
|
||||
location?: string;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
aspectRatio?: 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 {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
/** Absolute site URL, e.g. https://folio.example. `SITE_URL` overrides it. */
|
||||
url?: string;
|
||||
lang?: string;
|
||||
seo?: {
|
||||
title?: string;
|
||||
@@ -300,6 +355,8 @@ export interface SettingsYaml {
|
||||
titleTemplate?: string;
|
||||
noIndex?: 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. */
|
||||
mode?: ColorMode;
|
||||
@@ -450,11 +507,60 @@ export function resolveWatermarkOpacity(raw?: number): number {
|
||||
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 {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.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
69
lib/downloadName.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export interface Env {
|
||||
ADMIN_PASSWORD?: string;
|
||||
SITE_PASSWORD?: 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 {
|
||||
@@ -111,6 +115,11 @@ function parseEnv(): Env {
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
|
||||
SITE_PASSWORD: process.env.SITE_PASSWORD || 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ export const de: Dictionary = {
|
||||
photos: (n: number) => plural(n, 'Foto', 'Fotos'),
|
||||
albums: (n: number) => plural(n, 'Album', 'Alben'),
|
||||
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',
|
||||
loadingPhotos: 'Fotos werden geladen',
|
||||
},
|
||||
@@ -123,11 +128,25 @@ export const de: Dictionary = {
|
||||
iso: 'ISO',
|
||||
location: 'Ort',
|
||||
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',
|
||||
shortcutNavigate: 'Vorheriges / nächstes Foto',
|
||||
shortcutInfo: 'Fotoinfos',
|
||||
shortcutFullscreen: 'Vollbild',
|
||||
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',
|
||||
shortcutClose: 'Ansicht schließen',
|
||||
},
|
||||
|
||||
@@ -29,6 +29,11 @@ export const en = {
|
||||
photos: (n: number) => plural(n, 'photo', 'photos'),
|
||||
albums: (n: number) => plural(n, 'album', 'albums'),
|
||||
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',
|
||||
loadingPhotos: 'Loading photos',
|
||||
},
|
||||
@@ -123,11 +128,25 @@ export const en = {
|
||||
iso: 'ISO',
|
||||
location: 'Location',
|
||||
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',
|
||||
shortcutNavigate: 'Previous / next photo',
|
||||
shortcutInfo: 'Photo info',
|
||||
shortcutFullscreen: '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',
|
||||
shortcutClose: 'Close the viewer',
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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 { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
|
||||
|
||||
@@ -423,7 +423,7 @@ class ImmichClient {
|
||||
...album,
|
||||
albumName: name,
|
||||
description,
|
||||
slug: slugify(name),
|
||||
slug: albumSlug(name, album.id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -530,9 +530,8 @@ class ImmichClient {
|
||||
subpageSlug: string,
|
||||
forceFresh = false,
|
||||
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||
const subpage = this.config.subpages.find(
|
||||
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
|
||||
);
|
||||
const wanted = normalizeSlug(subpageSlug);
|
||||
const subpage = this.config.subpages.find((sp) => sp.slug === wanted && sp.enabled !== false);
|
||||
if (!subpage) return null;
|
||||
|
||||
const allAlbums = await this.getAlbums(forceFresh);
|
||||
@@ -700,7 +699,7 @@ class ImmichClient {
|
||||
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
||||
album.albumName = name;
|
||||
album.description = description;
|
||||
album.slug = slugify(name);
|
||||
album.slug = albumSlug(name, album.id);
|
||||
|
||||
this.cacheSet(cacheKey, album);
|
||||
return album;
|
||||
@@ -728,13 +727,15 @@ class ImmichClient {
|
||||
|
||||
let searchSet = albums;
|
||||
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;
|
||||
const subpageIds = new Set(subpage.albumIds);
|
||||
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;
|
||||
return this.getAlbum(match.id, forceFresh);
|
||||
}
|
||||
@@ -743,7 +744,8 @@ class ImmichClient {
|
||||
* Check if a slug corresponds to a subpage.
|
||||
*/
|
||||
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
171
lib/lightboxShortcuts.ts
Normal 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
107
lib/mapPrecision.ts
Normal 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
36
lib/photoHash.ts
Normal 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
96
lib/publicPages.ts
Normal 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
50
lib/siteShape.ts
Normal 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
94
lib/siteUrl.ts
Normal 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
24
lib/slideshow.ts
Normal 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
60
lib/structuredData.ts
Normal 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
134
lib/updateCheck.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
31
lib/urls.ts
31
lib/urls.ts
@@ -96,6 +96,37 @@ export function assetExifSummary(asset: Pick<ImmichAsset, 'exifInfo'>): ExifSumm
|
||||
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.
|
||||
* Returns undefined if dimensions are not available.
|
||||
|
||||
108
package-lock.json
generated
108
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-folio",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-folio",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -14,7 +14,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"js-yaml": "^4.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"thumbhash": "^0.1.1"
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"prettier": "^3.9.6",
|
||||
@@ -1623,15 +1623,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
|
||||
"integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz",
|
||||
"integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz",
|
||||
"integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.1.tgz",
|
||||
"integrity": "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1640,9 +1640,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
|
||||
"integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz",
|
||||
"integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1656,9 +1656,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
|
||||
"integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz",
|
||||
"integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1672,9 +1672,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
|
||||
"integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz",
|
||||
"integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1691,9 +1691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
|
||||
"integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz",
|
||||
"integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1710,9 +1710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
|
||||
"integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz",
|
||||
"integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1729,9 +1729,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
|
||||
"integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz",
|
||||
"integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1748,9 +1748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
|
||||
"integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz",
|
||||
"integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1764,9 +1764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
|
||||
"integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz",
|
||||
"integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2221,9 +2221,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -4054,13 +4054,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz",
|
||||
"integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.1.tgz",
|
||||
"integrity": "sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
@@ -5835,13 +5835,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
|
||||
"integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
|
||||
"version": "16.3.1",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz",
|
||||
"integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.3.0",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"@next/env": "16.3.1",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.5.23",
|
||||
@@ -5854,14 +5854,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.3.0",
|
||||
"@next/swc-darwin-x64": "16.3.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.0",
|
||||
"@next/swc-linux-arm64-musl": "16.3.0",
|
||||
"@next/swc-linux-x64-gnu": "16.3.0",
|
||||
"@next/swc-linux-x64-musl": "16.3.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.0",
|
||||
"@next/swc-win32-x64-msvc": "16.3.0",
|
||||
"@next/swc-darwin-arm64": "16.3.1",
|
||||
"@next/swc-darwin-x64": "16.3.1",
|
||||
"@next/swc-linux-arm64-gnu": "16.3.1",
|
||||
"@next/swc-linux-arm64-musl": "16.3.1",
|
||||
"@next/swc-linux-x64-gnu": "16.3.1",
|
||||
"@next/swc-linux-x64-musl": "16.3.1",
|
||||
"@next/swc-win32-arm64-msvc": "16.3.1",
|
||||
"@next/swc-win32-x64-msvc": "16.3.1",
|
||||
"sharp": "^0.35.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-folio",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"js-yaml": "^4.3.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "16.3.0",
|
||||
"next": "16.3.1",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"thumbhash": "^0.1.1"
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.0",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"prettier": "^3.9.6",
|
||||
|
||||
301
pnpm-lock.yaml
generated
301
pnpm-lock.yaml
generated
@@ -24,8 +24,8 @@ importers:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
next:
|
||||
specifier: 16.3.0
|
||||
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)
|
||||
specifier: 16.3.1
|
||||
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:
|
||||
specifier: 19.2.8
|
||||
version: 19.2.8
|
||||
@@ -61,8 +61,8 @@ importers:
|
||||
specifier: ^9
|
||||
version: 9.39.5
|
||||
eslint-config-next:
|
||||
specifier: 16.3.0
|
||||
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)
|
||||
specifier: 16.3.1
|
||||
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:
|
||||
specifier: ^10.1.8
|
||||
version: 10.1.8(eslint@9.39.5)
|
||||
@@ -601,67 +601,67 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.2.2':
|
||||
resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==}
|
||||
'@napi-rs/wasm-runtime@1.2.3':
|
||||
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3
|
||||
'@emnapi/runtime': ^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.4
|
||||
|
||||
'@next/env@16.3.0':
|
||||
resolution: {integrity: sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==}
|
||||
'@next/env@16.3.1':
|
||||
resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==}
|
||||
|
||||
'@next/eslint-plugin-next@16.3.0':
|
||||
resolution: {integrity: sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==}
|
||||
'@next/eslint-plugin-next@16.3.1':
|
||||
resolution: {integrity: sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.3.0':
|
||||
resolution: {integrity: sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==}
|
||||
'@next/swc-darwin-arm64@16.3.1':
|
||||
resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.3.0':
|
||||
resolution: {integrity: sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==}
|
||||
'@next/swc-darwin-x64@16.3.1':
|
||||
resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.3.0':
|
||||
resolution: {integrity: sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==}
|
||||
'@next/swc-linux-arm64-gnu@16.3.1':
|
||||
resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.3.0':
|
||||
resolution: {integrity: sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==}
|
||||
'@next/swc-linux-arm64-musl@16.3.1':
|
||||
resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.3.0':
|
||||
resolution: {integrity: sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==}
|
||||
'@next/swc-linux-x64-gnu@16.3.1':
|
||||
resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.3.0':
|
||||
resolution: {integrity: sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==}
|
||||
'@next/swc-linux-x64-musl@16.3.1':
|
||||
resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.3.0':
|
||||
resolution: {integrity: sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==}
|
||||
'@next/swc-win32-arm64-msvc@16.3.1':
|
||||
resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.3.0':
|
||||
resolution: {integrity: sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==}
|
||||
'@next/swc-win32-x64-msvc@16.3.1':
|
||||
resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -835,8 +835,8 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
'@swc/helpers@0.5.23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@tybys/wasm-util@0.10.3':
|
||||
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
|
||||
@@ -879,63 +879,63 @@ packages:
|
||||
'@types/react@19.2.18':
|
||||
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.66.0':
|
||||
resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==}
|
||||
'@typescript-eslint/eslint-plugin@8.67.0':
|
||||
resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.66.0
|
||||
'@typescript-eslint/parser': ^8.67.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.66.0':
|
||||
resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==}
|
||||
'@typescript-eslint/parser@8.67.0':
|
||||
resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.66.0':
|
||||
resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==}
|
||||
'@typescript-eslint/project-service@8.67.0':
|
||||
resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.66.0':
|
||||
resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==}
|
||||
'@typescript-eslint/scope-manager@8.67.0':
|
||||
resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.66.0':
|
||||
resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==}
|
||||
'@typescript-eslint/tsconfig-utils@8.67.0':
|
||||
resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.66.0':
|
||||
resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==}
|
||||
'@typescript-eslint/type-utils@8.67.0':
|
||||
resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/types@8.66.0':
|
||||
resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==}
|
||||
'@typescript-eslint/types@8.67.0':
|
||||
resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.66.0':
|
||||
resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==}
|
||||
'@typescript-eslint/typescript-estree@8.67.0':
|
||||
resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.66.0':
|
||||
resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==}
|
||||
'@typescript-eslint/utils@8.67.0':
|
||||
resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.66.0':
|
||||
resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==}
|
||||
'@typescript-eslint/visitor-keys@8.67.0':
|
||||
resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
|
||||
@@ -1185,8 +1185,8 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
baseline-browser-mapping@2.11.13:
|
||||
resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==}
|
||||
baseline-browser-mapping@2.11.16:
|
||||
resolution: {integrity: sha512-H/bNPUFHewJHyCTdjn1n3Pit5+2GmWT6mmeHImPX+8MA9NA6b67jO4gYmi4jTbCJb2otq34KMZnovndDPqJwhQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1311,8 +1311,8 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
electron-to-chromium@1.5.403:
|
||||
resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==}
|
||||
electron-to-chromium@1.5.412:
|
||||
resolution: {integrity: sha512-z4rMe3esBzlzovKHj4gxJnsCGZRK5l4baUvm+gCGJBPE+gsyUMKsuU9tnEUtI1dOebXz1ytAPGjvXhmQ7rIPwA==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
@@ -1369,8 +1369,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-next@16.3.0:
|
||||
resolution: {integrity: sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==}
|
||||
eslint-config-next@16.3.1:
|
||||
resolution: {integrity: sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@@ -1482,6 +1482,7 @@ packages:
|
||||
eslint@9.39.5:
|
||||
resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
|
||||
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
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
@@ -1607,8 +1608,8 @@ packages:
|
||||
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-tsconfig@4.14.1:
|
||||
resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==}
|
||||
get-tsconfig@4.14.3:
|
||||
resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
@@ -1935,8 +1936,8 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
next@16.3.0:
|
||||
resolution: {integrity: sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==}
|
||||
next@16.3.1:
|
||||
resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -2340,8 +2341,8 @@ packages:
|
||||
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
typescript-eslint@8.66.0:
|
||||
resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==}
|
||||
typescript-eslint@8.67.0:
|
||||
resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
@@ -2362,8 +2363,8 @@ packages:
|
||||
unrs-resolver@1.12.2:
|
||||
resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
|
||||
|
||||
update-browserslist-db@1.3.0:
|
||||
resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==}
|
||||
update-browserslist-db@1.3.1:
|
||||
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
@@ -2925,44 +2926,44 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@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:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@tybys/wasm-util': 0.10.3
|
||||
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:
|
||||
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5)
|
||||
fast-glob: 3.3.1
|
||||
transitivePeerDependencies:
|
||||
- eslint
|
||||
|
||||
'@next/swc-darwin-arm64@16.3.0':
|
||||
'@next/swc-darwin-arm64@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.3.0':
|
||||
'@next/swc-darwin-x64@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.3.0':
|
||||
'@next/swc-linux-arm64-gnu@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.3.0':
|
||||
'@next/swc-linux-arm64-musl@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.3.0':
|
||||
'@next/swc-linux-x64-gnu@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.3.0':
|
||||
'@next/swc-linux-x64-musl@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.3.0':
|
||||
'@next/swc-win32-arm64-msvc@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.3.0':
|
||||
'@next/swc-win32-x64-msvc@16.3.1':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@@ -3064,7 +3065,7 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -3108,14 +3109,14 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/type-utils': 8.66.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/visitor-keys': 8.66.0
|
||||
'@typescript-eslint/parser': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.67.0
|
||||
'@typescript-eslint/type-utils': 8.67.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.67.0
|
||||
eslint: 9.39.5
|
||||
ignore: 7.0.6
|
||||
natural-compare: 1.4.0
|
||||
@@ -3124,41 +3125,41 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.66.0
|
||||
'@typescript-eslint/scope-manager': 8.67.0
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.67.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.5
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.66.0':
|
||||
'@typescript-eslint/scope-manager@8.67.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/visitor-keys': 8.66.0
|
||||
'@typescript-eslint/types': 8.67.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:
|
||||
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:
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.66.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.5
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
@@ -3166,14 +3167,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/project-service': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/visitor-keys': 8.66.0
|
||||
'@typescript-eslint/project-service': 8.67.0(typescript@5.9.3)
|
||||
'@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
'@typescript-eslint/visitor-keys': 8.67.0
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
@@ -3183,20 +3184,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5)
|
||||
'@typescript-eslint/scope-manager': 8.66.0
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/scope-manager': 8.67.0
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
|
||||
eslint: 9.39.5
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.66.0':
|
||||
'@typescript-eslint/visitor-keys@8.67.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.66.0
|
||||
'@typescript-eslint/types': 8.67.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.12.2':
|
||||
@@ -3257,7 +3258,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@emnapi/core': 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
|
||||
|
||||
'@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
|
||||
@@ -3434,7 +3435,7 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.11.13: {}
|
||||
baseline-browser-mapping@2.11.16: {}
|
||||
|
||||
brace-expansion@1.1.18:
|
||||
dependencies:
|
||||
@@ -3451,11 +3452,11 @@ snapshots:
|
||||
|
||||
browserslist@4.28.8:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.11.13
|
||||
baseline-browser-mapping: 2.11.16
|
||||
caniuse-lite: 1.0.30001809
|
||||
electron-to-chromium: 1.5.403
|
||||
electron-to-chromium: 1.5.412
|
||||
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:
|
||||
dependencies:
|
||||
@@ -3560,7 +3561,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
electron-to-chromium@1.5.403: {}
|
||||
electron-to-chromium@1.5.412: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
@@ -3710,18 +3711,18 @@ snapshots:
|
||||
|
||||
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:
|
||||
'@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-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-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-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.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-react: 7.37.5(eslint@9.39.5)
|
||||
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5)
|
||||
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:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -3742,33 +3743,33 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.5
|
||||
get-tsconfig: 4.14.1
|
||||
get-tsconfig: 4.14.3
|
||||
is-bun-module: 2.0.0
|
||||
stable-hash: 0.0.5
|
||||
tinyglobby: 0.2.17
|
||||
unrs-resolver: 1.12.2
|
||||
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:
|
||||
- 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:
|
||||
debug: 3.2.7
|
||||
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-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:
|
||||
- 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:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -3779,7 +3780,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 9.39.5
|
||||
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
|
||||
is-core-module: 2.16.2
|
||||
is-glob: 4.0.3
|
||||
@@ -3791,7 +3792,7 @@ snapshots:
|
||||
string.prototype.trimend: 1.0.10
|
||||
tsconfig-paths: 3.15.0
|
||||
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:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
@@ -4034,7 +4035,7 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
get-tsconfig@4.14.1:
|
||||
get-tsconfig@4.14.3:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
@@ -4347,25 +4348,25 @@ snapshots:
|
||||
|
||||
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:
|
||||
'@next/env': 16.3.0
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.11.13
|
||||
'@next/env': 16.3.1
|
||||
'@swc/helpers': 0.5.23
|
||||
baseline-browser-mapping: 2.11.16
|
||||
caniuse-lite: 1.0.30001809
|
||||
postcss: 8.5.23
|
||||
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)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.3.0
|
||||
'@next/swc-darwin-x64': 16.3.0
|
||||
'@next/swc-linux-arm64-gnu': 16.3.0
|
||||
'@next/swc-linux-arm64-musl': 16.3.0
|
||||
'@next/swc-linux-x64-gnu': 16.3.0
|
||||
'@next/swc-linux-x64-musl': 16.3.0
|
||||
'@next/swc-win32-arm64-msvc': 16.3.0
|
||||
'@next/swc-win32-x64-msvc': 16.3.0
|
||||
'@next/swc-darwin-arm64': 16.3.1
|
||||
'@next/swc-darwin-x64': 16.3.1
|
||||
'@next/swc-linux-arm64-gnu': 16.3.1
|
||||
'@next/swc-linux-arm64-musl': 16.3.1
|
||||
'@next/swc-linux-x64-gnu': 16.3.1
|
||||
'@next/swc-linux-x64-musl': 16.3.1
|
||||
'@next/swc-win32-arm64-msvc': 16.3.1
|
||||
'@next/swc-win32-x64-msvc': 16.3.1
|
||||
'@playwright/test': 1.62.1
|
||||
sharp: 0.35.3(@types/node@20.19.43)
|
||||
transitivePeerDependencies:
|
||||
@@ -4863,12 +4864,12 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
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:
|
||||
'@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/parser': 8.66.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.66.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.66.0(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.67.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
'@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.67.0(eslint@9.39.5)(typescript@5.9.3)
|
||||
eslint: 9.39.5
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -4912,7 +4913,7 @@ snapshots:
|
||||
'@unrs/resolver-binding-win32-ia32-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:
|
||||
browserslist: 4.28.8
|
||||
escalade: 3.2.0
|
||||
|
||||
Reference in New Issue
Block a user