release: v0.16.0

See CHANGELOG.md for the full v0.16.0 entry.

---------

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>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-09-22 14:12:13 +02:00
committed by GitHub
parent c3f92ca4ec
commit a3a6319c29
120 changed files with 6818 additions and 975 deletions

View File

@@ -27,6 +27,7 @@ import AlbumDrawer from './page-builder/AlbumDrawer';
import { SortableAlbumCard } from './page-builder/AlbumCard';
import SubpageDrawer from './page-builder/SubpageDrawer';
import { findAlbumAddress } from './page-builder/findAlbumAddress';
import { parseAlbumEntries, serializeAlbumEntries } from './page-builder/albumEntries';
import {
seedCoverGrid,
type ActiveEditAlbumAddress,
@@ -40,8 +41,7 @@ import {
} from './page-builder/types';
import { useScrollLock } from './useScrollLock';
import { useUnsavedGuard } from './useUnsavedGuard';
import { DEFAULT_ALBUM_SORT, isAlbumSortMode } from '@/lib/albumSort';
import { type AlbumEntryObject } from '@/lib/config/schema';
import { reportIfSessionExpired } from './sessionExpiry';
import {
IconCamera,
IconFolder,
@@ -62,73 +62,6 @@ interface GalleryState {
subpages: Subpage[];
}
// ── Helpers ────────────────────────────────────────────────────
type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
/**
* Whether an entry carries anything beyond its ID.
*
* Both collapse rules below depend on this, and they are the reason every new
* per-album option has to be listed here: an entry that looks "empty" is
* serialized back to a bare UUID string, so a field missing from this check is
* silently dropped on the next save.
*/
function hasAlbumOptions(entry: AlbumEntry): boolean {
return Boolean(
entry.description ||
entry.password ||
entry.heroImage ||
(entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
entry.assetOrder?.length ||
entry.grid ||
entry.coverPosition,
);
}
function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
const [id, value] = Object.entries(entry)[0];
if (typeof value === 'string') return { id, title: value };
return {
id,
title: value.title,
description: value.description,
password: value.password,
heroImage: value.heroImage,
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
assetOrder: value.assetOrder,
grid: value.grid,
coverPosition: value.coverPosition,
};
});
}
function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
const extras = hasAlbumOptions(entry);
if (!entry.title && !extras) return entry.id;
if (entry.title && !extras) return { [entry.id]: entry.title };
const val: AlbumEntryObject = {};
// Only when set: a sort-only entry would otherwise be written with an empty
// title, which deriveGallery ignores but which still lands in the YAML.
if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
// Persisted regardless of the mode, so manual → newest → manual does not
// throw away a hand-curated order.
if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
if (entry.grid) val.grid = entry.grid;
if (entry.coverPosition) val.coverPosition = entry.coverPosition;
return { [entry.id]: val };
});
}
// ── Sortable Hero Tile ─────────────────────────────────────────
function SortableHeroTile({
@@ -269,6 +202,12 @@ export default function PageBuilder() {
const [gallery, setGallery] = useState<GalleryState>({ hero: [], albums: [], subpages: [] });
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbumInfo[]>([]);
const [loading, setLoading] = useState(true);
/**
* Set when gallery.yaml could not be fetched. It blocks saving: the builder
* writes the whole file from its own state, so saving an empty builder
* publishes an empty gallery.
*/
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
@@ -356,25 +295,35 @@ export default function PageBuilder() {
async function loadData() {
setLoading(true);
setLoadError(null);
try {
const [galleryRes, albumsRes] = await Promise.all([
fetch('/api/admin/gallery'),
fetch('/api/admin/albums'),
]);
if (galleryRes.ok) {
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
if (!galleryRes.ok) {
throw new Error(
galleryRes.status === 401
? 'Your session has expired. Sign in again to continue.'
: `The server answered ${galleryRes.status}.`,
);
}
const { gallery: raw } = await galleryRes.json();
const parsed = parseGalleryYaml(raw);
setGallery(parsed);
openAlbumFromLink(parsed);
// A failed album list is survivable — it only empties the picker, and
// saving with it empty changes nothing in gallery.yaml.
if (albumsRes.ok) {
const { albums } = await albumsRes.json();
setImmichAlbums(albums);
}
} catch (err) {
console.error('Failed to load admin data:', err);
setLoadError(err instanceof Error ? err.message : 'The page structure could not be loaded.');
} finally {
setLoading(false);
}
@@ -424,6 +373,7 @@ export default function PageBuilder() {
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
@@ -451,6 +401,7 @@ export default function PageBuilder() {
password: sp.password as string | undefined,
enabled: sp.enabled !== false,
hidden: sp.hidden === true,
location: sp.location as string | undefined,
essayText: sp.essayText as string | undefined,
essayFile: sp.essayFile as string | undefined,
albums: parseAlbumEntries(
@@ -474,6 +425,9 @@ export default function PageBuilder() {
// ── Save ──────────────────────────────────────────────────────
async function handleSave() {
// Not reachable from the UI in this state, but Cmd+S still gets here.
if (loadError) return;
setSaving(true);
setSaveMessage('');
@@ -497,6 +451,7 @@ export default function PageBuilder() {
if (sp.essayFile) entry.essayFile = sp.essayFile;
if (sp.grid) entry.grid = sp.grid;
if (sp.coverGrid) entry.coverGrid = sp.coverGrid;
if (sp.location) entry.location = sp.location;
if (sp.sections && sp.sections.length > 0) {
entry.sections = sp.sections.map((sec) => {
@@ -530,7 +485,7 @@ export default function PageBuilder() {
setDirty(false);
setSaveMessage(data.message || 'Saved successfully!');
setTimeout(() => setSaveMessage(''), 5000);
} else {
} else if (!reportIfSessionExpired(res)) {
const err = await res.json();
setSaveMessage(`Error: ${err.error}`);
}
@@ -729,7 +684,12 @@ export default function PageBuilder() {
}
// ── Album Removal ────────────────────────────────────────────
// Confirmed like removeSubpage: an album entry carries its grid overrides,
// cover asset and manual assetOrder with it, there is no undo stack in the
// builder, and the only escape from an accidental click used to be
// reloading the page — which discards every other unsaved edit too (#597).
function removeStandaloneAlbum(index: number) {
if (!confirm('Remove this album from the gallery?')) return;
setGallery((g) => ({
...g,
albums: g.albums.filter((_, i) => i !== index),
@@ -738,6 +698,7 @@ export default function PageBuilder() {
}
function removeSubpageAlbum(subpageIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the subpage?')) return;
setGallery((g) => {
const subpages = [...g.subpages];
const sp = { ...subpages[subpageIndex] };
@@ -749,6 +710,7 @@ export default function PageBuilder() {
}
function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) {
if (!confirm('Remove this album from the section?')) return;
setGallery((g) => {
const subpages = [...g.subpages];
const sp = { ...subpages[subpageIndex] };
@@ -765,6 +727,7 @@ export default function PageBuilder() {
// ── Hero Management ──────────────────────────────────────────
function removeHero(index: number) {
if (!confirm('Remove this photo from the home page hero?')) return;
setGallery((g) => ({
...g,
hero: g.hero.filter((_, i) => i !== index),
@@ -813,6 +776,26 @@ export default function PageBuilder() {
);
}
/**
* Replaces the builder rather than sitting above it. An empty builder looks
* exactly like a site with no pages yet, and saving it used to publish an
* empty gallery.yaml over a working one.
*/
if (loadError) {
return (
<div className="admin-error" role="alert">
<strong>The page structure could not be loaded.</strong> {loadError}
<p>
Nothing has been changed. Saving stays disabled until it loads, so an empty builder cannot
overwrite your gallery.
</p>
<button className="admin-btn admin-btn-secondary" onClick={loadData}>
Try again
</button>
</div>
);
}
// Filter standalone albums
const filteredAlbums = gallery.albums.filter((album) => {
if (!searchQuery) return true;