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
+590 -434

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "1.1.49", "version": "1.1.50",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+285 -263
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 StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'testimonials' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView = type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'dashboard'
| 'podcast' | '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' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'subscribers' | 'study-users' | 'email-templates'
@@ -709,92 +713,109 @@ interface AdminSectionLink {
label: string 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', group: 'Overview',
options: [{ value: 'dashboard', label: 'Dashboard' }], items: [{ kind: 'leaf', view: 'dashboard', label: 'Dashboard' }],
}, },
{ {
group: 'Site', group: 'Site',
options: [ items: [
{ value: 'homepage', label: 'Homepage' }, { kind: 'parent', id: 'homepage', label: 'Homepage', children: [
{ value: 'start-here', label: 'Start Here' }, { kind: 'leaf', view: 'homepage:hero', label: 'Hero' },
{ value: 'about', label: 'About' }, { kind: 'leaf', view: 'homepage:share', label: 'Share Section' },
{ value: 'contact', label: 'Contact' }, { 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', group: 'Content',
options: [ items: [
{ value: 'podcast', label: 'Podcast Hub' }, { kind: 'parent', id: 'podcast', label: 'Podcast Hub', children: [
{ value: 'colossians-study', label: 'Studies' }, { kind: 'leaf', view: 'podcast:current-series', label: 'Current Series' },
{ value: 'downloads', label: 'Resources' }, { kind: 'leaf', view: 'podcast:highlights', label: 'Ep. Highlights' },
{ value: 'custom-links', label: 'Custom Links' }, { kind: 'leaf', view: 'podcast:finished-books', label: 'Finished Books' },
{ value: 'content-blocks', label: 'Content Blocks' }, { kind: 'leaf', view: 'podcast:scripts', label: 'Episode Scripts' },
{ value: 'books', label: 'Books' }, { kind: 'leaf', view: 'podcast:rss', label: 'RSS Feed' },
{ value: 'recommended-resources', label: 'Recommended Resources' }, ]},
{ 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', group: 'Audience',
options: [ items: [
{ value: 'questions', label: 'Questions' }, { kind: 'leaf', view: 'questions', label: 'Questions' },
{ value: 'study-comments', label: 'Study Comments' }, { kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
{ value: 'subscribers', label: 'Subscribers' }, { kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
{ value: 'study-users', label: 'Study Users' }, { kind: 'leaf', view: 'study-users', label: 'Study Users' },
], ],
}, },
{ {
group: 'Insights', group: 'Insights & Tools',
options: [ items: [
{ value: 'analytics', label: 'Analytics' }, { kind: 'leaf', view: 'analytics', label: 'Analytics' },
], { kind: 'leaf', view: 'assets', label: 'Asset Manager' },
}, { kind: 'leaf', view: 'qr-codes', label: 'QR Codes' },
{
group: 'Tools',
options: [
{ value: 'assets', label: 'Asset Manager' },
{ value: 'qr-codes', label: 'QR Codes' },
], ],
}, },
{ {
group: 'Configure', group: 'Configure',
options: [ items: [
{ value: 'global', label: 'Footer & Global' }, { kind: 'leaf', view: 'global', label: 'Footer & Global' },
{ value: 'seo', label: 'SEO & Redirects' }, { kind: 'leaf', view: 'seo', label: 'SEO & Redirects' },
{ value: 'email-templates', label: 'Email Templates' }, { kind: 'leaf', view: 'email-templates', label: 'Email Templates' },
{ value: 'legal', label: 'Legal Pages' }, { kind: 'leaf', view: 'legal', label: 'Legal Pages' },
{ value: 'security', label: 'Security' }, { kind: 'leaf', view: 'security', label: 'Security' },
{ value: 'brand', label: 'Brand Kit' }, { 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[]>> = { 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: [ downloads: [
{ id: 'downloads-study-guide', label: 'Study Guide' }, { id: 'downloads-study-guide', label: 'Study Guide' },
{ id: 'downloads-library', label: 'Download Library' }, { 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' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
const BRAND_KIT_SWATCHES = [ 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 [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [adminView, setAdminView] = useState<AdminView>('dashboard') 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 [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d') 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 unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
const unansweredCount = questions.filter(q => !q.answer?.trim()).length const unansweredCount = questions.filter(q => !q.answer?.trim()).length
const navSearchTerm = navSearch.trim().toLowerCase() const navSearchTerm = navSearch.trim().toLowerCase()
const filteredAdminViewOptions = ADMIN_VIEW_OPTIONS const filteredNav: NavGroup[] = ADMIN_NAV
.map(group => ({ .map(group => ({
...group, ...group,
options: group.options.filter(option => { items: group.items
if (!navSearchTerm) return true .map(item => {
return `${group.group} ${option.label} ${option.value}`.toLowerCase().includes(navSearchTerm) 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 // Silently saves draft every 30s when there are unsaved changes
useAutosave(form, isDirty, handleSaveDraft) useAutosave(form, isDirty, handleSaveDraft)
@@ -2070,6 +2102,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
if (!confirmLeaveUnsavedChanges()) return if (!confirmLeaveUnsavedChanges()) return
setAdminView(view) setAdminView(view)
setMobileNavOpen(false) setMobileNavOpen(false)
const parentId = getNavParentId(view)
if (parentId) setNavOpenGroups(prev => new Set([...prev, parentId]))
} }
function getAdminViewBadge(view: AdminView) { function getAdminViewBadge(view: AdminView) {
@@ -2643,52 +2677,40 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
aria-expanded={mobileNavOpen} aria-expanded={mobileNavOpen}
aria-controls="admin-sidebar-nav" aria-controls="admin-sidebar-nav"
> >
{mobileNavOpen ? 'Close' : 'Menu'} <span className="admin-menu-bar" /><span className="admin-menu-bar" /><span className="admin-menu-bar" />
</button> </button>
<div className="admin-topbar-brand"> <div className="admin-topbar-brand">
<span className="admin-topbar-ornament">✦</span> <span className="admin-topbar-ornament">✦</span>
<span className="admin-topbar-title">Site Admin</span> <span className="admin-topbar-title">Siteforge</span>
<span className="admin-topbar-sub">Verse by Verse with Nate</span>
</div> </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>
<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">Draft: {formatDate(publishState.draftUpdatedAt)}</span>
<span className="admin-topbar-meta">Live: {formatDate(publishState.publishedAt)}</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}> <button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}>
{status === 'saving' ? 'Saving…' : 'Save Draft'} {status === 'saving' ? 'Saving…' : 'Save Draft'}
</button> </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'} {previewOpen ? 'Close Preview' : 'Preview'}
</button> </button>
{publishConfirmOpen ? ( {publishConfirmOpen ? (
<> <>
<span style={{ fontSize: '0.82rem', color: '#e0c070' }}>Publish to live site?</span> <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-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> </div>
</header> </header>
@@ -2696,7 +2718,73 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
<div className={`admin-layout${previewOpen ? ' admin-layout--split' : ''}`}> <div className={`admin-layout${previewOpen ? ' admin-layout--split' : ''}`}>
{/* ── Sidebar ── */} {/* ── Sidebar ── */}
<nav id="admin-sidebar-nav" className={`admin-sidebar${mobileNavOpen ? ' admin-sidebar--open' : ''}`} aria-label="Admin navigation"> <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 <Link
to="/" to="/"
className="admin-meta-link" className="admin-meta-link"
@@ -2718,32 +2806,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
> >
Log Out Log Out
</button> </button>
{appVersion && <span className="admin-sidebar-version">{appVersion}</span>}
</div> </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> </nav>
{mobileNavOpen && <button type="button" className="admin-sidebar-backdrop" aria-label="Close navigation menu" onClick={() => setMobileNavOpen(false)} />} {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>} {c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
</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> </div>
) )
} }
@@ -2976,18 +3040,11 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
})()} })()}
{/* HOMEPAGE */} {/* HOMEPAGE */}
{adminView === 'homepage' && ( {adminView === 'homepage:hero' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Homepage</h2> <h2>Hero</h2>
<p>Controls the hero, share section, PRISM video block, and "Where to Next" navigation cards.</p> <p>Primary landing page headline, tagline, podcast art, and call-to-action button labels.</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>
</div> </div>
{FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => ( {FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => (
<div className="admin-field" key={key}> <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`)} {(key.includes('ArtUrl') || key.includes('ImageUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
</div> </div>
))} ))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="homepage-share"> {adminView === 'homepage:share' && (
<h3>Share Section</h3> <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> <p>Controls the share block that points visitors toward the next step.</p>
</div> </div>
{FIELDS.filter(f => f.section === 'share').map(({ key, label, multiline }) => ( {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`)} {key.includes('ImageUrl') && renderImagePreview(form[key] as string, `${label} preview`)}
</div> </div>
))} ))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="homepage-prism"> {adminView === 'homepage:prism' && (
<h3>PRISM Block</h3> <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> <p>Video URL, intro text, pattern steps, and closing copy for the PRISM section.</p>
</div> </div>
{FIELDS.filter(f => f.section === 'prism').map(({ key, label, multiline }) => ( {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)} />} : <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
</div> </div>
))} ))}
{renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="homepage-where-to-next-labels"> {adminView === 'homepage:where-to-next' && (
<h3>Where to Next — Section Labels</h3> <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>
<div className="admin-field"> <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 }))} /> <input id="field-whereToNextEyebrow" type="text" value={form.whereToNextEyebrow ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextEyebrow: e.target.value }))} />
</div> </div>
<div className="admin-field"> <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 }))} /> <input id="field-whereToNextHeading" type="text" value={form.whereToNextHeading ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextHeading: e.target.value }))} />
</div> </div>
<div className="admin-panel-subhead" style={{ marginTop: '1.5rem' }}><h3>Navigation Cards</h3><p>Title, description, and path for each card.</p></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>
{(form.whereToNextCards ?? []).map((card, idx) => ( {(form.whereToNextCards ?? []).map((card, idx) => (
<div key={card.id} className="admin-array-row"> <div key={card.id} className="admin-array-row">
<div className="admin-array-fields"> <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> <button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).filter(c => c.id !== card.id) }))}>Remove</button>
</div> </div>
))} ))}
<button <button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, whereToNextCards: [...(f.whereToNextCards ?? []), { id: Date.now().toString(36), title: '', description: '', path: '/' }] }))}>
type="button"
className="btn-admin-add"
onClick={() => setForm(f => ({ ...f, whereToNextCards: [...(f.whereToNextCards ?? []), { id: Date.now().toString(36), title: '', description: '', path: '/' }] }))}
>
+ Add Card + Add Card
</button> </button>
{renderSaveStatus()} {renderSaveStatus()}
@@ -3136,21 +3201,14 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section> </section>
)} )}
{/* ABOUT */} {/* ABOUT — SHOW COPY */}
{adminView === 'about' && ( {adminView === 'about:show-copy' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>About</h2> <h2>Show Copy</h2>
<p>Manage the main show description, Nate bio, and about images.</p> <p>Top section of the About page — eyebrow, heading, show description paragraphs, and listen button label.</p>
</div> </div>
{FIELDS.filter(f => ['aboutShowEyebrow', 'aboutShowHeading', 'aboutShowP1', 'aboutShowP2', 'aboutListenBtnLabel'].includes(f.key)).map(({ key, label, multiline }) => (
{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 }) => (
<div className="admin-field" key={key}> <div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label> <label htmlFor={`field-${key}`}>{label}</label>
{multiline ? ( {multiline ? (
@@ -3160,58 +3218,57 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)} )}
</div> </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"> {/* ABOUT — BIO */}
<h3>Nate Bio</h3> {adminView === 'about:bio' && (
<p>Profile eyebrow and biography copy for the About Nate block.</p> <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> </div>
{FIELDS.filter(f => ['aboutEyebrow', 'aboutNate'].includes(f.key)).map(({ key, label, multiline }) => ( {FIELDS.filter(f => ['aboutEyebrow', 'aboutNate'].includes(f.key)).map(({ key, label, multiline }) => (
<div className="admin-field" key={key}> <div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label> <label htmlFor={`field-${key}`}>{label}</label>
{multiline ? ( {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)} /> <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
)} )}
</div> </div>
))} ))}
<div className="admin-field">
<div className="admin-panel-subhead" id="about-images"> <label htmlFor="field-aboutPhotoUrl">Portrait Image URL</label>
<h3>Images</h3> <input id="field-aboutPhotoUrl" type="text" value={form.aboutPhotoUrl ?? ''} onChange={e => handleChange('aboutPhotoUrl', e.target.value)} />
<p>Portrait and scripture artwork used on the About page.</p> {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> </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()} {renderSaveStatus()}
</section>
)}
<div className="admin-panel-subhead" id="about-testimonials"> {/* ABOUT — TESTIMONIALS */}
<h3>Listener Testimonials</h3> {adminView === 'about:testimonials' && (
<p>Quotes shown on the About page and homepage. Add Apple Podcast reviews, listener emails, or any kind words.</p> <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> </div>
{(form.testimonials ?? []).length === 0 && ( {(form.testimonials ?? []).length === 0 && (
<p className="admin-stats-note">No testimonials yet. Add one below.</p> <p className="admin-stats-note">No testimonials yet. Add one below.</p>
)} )}
{(form.testimonials ?? []).map(item => ( {(form.testimonials ?? []).map(item => (
<AdminCollapsibleCard <AdminCollapsibleCard key={item.id} title={item.name || 'Unnamed listener'} subtitle={item.source || 'No source'}>
key={item.id}
title={item.name || 'Unnamed listener'}
subtitle={item.source || 'No source'}
>
<div className="admin-array-row"> <div className="admin-array-row">
<div className="admin-array-fields"> <div className="admin-array-fields">
<div className="admin-field"> <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: '' }] }))}> <button type="button" className="btn-admin-add" onClick={() => setForm(f => ({ ...f, testimonials: [...(f.testimonials ?? []), { id: Date.now().toString(36), quote: '', name: '', source: '' }] }))}>
+ Add Testimonial + Add Testimonial
</button> </button>
{renderSaveStatus()}
</section> </section>
)} )}
{/* CONTACT */} {/* CONTACT — PROFILE */}
{adminView === 'contact' && ( {adminView === 'contact:profile' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Contact</h2> <h2>Profile</h2>
<p>Manage the contact page profile image and contact copy.</p> <p>Name, role, section eyebrow, page heading, and profile photo shown on the contact page.</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>
</div> </div>
{FIELDS.filter(f => ['contactPhotoUrl', 'contactEyebrow', 'contactHeading', 'contactName', 'contactRole'].includes(f.key)).map(({ key, label, multiline }) => ( {FIELDS.filter(f => ['contactPhotoUrl', 'contactEyebrow', 'contactHeading', 'contactName', 'contactRole'].includes(f.key)).map(({ key, label, multiline }) => (
<div className="admin-field" key={key}> <div className="admin-field" key={key}>
@@ -3265,62 +3316,33 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)} )}
</div> </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()} {renderSaveStatus()}
</section> </section>
)} )}
{/* PODCAST HUB */} {/* CONTACT — COPY & SCRIPTURE */}
{adminView === 'podcast' && ( {adminView === 'contact:copy' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Podcast Hub</h2> <h2>Copy & Scripture</h2>
<p>Manage all podcast-related content from one place.</p> <p>Intro paragraph, quote, contact bullet points, and the scripture block.</p>
</div> </div>
<div className="admin-tabs admin-tabs--podcast"> {FIELDS.filter(f => ['contactQuote', 'contactIntro', 'contactPoint1', 'contactPoint2', 'contactVerse', 'contactVerseRef'].includes(f.key)).map(({ key, label, multiline }) => (
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button> <div className="admin-field" key={key}>
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button> <label htmlFor={`field-${key}`}>{label}</label>
<button type="button" className={`admin-tab${podcastTab === 'finished-books' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('finished-books')}>Finished Books</button> {multiline ? (
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button> <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
<button type="button" className={`admin-tab${podcastTab === 'rss-feed' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('rss-feed')}>RSS Feed</button> ) : (
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
)}
</div> </div>
))}
{renderSaveStatus()}
</section> </section>
)} )}
{/* CURRENT SERIES */} {/* PODCAST — CURRENT SERIES */}
{(adminView === 'podcast' && podcastTab === 'current-series') && ( {adminView === 'podcast:current-series' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Current Series</h2> <h2>Current Series</h2>
@@ -3403,8 +3425,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section> </section>
)} )}
{/* EPISODE HIGHLIGHTS */} {/* PODCAST — EPISODE HIGHLIGHTS */}
{(adminView === 'podcast' && podcastTab === 'episode-highlights') && ( {adminView === 'podcast:highlights' && (
<section className="admin-panel-section" aria-label="Episode highlights"> <section className="admin-panel-section" aria-label="Episode highlights">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Episode Highlights</h2> <h2>Episode Highlights</h2>
@@ -3467,8 +3489,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section> </section>
)} )}
{/* FINISHED BOOKS */} {/* PODCAST — FINISHED BOOKS */}
{(adminView === 'podcast' && podcastTab === 'finished-books') && ( {adminView === 'podcast:finished-books' && (
<section className="admin-panel-section" aria-label="Finished books"> <section className="admin-panel-section" aria-label="Finished books">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Finished Books</h2> <h2>Finished Books</h2>
@@ -4171,7 +4193,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)} )}
{/* EPISODE SCRIPTS */} {/* EPISODE SCRIPTS */}
{(adminView === 'podcast' && podcastTab === 'episode-scripts') && ( {adminView === 'podcast:scripts' && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Episode Scripts</h2> <h2>Episode Scripts</h2>
@@ -4247,8 +4269,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
</section> </section>
)} )}
{/* RSS FEED */} {/* PODCAST — RSS FEED */}
{(adminView === 'podcast' && podcastTab === 'rss-feed') && (() => { {adminView === 'podcast:rss' && (() => {
const canonicalUrl = (form as { seo?: { canonicalUrl?: string } }).seo?.canonicalUrl?.replace(/\/$/, '') ?? '' const canonicalUrl = (form as { seo?: { canonicalUrl?: string } }).seo?.canonicalUrl?.replace(/\/$/, '') ?? ''
const feedUrl = canonicalUrl ? `${canonicalUrl}/feed.xml` : '/feed.xml' const feedUrl = canonicalUrl ? `${canonicalUrl}/feed.xml` : '/feed.xml'
const anchorFeedUrl = 'https://anchor.fm/s/11068d290/podcast/rss' const anchorFeedUrl = 'https://anchor.fm/s/11068d290/podcast/rss'
+304 -170
View File
@@ -7495,9 +7495,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
background: #070707; background: #0c0a08;
color: var(--brand-warm-white);
} }
/* ── Topbar ── */
.admin-topbar { .admin-topbar {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -7505,101 +7507,183 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background: #070707; height: 48px;
border-bottom: 1px solid rgba(201,168,76,0.2); background: #0f0d0b;
padding: 0.65rem 1.5rem; border-bottom: 1px solid rgba(201,168,76,0.14);
padding: 0 1.25rem;
gap: 1rem; gap: 1rem;
flex-shrink: 0;
}
.admin-topbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.admin-topbar-right {
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
} }
.admin-mobile-menu-btn {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 6px;
flex-shrink: 0;
}
.admin-mobile-menu-btn:hover { background: rgba(201,168,76,0.1); }
.admin-menu-bar {
display: block;
width: 18px;
height: 1.5px;
background: rgba(240,234,216,0.7);
border-radius: 1px;
transition: background 150ms;
}
.admin-topbar-brand { .admin-topbar-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.45rem;
flex-shrink: 0;
} }
.admin-topbar-ornament { .admin-topbar-ornament {
color: var(--brand-gold); color: var(--brand-gold);
font-size: 1rem; font-size: 0.9rem;
} }
.admin-topbar-title { .admin-topbar-title {
font-family: var(--brand-font-heading); font-family: var(--brand-font-heading);
font-size: 1.1rem; font-size: 1rem;
font-weight: 600;
color: var(--brand-warm-white); color: var(--brand-warm-white);
letter-spacing: 0.03em; letter-spacing: 0.04em;
} }
.admin-topbar-sub { .admin-topbar-crumb {
font-size: 0.78rem;
color: rgba(201,168,76,0.5);
letter-spacing: 0.05em;
}
.admin-topbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.4rem;
flex-wrap: wrap; min-width: 0;
overflow: hidden;
} }
.admin-view-picker { .admin-crumb-sep {
display: inline-flex; color: rgba(201,168,76,0.35);
align-items: center; font-size: 0.9rem;
gap: 0.45rem; flex-shrink: 0;
padding: 0.35rem 0.7rem;
border: 1px solid rgba(201,168,76,0.22);
border-radius: 999px;
background: rgba(201,168,76,0.06);
} }
.admin-view-picker-label { .admin-crumb-parent {
font-size: 0.72rem; font-size: 0.82rem;
font-weight: 700; color: rgba(240,234,216,0.45);
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240,234,216,0.6);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.admin-view-picker-select { .admin-crumb-current {
border: none;
background: transparent;
color: var(--brand-warm-white);
font-family: var(--brand-font-body);
font-size: 0.86rem; font-size: 0.86rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.03em; color: var(--brand-warm-white);
min-width: 11rem; white-space: nowrap;
outline: none; overflow: hidden;
text-overflow: ellipsis;
} }
.admin-topbar-meta { .admin-topbar-meta {
font-size: 0.78rem; font-size: 0.75rem;
color: rgba(240,234,216,0.45); color: rgba(240,234,216,0.38);
}
.admin-mobile-menu-btn {
display: none;
background: transparent;
color: var(--brand-gold);
border: 1px solid rgba(201,168,76,0.35);
border-radius: 999px;
padding: 0.45rem 0.9rem;
font-family: var(--brand-font-body);
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
.admin-mobile-menu-btn--open { .admin-unsaved-dot {
background: rgba(201,168,76,0.15); font-size: 0.75rem;
border-color: rgba(201,168,76,0.6); color: #e09a40;
color: var(--brand-warm-white); white-space: nowrap;
} }
/* ── Topbar buttons ── */
.btn-admin-save {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 0.9rem;
background: var(--brand-gold);
color: #100d07;
border: none;
border-radius: 6px;
font-family: var(--brand-font-body);
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
white-space: nowrap;
transition: background 150ms, opacity 150ms;
}
.btn-admin-save:disabled { opacity: 0.45; cursor: default; }
.btn-admin-save:not(:disabled):hover { background: #d9b85a; }
.btn-admin-publish {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 0.9rem;
background: rgba(201,168,76,0.12);
color: var(--brand-gold);
border: 1px solid rgba(201,168,76,0.3);
border-radius: 6px;
font-family: var(--brand-font-body);
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
white-space: nowrap;
transition: background 150ms, border-color 150ms;
}
.btn-admin-publish:hover { background: rgba(201,168,76,0.2); border-color: rgba(201,168,76,0.55); }
.btn-admin-ghost {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 0.8rem;
background: transparent;
color: rgba(240,234,216,0.6);
border: 1px solid rgba(240,234,216,0.14);
border-radius: 6px;
font-family: var(--brand-font-body);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 150ms, color 150ms;
}
.btn-admin-ghost:hover { background: rgba(240,234,216,0.06); color: var(--brand-warm-white); }
.btn-admin-ghost--active { background: rgba(240,234,216,0.08); color: var(--brand-warm-white); }
/* ── Layout ── */
.admin-layout { .admin-layout {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -7608,158 +7692,216 @@
/* ── Sidebar ── */ /* ── Sidebar ── */
.admin-sidebar { .admin-sidebar {
width: 210px; width: 196px;
min-width: 210px; min-width: 196px;
background: #09090a;
border-right: 1px solid rgba(201,168,76,0.1);
padding: 1.25rem 0 2rem;
overflow-y: auto;
position: sticky;
top: 51px; /* matches topbar height */
height: calc(100vh - 51px);
}
.admin-sidebar-meta-links {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 1.1rem 1rem; background: #0d0b09;
border-bottom: 1px solid rgba(201,168,76,0.08); border-right: 1px solid rgba(201,168,76,0.09);
margin-bottom: 1rem; position: sticky;
gap: 0.25rem; top: 48px;
height: calc(100vh - 48px);
overflow: hidden;
}
.admin-sidebar-top {
padding: 0.75rem 0.85rem 0.5rem;
border-bottom: 1px solid rgba(201,168,76,0.07);
flex-shrink: 0;
}
.admin-nav-search input {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(201,168,76,0.15);
border-radius: 6px;
background: rgba(255,255,255,0.04);
color: var(--brand-warm-white);
padding: 0.45rem 0.65rem;
font-size: 0.82rem;
font-family: var(--brand-font-body);
transition: border-color 150ms;
}
.admin-nav-search input:focus { outline: none; border-color: rgba(201,168,76,0.4); }
.admin-nav-search input::placeholder { color: rgba(240,234,216,0.28); }
.admin-sidebar-scroll {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0 0.75rem;
scrollbar-width: thin;
scrollbar-color: rgba(201,168,76,0.15) transparent;
}
.admin-sidebar-footer {
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.65rem 0.85rem 0.75rem;
border-top: 1px solid rgba(201,168,76,0.07);
flex-shrink: 0;
} }
.admin-meta-link { .admin-meta-link {
display: block; display: block;
background: none; background: none;
border: none; border: none;
color: rgba(240,234,216,0.45); color: rgba(240,234,216,0.38);
font-size: 0.82rem; font-size: 0.78rem;
padding: 0.25rem 0; font-family: var(--brand-font-body);
padding: 0.2rem 0;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
text-align: left; text-align: left;
transition: color 150ms; transition: color 150ms;
} }
.admin-meta-link:hover { .admin-meta-link:hover { color: rgba(240,234,216,0.7); }
color: var(--brand-warm-white);
}
.admin-sidebar-version { .admin-sidebar-version {
padding: 0.5rem 1.1rem 0.75rem; font-size: 0.68rem;
font-size: 0.72rem; color: rgba(240,234,216,0.2);
color: rgba(240,234,216,0.28); margin-top: 0.35rem;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.admin-nav-search {
display: grid;
gap: 0.4rem;
padding: 0 1.1rem 1rem;
}
.admin-nav-search label {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(240,234,216,0.6);
}
.admin-nav-search input {
width: 100%;
border: 1px solid rgba(201,168,76,0.18);
border-radius: 10px;
background: rgba(201,168,76,0.05);
color: var(--brand-warm-white);
padding: 0.65rem 0.8rem;
font-size: 0.88rem;
}
.admin-nav-search input::placeholder {
color: rgba(240,234,216,0.34);
}
.admin-nav-empty { .admin-nav-empty {
padding: 0 1.1rem; padding: 0.5rem 0.85rem;
font-size: 0.82rem;
color: rgba(240,234,216,0.35);
} }
/* ── Nav groups ── */
.admin-nav-group { .admin-nav-group {
margin-bottom: 1.15rem; margin-bottom: 0.25rem;
padding-top: 0.55rem;
border-top: 1px solid rgba(201, 168, 76, 0.14);
} }
.admin-nav-group:first-of-type { .admin-nav-group-label {
padding-top: 0; display: block;
border-top: 0; font-size: 0.68rem;
}
.admin-nav-label {
display: inline-flex;
align-items: center;
font-size: 0.76rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.11em;
text-transform: uppercase; text-transform: uppercase;
color: #ffe3a3; color: rgba(201,168,76,0.5);
background: rgba(201, 168, 76, 0.22); padding: 0.55rem 0.85rem 0.2rem;
border: 1px solid rgba(201, 168, 76, 0.45);
border-radius: 999px;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
padding: 0.2rem 0.62rem;
margin-left: 1.1rem;
margin-bottom: 0.42rem;
} }
/* ── Flat leaf nav items ── */
.admin-nav-item { .admin-nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.45rem;
width: 100%; width: 100%;
text-align: left; text-align: left;
background: none; background: none;
border: none; border: none;
border-left: 2px solid transparent; border-left: 2px solid transparent;
color: rgba(240,234,216,0.84); color: rgba(240,234,216,0.72);
padding: 0.4rem 1.1rem; padding: 0.38rem 0.85rem;
font-size: 0.9rem; font-family: var(--brand-font-body);
font-size: 0.84rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: color 150ms, background 150ms; transition: color 150ms, background 150ms;
line-height: 1.4; line-height: 1.4;
} }
.admin-nav-item:hover { color: var(--brand-warm-white); background: rgba(201,168,76,0.07); }
.admin-nav-item--active {
color: #ffe4a8 !important;
background: rgba(201,168,76,0.12);
border-left-color: var(--brand-gold);
}
.admin-nav-badge { .admin-nav-badge {
margin-left: auto; margin-left: auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 1.25rem; min-width: 1.2rem;
height: 1.25rem; height: 1.2rem;
padding: 0 0.35rem; padding: 0 0.3rem;
border-radius: 999px; border-radius: 999px;
background: var(--brand-gold); background: var(--brand-gold);
color: #1a160d; color: #1a160d;
font-size: 0.7rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.admin-nav-badge--neutral { .admin-nav-badge--neutral {
background: rgba(201,168,76,0.25); background: rgba(201,168,76,0.22);
color: var(--brand-gold); color: var(--brand-gold);
} }
.admin-nav-item:hover { /* ── Parent nav items (accordion) ── */
color: #fff6de; .admin-nav-parent-wrap { }
background: rgba(201,168,76,0.11);
.admin-nav-parent {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
background: none;
border: none;
border-left: 2px solid transparent;
color: rgba(240,234,216,0.72);
padding: 0.38rem 0.85rem;
font-family: var(--brand-font-body);
font-size: 0.84rem;
font-weight: 500;
cursor: pointer;
transition: color 150ms, background 150ms;
line-height: 1.4;
} }
.admin-nav-item--active { .admin-nav-parent:hover { color: var(--brand-warm-white); background: rgba(201,168,76,0.07); }
.admin-nav-parent--active {
color: rgba(240,234,216,0.95);
border-left-color: rgba(201,168,76,0.4);
}
.admin-nav-chevron {
color: rgba(240,234,216,0.3);
flex-shrink: 0;
transition: transform 200ms;
}
.admin-nav-chevron--open { transform: rotate(180deg); }
/* ── Sub-items ── */
.admin-nav-sub {
padding-bottom: 0.2rem;
}
.admin-nav-sub-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
border-left: 2px solid transparent;
color: rgba(240,234,216,0.55);
padding: 0.32rem 0.85rem 0.32rem 1.75rem;
font-family: var(--brand-font-body);
font-size: 0.81rem;
font-weight: 400;
cursor: pointer;
transition: color 150ms, background 150ms;
line-height: 1.4;
}
.admin-nav-sub-item:hover { color: rgba(240,234,216,0.85); background: rgba(201,168,76,0.06); }
.admin-nav-sub-item--active {
color: #ffe4a8 !important; color: #ffe4a8 !important;
background: rgba(201,168,76,0.18); background: rgba(201,168,76,0.1);
border-left-color: var(--brand-gold); border-left-color: var(--brand-gold);
font-weight: 500;
} }
/* ── Content Panel ── */ /* ── Content Panel ── */
@@ -8247,27 +8389,31 @@
justify-content: center; justify-content: center;
} }
.admin-mobile-menu-btn {
display: flex;
}
.admin-sidebar { .admin-sidebar {
display: none; display: none;
position: fixed; position: fixed;
top: 52px; top: 48px;
left: 0; left: 0;
z-index: 130; z-index: 130;
width: min(320px, 84vw); width: min(280px, 84vw);
min-width: 0; min-width: 0;
height: calc(100vh - 52px); height: calc(100vh - 48px);
border-right: 1px solid rgba(201,168,76,0.22); border-right: 1px solid rgba(201,168,76,0.22);
box-shadow: 16px 0 40px rgba(0, 0, 0, 0.5); box-shadow: 16px 0 48px rgba(0, 0, 0, 0.55);
} }
.admin-sidebar--open { .admin-sidebar--open {
display: block; display: flex;
} }
.admin-sidebar-backdrop { .admin-sidebar-backdrop {
display: block; display: block;
position: fixed; position: fixed;
inset: 52px 0 0 0; inset: 48px 0 0 0;
z-index: 120; z-index: 120;
border: none; border: none;
background: rgba(0, 0, 0, 0.48); background: rgba(0, 0, 0, 0.48);
@@ -8277,9 +8423,11 @@
.admin-panel { .admin-panel {
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
} }
.admin-topbar-sub {
.admin-topbar-meta {
display: none; display: none;
} }
.admin-preview-pane { .admin-preview-pane {
display: none; display: none;
} }
@@ -8650,21 +8798,7 @@
} }
} }
/* ── Admin Redesign: Topbar state zone ── */ /* ── Admin Redesign: Analytics CSS classes ── */
.admin-topbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.admin-topbar-state {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
justify-content: center;
flex-wrap: wrap;
}
/* ── Admin Redesign: Analytics CSS classes ── */ /* ── Admin Redesign: Analytics CSS classes ── */
.admin-analytics-grid { .admin-analytics-grid {