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