release: v0.15.0 (#585)

Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
Ralksta
2026-09-19 22:24:10 +02:00
committed by GitHub
parent e99d3f2f1b
commit c3f92ca4ec
74 changed files with 4791 additions and 2146 deletions

View File

@@ -15,6 +15,7 @@ export function SortableAlbumCard({
thumbnailId,
onRemove,
onEdit,
highlighted,
}: {
album: AlbumEntry;
index: number;
@@ -23,6 +24,7 @@ export function SortableAlbumCard({
thumbnailId: string | null;
onRemove: () => void;
onEdit: () => void;
highlighted?: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `album-${album.id}-${index}`,
@@ -45,6 +47,7 @@ export function SortableAlbumCard({
onRemove={onRemove}
onEdit={onEdit}
dragListeners={listeners}
highlighted={highlighted}
/>
</div>
);
@@ -58,6 +61,8 @@ interface AlbumCardProps {
onRemove: () => void;
onEdit: () => void;
dragListeners?: Record<string, unknown>;
/** Marked briefly when a diagnostics link pointed at this album. */
highlighted?: boolean;
}
export default function AlbumCard({
@@ -68,13 +73,17 @@ export default function AlbumCard({
onRemove,
onEdit,
dragListeners,
highlighted,
}: AlbumCardProps) {
const heroThumb = album.heroImage || thumbnailId;
const hasPassword = !!album.password;
const hasTitleOverride = !!album.title;
return (
<div className={`album-tile ${hasPassword ? 'has-password' : ''}`}>
<div
className={`album-tile ${hasPassword ? 'has-password' : ''} ${highlighted ? 'album-tile--linked' : ''}`}
data-album-id={album.id}
>
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">

View File

@@ -106,6 +106,7 @@ export default function AlbumDrawer({
<div className="admin-field">
<label>Layout override (Experimental)</label>
<select
aria-label="Layout override"
value={album.grid?.layout || ''}
onChange={(e) => {
const layout = e.target.value || undefined;

View File

@@ -45,6 +45,8 @@ import {
interface SubpageDrawerProps {
sp: Subpage;
spIndex: number;
/** 1-based position among the enabled subpages; undefined while disabled. */
kickerIndex?: number;
immichAlbums: ImmichAlbumInfo[];
sensors: SensorDescriptor<SensorOptions>[];
drawerMode: 'edit' | 'preview';
@@ -64,6 +66,8 @@ interface SubpageDrawerProps {
getAlbumName: (id: string) => string;
getAlbumCount: (id: string) => number;
getAlbumThumbnailId: (id: string) => string | null;
/** Album to mark, when the sheet was opened from a diagnostics link. */
highlightedAlbumId?: string | null;
}
/**
@@ -76,6 +80,7 @@ interface SubpageDrawerProps {
export default function SubpageDrawer({
sp,
spIndex,
kickerIndex,
immichAlbums,
sensors,
drawerMode,
@@ -95,6 +100,7 @@ export default function SubpageDrawer({
getAlbumName,
getAlbumCount,
getAlbumThumbnailId,
highlightedAlbumId,
}: SubpageDrawerProps) {
return (
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
@@ -141,7 +147,7 @@ export default function SubpageDrawer({
<div className="subpage-drawer-body">
{drawerMode === 'preview' ? (
<SubpagePreview sp={sp} immichAlbums={immichAlbums} />
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
) : (
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
<div className="admin-sheet-col">
@@ -300,6 +306,7 @@ export default function SubpageDrawer({
<div className="admin-field" style={{ marginTop: '1rem' }}>
<label>Page Layout Style</label>
<select
aria-label="Page layout style"
value={sp.grid?.layout || 'masonry'}
onChange={(e) => {
const newLayout = e.target.value;
@@ -446,6 +453,7 @@ export default function SubpageDrawer({
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
onEdit={() =>
onEditAlbum({
@@ -506,6 +514,7 @@ export default function SubpageDrawer({
name={getAlbumName(album.id)}
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
highlighted={album.id === highlightedAlbumId}
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
onEdit={() =>
onEditAlbum({

View File

@@ -1,77 +1,215 @@
'use client';
import { IconFolder } from '../Icons';
import type { ImmichAlbumInfo, Subpage } from './types';
import { useEffect, useRef, useState } from 'react';
import { buildCoverGridVars } from '@/lib/config/schema';
import { dictionaryFor } from '@/lib/i18n';
import type { AlbumEntry, ImmichAlbumInfo, Subpage } from './types';
interface SubpagePreviewProps {
sp: Subpage;
immichAlbums: ImmichAlbumInfo[];
/** 1-based position among the enabled subpages, for the header kicker. */
index?: number;
}
/** The drawer's read-only "Live Preview" tab: hero banner, sections, covers. */
export default function SubpagePreview({ sp, immichAlbums }: SubpagePreviewProps) {
/**
* The width the page is laid out at before it is scaled into the drawer —
* a desktop viewport, which is also what the stylesheet's media queries see.
*/
const CANVAS_WIDTH = 1280;
/** Mirrors the fallback in lib/config/index.ts when settings.yaml sets none. */
const DEFAULT_GRID_COLUMNS = 3;
const pad2 = (n: number) => String(n).padStart(2, '0');
interface PreviewAlbum {
key: string;
name: string;
assetCount: number;
thumb: string | null;
coverPosition?: string;
}
/**
* The drawer's read-only "Live Preview" tab.
*
* It used to draw its own hero banner and tiles in admin styling, which knew
* nothing of the preset, the cover grid or the caption bar — the preview showed
* a page the site never rendered. It now emits the same markup and classes as
* `SubpageGridView`, so the preset CSS (keyed on `[data-preset]`, which the
* admin's <html> carries as well) styles it exactly like the public page. The
* cover grid is sized by the same `buildCoverGridVars()` from the same inputs.
*
* The page is laid out at CANVAS_WIDTH and scaled down with `zoom` rather than
* squeezed into the drawer, so a three-column grid still looks like one.
*/
export default function SubpagePreview({ sp, immichAlbums, index }: SubpagePreviewProps) {
const frameRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(0.4);
const [site, setSite] = useState<{ columns: number; lang?: string }>({
columns: DEFAULT_GRID_COLUMNS,
});
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const observer = new ResizeObserver(([entry]) => {
setZoom(entry.contentRect.width / CANVAS_WIDTH);
});
observer.observe(frame);
return () => observer.disconnect();
}, []);
// Fetched on every open rather than cached: the global column count and the
// language may have been changed in Settings since the page builder loaded.
useEffect(() => {
let cancelled = false;
fetch('/api/admin/settings')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data?.settings) return;
setSite({
columns: data.settings.grid?.columns ?? DEFAULT_GRID_COLUMNS,
lang: data.settings.lang,
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const t = dictionaryFor(site.lang);
// Only the two keys the cover grid reads; see buildCoverGridVars().
const gridStyle = buildCoverGridVars(
{ columns: sp.coverGrid?.columns, gap: sp.coverGrid?.gap },
site.columns,
) as React.CSSProperties;
const toPreviewAlbum = (alb: AlbumEntry, i: number): PreviewAlbum => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
return {
key: `${alb.id}-${i}`,
name: alb.title || immichAlb?.albumName || alb.id,
assetCount: immichAlb?.assetCount ?? 0,
thumb: alb.heroImage || immichAlb?.thumbnailAssetId || null,
coverPosition: alb.coverPosition,
};
};
const sections = sp.sections ?? [];
const allAlbums = (sections.length > 0 ? sections.flatMap((sec) => sec.albums) : sp.albums).map(
toPreviewAlbum,
);
const totalPhotos = allAlbums.reduce((sum, a) => sum + a.assetCount, 0);
const title = sp.title || sp.name;
const grid = (albums: PreviewAlbum[]) => (
<div className="subpage-grid" style={gridStyle}>
{albums.map((album, i) => (
<div key={album.key} className="subpage-grid__item">
<span className="subpage-grid__item-media">
{album.thumb ? (
// A plain <img> styled like next/image's `fill`: the admin
// thumbnail route takes a raw asset ID, which the image loader
// does not.
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/api/admin/thumbnail/${album.thumb}`}
alt=""
style={{
position: 'absolute',
inset: 0,
...(album.coverPosition ? { objectPosition: album.coverPosition } : {}),
}}
/>
) : (
<span
className="skeleton"
style={{ display: 'block', width: '100%', height: '100%' }}
/>
)}
<span className="subpage-grid__item-badge">{t.common.photos(album.assetCount)}</span>
<span className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.name}</span>
<span className="subpage-grid__item-count">{t.common.photos(album.assetCount)}</span>
</span>
</span>
<span className="subpage-grid__item-caption">
<span className="subpage-grid__item-index">{pad2(i + 1)}</span>
<span className="subpage-grid__item-caption-title">{album.name}</span>
<span className="subpage-grid__item-caption-count">
{t.common.photos(album.assetCount)}
</span>
</span>
</div>
))}
</div>
);
return (
<div className="drawer-live-preview-panel">
<div className="preview-hero-banner">
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
</div>
<div className="subpage-preview">
<p className="subpage-preview__note">
Rendered with the site&rsquo;s saved theme preset at desktop width. Unsaved edits in this
drawer show up here; theme changes show up once saved in Settings.
</p>
<div className="subpage-preview__frame" ref={frameRef}>
{/* inert: a picture of the page, not a second copy of its links. */}
<div
className="subpage-preview__canvas"
style={{ width: CANVAS_WIDTH, zoom }}
inert
aria-hidden="true"
>
<div className="main">
<div className="subpage-container">
<header className="subpage-header">
<div className="subpage-header__main">
{index !== undefined && (
<p className="subpage-header__kicker">
{t.subpage.collectionKicker(pad2(index))}
</p>
)}
{title && <h1 className="subpage-title">{title}</h1>}
{sp.subtitle && <p className="subpage-subtitle">{sp.subtitle}</p>}
</div>
<p className="subpage-header__meta">
{t.common.albums(allAlbums.length)} · {t.common.photos(totalPhotos)}
</p>
</header>
{sp.sections && sp.sections.length > 0 ? (
sp.sections.map((sec, sIdx) => (
<div key={sIdx} className="preview-section-group">
<h3 className="preview-section-title">{sec.title || 'Untitled Section'}</h3>
{sec.description && <p className="preview-section-desc">{sec.description}</p>}
<div className="preview-albums-grid">
{sec.albums.map((alb, aIdx) => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
return (
<div key={aIdx} className="preview-album-tile">
<div className="preview-album-cover">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
</div>
<span className="preview-album-title">
{alb.title || immichAlb?.albumName || alb.id}
{sections.length > 0 && (
<nav className="subpage-toc">
{sections.map((sec, i) => (
<span key={i} className="subpage-toc__entry">
<span className="subpage-toc__link">
<span className="subpage-toc__num">{pad2(i + 1)}</span>
<span className="subpage-toc__label">{sec.title}</span>
</span>
</span>
</div>
);
})}
))}
</nav>
)}
{sections.length > 0
? sections.map((sec, i) => (
<section key={i} className="subpage-section">
<header className="subpage-section__header">
<h2 className="subpage-section__title">{sec.title}</h2>
{sec.description && (
<p className="subpage-section__desc">{sec.description}</p>
)}
<div className="subpage-section__rule" />
</header>
{grid(sec.albums.map(toPreviewAlbum))}
</section>
))
: grid(allAlbums)}
</div>
</div>
))
) : (
<div className="preview-albums-grid">
{sp.albums.map((alb, aIdx) => {
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
return (
<div key={aIdx} className="preview-album-tile">
<div className="preview-album-cover">
{thumb ? (
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
) : (
<div className="subpage-tile-placeholder">
<IconFolder />
</div>
)}
</div>
<span className="preview-album-title">
{alb.title || immichAlb?.albumName || alb.id}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { ActiveEditAlbumAddress, AlbumEntry, Subpage } from './types';
/**
* Where an album sits in the page builder, as the address its drawer opens by.
*
* Used by the `/admin/pages?album=<id>` deep link from the diagnostics page. An
* album can be listed more than once; the first place wins — standalone, then
* each subpage's own albums, then its sections — which is also the order the
* builder shows them in.
*/
export function findAlbumAddress(
gallery: { albums: AlbumEntry[]; subpages: Subpage[] },
albumId: string,
): ActiveEditAlbumAddress | null {
const standalone = gallery.albums.findIndex((a) => a.id === albumId);
if (standalone !== -1) return { type: 'standalone', albumIndex: standalone };
for (const [subpageIndex, sp] of gallery.subpages.entries()) {
const direct = sp.albums.findIndex((a) => a.id === albumId);
if (direct !== -1) return { type: 'subpage', subpageIndex, albumIndex: direct };
for (const [sectionIndex, section] of (sp.sections ?? []).entries()) {
const inSection = section.albums.findIndex((a) => a.id === albumId);
if (inSection !== -1) {
return { type: 'section', subpageIndex, sectionIndex, albumIndex: inSection };
}
}
}
return null;
}