Polish hero UX and add admin brand kit panel

This commit is contained in:
nmemmert
2026-04-16 10:31:35 -04:00
parent 5562db992b
commit 0c328057c8
5 changed files with 1128 additions and 446 deletions
+262 -45
View File
@@ -73,30 +73,108 @@ interface Question {
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries'>
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
{ key: 'eyebrow', label: 'Hero Eyebrow Text' },
{ key: 'heroTagline', label: 'Hero Tagline' },
{ key: 'aboutShowHeading', label: 'About Show — Heading' },
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true },
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true },
{ key: 'aboutNate', label: 'About Nate', multiline: true },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' },
{ key: 'seriesTitle', label: 'Series Title' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true },
{ key: 'seriesImageUrl', label: 'Series Cover Image URL' },
{ key: 'seriesListenUrl', label: 'Series Listen URL' },
{ key: 'studyGuideTitle', label: 'Study Guide Title' },
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true },
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' },
{ key: 'shareHeading', label: 'Share Section — Heading' },
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true },
type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share'
const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [
{
id: 'hero',
title: 'Hero Section',
description: 'Controls the top intro copy on the homepage.',
},
{
id: 'start-here',
title: 'Start Here Page',
description: 'Edit the separate Start Here page heading, intro, and step cards.',
},
{
id: 'about',
title: 'About Section',
description: 'Manage the main show description and Nate bio content.',
},
{
id: 'series',
title: 'Current Series',
description: 'Update the active study card and companion guide content.',
},
{
id: 'share',
title: 'Share Section',
description: 'Controls the copy shown near the QR/share area.',
},
]
const BRAND_KIT_SWATCHES = [
{ name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' },
{ name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' },
{ name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' },
{ name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' },
{ name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' },
{ name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' },
{ name: 'Warm White', hex: '#f0ead8', role: 'Primary text' },
{ name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' },
] as const
const BRAND_KIT_TYPE = [
{
label: 'Brand Font',
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported',
sample: 'Verse by Verse with Nate',
},
{
label: 'Subtitle / Small Caps',
spec: 'Uppercase with tracking for labels, nav, and support text',
sample: 'A Journey Through Scripture',
},
{
label: 'Body / Quote',
spec: 'Cormorant Garamond light/italic for long-form text and pull copy',
sample: 'Verse by verse. Nugget by nugget.',
},
] as const
const BRAND_KIT_VERIFICATION = [
'Global font import updated to Cormorant Garamond.',
'Shared brand variables now define black, gold, border, warm white, and muted text colors.',
'Live site CSS now references the brand variables for core text and accent styling.',
'Admin surfaces inherit the same typography and palette tokens used by the public site.',
] as const
const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [
{ key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' },
{ key: 'heroTagline', label: 'Hero Tagline', section: 'hero' },
{ key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' },
{ key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' },
{ key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' },
{ key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' },
{ key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' },
{ key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' },
{ key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' },
{ key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' },
{ key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' },
{ key: 'aboutShowHeading', label: 'About Show — Heading', section: 'about' },
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
{ key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' },
{ key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' },
{ key: 'studyGuideTitle', label: 'Study Guide Title', section: 'series' },
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true, section: 'series' },
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)', section: 'series' },
{ key: 'shareHeading', label: 'Share Section — Heading', section: 'share' },
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true, section: 'share' },
]
export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content)
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions'>('content')
const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'brand'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
@@ -110,6 +188,25 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
const isDirty = JSON.stringify(form) !== lastSavedSnapshot
useEffect(() => {
const nextSnapshot = JSON.stringify(content)
setForm(content)
setLastSavedSnapshot(nextSnapshot)
}, [content])
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
@@ -300,6 +397,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
setForm(f => ({ ...f, [key]: value }))
}
function confirmLeaveUnsavedChanges() {
if (!isDirty) return true
return confirm('You have unsaved changes. Leave this section without saving?')
}
function handleAdminTabChange(nextTab: 'content' | 'stats' | 'questions' | 'brand') {
if (nextTab === adminTab) return
if (!confirmLeaveUnsavedChanges()) return
setAdminTab(nextTab)
}
function handleContentTabChange(nextTab: 'main' | 'custom') {
if (nextTab === contentTab) return
if (!confirmLeaveUnsavedChanges()) return
setContentTab(nextTab)
}
function addLink() {
setForm(f => ({
...f,
@@ -581,6 +695,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
throw new Error((data as { message?: string }).message ?? 'Save failed')
}
onSave(form)
setLastSavedSnapshot(JSON.stringify(form))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
@@ -656,8 +771,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<h1>Site Admin</h1>
<p className="admin-sub">Verse by Verse with Nate</p>
<div className="admin-header-actions">
<Link to="/" className="admin-back"> Back to site</Link>
<button type="button" className="btn-admin-logout" onClick={() => void onLogout()}>
<Link
to="/"
className="admin-back"
onClick={e => {
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
}}
>
Back to site
</Link>
<button
type="button"
className="btn-admin-logout"
onClick={() => {
if (!confirmLeaveUnsavedChanges()) return
void onLogout()
}}
>
Log Out
</button>
</div>
@@ -670,7 +800,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={adminTab === 'content'}
className={`admin-tab ${adminTab === 'content' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('content')}
onClick={() => handleAdminTabChange('content')}
>
Content
</button>
@@ -679,7 +809,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={adminTab === 'stats'}
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('stats')}
onClick={() => handleAdminTabChange('stats')}
>
Site Stats
</button>
@@ -688,12 +818,82 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={adminTab === 'questions'}
className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('questions')}
onClick={() => handleAdminTabChange('questions')}
>
Questions ({questions.length})
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'brand'}
className={`admin-tab ${adminTab === 'brand' ? 'admin-tab--active' : ''}`}
onClick={() => handleAdminTabChange('brand')}
>
Brand Kit
</button>
</div>
{adminTab === 'brand' && (
<section className="admin-brand-kit" aria-label="Brand kit">
<div className="admin-stats-head">
<h2>Brand Kit</h2>
<p>Reference palette, typography, and implementation status for the live site.</p>
</div>
<div className="admin-brand-hero">
<img src="/images/podcast-art.jpeg" alt="Verse by Verse with Nate artwork" />
<div>
<p className="admin-brand-kicker">Verse by Verse with Nate</p>
<h3>A Journey Through Scripture</h3>
<p className="admin-brand-tagline">Verse by verse. Nugget by nugget.</p>
</div>
</div>
<div className="admin-brand-grid">
<article className="admin-brand-card">
<h3>Color Palette</h3>
<div className="admin-brand-swatches">
{BRAND_KIT_SWATCHES.map(swatch => (
<div key={swatch.hex} className="admin-brand-swatch">
<div className="admin-brand-swatch-block" style={{ background: swatch.hex }} />
<div className="admin-brand-swatch-copy">
<strong>{swatch.name}</strong>
<span>{swatch.hex}</span>
<p>{swatch.role}</p>
</div>
</div>
))}
</div>
</article>
<article className="admin-brand-card">
<h3>Typography</h3>
<div className="admin-brand-type-list">
{BRAND_KIT_TYPE.map(item => (
<div key={item.label} className="admin-brand-type-row">
<p className="admin-brand-type-label">{item.label}</p>
<p className="admin-brand-type-spec">{item.spec}</p>
<p className="admin-brand-type-sample">{item.sample}</p>
</div>
))}
</div>
</article>
</div>
<article className="admin-brand-card admin-brand-card--verify">
<h3>Verified On Site</h3>
<ul className="admin-brand-checklist">
{BRAND_KIT_VERIFICATION.map(item => (
<li key={item}>{item}</li>
))}
</ul>
<p className="admin-brand-note">
This admin panel reflects the live implementation now loaded from the shared font import and brand tokens.
</p>
</article>
</section>
)}
{adminTab === 'stats' && (
<section className="admin-stats" aria-label="Site hit statistics">
<div className="admin-stats-head">
@@ -967,7 +1167,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={contentTab === 'main'}
className={`admin-tab ${contentTab === 'main' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('main')}
onClick={() => handleContentTabChange('main')}
>
Main Content
</button>
@@ -976,12 +1176,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
role="tab"
aria-selected={contentTab === 'custom'}
className={`admin-tab ${contentTab === 'custom' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('custom')}
onClick={() => handleContentTabChange('custom')}
>
Custom Content
</button>
</div>
{isDirty && <p className="admin-status admin-status--warn">Unsaved changes</p>}
{contentTab === 'main' && (
<>
<div className="admin-archive-helper">
@@ -995,26 +1197,41 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</button>
</div>
{FIELDS.map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? (
<textarea
id={`field-${key}`}
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
rows={4}
/>
) : (
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
)}
</div>
))}
{MAIN_CONTENT_SECTIONS.map(section => {
const sectionFields = FIELDS.filter(field => field.section === section.id)
return (
<section className="admin-form-section" key={section.id}>
<div className="admin-section-header admin-section-header--compact">
<h3>{section.title}</h3>
<p>{section.description}</p>
</div>
<div className="admin-form-section-fields">
{sectionFields.map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? (
<textarea
id={`field-${key}`}
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
rows={4}
/>
) : (
<input
id={`field-${key}`}
type="text"
value={form[key] as string}
onChange={e => handleChange(key, e.target.value)}
/>
)}
</div>
))}
</div>
</section>
)
})}
</>
)}