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:
Ralksta
2026-08-10 09:56:20 +02:00
committed by GitHub
parent c9036fdc94
commit 2318409e75
78 changed files with 3848 additions and 553 deletions

View File

@@ -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} />

View File

@@ -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>
)}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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>}

View File

@@ -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);
}

View File

@@ -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">

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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.',

View File

@@ -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', () => {

View 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' });
});
});

View File

@@ -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 },
);
}
}

View File

@@ -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.',

View File

@@ -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',
},

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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;