Revamp /admin with drill-down sidebar nav and focused sub-views; v1.1.50
Publish Container / docker (push) Successful in 13m50s
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:
1 parent
a072076818
commit
e593031d0d
3 files changed
+683
-527
No files matched your search
+286
-264
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user