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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user