release: v0.13.0 (#535)

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

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

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

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

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Ralksta
2026-08-25 10:08:12 +02:00
committed by GitHub
parent 10ec68f493
commit a813433891
73 changed files with 4697 additions and 453 deletions

View File

@@ -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} />}
</>
);
}

View File

@@ -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'}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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

View File

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

View File

@@ -35,6 +35,10 @@ import {
COVER_GRID_COLUMNS_MAX,
COVER_GRID_COLUMNS_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&apos;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&apos;s own spacing. Tablet widths show
at most two covers per row.
</p>
</div>
)}
{/* Photo grid — the albums opened from this page.
Separate from the cover grid above: one field
used to drive both, so a cover gap retuned
every photo grid on the page (#523). */}
{sp.essayText == null && (
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Photo Grid (Albums On This Page)</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="number"
min={PHOTO_GRID_COLUMNS_MIN}
max={PHOTO_GRID_COLUMNS_MAX}
value={sp.grid?.columns ?? ''}
placeholder="Columns (site default)"
onChange={(e) => {
const raw = e.target.value;
const columns = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
columns:
columns != null &&
columns >= PHOTO_GRID_COLUMNS_MIN &&
columns <= PHOTO_GRID_COLUMNS_MAX
? columns
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
<input
type="number"
min={0}
max={PHOTO_GRID_GAP_MAX}
value={sp.grid?.gap ?? ''}
placeholder="Gap in px (theme default)"
onChange={(e) => {
const raw = e.target.value;
const gap = raw === '' ? undefined : Number(raw);
updateSubpage(spIndex, {
grid: normalizeSubpageGrid({
...(sp.grid || {}),
gap:
gap != null && gap >= 0 && gap <= PHOTO_GRID_GAP_MAX
? gap
: undefined,
}),
});
}}
style={{
padding: '8px 12px',
borderRadius: '6px',
flex: 1,
fontSize: '0.9rem',
}}
/>
</div>
<p className="admin-field-hint">
How the photos inside every album on this page are laid out.
Leave both empty to follow Settings &rsaquo; Grid; a single
album can still override this in gallery.yaml.
</p>
</div>
)}

View File

@@ -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
View File

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

View File

@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { 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. */

View File

@@ -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,
});
}

View File

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

View File

@@ -11,6 +11,8 @@ import { decodeAssetId } from '@/lib/tokens';
import { getConfig } from '@/lib/config';
import { 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 } : {}),
};

View File

@@ -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');
});
});

View File

@@ -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) => ({

View File

@@ -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;

View File

@@ -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),
};
});

View File

@@ -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
View File

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

32
app/sitemap.ts Normal file
View File

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