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

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

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

No files matched your search

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