Polish hero UX and add admin brand kit panel
This commit is contained in:
+262
-45
@@ -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>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user