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

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