chore(release): v0.10.0 (#400)
Photo Essay mode, client proofing, studio-modern preset, admin panel overhaul, watermark, cookieless analytics, subpage toggle, SEO title templates. Analytics API now admin-only, Trivy scan on publish. Album order mirrors the Immich timeline; map theme, admin status panel, lightbox counter and Docker health check fixed. See CHANGELOG.md for the full list and the health-check upgrade note.
This commit is contained in:
@@ -20,6 +20,31 @@ interface AlbumDetailViewProps {
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra detail for the album header — shooting date and camera/lens, read off
|
||||
* the first asset that carries EXIF. Both are rendered in their own spans and
|
||||
* hidden by default; only presets that ask for them show them, so the header
|
||||
* keeps reading "86 photos" everywhere else.
|
||||
*/
|
||||
function albumMetaDetail(album: ImmichAlbum): { date?: string; gear?: string } {
|
||||
const first = album.assets.find((a) => a.exifInfo);
|
||||
const exif = first?.exifInfo;
|
||||
|
||||
const taken = exif?.dateTimeOriginal || first?.fileCreatedAt;
|
||||
const gear = [exif?.model, exif?.lensModel].filter(Boolean).join(' · ');
|
||||
|
||||
return {
|
||||
...(taken ? { date: formatMonthYear(taken) } : {}),
|
||||
...(gear ? { gear } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMonthYear(iso: string): string | undefined {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
album,
|
||||
images,
|
||||
@@ -32,6 +57,8 @@ export function AlbumDetailView({
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
}: AlbumDetailViewProps) {
|
||||
const metaDetail = albumMetaDetail(album);
|
||||
|
||||
return (
|
||||
<>
|
||||
{heroImageUrl && (
|
||||
@@ -49,16 +76,26 @@ export function AlbumDetailView({
|
||||
</div>
|
||||
)}
|
||||
<div className={`album-header${heroImageUrl ? ' album-header--has-hero' : ''}`}>
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && (
|
||||
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||
<div className="album-header__main">
|
||||
<BackLink href={backLinkHref} label={backLinkLabel} />
|
||||
<h1 className="album-header__title">{album.albumName}</h1>
|
||||
{subtitle && (
|
||||
<p className="subpage-subtitle" style={{ textAlign: 'left', marginLeft: 0 }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{album.description && <p className="album-header__description">{album.description}</p>}
|
||||
</div>
|
||||
<p className="album-header__meta">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
<span className="album-header__meta-count">
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
{metaDetail.date && (
|
||||
<span className="album-header__meta-date"> · {metaDetail.date}</span>
|
||||
)}
|
||||
{metaDetail.gear && (
|
||||
<span className="album-header__meta-gear">{metaDetail.gear}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
|
||||
|
||||
@@ -23,8 +23,13 @@ interface SubpageGridViewProps {
|
||||
albums: SubpageAlbum[];
|
||||
coverPlaceholders: (Placeholder | null)[];
|
||||
sections?: SubpageSectionConfig[];
|
||||
/** 1-based position among all subpages — feeds the "03 — Collection" kicker. */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const photoCount = (n: number) => `${n} ${n === 1 ? 'photo' : 'photos'}`;
|
||||
|
||||
function AlbumGrid({
|
||||
albums,
|
||||
placeholderMap,
|
||||
@@ -36,7 +41,7 @@ function AlbumGrid({
|
||||
}) {
|
||||
return (
|
||||
<div className="subpage-grid">
|
||||
{albums.map((album) => {
|
||||
{albums.map((album, i) => {
|
||||
const ph = placeholderMap.get(album.id) ?? null;
|
||||
return (
|
||||
<Link
|
||||
@@ -44,33 +49,42 @@ function AlbumGrid({
|
||||
href={`/${slug}/${album.slug}`}
|
||||
className="subpage-grid__item"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
aria-label={`${album.albumName}, ${album.assetCount} ${album.assetCount === 1 ? 'photo' : 'photos'}`}
|
||||
aria-label={`${album.albumName}, ${photoCount(album.assetCount)}`}
|
||||
>
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="skeleton"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="subpage-grid__item-badge" aria-hidden="true">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
</span>
|
||||
<div className="subpage-grid__item-overlay" aria-hidden="true">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">
|
||||
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
|
||||
<span className="subpage-grid__item-media">
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1000px) 50vw, 33vw"
|
||||
loading="lazy"
|
||||
{...(ph ? { placeholder: 'blur' as const, blurDataURL: ph.blurDataURL } : {})}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="skeleton"
|
||||
style={{ display: 'block', width: '100%', height: '100%' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="subpage-grid__item-badge" aria-hidden="true">
|
||||
{photoCount(album.assetCount)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="subpage-grid__item-overlay" aria-hidden="true">
|
||||
<span className="subpage-grid__item-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-count">{photoCount(album.assetCount)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* Always-visible caption bar — shown by presets that use it. */}
|
||||
<span className="subpage-grid__item-caption" aria-hidden="true">
|
||||
<span className="subpage-grid__item-index">{pad2(i + 1)}</span>
|
||||
<span className="subpage-grid__item-caption-title">{album.albumName}</span>
|
||||
<span className="subpage-grid__item-caption-count">
|
||||
{photoCount(album.assetCount)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -85,19 +99,31 @@ export function SubpageGridView({
|
||||
albums,
|
||||
coverPlaceholders,
|
||||
sections,
|
||||
index,
|
||||
}: SubpageGridViewProps) {
|
||||
// Build lookup maps once
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
const placeholderMap = new Map(albums.map((a, i) => [a.id, coverPlaceholders[i] ?? null]));
|
||||
|
||||
const hasSections = sections && sections.length > 0;
|
||||
const totalPhotos = albums.reduce((sum, a) => sum + a.assetCount, 0);
|
||||
|
||||
return (
|
||||
<div className="subpage-container">
|
||||
{(title || subtitle) && (
|
||||
<header className="subpage-header">
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
<div className="subpage-header__main">
|
||||
{index !== undefined && (
|
||||
<p className="subpage-header__kicker" aria-hidden="true">
|
||||
{pad2(index)} — Collection
|
||||
</p>
|
||||
)}
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{subtitle && <p className="subpage-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<p className="subpage-header__meta" aria-hidden="true">
|
||||
{albums.length} {albums.length === 1 ? 'album' : 'albums'} · {photoCount(totalPhotos)}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import { cookies } from 'next/headers';
|
||||
import type { Metadata } from 'next';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
import { notFound } from 'next/navigation';
|
||||
// Only the type: rendering moved to AlbumDetailView/SubpageGridView, which
|
||||
// import the component themselves.
|
||||
import type { PhotoItem } from './PhotoGrid';
|
||||
import {
|
||||
imageUrl,
|
||||
@@ -28,7 +26,9 @@ import {
|
||||
import { encodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig, type GridConfig } from '@/lib/config';
|
||||
import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import { isAdminAuthenticated } from '@/lib/admin/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AdminDiagnosticBanner } from '@/components/AdminDiagnosticBanner';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
@@ -39,6 +39,7 @@ export const dynamic = 'force-dynamic';
|
||||
|
||||
interface PathPageProps {
|
||||
params: Promise<{ path: string[] }>;
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PathPageProps): Promise<Metadata> {
|
||||
@@ -110,8 +111,6 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
||||
const exif = showExif && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
|
||||
const isVideo = a.type === 'VIDEO';
|
||||
return {
|
||||
// Opaque token, never the raw Immich UUID — this object is serialized
|
||||
// into the RSC payload and shipped to the browser. Used only as a React key.
|
||||
id: encodeAssetId(a.id),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
thumbUrl: imageUrl(a.id, 'preview'),
|
||||
@@ -163,8 +162,11 @@ async function getAlbumHeroData(
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PathPage({ params }: PathPageProps) {
|
||||
export default async function PathPage({ params, searchParams }: PathPageProps) {
|
||||
const { path } = await params;
|
||||
const sParams = (await searchParams) || {};
|
||||
const forceFresh = sParams.fresh === '1' || sParams.preview === 'true';
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
// Build grid CSS custom properties, optionally merging subpage overrides
|
||||
@@ -191,14 +193,22 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
const gate = await gateIfProtected(subpageSlug);
|
||||
if (gate) return gate;
|
||||
|
||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug, forceFresh);
|
||||
|
||||
if (!album) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={`${subpageSlug}/${albumSlug}`}
|
||||
reason={`Album "${albumSlug}" could not be found or returned no assets from Immich.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Look up subpage config for grid overrides and back link
|
||||
const subpageData = await immich.getSubpageAlbums(subpageSlug);
|
||||
const subpageData = await immich.getSubpageAlbums(subpageSlug, forceFresh);
|
||||
const spGrid = subpageData?.subpage.grid;
|
||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
||||
|
||||
@@ -233,8 +243,18 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
const gate = await gateIfProtected(slug);
|
||||
if (gate) return gate;
|
||||
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
const result = await immich.getSubpageAlbums(slug, forceFresh);
|
||||
if (!result || result.albums.length === 0) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
const spConfig = config.subpages.find((sp) => sp.slug === slug);
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={slug}
|
||||
subpageName={spConfig?.name}
|
||||
configuredAlbumCount={spConfig?.albumIds.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -255,7 +275,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug, forceFresh))
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
@@ -293,8 +313,20 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
// ── Single album → full-bleed (skip album grid) ──────────
|
||||
if (albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
|
||||
if (!album) notFound();
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug, forceFresh);
|
||||
if (!album) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={slug}
|
||||
subpageName={result.subpage.name}
|
||||
configuredAlbumCount={result.subpage.albumIds.length}
|
||||
reason={`Single album "${albums[0].albumName}" was not found or returned 0 assets.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
const images = toPhotoItems(album.assets, config.exifOnHover);
|
||||
|
||||
@@ -336,6 +368,10 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
}),
|
||||
);
|
||||
|
||||
// 1-based position among the enabled subpages — drives the header kicker.
|
||||
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
||||
const subpageIndex = enabledSubpages.findIndex((sp) => sp.slug === slug);
|
||||
|
||||
return (
|
||||
<SubpageGridView
|
||||
slug={slug}
|
||||
@@ -344,13 +380,22 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
albums={albumsWithHero}
|
||||
coverPlaceholders={coverPlaceholders}
|
||||
sections={result.subpage.sections}
|
||||
{...(subpageIndex >= 0 ? { index: subpageIndex + 1 } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise treat as a standalone album slug
|
||||
const album = await immich.getAlbumBySlug(slug);
|
||||
const album = await immich.getAlbumBySlug(slug, undefined, forceFresh);
|
||||
if (!album) {
|
||||
if (await isAdminAuthenticated()) {
|
||||
return (
|
||||
<AdminDiagnosticBanner
|
||||
slug={slug}
|
||||
reason={`Standalone album slug "${slug}" could not be found in published Immich albums.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* Kicker above the name — opt-in per preset. */
|
||||
.about__kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.about__name {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 2.4rem;
|
||||
|
||||
@@ -96,6 +96,9 @@ export default async function AboutPage() {
|
||||
|
||||
{/* ── Text ─────────────────────────────────────── */}
|
||||
<div className="about__text-col">
|
||||
<p className="about__kicker" aria-hidden="true">
|
||||
About
|
||||
</p>
|
||||
{meta.name && <h1 className="about__name">{meta.name}</h1>}
|
||||
{meta.location && <p className="about__location">{meta.location}</p>}
|
||||
|
||||
|
||||
@@ -539,7 +539,9 @@
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.album-tile:hover {
|
||||
@@ -706,7 +708,10 @@
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.subpage-tile:hover {
|
||||
@@ -771,8 +776,14 @@
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.subpage-detail-header {
|
||||
@@ -1118,8 +1129,11 @@
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s, box-shadow 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.preset-card:hover {
|
||||
@@ -1243,6 +1257,12 @@
|
||||
animation: pulse-red 1.5s infinite;
|
||||
}
|
||||
|
||||
/* The diagnostics request itself did not run — deliberately not red, and not
|
||||
animated: an unanswered check is not a fault report. */
|
||||
.status-badge-btn.unknown .status-dot {
|
||||
background-color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -1341,6 +1361,10 @@
|
||||
color: var(--admin-error, #ef4444);
|
||||
}
|
||||
|
||||
.status-val.unknown {
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Search Bar ────────────────────────────────────────────── */
|
||||
.builder-search-container {
|
||||
padding: 0.75rem 1.5rem;
|
||||
@@ -1399,15 +1423,27 @@
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes pulse-green {
|
||||
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Color Field */
|
||||
@@ -1530,7 +1566,9 @@
|
||||
cursor: pointer;
|
||||
background: var(--admin-bg-elevated);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s, transform 0.1s;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.asset-picker-tile:hover:not(.used) {
|
||||
@@ -1739,7 +1777,9 @@
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
stroke-width: 2px;
|
||||
transition: transform 0.2s ease, color 0.15s ease;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.svg-drag {
|
||||
@@ -2147,7 +2187,9 @@ h3 .svg-icon {
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.section-card:hover {
|
||||
@@ -2805,7 +2847,10 @@ h3 .svg-icon {
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.color-swatch-btn:hover {
|
||||
@@ -2978,11 +3023,27 @@ h3 .svg-icon {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mini-aspect-box.ratio-1 { width: 34px; height: 34px; }
|
||||
.mini-aspect-box.ratio-3-2 { width: 46px; height: 30px; }
|
||||
.mini-aspect-box.ratio-2-3 { width: 30px; height: 44px; }
|
||||
.mini-aspect-box.ratio-16-9 { width: 50px; height: 28px; }
|
||||
.mini-aspect-box.ratio-auto { width: 42px; height: 32px; border-style: solid; }
|
||||
.mini-aspect-box.ratio-1 {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
.mini-aspect-box.ratio-3-2 {
|
||||
width: 46px;
|
||||
height: 30px;
|
||||
}
|
||||
.mini-aspect-box.ratio-2-3 {
|
||||
width: 30px;
|
||||
height: 44px;
|
||||
}
|
||||
.mini-aspect-box.ratio-16-9 {
|
||||
width: 50px;
|
||||
height: 28px;
|
||||
}
|
||||
.mini-aspect-box.ratio-auto {
|
||||
width: 42px;
|
||||
height: 32px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.mini-aspect-inner {
|
||||
width: 100%;
|
||||
@@ -3021,9 +3082,15 @@ h3 .svg-icon {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.demo-tile.h-high { flex: 2; }
|
||||
.demo-tile.h-low { flex: 1; }
|
||||
.demo-tile.h-med { flex: 1.5; }
|
||||
.demo-tile.h-high {
|
||||
flex: 2;
|
||||
}
|
||||
.demo-tile.h-low {
|
||||
flex: 1;
|
||||
}
|
||||
.demo-tile.h-med {
|
||||
flex: 1.5;
|
||||
}
|
||||
|
||||
.demo-uniform-grid {
|
||||
display: grid;
|
||||
@@ -3038,7 +3105,9 @@ h3 .svg-icon {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.demo-tile.hero-tile { flex: 2; }
|
||||
.demo-tile.hero-tile {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.demo-filmstrip-row {
|
||||
display: flex;
|
||||
@@ -3154,8 +3223,12 @@ h3 .svg-icon {
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.subpage-drawer-header {
|
||||
@@ -3249,7 +3322,9 @@ h3 .svg-icon {
|
||||
transform: translateX(-50%);
|
||||
background: var(--admin-bg-raised, #1f1f1f);
|
||||
border: 1px solid var(--admin-border-hover, #444);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 0.75rem 1.25rem;
|
||||
z-index: 900;
|
||||
@@ -3272,9 +3347,18 @@ h3 .svg-icon {
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7); }
|
||||
70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(255, 152, 0, 0); }
|
||||
100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0); }
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 8px rgba(255, 152, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 152, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Analytics View ────────────────────────────────────────── */
|
||||
@@ -3546,9 +3630,194 @@ h3 .svg-icon {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Photo Essay & Storytelling Builder ───────────────────────── */
|
||||
|
||||
.essay-block-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.essay-toolbar-sticky {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--admin-bg-raised, #1a1a1a);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.essay-toolbar-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text, #f0f0f0);
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.essay-toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--admin-border, #2e2e2e);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.essay-block-card {
|
||||
background: var(--admin-bg-raised, #161616);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.essay-block-card:hover {
|
||||
border-color: var(--admin-border-hover, #444444);
|
||||
}
|
||||
|
||||
.essay-block-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.essay-block-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--admin-bg-elevated, #222);
|
||||
color: var(--admin-text-secondary, #a0a0a0);
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
}
|
||||
|
||||
.essay-block-badge.badge-photo {
|
||||
background: rgba(10, 132, 255, 0.15);
|
||||
color: #64d2ff;
|
||||
border-color: rgba(10, 132, 255, 0.3);
|
||||
}
|
||||
|
||||
.essay-block-badge.badge-heading {
|
||||
background: rgba(191, 90, 242, 0.15);
|
||||
color: #da8fff;
|
||||
border-color: rgba(191, 90, 242, 0.3);
|
||||
}
|
||||
|
||||
.essay-block-badge.badge-quote {
|
||||
background: rgba(255, 159, 10, 0.15);
|
||||
color: #ffd60a;
|
||||
border-color: rgba(255, 159, 10, 0.3);
|
||||
}
|
||||
|
||||
.essay-block-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Polished dark inputs for Essay Editor */
|
||||
.essay-block-editor input[type='text'],
|
||||
.essay-block-editor textarea,
|
||||
.essay-block-editor select {
|
||||
background: var(--admin-bg-input, #111111) !important;
|
||||
border: 1px solid var(--admin-border, #2e2e2e) !important;
|
||||
border-radius: 6px !important;
|
||||
color: var(--admin-text, #f0f0f0) !important;
|
||||
font-size: 0.85rem !important;
|
||||
padding: 0.55rem 0.75rem !important;
|
||||
outline: none !important;
|
||||
transition: border-color 0.15s !important;
|
||||
}
|
||||
|
||||
.essay-block-editor input[type='text']:focus,
|
||||
.essay-block-editor textarea:focus,
|
||||
.essay-block-editor select:focus {
|
||||
border-color: var(--admin-border-focus, #888888) !important;
|
||||
}
|
||||
|
||||
.essay-photo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
background: var(--admin-bg, #111111);
|
||||
padding: 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
}
|
||||
|
||||
.essay-photo-preview-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.essay-photo-thumbnail {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
background: var(--admin-bg-elevated, #222);
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--admin-border, #333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.essay-photo-empty {
|
||||
width: 120px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
background: var(--admin-bg-elevated, #1a1a1a);
|
||||
border: 1px dashed var(--admin-border, #333);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--admin-text-muted, #707070);
|
||||
font-size: 0.75rem;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.essay-photo-meta {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.essay-pill-selector {
|
||||
display: inline-flex;
|
||||
background: var(--admin-bg-input, #000);
|
||||
padding: 2px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--admin-border, #2e2e2e);
|
||||
}
|
||||
|
||||
.essay-pill-btn {
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-text-muted, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.essay-pill-btn.active {
|
||||
background: var(--admin-btn-primary-bg, #ffffff);
|
||||
color: var(--admin-btn-primary-text, #000000);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
// Distinct from `status === null`: the check itself did not run. Without this
|
||||
// an expired session or a 500 rendered exactly like a real outage — every
|
||||
// indicator flipped to its alarming value at once (#341).
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||
@@ -46,20 +50,41 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
setStatusError(false);
|
||||
} else {
|
||||
setStatus(null);
|
||||
setStatusError(true);
|
||||
}
|
||||
} catch {
|
||||
setStatus(null);
|
||||
setStatusError(true);
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render one diagnostic as ok / bad / unknown. "Unknown" covers both the
|
||||
* in-flight check and a check that failed to run — neither is evidence that
|
||||
* the thing being checked is broken.
|
||||
*/
|
||||
const indicator = (healthy: boolean, okLabel: string, badLabel: string) => {
|
||||
if (statusLoading) return { className: 'unknown', label: 'Checking…' };
|
||||
if (statusError || !status) return { className: 'unknown', label: 'Unknown' };
|
||||
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const immichIndicator = indicator(
|
||||
status?.immich?.status === 'connected',
|
||||
'Connected',
|
||||
'Disconnected',
|
||||
);
|
||||
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<header className="admin-header">
|
||||
@@ -90,7 +115,7 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
{/* Diagnostics Badge */}
|
||||
<div className="status-indicator-container">
|
||||
<button
|
||||
className={`status-badge-btn ${status?.immich?.status === 'connected' ? 'connected' : 'disconnected'}`}
|
||||
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
|
||||
onClick={() => {
|
||||
setShowStatus(!showStatus);
|
||||
if (!showStatus) fetchStatus();
|
||||
@@ -99,18 +124,24 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
>
|
||||
<span className="status-dot"></span>
|
||||
<span className="status-text">
|
||||
{statusLoading ? 'Checking...' : status?.immich?.status === 'connected' ? 'System OK' : 'System Degraded'}
|
||||
{statusLoading
|
||||
? 'Checking...'
|
||||
: immichIndicator.className === 'ok'
|
||||
? 'System OK'
|
||||
: immichIndicator.className === 'error'
|
||||
? 'System Degraded'
|
||||
: 'Status Unknown'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
{showStatus && (
|
||||
<>
|
||||
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
|
||||
<div className="status-dropdown">
|
||||
<div className="status-dropdown-header">
|
||||
<h4>System Diagnostics</h4>
|
||||
<button
|
||||
className="status-refresh-btn"
|
||||
<button
|
||||
className="status-refresh-btn"
|
||||
onClick={fetchStatus}
|
||||
disabled={statusLoading}
|
||||
title="Refresh diagnostics"
|
||||
@@ -121,14 +152,14 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<div className="status-dropdown-body">
|
||||
<div className="status-item">
|
||||
<span className="status-label">Immich Connection</span>
|
||||
<span className={`status-val ${status?.immich?.status === 'connected' ? 'ok' : 'error'}`}>
|
||||
{status?.immich?.status === 'connected' ? 'Connected' : 'Disconnected'}
|
||||
<span className={`status-val ${immichIndicator.className}`}>
|
||||
{immichIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="status-label">Config Integrity</span>
|
||||
<span className={`status-val ${status?.config?.status === 'valid' ? 'ok' : 'error'}`}>
|
||||
{status?.config?.status === 'valid' ? 'Valid' : 'Degraded'}
|
||||
<span className={`status-val ${configIndicator.className}`}>
|
||||
{configIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
@@ -138,7 +169,9 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<div className="status-item">
|
||||
<span className="status-label">Latest Backup</span>
|
||||
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
|
||||
{status?.backups?.lastBackup ? new Date(status.backups.lastBackup).toLocaleDateString() : 'None'}
|
||||
{status?.backups?.lastBackup
|
||||
? new Date(status.backups.lastBackup).toLocaleDateString()
|
||||
: 'None'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
|
||||
@@ -7,7 +7,17 @@ import {
|
||||
type ParsedEssay,
|
||||
type EssayBlock,
|
||||
} from '@/lib/essay';
|
||||
import * as Icons from './Icons';
|
||||
import {
|
||||
IconCamera,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconTrash,
|
||||
IconGripVertical,
|
||||
IconQuote,
|
||||
IconFileText,
|
||||
IconSparkles,
|
||||
IconArrowLeftRight,
|
||||
} from './Icons';
|
||||
|
||||
interface EssayBlockEditorProps {
|
||||
markdown: string;
|
||||
@@ -83,21 +93,21 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' };
|
||||
break;
|
||||
case 'quote':
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: 'Author' };
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: '' };
|
||||
break;
|
||||
case 'photo':
|
||||
newBlock = {
|
||||
type: 'photo',
|
||||
assetId: 'sample-asset',
|
||||
caption: 'Photo caption',
|
||||
assetId: '',
|
||||
caption: '',
|
||||
layout: 'contained',
|
||||
};
|
||||
break;
|
||||
case 'photo-pair':
|
||||
newBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
caption: 'Side by side caption',
|
||||
assetIds: ['', ''],
|
||||
caption: '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -106,57 +116,45 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="essay-block-editor" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div
|
||||
className="essay-block-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.04))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.1))',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, marginRight: 'auto' }}>
|
||||
✨ Add Story Block:
|
||||
</span>
|
||||
<div className="essay-block-editor">
|
||||
{/* Sticky Dark Glass Toolbar */}
|
||||
<div className="essay-toolbar-sticky">
|
||||
<span className="essay-toolbar-label">✨ Add Story Block:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('paragraph')}
|
||||
>
|
||||
+ Paragraph
|
||||
<IconFileText size={13} /> + Paragraph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('heading')}
|
||||
>
|
||||
+ Heading
|
||||
<IconSparkles size={13} /> + Heading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('quote')}
|
||||
>
|
||||
+ Pullquote
|
||||
<IconQuote size={13} /> + Pullquote
|
||||
</button>
|
||||
<div className="essay-toolbar-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo')}
|
||||
>
|
||||
+ Photo
|
||||
<IconCamera size={13} /> + Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo-pair')}
|
||||
>
|
||||
+ 2-Photo Pair
|
||||
<IconCamera size={13} /> + 2-Photo Pair
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -166,51 +164,37 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Render Blocks */}
|
||||
{essay.blocks.map((block, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="essay-block-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.03))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.08))',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Block Header / Action Controls */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<div key={idx} className="essay-block-card">
|
||||
{/* Card Header */}
|
||||
<div className="essay-block-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<IconGripVertical size={14} style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }} />
|
||||
<span
|
||||
className={`essay-block-badge ${
|
||||
block.type === 'photo' || block.type === 'photo-pair'
|
||||
? 'badge-photo'
|
||||
: block.type === 'heading'
|
||||
? 'badge-heading'
|
||||
: block.type === 'quote'
|
||||
? 'badge-quote'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="essay-block-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'up')}
|
||||
disabled={idx === 0}
|
||||
title="Move Up"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▲
|
||||
<IconChevronUp size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -218,31 +202,32 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
onClick={() => handleMoveBlock(idx, 'down')}
|
||||
disabled={idx === essay.blocks.length - 1}
|
||||
title="Move Down"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▼
|
||||
<IconChevronDown size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon text-danger"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleDeleteBlock(idx)}
|
||||
title="Delete Block"
|
||||
style={{ padding: '2px 6px', color: '#ff4d4f' }}
|
||||
style={{ color: 'var(--admin-error)' }}
|
||||
>
|
||||
<Icons.IconTrash size={14} />
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render Block Form Controls */}
|
||||
{/* Block Form Fields */}
|
||||
|
||||
{/* 1. HEADING BLOCK */}
|
||||
{block.type === 'heading' && (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
||||
}
|
||||
style={{ width: '80px', padding: '6px', borderRadius: '4px' }}
|
||||
style={{ width: '70px' }}
|
||||
>
|
||||
<option value={1}>H1</option>
|
||||
<option value={2}>H2</option>
|
||||
@@ -252,12 +237,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section title..."
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
placeholder="Section heading..."
|
||||
style={{ flex: 1, fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. PARAGRAPH BLOCK */}
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
@@ -265,19 +251,19 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* allowed)"
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', resize: 'vertical' }}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3. PULLQUOTE BLOCK */}
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', borderLeft: '3px solid var(--admin-accent)', paddingLeft: '10px' }}>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Quote text..."
|
||||
style={{ padding: '6px 10px', borderRadius: '4px' }}
|
||||
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -285,77 +271,80 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, author: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Author / Attribution (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
placeholder="— Author / Attribution (optional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. PHOTO BLOCK */}
|
||||
{block.type === 'photo' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: '#222',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="essay-photo-card">
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetId ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetId}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
className="essay-photo-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="essay-photo-empty">
|
||||
<IconCamera size={20} />
|
||||
<span>No Photo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset ID or Index"
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
<div className="essay-photo-meta">
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick Photo
|
||||
<IconCamera size={14} />
|
||||
{block.assetId ? 'Change Photo' : 'Select Photo'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="essay-pill-selector">
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'contained' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'contained' })}
|
||||
>
|
||||
Contained (68ch)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'wide' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'wide' })}
|
||||
>
|
||||
Wide (1100px)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'fullbleed' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'fullbleed' })}
|
||||
>
|
||||
Fullbleed (100vw)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.8rem', opacity: 0.8 }}>Display Width:</span>
|
||||
<select
|
||||
value={block.layout}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
layout: e.target.value as 'contained' | 'wide' | 'fullbleed',
|
||||
})
|
||||
}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
>
|
||||
<option value="contained">Contained (68ch)</option>
|
||||
<option value="wide">Wide (1100px)</option>
|
||||
<option value="fullbleed">Fullbleed (100vw)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset UUID..."
|
||||
style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -366,74 +355,130 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Photo caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. PHOTO-PAIR BLOCK */}
|
||||
{block.type === 'photo-pair' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 1 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
<div className="essay-photo-card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
{/* Photo 1 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
Photo 1
|
||||
</span>
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetIds[0] ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetIds[0]}`}
|
||||
alt=""
|
||||
className="essay-photo-thumbnail"
|
||||
style={{ width: '80px', height: '60px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}>
|
||||
<IconCamera size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Select Photo 1
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
</button>
|
||||
)}
|
||||
}
|
||||
placeholder="UUID 1..."
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 2 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
{/* Photo 2 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
Photo 2
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn-icon"
|
||||
title="Swap Photo 1 & Photo 2"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
)
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[1], block.assetIds[0]],
|
||||
})
|
||||
}
|
||||
>
|
||||
Pick
|
||||
<IconArrowLeftRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetIds[1] ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetIds[1]}`}
|
||||
alt=""
|
||||
className="essay-photo-thumbnail"
|
||||
style={{ width: '80px', height: '60px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}>
|
||||
<IconCamera size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Select Photo 2
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="UUID 2..."
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,7 +489,6 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Side-by-side caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -265,3 +265,45 @@ export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth =
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="9" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="19" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrowLeftRight({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m16 3 4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="m8 21-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -908,11 +908,11 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
<div className="save-bar-right">
|
||||
<a
|
||||
href="/"
|
||||
href="/?fresh=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-ghost admin-btn-preview"
|
||||
title="Open site in new tab"
|
||||
title="Open site in new tab (bypassing cache)"
|
||||
>
|
||||
↗ Preview Site
|
||||
</a>
|
||||
@@ -1120,11 +1120,11 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/${slugify(sp.name)}`}
|
||||
href={`/${slugify(sp.name)}?fresh=1`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
title="Open live page in new tab"
|
||||
title="Open live page in new tab (bypassing cache)"
|
||||
>
|
||||
/{slugify(sp.name)} ↗
|
||||
</a>
|
||||
@@ -1315,8 +1315,15 @@ export default function PageBuilder() {
|
||||
markdown={sp.essayText || ''}
|
||||
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
||||
onSelectPhoto={(callback) => {
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex });
|
||||
// We can pass a photo picker handler if needed
|
||||
const firstAlbumId = sp.albums[0]?.id;
|
||||
setHeroPickerTarget({
|
||||
albumId: firstAlbumId,
|
||||
title: 'Select Photo for Photo Essay',
|
||||
onSelect: (assetId) => {
|
||||
callback(assetId);
|
||||
setHeroPickerTarget(null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ interface Settings {
|
||||
};
|
||||
}
|
||||
|
||||
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
@@ -81,6 +81,13 @@ const THEME_INFO: Record<string, { desc: string; label: string; accent: string;
|
||||
tile: '#242424',
|
||||
accent: '#e60012',
|
||||
},
|
||||
'studio-modern': {
|
||||
label: 'Studio Modern',
|
||||
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
|
||||
bg: '#121212',
|
||||
tile: '#191919',
|
||||
accent: '#e60012',
|
||||
},
|
||||
minimal: {
|
||||
label: 'Minimal',
|
||||
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
|
||||
|
||||
@@ -100,6 +100,27 @@ const ROUTES: {
|
||||
{ params: Promise.resolve({ albumId: 'a1' }) },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'GET /api/admin/analytics',
|
||||
path: 'analytics',
|
||||
load: () => import('../analytics/route'),
|
||||
method: 'GET',
|
||||
args: () => [],
|
||||
},
|
||||
{
|
||||
name: 'GET /api/admin/backups',
|
||||
path: 'backups',
|
||||
load: () => import('../backups/route'),
|
||||
method: 'GET',
|
||||
args: () => [],
|
||||
},
|
||||
{
|
||||
name: 'POST /api/admin/backups',
|
||||
path: 'backups',
|
||||
load: () => import('../backups/route'),
|
||||
method: 'POST',
|
||||
args: () => [new Request('http://localhost/api/admin/backups', { method: 'POST', body: '{}' })],
|
||||
},
|
||||
];
|
||||
|
||||
describe('admin route guards', () => {
|
||||
|
||||
67
app/api/admin/__tests__/status.test.ts
Normal file
67
app/api/admin/__tests__/status.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/admin/auth', () => ({
|
||||
isAdminEnabled: () => true,
|
||||
isAdminAuthenticated: async () => true,
|
||||
COOKIE_NAME: 'folio_admin_session',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/immich', () => ({
|
||||
immich: { ping: vi.fn(async () => true) },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/cache', () => ({
|
||||
cache: { size: 0 },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/yaml-service', () => ({
|
||||
readGalleryYaml: vi.fn(),
|
||||
readSettingsYaml: vi.fn(),
|
||||
listBackups: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
import { GET } from '../status/route';
|
||||
import { readGalleryYaml, readSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
|
||||
const mockGallery = readGalleryYaml as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockSettings = readSettingsYaml as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
async function status() {
|
||||
return (await GET()).json();
|
||||
}
|
||||
|
||||
describe('GET /api/admin/status — config integrity', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGallery.mockResolvedValue({ albums: ['a'] });
|
||||
mockSettings.mockResolvedValue({ title: 'Site' });
|
||||
});
|
||||
|
||||
it('reports a fully configured install as valid', async () => {
|
||||
expect((await status()).config).toMatchObject({ status: 'valid', settings: 'valid' });
|
||||
});
|
||||
|
||||
/**
|
||||
* settings.yaml is optional — getConfig() falls back to defaults when it is
|
||||
* absent. Reporting that install as "Degraded" sent people hunting for a
|
||||
* fault that did not exist (#341).
|
||||
*/
|
||||
it('treats a missing settings.yaml as valid, not as a fault', async () => {
|
||||
mockSettings.mockResolvedValue(null); // what readSettingsYaml returns on ENOENT
|
||||
|
||||
expect((await status()).config).toMatchObject({ status: 'valid', settings: 'valid' });
|
||||
});
|
||||
|
||||
it('still reports a settings.yaml that cannot be parsed', async () => {
|
||||
mockSettings.mockRejectedValue(new Error('bad indentation'));
|
||||
|
||||
expect((await status()).config).toMatchObject({ status: 'invalid', settings: 'invalid' });
|
||||
});
|
||||
|
||||
// gallery.yaml, unlike settings.yaml, is genuinely required.
|
||||
it('reports a missing gallery.yaml as invalid', async () => {
|
||||
mockGallery.mockResolvedValue(null);
|
||||
|
||||
expect((await status()).config).toMatchObject({ status: 'invalid', gallery: 'invalid' });
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
|
||||
const ANALYTICS_FILE = path.join(process.cwd(), 'content', 'analytics.json');
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = getConfigOrNull();
|
||||
let data;
|
||||
@@ -45,6 +53,9 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch analytics' }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ error: err.message || 'Failed to fetch analytics' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function PUT(request: Request) {
|
||||
immich.invalidateAll();
|
||||
// Revalidate all pages so the homepage picks up new hero images immediately
|
||||
revalidatePath('/', 'layout');
|
||||
revalidatePath('/[...path]', 'page');
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Saved successfully. Backup of previous version created.',
|
||||
|
||||
@@ -22,7 +22,12 @@ export async function GET() {
|
||||
|
||||
// 2. Config Health Check
|
||||
let galleryValid = false;
|
||||
let settingsValid = false;
|
||||
// settings.yaml is optional — getConfig() falls back to defaults when it is
|
||||
// absent, so a missing file is a valid installation, not a fault. Only a file
|
||||
// that exists and cannot be parsed counts as degraded: readSettingsYaml()
|
||||
// returns null for ENOENT and rethrows everything else.
|
||||
let settingsValid = true;
|
||||
|
||||
try {
|
||||
const gallery = await readGalleryYaml();
|
||||
galleryValid = !!gallery;
|
||||
@@ -31,17 +36,17 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await readSettingsYaml();
|
||||
settingsValid = !!settings;
|
||||
await readSettingsYaml();
|
||||
} catch (err) {
|
||||
console.error('[Admin API] Settings YAML parse failed:', err);
|
||||
settingsValid = false;
|
||||
}
|
||||
|
||||
// 3. Backup Info
|
||||
const galleryBackups = await listBackups('gallery.yaml');
|
||||
const settingsBackups = await listBackups('settings.yaml');
|
||||
const backupCount = galleryBackups.length + settingsBackups.length;
|
||||
|
||||
|
||||
// Find timestamp of the latest backup
|
||||
let lastBackup: string | null = null;
|
||||
const allBackups = [...galleryBackups, ...settingsBackups].sort().reverse();
|
||||
@@ -60,7 +65,7 @@ export async function GET() {
|
||||
status: immichOk ? 'connected' : 'disconnected',
|
||||
},
|
||||
config: {
|
||||
status: (galleryValid && settingsValid) ? 'valid' : 'invalid',
|
||||
status: galleryValid && settingsValid ? 'valid' : 'invalid',
|
||||
gallery: galleryValid ? 'valid' : 'invalid',
|
||||
settings: settingsValid ? 'valid' : 'invalid',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
@import url('./themes/classic.css');
|
||||
@import url('./themes/noir.css');
|
||||
@import url('./themes/monograph.css');
|
||||
@import url('./themes/studio-modern.css');
|
||||
|
||||
@import url('./tokens.css');
|
||||
|
||||
@@ -28,6 +29,22 @@
|
||||
outline: 2px solid var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Screen-reader-only text ──────────────────────── */
|
||||
/* Used by the lightbox counter, which announces "Photo 3 of 53" while showing
|
||||
the compact "3 / 53". Without this rule both strings render on top of each
|
||||
other. */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ── Header / Navigation ──────────────────────────── */
|
||||
.header {
|
||||
position: fixed;
|
||||
@@ -61,6 +78,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Brand wordmark — only presets that ask for it render it visibly. */
|
||||
.header__wordmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header__nav-link {
|
||||
padding: 18px 16px;
|
||||
font-family: var(--font-sans);
|
||||
@@ -210,6 +232,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Camera chip over the hero image — opt-in per preset. */
|
||||
.hero__exif-chip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Hero: Fullbleed Layout ─────────────────────── */
|
||||
.hero--fullbleed {
|
||||
position: relative;
|
||||
@@ -466,7 +493,10 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Index + album count ship with every hero nav link; presets opt in to
|
||||
showing them. Only the indexed nav shows the index by default. */
|
||||
.hero__nav-index {
|
||||
display: none;
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 400;
|
||||
@@ -475,6 +505,15 @@
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.hero__nav--indexed .hero__nav-index {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.hero__nav-count,
|
||||
.hero__thumbnail-index {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Hero: Mosaic Layout ────────────────────────── */
|
||||
.hero--mosaic {
|
||||
position: relative;
|
||||
@@ -605,6 +644,13 @@
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Kicker + meta block are rendered for every preset but stay hidden
|
||||
unless a preset opts in (studio-modern uses both). */
|
||||
.subpage-header__kicker,
|
||||
.subpage-header__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subpage-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2.5rem;
|
||||
@@ -718,6 +764,20 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Media wrapper keeps the cover square-on while presets that want a
|
||||
caption bar below the image can grow the card. */
|
||||
.subpage-grid__item-media {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subpage-grid__item-caption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subpage-grid__item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -905,6 +965,13 @@
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Date + gear ship with every album header but stay hidden unless a preset
|
||||
opts in, so the meta line reads "86 photos" as it always has. */
|
||||
.album-header__meta-date,
|
||||
.album-header__meta-gear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
column-count: var(--grid-columns, 3);
|
||||
column-gap: var(--grid-gap, 12px);
|
||||
|
||||
@@ -130,6 +130,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</a>
|
||||
<header className="header">
|
||||
<nav className="header__nav">
|
||||
{/* Brand wordmark — presets that show it pair it with the dot. */}
|
||||
<span className="header__wordmark" aria-hidden="true">
|
||||
{config.siteTitle}
|
||||
</span>
|
||||
<Link href="/" className="header__nav-link">
|
||||
Home
|
||||
</Link>
|
||||
|
||||
@@ -10,8 +10,15 @@
|
||||
padding: 2rem var(--page-padding, 2rem) 1rem;
|
||||
}
|
||||
|
||||
/* Kicker + meta block are rendered for every preset but stay hidden
|
||||
unless a preset opts in (studio-modern uses both). */
|
||||
.map-page__kicker,
|
||||
.map-page__meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.map-page__title {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 600;
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
@@ -19,7 +26,7 @@
|
||||
}
|
||||
|
||||
.map-page__subtitle {
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
@@ -31,9 +38,9 @@
|
||||
flex: 1;
|
||||
min-height: 500px;
|
||||
margin: 0 var(--page-padding, 2rem) 2rem;
|
||||
border-radius: var(--radius, 0);
|
||||
border-radius: var(--radius-sm, 0);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15));
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15));
|
||||
}
|
||||
|
||||
.map-container__loading {
|
||||
@@ -42,7 +49,7 @@
|
||||
justify-content: center;
|
||||
height: 500px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -51,16 +58,16 @@
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-radius: var(--radius, 4px) !important;
|
||||
border-radius: var(--radius-md, 4px) !important;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.leaflet-popup-tip {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
border: 1px solid var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
|
||||
border-top: none !important;
|
||||
border-left: none !important;
|
||||
}
|
||||
@@ -71,7 +78,7 @@
|
||||
}
|
||||
|
||||
.map-popup {
|
||||
font-family: var(--font-body);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.map-popup__cover {
|
||||
@@ -86,7 +93,7 @@
|
||||
}
|
||||
|
||||
.map-popup__city {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.125rem;
|
||||
@@ -166,7 +173,7 @@
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-primary, #1a1a1a) !important;
|
||||
color: var(--text-primary, #fff) !important;
|
||||
border-color: var(--border-color, rgba(128, 128, 128, 0.15)) !important;
|
||||
border-color: var(--border-subtle, rgba(128, 128, 128, 0.15)) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
|
||||
@@ -23,6 +23,12 @@ export default function MapPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enabledSubpages = config.subpages.filter((sp) => sp.enabled !== false);
|
||||
const collectionCount = enabledSubpages.length;
|
||||
const albumCount =
|
||||
enabledSubpages.reduce((sum, sp) => sum + sp.albumIds.length, 0) +
|
||||
config.standaloneAlbums.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Leaflet CSS from CDN */}
|
||||
@@ -35,9 +41,18 @@ export default function MapPage() {
|
||||
/>
|
||||
<div className="map-page">
|
||||
<div className="map-page__header">
|
||||
<BackLink href="/" label="Back to Gallery" />
|
||||
<h1 className="map-page__title">Map</h1>
|
||||
<p className="map-page__subtitle">Where the photos were taken</p>
|
||||
<div className="map-page__header-main">
|
||||
<BackLink href="/" label="Back to Gallery" />
|
||||
<p className="map-page__kicker" aria-hidden="true">
|
||||
Where
|
||||
</p>
|
||||
<h1 className="map-page__title">Map</h1>
|
||||
<p className="map-page__subtitle">Where the photos were taken</p>
|
||||
</div>
|
||||
<p className="map-page__meta" aria-hidden="true">
|
||||
{collectionCount} {collectionCount === 1 ? 'collection' : 'collections'} · {albumCount}{' '}
|
||||
{albumCount === 1 ? 'album' : 'albums'}
|
||||
</p>
|
||||
</div>
|
||||
<MapView />
|
||||
</div>
|
||||
|
||||
109
app/page.tsx
109
app/page.tsx
@@ -6,7 +6,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { immich } from '@/lib/immich';
|
||||
import { immich, type ImmichAsset } from '@/lib/immich';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { HeroCarousel } from '@/components/HeroCarousel';
|
||||
@@ -15,24 +15,63 @@ import { FadeIn } from '@/components/FadeIn';
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type HeroSubpage = { slug: string; name: string; albumCount: number };
|
||||
type HeroAlbum = { id: string; slug: string; albumName: string; assetCount: number };
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
|
||||
/**
|
||||
* Flatten subpages + standalone albums into one indexed nav list.
|
||||
* Every entry carries an index and a count; presets decide what to show.
|
||||
*/
|
||||
function heroNavEntries(subpages: HeroSubpage[], albums: HeroAlbum[]) {
|
||||
return [
|
||||
...subpages.map((sp) => ({
|
||||
key: `sp-${sp.slug}`,
|
||||
href: `/${sp.slug}`,
|
||||
label: sp.name,
|
||||
count: plural(sp.albumCount, 'album'),
|
||||
})),
|
||||
...albums.map((a) => ({
|
||||
key: `al-${a.id}`,
|
||||
href: `/${a.slug}`,
|
||||
label: a.albumName,
|
||||
count: plural(a.assetCount, 'photo'),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera line for the hero chip: "Q3 · 28MM · ƒ/5.6 · 1/250 · ISO 200".
|
||||
* Returns undefined when the asset carries no usable EXIF.
|
||||
*/
|
||||
function heroExifLine(asset: ImmichAsset): string | undefined {
|
||||
const e = asset.exifInfo;
|
||||
if (!e) return undefined;
|
||||
const parts = [
|
||||
e.city || e.model || undefined,
|
||||
e.focalLength ? `${Math.round(e.focalLength)}mm` : undefined,
|
||||
e.fNumber ? `ƒ/${e.fNumber}` : undefined,
|
||||
e.exposureTime ? `${e.exposureTime}s` : undefined,
|
||||
e.iso ? `ISO ${e.iso}` : undefined,
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
||||
}
|
||||
|
||||
/** Shared nav links for hero sections. */
|
||||
function HeroNavLinks({
|
||||
subpages,
|
||||
albums,
|
||||
}: {
|
||||
subpages: { slug: string; name: string }[];
|
||||
albums: { id: string; slug: string; albumName: string }[];
|
||||
}) {
|
||||
function HeroNavLinks({ subpages, albums }: { subpages: HeroSubpage[]; albums: HeroAlbum[] }) {
|
||||
return (
|
||||
<>
|
||||
{subpages.map((sp) => (
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{albums.map((album) => (
|
||||
<Link key={album.id} href={`/${album.slug}`} className="hero__nav-link">
|
||||
{album.albumName}
|
||||
{heroNavEntries(subpages, albums).map((entry, i) => (
|
||||
<Link key={entry.key} href={entry.href} className="hero__nav-link">
|
||||
<span className="hero__nav-index" aria-hidden="true">
|
||||
{pad2(i + 1)}
|
||||
</span>
|
||||
<span className="hero__nav-label">{entry.label}</span>
|
||||
<span className="hero__nav-count" aria-hidden="true">
|
||||
{entry.count}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
@@ -48,8 +87,8 @@ function HeroTextContent({
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
subpages: { slug: string; name: string }[];
|
||||
albums: { id: string; slug: string; albumName: string }[];
|
||||
subpages: HeroSubpage[];
|
||||
albums: HeroAlbum[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -82,14 +121,16 @@ export default async function HomePage() {
|
||||
immich.getStandaloneAlbums(),
|
||||
]);
|
||||
|
||||
// Fetch ThumbHash for all hero images
|
||||
// Fetch ThumbHash + camera line for all hero images
|
||||
const heroData = await Promise.all(
|
||||
config.heroImages.map(async (id) => {
|
||||
const asset = await immich.getAssetInfo(id);
|
||||
const ph = asset ? assetPlaceholder(asset) : null;
|
||||
const exif = asset ? heroExifLine(asset) : undefined;
|
||||
return {
|
||||
src: imageUrl(id, 'preview'),
|
||||
...(ph ? { blurDataURL: ph.blurDataURL } : {}),
|
||||
...(exif ? { exif } : {}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -114,20 +155,7 @@ export default async function HomePage() {
|
||||
</FadeIn>
|
||||
<FadeIn delay={300}>
|
||||
<nav className="hero__nav hero__nav--indexed">
|
||||
{subpages.map((sp, i) => (
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="hero__nav-link">
|
||||
<span className="hero__nav-index">{String(i + 1).padStart(2, '0')}</span>
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{albums.map((album, i) => (
|
||||
<Link key={album.id} href={`/${album.slug}`} className="hero__nav-link">
|
||||
<span className="hero__nav-index">
|
||||
{String(subpages.length + i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
{album.albumName}
|
||||
</Link>
|
||||
))}
|
||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
||||
</nav>
|
||||
</FadeIn>
|
||||
</div>
|
||||
@@ -137,11 +165,7 @@ export default async function HomePage() {
|
||||
|
||||
// ── Stacked: fullbleed image + text at bottom + thumbnail strip ─
|
||||
if (heroStyle === 'stacked') {
|
||||
// Fetch album thumbnails for the strip
|
||||
const allEntries = [
|
||||
...subpages.map((sp) => ({ slug: sp.slug, name: sp.name })),
|
||||
...albums.map((a) => ({ slug: a.slug, name: a.albumName })),
|
||||
];
|
||||
const allEntries = heroNavEntries(subpages, albums);
|
||||
|
||||
return (
|
||||
<div className="hero hero--stacked">
|
||||
@@ -160,9 +184,12 @@ export default async function HomePage() {
|
||||
</div>
|
||||
<FadeIn delay={200}>
|
||||
<nav className="hero__thumbnail-strip">
|
||||
{allEntries.map((entry) => (
|
||||
<Link key={entry.slug} href={`/${entry.slug}`} className="hero__thumbnail-item">
|
||||
<span className="hero__thumbnail-label">{entry.name}</span>
|
||||
{allEntries.map((entry, i) => (
|
||||
<Link key={entry.key} href={entry.href} className="hero__thumbnail-item">
|
||||
<span className="hero__thumbnail-index" aria-hidden="true">
|
||||
{pad2(i + 1)}
|
||||
</span>
|
||||
<span className="hero__thumbnail-label">{entry.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
1187
app/themes/studio-modern.css
Normal file
1187
app/themes/studio-modern.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,55 @@
|
||||
--accent-dim: rgba(200, 0, 15, 0.08);
|
||||
}
|
||||
|
||||
/* ── Studio Modern: Leica Precision Palette ──────── */
|
||||
[data-preset='studio-modern'] {
|
||||
--bg-primary: #121212;
|
||||
--bg-secondary: #0d0d0d;
|
||||
--bg-card: #191919;
|
||||
--bg-card-hover: #202020;
|
||||
--bg-overlay: rgba(10, 10, 10, 0.92);
|
||||
|
||||
--text-primary: #f4f4f2;
|
||||
--text-secondary: #9c9c96;
|
||||
--text-muted: #6b6b66;
|
||||
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.2);
|
||||
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
|
||||
--max-width: 1440px;
|
||||
--header-height: 64px;
|
||||
|
||||
/* Signal red. Light mode darkens whatever accent is configured so it
|
||||
still carries on a near-white background (#e60012 -> ~#c4000f). */
|
||||
--sm-accent: var(--accent);
|
||||
|
||||
/* Gutter + hairline, reused across app/themes/studio-modern.css */
|
||||
--sm-gutter: 48px;
|
||||
--sm-hairline: 1px solid var(--border-subtle);
|
||||
--sm-chip-bg: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
[data-preset='studio-modern'][data-theme='light'] {
|
||||
--sm-accent: color-mix(in srgb, var(--accent) 86%, #000);
|
||||
|
||||
--bg-primary: #fafaf8;
|
||||
--bg-secondary: #f0f0ec;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-hover: #f5f5f1;
|
||||
--bg-overlay: rgba(250, 250, 248, 0.96);
|
||||
|
||||
--text-primary: #161614;
|
||||
--text-secondary: #5a5a55;
|
||||
--text-muted: #8a8a84;
|
||||
|
||||
--border-subtle: rgba(0, 0, 0, 0.09);
|
||||
--border-hover: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* ── Minimal: Swiss Brutalist Palette ────────────── */
|
||||
[data-preset='minimal'] {
|
||||
--bg-primary: #000000;
|
||||
|
||||
Reference in New Issue
Block a user