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

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