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:
@@ -22,6 +22,10 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
// Distinct from `status === null`: the check itself did not run. Without this
|
||||
// an expired session or a 500 rendered exactly like a real outage — every
|
||||
// indicator flipped to its alarming value at once (#341).
|
||||
const [statusError, setStatusError] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch('/api/admin/auth', { method: 'DELETE' });
|
||||
@@ -46,20 +50,41 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
setStatusError(false);
|
||||
} else {
|
||||
setStatus(null);
|
||||
setStatusError(true);
|
||||
}
|
||||
} catch {
|
||||
setStatus(null);
|
||||
setStatusError(true);
|
||||
} finally {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render one diagnostic as ok / bad / unknown. "Unknown" covers both the
|
||||
* in-flight check and a check that failed to run — neither is evidence that
|
||||
* the thing being checked is broken.
|
||||
*/
|
||||
const indicator = (healthy: boolean, okLabel: string, badLabel: string) => {
|
||||
if (statusLoading) return { className: 'unknown', label: 'Checking…' };
|
||||
if (statusError || !status) return { className: 'unknown', label: 'Unknown' };
|
||||
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const immichIndicator = indicator(
|
||||
status?.immich?.status === 'connected',
|
||||
'Connected',
|
||||
'Disconnected',
|
||||
);
|
||||
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
<header className="admin-header">
|
||||
@@ -90,7 +115,7 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
{/* Diagnostics Badge */}
|
||||
<div className="status-indicator-container">
|
||||
<button
|
||||
className={`status-badge-btn ${status?.immich?.status === 'connected' ? 'connected' : 'disconnected'}`}
|
||||
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
|
||||
onClick={() => {
|
||||
setShowStatus(!showStatus);
|
||||
if (!showStatus) fetchStatus();
|
||||
@@ -99,18 +124,24 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
>
|
||||
<span className="status-dot"></span>
|
||||
<span className="status-text">
|
||||
{statusLoading ? 'Checking...' : status?.immich?.status === 'connected' ? 'System OK' : 'System Degraded'}
|
||||
{statusLoading
|
||||
? 'Checking...'
|
||||
: immichIndicator.className === 'ok'
|
||||
? 'System OK'
|
||||
: immichIndicator.className === 'error'
|
||||
? 'System Degraded'
|
||||
: 'Status Unknown'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
|
||||
{showStatus && (
|
||||
<>
|
||||
<div className="status-dropdown-backdrop" onClick={() => setShowStatus(false)} />
|
||||
<div className="status-dropdown">
|
||||
<div className="status-dropdown-header">
|
||||
<h4>System Diagnostics</h4>
|
||||
<button
|
||||
className="status-refresh-btn"
|
||||
<button
|
||||
className="status-refresh-btn"
|
||||
onClick={fetchStatus}
|
||||
disabled={statusLoading}
|
||||
title="Refresh diagnostics"
|
||||
@@ -121,14 +152,14 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<div className="status-dropdown-body">
|
||||
<div className="status-item">
|
||||
<span className="status-label">Immich Connection</span>
|
||||
<span className={`status-val ${status?.immich?.status === 'connected' ? 'ok' : 'error'}`}>
|
||||
{status?.immich?.status === 'connected' ? 'Connected' : 'Disconnected'}
|
||||
<span className={`status-val ${immichIndicator.className}`}>
|
||||
{immichIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
<span className="status-label">Config Integrity</span>
|
||||
<span className={`status-val ${status?.config?.status === 'valid' ? 'ok' : 'error'}`}>
|
||||
{status?.config?.status === 'valid' ? 'Valid' : 'Degraded'}
|
||||
<span className={`status-val ${configIndicator.className}`}>
|
||||
{configIndicator.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
@@ -138,7 +169,9 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<div className="status-item">
|
||||
<span className="status-label">Latest Backup</span>
|
||||
<span className="status-val" title={status?.backups?.lastBackup || 'None'}>
|
||||
{status?.backups?.lastBackup ? new Date(status.backups.lastBackup).toLocaleDateString() : 'None'}
|
||||
{status?.backups?.lastBackup
|
||||
? new Date(status.backups.lastBackup).toLocaleDateString()
|
||||
: 'None'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="status-item">
|
||||
|
||||
@@ -7,7 +7,17 @@ import {
|
||||
type ParsedEssay,
|
||||
type EssayBlock,
|
||||
} from '@/lib/essay';
|
||||
import * as Icons from './Icons';
|
||||
import {
|
||||
IconCamera,
|
||||
IconChevronUp,
|
||||
IconChevronDown,
|
||||
IconTrash,
|
||||
IconGripVertical,
|
||||
IconQuote,
|
||||
IconFileText,
|
||||
IconSparkles,
|
||||
IconArrowLeftRight,
|
||||
} from './Icons';
|
||||
|
||||
interface EssayBlockEditorProps {
|
||||
markdown: string;
|
||||
@@ -83,21 +93,21 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' };
|
||||
break;
|
||||
case 'quote':
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: 'Author' };
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: '' };
|
||||
break;
|
||||
case 'photo':
|
||||
newBlock = {
|
||||
type: 'photo',
|
||||
assetId: 'sample-asset',
|
||||
caption: 'Photo caption',
|
||||
assetId: '',
|
||||
caption: '',
|
||||
layout: 'contained',
|
||||
};
|
||||
break;
|
||||
case 'photo-pair':
|
||||
newBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
caption: 'Side by side caption',
|
||||
assetIds: ['', ''],
|
||||
caption: '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -106,57 +116,45 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="essay-block-editor" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div
|
||||
className="essay-block-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.04))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.1))',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, marginRight: 'auto' }}>
|
||||
✨ Add Story Block:
|
||||
</span>
|
||||
<div className="essay-block-editor">
|
||||
{/* Sticky Dark Glass Toolbar */}
|
||||
<div className="essay-toolbar-sticky">
|
||||
<span className="essay-toolbar-label">✨ Add Story Block:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('paragraph')}
|
||||
>
|
||||
+ Paragraph
|
||||
<IconFileText size={13} /> + Paragraph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('heading')}
|
||||
>
|
||||
+ Heading
|
||||
<IconSparkles size={13} /> + Heading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('quote')}
|
||||
>
|
||||
+ Pullquote
|
||||
<IconQuote size={13} /> + Pullquote
|
||||
</button>
|
||||
<div className="essay-toolbar-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo')}
|
||||
>
|
||||
+ Photo
|
||||
<IconCamera size={13} /> + Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo-pair')}
|
||||
>
|
||||
+ 2-Photo Pair
|
||||
<IconCamera size={13} /> + 2-Photo Pair
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -166,51 +164,37 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Render Blocks */}
|
||||
{essay.blocks.map((block, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="essay-block-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.03))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.08))',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Block Header / Action Controls */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<div key={idx} className="essay-block-card">
|
||||
{/* Card Header */}
|
||||
<div className="essay-block-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<IconGripVertical size={14} style={{ color: 'var(--admin-text-muted)', cursor: 'grab' }} />
|
||||
<span
|
||||
className={`essay-block-badge ${
|
||||
block.type === 'photo' || block.type === 'photo-pair'
|
||||
? 'badge-photo'
|
||||
: block.type === 'heading'
|
||||
? 'badge-heading'
|
||||
: block.type === 'quote'
|
||||
? 'badge-quote'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="essay-block-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'up')}
|
||||
disabled={idx === 0}
|
||||
title="Move Up"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▲
|
||||
<IconChevronUp size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -218,31 +202,32 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
onClick={() => handleMoveBlock(idx, 'down')}
|
||||
disabled={idx === essay.blocks.length - 1}
|
||||
title="Move Down"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▼
|
||||
<IconChevronDown size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon text-danger"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleDeleteBlock(idx)}
|
||||
title="Delete Block"
|
||||
style={{ padding: '2px 6px', color: '#ff4d4f' }}
|
||||
style={{ color: 'var(--admin-error)' }}
|
||||
>
|
||||
<Icons.IconTrash size={14} />
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render Block Form Controls */}
|
||||
{/* Block Form Fields */}
|
||||
|
||||
{/* 1. HEADING BLOCK */}
|
||||
{block.type === 'heading' && (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
||||
}
|
||||
style={{ width: '80px', padding: '6px', borderRadius: '4px' }}
|
||||
style={{ width: '70px' }}
|
||||
>
|
||||
<option value={1}>H1</option>
|
||||
<option value={2}>H2</option>
|
||||
@@ -252,12 +237,13 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section title..."
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
placeholder="Section heading..."
|
||||
style={{ flex: 1, fontWeight: block.level === 1 ? 700 : block.level === 2 ? 600 : 500 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2. PARAGRAPH BLOCK */}
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
@@ -265,19 +251,19 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* allowed)"
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', resize: 'vertical' }}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* markdown supported)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3. PULLQUOTE BLOCK */}
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', borderLeft: '3px solid var(--admin-accent)', paddingLeft: '10px' }}>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Quote text..."
|
||||
style={{ padding: '6px 10px', borderRadius: '4px' }}
|
||||
style={{ fontStyle: 'italic', fontFamily: 'serif', fontSize: '1rem' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -285,77 +271,80 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, author: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Author / Attribution (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
placeholder="— Author / Attribution (optional)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 4. PHOTO BLOCK */}
|
||||
{block.type === 'photo' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: '#222',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="essay-photo-card">
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetId ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetId}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
className="essay-photo-thumbnail"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="essay-photo-empty">
|
||||
<IconCamera size={20} />
|
||||
<span>No Photo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset ID or Index"
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
<div className="essay-photo-meta">
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn admin-btn-sm admin-btn-primary"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick Photo
|
||||
<IconCamera size={14} />
|
||||
{block.assetId ? 'Change Photo' : 'Select Photo'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="essay-pill-selector">
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'contained' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'contained' })}
|
||||
>
|
||||
Contained (68ch)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'wide' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'wide' })}
|
||||
>
|
||||
Wide (1100px)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`essay-pill-btn ${block.layout === 'fullbleed' ? 'active' : ''}`}
|
||||
onClick={() => handleUpdateBlock(idx, { ...block, layout: 'fullbleed' })}
|
||||
>
|
||||
Fullbleed (100vw)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.8rem', opacity: 0.8 }}>Display Width:</span>
|
||||
<select
|
||||
value={block.layout}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
layout: e.target.value as 'contained' | 'wide' | 'fullbleed',
|
||||
})
|
||||
}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
>
|
||||
<option value="contained">Contained (68ch)</option>
|
||||
<option value="wide">Wide (1100px)</option>
|
||||
<option value="fullbleed">Fullbleed (100vw)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset UUID..."
|
||||
style={{ fontSize: '0.75rem', color: 'var(--admin-text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -366,74 +355,130 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Photo caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. PHOTO-PAIR BLOCK */}
|
||||
{block.type === 'photo-pair' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 1 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
<div className="essay-photo-card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
{/* Photo 1 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
Photo 1
|
||||
</span>
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetIds[0] ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetIds[0]}`}
|
||||
alt=""
|
||||
className="essay-photo-thumbnail"
|
||||
style={{ width: '80px', height: '60px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}>
|
||||
<IconCamera size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Select Photo 1
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
</button>
|
||||
)}
|
||||
}
|
||||
placeholder="UUID 1..."
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 2 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
{/* Photo 2 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--admin-text-secondary)' }}>
|
||||
Photo 2
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn-icon"
|
||||
title="Swap Photo 1 & Photo 2"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
)
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[1], block.assetIds[0]],
|
||||
})
|
||||
}
|
||||
>
|
||||
Pick
|
||||
<IconArrowLeftRight size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="essay-photo-preview-container">
|
||||
{block.assetIds[1] ? (
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetIds[1]}`}
|
||||
alt=""
|
||||
className="essay-photo-thumbnail"
|
||||
style={{ width: '80px', height: '60px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="essay-photo-empty" style={{ width: '80px', height: '60px' }}>
|
||||
<IconCamera size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
<IconCamera size={12} /> Select Photo 2
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="UUID 2..."
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -444,7 +489,6 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Side-by-side caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -265,3 +265,45 @@ export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth =
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="9" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="9" cy="19" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
|
||||
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArrowLeftRight({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m16 3 4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="m8 21-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -908,11 +908,11 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
<div className="save-bar-right">
|
||||
<a
|
||||
href="/"
|
||||
href="/?fresh=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-ghost admin-btn-preview"
|
||||
title="Open site in new tab"
|
||||
title="Open site in new tab (bypassing cache)"
|
||||
>
|
||||
↗ Preview Site
|
||||
</a>
|
||||
@@ -1120,11 +1120,11 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/${slugify(sp.name)}`}
|
||||
href={`/${slugify(sp.name)}?fresh=1`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
title="Open live page in new tab"
|
||||
title="Open live page in new tab (bypassing cache)"
|
||||
>
|
||||
/{slugify(sp.name)} ↗
|
||||
</a>
|
||||
@@ -1315,8 +1315,15 @@ export default function PageBuilder() {
|
||||
markdown={sp.essayText || ''}
|
||||
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
||||
onSelectPhoto={(callback) => {
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex });
|
||||
// We can pass a photo picker handler if needed
|
||||
const firstAlbumId = sp.albums[0]?.id;
|
||||
setHeroPickerTarget({
|
||||
albumId: firstAlbumId,
|
||||
title: 'Select Photo for Photo Essay',
|
||||
onSelect: (assetId) => {
|
||||
callback(assetId);
|
||||
setHeroPickerTarget(null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,7 @@ interface Settings {
|
||||
};
|
||||
}
|
||||
|
||||
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||
const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
const PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
|
||||
@@ -81,6 +81,13 @@ const THEME_INFO: Record<string, { desc: string; label: string; accent: string;
|
||||
tile: '#242424',
|
||||
accent: '#e60012',
|
||||
},
|
||||
'studio-modern': {
|
||||
label: 'Studio Modern',
|
||||
desc: 'Leica precision: Archivo grotesque, mono EXIF, red signal accents.',
|
||||
bg: '#121212',
|
||||
tile: '#191919',
|
||||
accent: '#e60012',
|
||||
},
|
||||
minimal: {
|
||||
label: 'Minimal',
|
||||
desc: 'Pure raw layouts with tiny gaps and high whitespace.',
|
||||
|
||||
Reference in New Issue
Block a user