Revamp /admin with drill-down sidebar nav and focused sub-views; v1.1.50
Publish Container / docker (push) Successful in 13m50s

- Replace flat sidebar with accordion nav: parent items (Homepage, About,
  Contact, Podcast Hub) expand to reveal focused sub-items
- Split big sections into granular sub-views so each form shows only 3-8
  fields (Homepage: Hero/Share/PRISM/Where-to-Next, About: Show Copy/Bio/
  Testimonials, Contact: Profile/Copy & Scripture, Podcast: Current Series/
  Highlights/Finished Books/Scripts/RSS)
- New topbar: brand + breadcrumb (Homepage › Hero), clean gold Save Draft
  button, ghost Preview/Publish buttons — no more jump-to select
- New sidebar: compact 196px width, filter input at top, small-caps group
  labels, chevron-animated parent items, indented sub-items with active
  highlight, Back-to-site/Log-out/version at the bottom
- Replace PodcastTab state with adminView sub-views (podcast:current-series
  etc.) eliminating the secondary tab bar inside the panel
- Extend AdminView type with all new colon-separated sub-view values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 09:09:52 -04:00
1 parent a072076818
commit e593031d0d
3 files changed
+683 -527

No files matched your search

+286 -264
View File
@@ -697,8 +697,12 @@ interface Subscriber {
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'podcast'
| 'dashboard'
| 'homepage:hero' | 'homepage:share' | 'homepage:prism' | 'homepage:where-to-next'
| 'start-here'
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates'
@@ -709,92 +713,109 @@ interface AdminSectionLink {
label: string
}
const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminView; label: string }> }> = [
type NavLeaf = { kind: 'leaf'; view: AdminView; label: string }
type NavParent = { kind: 'parent'; id: string; label: string; children: NavLeaf[] }
type NavEntry = NavLeaf | NavParent
interface NavGroup { group: string; items: NavEntry[] }
const ADMIN_NAV: NavGroup[] = [
{
group: 'Overview',
options: [{ value: 'dashboard', label: 'Dashboard' }],
items: [{ kind: 'leaf', view: 'dashboard', label: 'Dashboard' }],
},
{
group: 'Site',
options: [
{ value: 'homepage', label: 'Homepage' },
{ value: 'start-here', label: 'Start Here' },
{ value: 'about', label: 'About' },
{ value: 'contact', label: 'Contact' },
items: [
{ kind: 'parent', id: 'homepage', label: 'Homepage', children: [
{ kind: 'leaf', view: 'homepage:hero', label: 'Hero' },
{ kind: 'leaf', view: 'homepage:share', label: 'Share Section' },
{ kind: 'leaf', view: 'homepage:prism', label: 'PRISM Block' },
{ kind: 'leaf', view: 'homepage:where-to-next', label: 'Where to Next' },
]},
{ kind: 'leaf', view: 'start-here', label: 'Start Here' },
{ kind: 'parent', id: 'about', label: 'About', children: [
{ kind: 'leaf', view: 'about:show-copy', label: 'Show Copy' },
{ kind: 'leaf', view: 'about:bio', label: 'Nate Bio' },
{ kind: 'leaf', view: 'about:testimonials', label: 'Testimonials' },
]},
{ kind: 'parent', id: 'contact', label: 'Contact', children: [
{ kind: 'leaf', view: 'contact:profile', label: 'Profile' },
{ kind: 'leaf', view: 'contact:copy', label: 'Copy & Scripture' },
]},
],
},
{
group: 'Content',
options: [
{ value: 'podcast', label: 'Podcast Hub' },
{ value: 'colossians-study', label: 'Studies' },
{ value: 'downloads', label: 'Resources' },
{ value: 'custom-links', label: 'Custom Links' },
{ value: 'content-blocks', label: 'Content Blocks' },
{ value: 'books', label: 'Books' },
{ value: 'recommended-resources', label: 'Recommended Resources' },
items: [
{ kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
{ kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
{ kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
{ kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
{ kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' },
{ kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
{ kind: 'leaf', view: 'custom-links', label: 'Custom Links' },
{ kind: 'leaf', view: 'content-blocks', label: 'Content Blocks' },
{ kind: 'leaf', view: 'books', label: 'Books' },
{ kind: 'leaf', view: 'recommended-resources', label: 'Recommended Resources' },
],
},
{
group: 'Audience',
options: [
{ value: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' },
items: [
{ kind: 'leaf', view: 'questions', label: 'Questions' },
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
],
},
{
group: 'Insights',
options: [
{ value: 'analytics', label: 'Analytics' },
],
},
{
group: 'Tools',
options: [
{ value: 'assets', label: 'Asset Manager' },
{ value: 'qr-codes', label: 'QR Codes' },
group: 'Insights & Tools',
items: [
{ kind: 'leaf', view: 'analytics', label: 'Analytics' },
{ kind: 'leaf', view: 'assets', label: 'Asset Manager' },
{ kind: 'leaf', view: 'qr-codes', label: 'QR Codes' },
],
},
{
group: 'Configure',
options: [
{ value: 'global', label: 'Footer & Global' },
{ value: 'seo', label: 'SEO & Redirects' },
{ value: 'email-templates', label: 'Email Templates' },
{ value: 'legal', label: 'Legal Pages' },
{ value: 'security', label: 'Security' },
{ value: 'brand', label: 'Brand Kit' },
items: [
{ kind: 'leaf', view: 'global', label: 'Footer & Global' },
{ kind: 'leaf', view: 'seo', label: 'SEO & Redirects' },
{ kind: 'leaf', view: 'email-templates', label: 'Email Templates' },
{ kind: 'leaf', view: 'legal', label: 'Legal Pages' },
{ kind: 'leaf', view: 'security', label: 'Security' },
{ kind: 'leaf', view: 'brand', label: 'Brand Kit' },
],
},
]
function getNavParentId(view: AdminView): string | null {
for (const group of ADMIN_NAV) {
for (const item of group.items) {
if (item.kind === 'parent' && item.children.some(c => c.view === view)) return item.id
}
}
return null
}
function getNavBreadcrumb(view: AdminView): { parent?: string; label: string } {
for (const group of ADMIN_NAV) {
for (const item of group.items) {
if (item.kind === 'parent') {
const child = item.children.find(c => c.view === view)
if (child) return { parent: item.label, label: child.label }
} else if (item.view === view) {
return { label: item.label }
}
}
}
return { label: String(view) }
}
const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
homepage: [
{ id: 'homepage-hero', label: 'Hero' },
{ id: 'homepage-share', label: 'Share Section' },
{ id: 'homepage-prism', label: 'PRISM Block' },
{ id: 'homepage-where-to-next-labels', label: 'Where to Next Labels' },
{ id: 'homepage-where-to-next-cards', label: 'Where to Next Cards' },
],
'start-here': [
{ id: 'start-here-intro', label: 'Intro' },
{ id: 'start-here-step-1', label: 'Step 1' },
{ id: 'start-here-step-2', label: 'Step 2' },
{ id: 'start-here-step-3', label: 'Step 3' },
],
about: [
{ id: 'about-show-copy', label: 'Show Copy' },
{ id: 'about-nate-bio', label: 'Nate Bio' },
{ id: 'about-images', label: 'Images' },
{ id: 'about-testimonials', label: 'Testimonials' },
],
contact: [
{ id: 'contact-profile', label: 'Profile' },
{ id: 'contact-intro', label: 'Intro Copy' },
{ id: 'contact-scripture', label: 'Scripture' },
],
downloads: [
{ id: 'downloads-study-guide', label: 'Study Guide' },
{ id: 'downloads-library', label: 'Download Library' },
@@ -816,8 +837,6 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
],
}
type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'episode-scripts' | 'rss-feed'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
const BRAND_KIT_SWATCHES = [
@@ -1106,7 +1125,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminView, setAdminView] = useState<AdminView>('dashboard')
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
const [navOpenGroups, setNavOpenGroups] = useState<Set<string>>(new Set())
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d')
@@ -1204,15 +1223,28 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
const navSearchTerm = navSearch.trim().toLowerCase()
const filteredAdminViewOptions = ADMIN_VIEW_OPTIONS
const filteredNav: NavGroup[] = ADMIN_NAV
.map(group => ({
...group,
options: group.options.filter(option => {
if (!navSearchTerm) return true
return `${group.group} ${option.label} ${option.value}`.toLowerCase().includes(navSearchTerm)
}),
items: group.items
.map(item => {
if (item.kind === 'parent') {
if (!navSearchTerm) return item
const matchesParent = `${group.group} ${item.label}`.toLowerCase().includes(navSearchTerm)
const filteredChildren = item.children.filter(c =>
`${group.group} ${item.label} ${c.label}`.toLowerCase().includes(navSearchTerm)
)
if (matchesParent) return item
if (filteredChildren.length > 0) return { ...item, children: filteredChildren }
return null
} else {
if (!navSearchTerm) return item
return `${group.group} ${item.label}`.toLowerCase().includes(navSearchTerm) ? item : null
}
})
.filter((item): item is NavEntry => item !== null),
}))
.filter(group => group.options.length > 0)
.filter(group => group.items.length > 0)
// Silently saves draft every 30s when there are unsaved changes
useAutosave(form, isDirty, handleSaveDraft)
@@ -2070,6 +2102,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
if (!confirmLeaveUnsavedChanges()) return
setAdminView(view)
setMobileNavOpen(false)
const parentId = getNavParentId(view)
if (parentId) setNavOpenGroups(prev => new Set([...prev, parentId]))
}
function getAdminViewBadge(view: AdminView) {
@@ -2643,52 +2677,40 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
aria-expanded={mobileNavOpen}
aria-controls="admin-sidebar-nav"
>
{mobileNavOpen ? 'Close' : 'Menu'}
<span className="admin-menu-bar" /><span className="admin-menu-bar" /><span className="admin-menu-bar" />
</button>
<div className="admin-topbar-brand">
<span className="admin-topbar-ornament">✦</span>
<span className="admin-topbar-title">Site Admin</span>
<span className="admin-topbar-sub">Verse by Verse with Nate</span>
<span className="admin-topbar-title">Siteforge</span>
</div>
{(() => {
const crumb = getNavBreadcrumb(adminView)
return (
<div className="admin-topbar-crumb">
{crumb.parent && <><span className="admin-crumb-parent">{crumb.parent}</span><span className="admin-crumb-sep">›</span></>}
<span className="admin-crumb-current">{crumb.label}</span>
</div>
)
})()}
</div>
<div className="admin-topbar-state">
{isDirty && <span className="admin-status admin-status--warn">● Unsaved</span>}
<div className="admin-topbar-right">
{isDirty && <span className="admin-unsaved-dot">● Unsaved</span>}
<span className="admin-topbar-meta">Draft: {formatDate(publishState.draftUpdatedAt)}</span>
<span className="admin-topbar-meta">Live: {formatDate(publishState.publishedAt)}</span>
</div>
<div className="admin-topbar-actions">
<label className="admin-view-picker" aria-label="Jump to admin section">
<span className="admin-view-picker-label">Jump to</span>
<select
className="admin-view-picker-select"
value={adminView}
onChange={e => navigateTo(e.target.value as AdminView)}
>
{ADMIN_VIEW_OPTIONS.map(group => (
<optgroup key={group.group} label={group.group}>
{group.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
</label>
<button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}>
{status === 'saving' ? 'Saving…' : 'Save Draft'}
</button>
<button type="button" className={`btn-admin-reset${previewOpen ? ' btn-admin-reset--active' : ''}`} onClick={() => setPreviewOpen(o => !o)}>
<button type="button" className={`btn-admin-ghost${previewOpen ? ' btn-admin-ghost--active' : ''}`} onClick={() => setPreviewOpen(o => !o)}>
{previewOpen ? 'Close Preview' : 'Preview'}
</button>
{publishConfirmOpen ? (
<>
<span style={{ fontSize: '0.82rem', color: '#e0c070' }}>Publish to live site?</span>
<button type="button" className="btn-admin-save" onClick={() => { setPublishConfirmOpen(false); handlePublishDraft() }}>Yes, Publish</button>
<button type="button" className="btn-admin-reset" onClick={() => setPublishConfirmOpen(false)}>Cancel</button>
<button type="button" className="btn-admin-ghost" onClick={() => setPublishConfirmOpen(false)}>Cancel</button>
</>
) : (
<button type="button" className="btn-admin-reset" onClick={() => setPublishConfirmOpen(true)}>Publish</button>
<button type="button" className="btn-admin-publish" onClick={() => setPublishConfirmOpen(true)}>Publish</button>
)}
</div>
</header>
@@ -2696,7 +2718,73 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<div className={`admin-layout${previewOpen ? ' admin-layout--split' : ''}`}>
{/* ── Sidebar ── */}
<nav id="admin-sidebar-nav" className={`admin-sidebar${mobileNavOpen ? ' admin-sidebar--open' : ''}`} aria-label="Admin navigation">
<div className="admin-sidebar-meta-links">
<div className="admin-sidebar-top">
<div className="admin-nav-search">
<input
id="admin-nav-search-input"
type="search"
value={navSearch}
onChange={e => setNavSearch(e.target.value)}
placeholder="Filter…"
aria-label="Filter admin sections"
/>
</div>
</div>
<div className="admin-sidebar-scroll">
{filteredNav.length === 0 ? (
<p className="admin-nav-empty">No sections match.</p>
) : (
filteredNav.map(group => (
<div key={group.group} className="admin-nav-group">
<span className="admin-nav-group-label">{group.group}</span>
{group.items.map(item => {
if (item.kind === 'parent') {
const isOpen = navOpenGroups.has(item.id) || (navSearchTerm !== '' && item.children.some(c => c.view === adminView))
const hasActive = item.children.some(c => c.view === adminView)
return (
<div key={item.id} className="admin-nav-parent-wrap">
<button
type="button"
className={`admin-nav-parent${hasActive ? ' admin-nav-parent--active' : ''}`}
onClick={() => setNavOpenGroups(prev => {
const next = new Set(prev)
if (next.has(item.id)) next.delete(item.id)
else next.add(item.id)
return next
})}
>
<span>{item.label}</span>
<svg className={`admin-nav-chevron${isOpen ? ' admin-nav-chevron--open' : ''}`} width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{isOpen && (
<div className="admin-nav-sub">
{item.children.map(child => (
<button
key={child.view}
type="button"
className={`admin-nav-sub-item${adminView === child.view ? ' admin-nav-sub-item--active' : ''}`}
onClick={() => navigateTo(child.view)}
>
{child.label}
</button>
))}
</div>
)}
</div>
)
} else {
return renderAdminNavItem(item.view, item.label)
}
})}
</div>
))
)}
</div>
<div className="admin-sidebar-footer">
<Link
to="/"
className="admin-meta-link"
@@ -2718,32 +2806,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
>
Log Out
</button>
{appVersion && <span className="admin-sidebar-version">{appVersion}</span>}
</div>
{appVersion && (
<div className="admin-sidebar-version">{appVersion}</div>
)}
<div className="admin-nav-search">
<label htmlFor="admin-nav-search-input">Find a section</label>
<input
id="admin-nav-search-input"
type="search"
value={navSearch}
onChange={e => setNavSearch(e.target.value)}
placeholder="Search admin..."
/>
</div>
{filteredAdminViewOptions.length === 0 ? (
<p className="admin-stats-note admin-nav-empty">No admin sections match this search.</p>
) : (
filteredAdminViewOptions.map(group => (
<div key={group.group} className="admin-nav-group">
<span className="admin-nav-label">{group.group}</span>
{group.options.map(option => renderAdminNavItem(option.value, option.label))}
</div>
))
)}
</nav>
{mobileNavOpen && <button type="button" className="admin-sidebar-backdrop" aria-label="Close navigation menu" onClick={() => setMobileNavOpen(false)} />}
@@ -2863,7 +2927,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
</div>
))}
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} onClick={() => setAdminView('contact')}>View All Contacts →</button>
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }} onClick={() => navigateTo('contact:profile')}>View All Contacts →</button>
</div>
)
}
@@ -2976,18 +3040,11 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
})()}
{/* HOMEPAGE */}
{adminView === 'homepage' && (
{adminView === 'homepage:hero' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Homepage</h2>
<p>Controls the hero, share section, PRISM video block, and "Where to Next" navigation cards.</p>
</div>
{renderSectionJumpNav('homepage')}
<div className="admin-panel-subhead" id="homepage-hero">
<h3>Hero</h3>
<p>Primary landing page headline, tag line, and hero call-to-action labels.</p>
<h2>Hero</h2>
<p>Primary landing page headline, tagline, podcast art, and call-to-action button labels.</p>
</div>
{FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
@@ -2999,9 +3056,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
{(key.includes('ArtUrl') || key.includes('ImageUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
</div>
))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="homepage-share">
<h3>Share Section</h3>
{adminView === 'homepage:share' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Share Section</h2>
<p>Controls the share block that points visitors toward the next step.</p>
</div>
{FIELDS.filter(f => f.section === 'share').map(({ key, label, multiline }) => (
@@ -3014,9 +3076,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
{key.includes('ImageUrl') && renderImagePreview(form[key] as string, `${label} preview`)}
</div>
))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="homepage-prism">
<h3>PRISM Block</h3>
{adminView === 'homepage:prism' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>PRISM Block</h2>
<p>Video URL, intro text, pattern steps, and closing copy for the PRISM section.</p>
</div>
{FIELDS.filter(f => f.section === 'prism').map(({ key, label, multiline }) => (
@@ -3027,23 +3094,25 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
</div>
))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="homepage-where-to-next-labels">
<h3>Where to Next — Section Labels</h3>
{adminView === 'homepage:where-to-next' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Where to Next</h2>
<p>Section heading, eyebrow, and navigation cards shown at the bottom of the homepage.</p>
</div>
<div className="admin-field">
<label htmlFor="field-whereToNextEyebrow">Eyebrow</label>
<label htmlFor="field-whereToNextEyebrow">Section Eyebrow</label>
<input id="field-whereToNextEyebrow" type="text" value={form.whereToNextEyebrow ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextEyebrow: e.target.value }))} />
</div>
<div className="admin-field">
<label htmlFor="field-whereToNextHeading">Heading</label>
<label htmlFor="field-whereToNextHeading">Section Heading</label>
<input id="field-whereToNextHeading" type="text" value={form.whereToNextHeading ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextHeading: e.target.value }))} />
</div>
<div className="admin-panel-subhead" id="homepage-where-to-next-cards">
<h3>Where to Next — Navigation Cards</h3>
<p>Edit the title, description, and link path for each card.</p>
</div>
<div className="admin-panel-subhead" style={{ marginTop: '1.5rem' }}><h3>Navigation Cards</h3><p>Title, description, and path for each card.</p></div>
{(form.whereToNextCards ?? []).map((card, idx) => (
<div key={card.id} className="admin-array-row">
<div className="admin-array-fields">
@@ -3063,11 +3132,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).filter(c => c.id !== card.id) }))}>Remove</button>
</div>
))}
<button
type="button"
className="btn-admin-add"
onClick={() => setForm(f => ({ ...f, whereToNextCards: [...(f.whereToNextCards ?? []), { id: Date.now().toString(36), title: '', description: '', path: '/' }] }))}
>
<button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, whereToNextCards: [...(f.whereToNextCards ?? []), { id: Date.now().toString(36), title: '', description: '', path: '/' }] }))}>
+ Add Card
</button>
{renderSaveStatus()}
@@ -3136,21 +3201,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section>
)}
{/* ABOUT */}
{adminView === 'about' && (
{/* ABOUT — SHOW COPY */}
{adminView === 'about:show-copy' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>About</h2>
<p>Manage the main show description, Nate bio, and about images.</p>
<h2>Show Copy</h2>
<p>Top section of the About page — eyebrow, heading, show description paragraphs, and listen button label.</p>
</div>
{renderSectionJumpNav('about')}
<div className="admin-panel-subhead" id="about-show-copy">
<h3>Show Copy</h3>
<p>Top section content that explains the show and listening invitation.</p>
</div>
{FIELDS.filter(f => ['aboutShowHeading', 'aboutShowP1', 'aboutShowP2', 'aboutShowEyebrow', 'aboutListenBtnLabel'].includes(f.key)).map(({ key, label, multiline }) => (
{FIELDS.filter(f => ['aboutShowEyebrow', 'aboutShowHeading', 'aboutShowP1', 'aboutShowP2', 'aboutListenBtnLabel'].includes(f.key)).map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label>
{multiline ? (
@@ -3160,58 +3218,57 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)}
</div>
))}
<div className="admin-field">
<label htmlFor="field-aboutVerseArtUrl">Scripture Artwork Image URL</label>
<input id="field-aboutVerseArtUrl" type="text" value={form.aboutVerseArtUrl ?? ''} onChange={e => handleChange('aboutVerseArtUrl', e.target.value)} />
{renderImageAssetSelector(form.aboutVerseArtUrl ?? '', value => handleChange('aboutVerseArtUrl', value), 'field-aboutVerseArtUrl-asset')}
<p className="admin-field-note">Scripture artwork shown under the "About the Show" text.</p>
{renderImagePreview(form.aboutVerseArtUrl ?? '', 'Scripture artwork preview')}
</div>
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="about-nate-bio">
<h3>Nate Bio</h3>
<p>Profile eyebrow and biography copy for the About Nate block.</p>
{/* ABOUT — BIO */}
{adminView === 'about:bio' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Nate Bio</h2>
<p>Profile eyebrow, biography copy, and portrait image for the About Nate block.</p>
</div>
{FIELDS.filter(f => ['aboutEyebrow', 'aboutNate'].includes(f.key)).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} />
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={6} />
) : (
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
)}
</div>
))}
<div className="admin-panel-subhead" id="about-images">
<h3>Images</h3>
<p>Portrait and scripture artwork used on the About page.</p>
<div className="admin-field">
<label htmlFor="field-aboutPhotoUrl">Portrait Image URL</label>
<input id="field-aboutPhotoUrl" type="text" value={form.aboutPhotoUrl ?? ''} onChange={e => handleChange('aboutPhotoUrl', e.target.value)} />
{renderImageAssetSelector(form.aboutPhotoUrl ?? '', value => handleChange('aboutPhotoUrl', value), 'field-aboutPhotoUrl-asset')}
<p className="admin-field-note">Portrait shown on the left side of the About page.</p>
{renderImagePreview(form.aboutPhotoUrl ?? '', 'About page portrait preview')}
</div>
{FIELDS.filter(f => ['aboutPhotoUrl', 'aboutVerseArtUrl'].includes(f.key)).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)} />
{(key.includes('ImageUrl') || key.includes('PhotoUrl') || key.includes('ArtUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
{key === 'aboutPhotoUrl' && <p className="admin-field-note">This controls the portrait shown on the left side of the About page.</p>}
{key === 'aboutVerseArtUrl' && <p className="admin-field-note">This controls the scripture artwork shown under the “About the Show” text. It is separate from the Contact page image.</p>}
{key === 'aboutPhotoUrl' && renderImagePreview(form[key] as string, 'About page portrait preview')}
{key === 'aboutVerseArtUrl' && renderImagePreview(form[key] as string, 'About page scripture artwork preview')}
</>
)}
</div>
))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="about-testimonials">
<h3>Listener Testimonials</h3>
<p>Quotes shown on the About page and homepage. Add Apple Podcast reviews, listener emails, or any kind words.</p>
{/* ABOUT — TESTIMONIALS */}
{adminView === 'about:testimonials' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Testimonials</h2>
<p>Listener quotes shown on the About page and homepage. Add Apple Podcast reviews, emails, or any kind words.</p>
</div>
{(form.testimonials ?? []).length === 0 && (
<p className="admin-stats-note">No testimonials yet. Add one below.</p>
)}
{(form.testimonials ?? []).map(item => (
<AdminCollapsibleCard
key={item.id}
title={item.name || 'Unnamed listener'}
subtitle={item.source || 'No source'}
>
<AdminCollapsibleCard key={item.id} title={item.name || 'Unnamed listener'} subtitle={item.source || 'No source'}>
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
@@ -3234,22 +3291,16 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, testimonials: [...(f.testimonials ?? []), { id: Date.now().toString(36), quote: '', name: '', source: '' }] }))}>
+ Add Testimonial
</button>
{renderSaveStatus()}
</section>
)}
{/* CONTACT */}
{adminView === 'contact' && (
{/* CONTACT — PROFILE */}
{adminView === 'contact:profile' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Contact</h2>
<p>Manage the contact page profile image and contact copy.</p>
</div>
{renderSectionJumpNav('contact')}
<div className="admin-panel-subhead" id="contact-profile">
<h3>Profile</h3>
<p>Name, role, headline, and image shown at the top of the contact page.</p>
<h2>Profile</h2>
<p>Name, role, section eyebrow, page heading, and profile photo shown on the contact page.</p>
</div>
{FIELDS.filter(f => ['contactPhotoUrl', 'contactEyebrow', 'contactHeading', 'contactName', 'contactRole'].includes(f.key)).map(({ key, label, multiline }) => (
<div className="admin-field" key={key}>
@@ -3265,62 +3316,33 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)}
</div>
))}
<div className="admin-panel-subhead" id="contact-intro">
<h3>Intro Copy</h3>
<p>Intro paragraph, quote, and main contact bullet points.</p>
</div>
{FIELDS.filter(f => ['contactQuote', 'contactIntro', 'contactPoint1', 'contactPoint2'].includes(f.key)).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 className="admin-panel-subhead" id="contact-scripture">
<h3>Scripture</h3>
</div>
{FIELDS.filter(f => ['contactVerse', 'contactVerseRef'].includes(f.key)).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)} />
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
</>
)}
</div>
))}
{renderSaveStatus()}
</section>
)}
{/* PODCAST HUB */}
{adminView === 'podcast' && (
{/* CONTACT — COPY & SCRIPTURE */}
{adminView === 'contact:copy' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Podcast Hub</h2>
<p>Manage all podcast-related content from one place.</p>
</div>
<div className="admin-tabs admin-tabs--podcast">
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-tab${podcastTab === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
<button type="button" className={`admin-tab${podcastTab === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button>
<h2>Copy & Scripture</h2>
<p>Intro paragraph, quote, contact bullet points, and the scripture block.</p>
</div>
{FIELDS.filter(f => ['contactQuote', 'contactIntro', 'contactPoint1', 'contactPoint2', 'contactVerse', 'contactVerseRef'].includes(f.key)).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>
))}
{renderSaveStatus()}
</section>
)}
{/* CURRENT SERIES */}
{(adminView === 'podcast' && podcastTab === 'current-series') && (
{/* PODCAST — CURRENT SERIES */}
{adminView === 'podcast:current-series' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Current Series</h2>
@@ -3403,8 +3425,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section>
)}
{/* EPISODE HIGHLIGHTS */}
{(adminView === 'podcast' && podcastTab === 'episode-highlights') && (
{/* PODCAST — EPISODE HIGHLIGHTS */}
{adminView === 'podcast:highlights' && (
<section className="admin-panel-section" aria-label="Episode highlights">
<div className="admin-panel-head">
<h2>Episode Highlights</h2>
@@ -3467,8 +3489,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section>
)}
{/* FINISHED BOOKS */}
{(adminView === 'podcast' && podcastTab === 'finished-books') && (
{/* PODCAST — FINISHED BOOKS */}
{adminView === 'podcast:finished-books' && (
<section className="admin-panel-section" aria-label="Finished books">
<div className="admin-panel-head">
<h2>Finished Books</h2>
@@ -4171,7 +4193,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)}
{/* EPISODE SCRIPTS */}
{(adminView === 'podcast' && podcastTab === 'episode-scripts') && (
{adminView === 'podcast:scripts' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Episode Scripts</h2>
@@ -4247,8 +4269,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section>
)}
{/* RSS FEED */}
{(adminView === 'podcast' && podcastTab === 'rss-feed') && (() => {
{/* PODCAST — RSS FEED */}
{adminView === 'podcast:rss' && (() => {
const canonicalUrl = (form as { seo?: { canonicalUrl?: string } }).seo?.canonicalUrl?.replace(/\/$/, '') ?? ''
const feedUrl = canonicalUrl ? `${canonicalUrl}/feed.xml` : '/feed.xml'
const anchorFeedUrl = 'https://anchor.fm/s/11068d290/podcast/rss'