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:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
|
||||
30
app/admin/components/page-builder/findAlbumAddress.ts
Normal file
30
app/admin/components/page-builder/findAlbumAddress.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user