diff --git a/.env.local.example b/.env.local.example index a4bfb4d..ef7f6b3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/.gitignore b/.gitignore index 9dfc779..7caad44 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9768591..06f4860 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index 995fcfa..330c9ef 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -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 | 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 && } {heroImageUrl && (
+ {nav && } ); } diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index d2c4ca7..69613d6 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -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({ > { - 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 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) => 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): 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 ( 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} diff --git a/app/admin/admin.css b/app/admin/admin.css index b88f86c..1db5b18 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -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; diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx index abe51f9..a2c982f 100644 --- a/app/admin/components/AdminDashboard.tsx +++ b/app/admin/components/AdminDashboard.tsx @@ -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) { In-Memory Cache {status?.cache?.size ?? 0} items
+ {/* + 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). + */} +
+ Version + {status?.update?.updateAvailable ? ( + + {status.update.current} → {status.update.latest} + + ) : ( + {status?.update?.current ?? '—'} + )} +
+
+ + + setShowDoctor(false)} /> + + ); +} diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx index 87d5553..e39122c 100644 --- a/app/admin/components/PageBuilder.tsx +++ b/app/admin/components/PageBuilder.tsx @@ -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).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() { />

- 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. +

+ + )} + + {/* 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 && ( +
+ +
+ { + 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', + }} + /> + { + 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', + }} + /> +
+

+ 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.

)} diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx index 09d9b2e..f45e7ac 100644 --- a/app/admin/components/SettingsEditor.tsx +++ b/app/admin/components/SettingsEditor.tsx @@ -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({}); + /** 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) {
- + update('grid.columns', parseInt(e.target.value) || 3)} /> @@ -1534,7 +1550,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) { update('grid.gap', parseInt(e.target.value) || 0)} /> @@ -1786,6 +1802,28 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
+
+ + update('url', e.target.value)} + placeholder="https://folio.example" + /> +

+ Needed for sitemap.xml, 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' && ( + <> +
+ Currently supplied by the SITE_URL environment variable ( + {siteUrlInfo.effective}). A value entered here takes precedence + once saved. + + )} +

+
+
; +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 75951bf..4fa0b28 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -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. */ diff --git a/app/api/admin/status/route.ts b/app/api/admin/status/route.ts index 5872844..842f44d 100644 --- a/app/api/admin/status/route.ts +++ b/app/api/admin/status/route.ts @@ -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, }); } diff --git a/app/api/download/[album]/[id]/route.ts b/app/api/download/[album]/[id]/route.ts new file mode 100644 index 0000000..1746881 --- /dev/null +++ b/app/api/download/[album]/[id]/route.ts @@ -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 = { + '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 }); +} diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 839aff1..a39d8d4 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -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 } : {}), }; diff --git a/app/api/exif/__tests__/exif-route.test.ts b/app/api/exif/__tests__/exif-route.test.ts index f5101d3..d95ab76 100644 --- a/app/api/exif/__tests__/exif-route.test.ts +++ b/app/api/exif/__tests__/exif-route.test.ts @@ -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; const mockAssetInfo = immich.getAssetInfo as unknown as ReturnType; +const mockGetAlbum = immich.getAlbum as unknown as ReturnType; 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) { + 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'); + }); +}); diff --git a/app/api/map/route.ts b/app/api/map/route.ts index fd55593..a4edb3c 100644 --- a/app/api/map/route.ts +++ b/app/api/map/route.ts @@ -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) => ({ diff --git a/app/globals.css b/app/globals.css index ac0be7b..d8870ba 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/app/journal/[slug]/page.tsx b/app/journal/[slug]/page.tsx index ff08f92..b17d1d5 100644 --- a/app/journal/[slug]/page.tsx +++ b/app/journal/[slug]/page.tsx @@ -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), }; }); diff --git a/app/layout.tsx b/app/layout.tsx index b219c4f..51717b6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -47,6 +47,10 @@ export async function generateMetadata(): Promise { }; 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, diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..330c012 --- /dev/null +++ b/app/robots.ts @@ -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 } : {}), + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..1817185 --- /dev/null +++ b/app/sitemap.ts @@ -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 { + 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 }] : []; + }); +} diff --git a/components/AlbumNav.tsx b/components/AlbumNav.tsx new file mode 100644 index 0000000..a9ceae9 --- /dev/null +++ b/components/AlbumNav.tsx @@ -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 ( + + ); +} + +export function AlbumNav({ prev, next }: AlbumNavPair) { + const t = getServerDictionary(); + if (!prev && !next) return null; + + return ( + + ); +} diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css index 43cf03b..17f5810 100644 --- a/components/Lightbox.module.css +++ b/components/Lightbox.module.css @@ -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 { diff --git a/components/Lightbox.tsx b/components/Lightbox.tsx index 74deace..9acb051 100644 --- a/components/Lightbox.tsx +++ b/components/Lightbox.tsx @@ -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(null); const overlayRef = useRef(null); const closeBtnRef = useRef(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 = (
@@ -354,7 +487,7 @@ export function Lightbox({ setImageLoaded(true)} onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)} @@ -380,7 +513,7 @@ export function Lightbox({ {/* Next button */} - {/* Counter */} -
- - Photo {currentIndex + 1} of {assets.length} - - + {/* + 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. + */} +

+ {slideshowSeconds === null + ? t.lightbox.slideshowStopped + : t.lightbox.slideshowRunning(slideshowSeconds)} +

+ + {/* + 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. + */} +
+
+ {/* Copy link — bottom left, opposite the info toggle */} + + + {/* Download the original — only when the album offers it (#475) */} + {current?.downloadUrl && ( + + + {t.lightbox.downloadShort} + + )} +
+ + {/* Counter */} +
+ + Photo {currentIndex + 1} of {assets.length} + + +
+ +
+ {/* Proofing favorite button */} + {proofing && current && ( + + )} + + {/* EXIF toggle */} + {showExifToggle && ( + + )} +
- {/* Proofing favorite button */} - {proofing && current && ( - - )} - - {/* EXIF toggle */} - {showExifToggle && ( - + {/* Clipboard unavailable — show the link rather than fail quietly */} + {copyState === 'manual' && ( +
+ + e.currentTarget.select()} + /> +
)} {/* EXIF panel */} diff --git a/components/MapView.tsx b/components/MapView.tsx index e8dc969..944e40e 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -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 = `
- ${escapeHtml(loc.city)} + ${escapeHtml(heading)}
-

${escapeHtml(loc.city)}

-

${escapeHtml(loc.country)}

+

${escapeHtml(heading)}

+ ${subheading ? `

${escapeHtml(subheading)}

` : ''}

${escapeHtml(t.common.photos(loc.photoCount))}

${albumLinks}
diff --git a/components/StructuredData.tsx b/components/StructuredData.tsx new file mode 100644 index 0000000..97b1013 --- /dev/null +++ b/components/StructuredData.tsx @@ -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 ` 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, '\\u003e'); +} + +export async function StructuredData({ data }: { data: unknown }) { + if (!data) return null; + const nonce = (await headers()).get('x-nonce') ?? undefined; + + return ( +