diff --git a/package.json b/package.json index 8fd49ba..6031766 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.1.49", + "version": "1.1.50", "type": "module", "scripts": { "dev": "vite", diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 22cd919..e73aaf0 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -697,8 +697,12 @@ interface Subscriber { type StringField = Exclude 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> = { - 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> = { ], } -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('dashboard') - const [podcastTab, setPodcastTab] = useState('current-series') + const [navOpenGroups, setNavOpenGroups] = useState>(new Set()) const [stats, setStats] = useState(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) { 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) { aria-expanded={mobileNavOpen} aria-controls="admin-sidebar-nav" > - {mobileNavOpen ? 'Close' : 'Menu'} +
✦ - Site Admin - Verse by Verse with Nate + Siteforge
+ {(() => { + const crumb = getNavBreadcrumb(adminView) + return ( +
+ {crumb.parent && <>{crumb.parent}›} + {crumb.label} +
+ ) + })()} -
- {isDirty && ● Unsaved} +
+ {isDirty && ● Unsaved} Draft: {formatDate(publishState.draftUpdatedAt)} Live: {formatDate(publishState.publishedAt)} -
-
- - {publishConfirmOpen ? ( <> Publish to live site? - + ) : ( - + )}
@@ -2696,7 +2718,73 @@ async function handleAssetUpload(event: ChangeEvent) {
{/* ── Sidebar ── */} {mobileNavOpen &&
))} - +
) } @@ -2976,18 +3040,11 @@ async function handleAssetUpload(event: ChangeEvent) { })()} {/* HOMEPAGE */} - {adminView === 'homepage' && ( + {adminView === 'homepage:hero' && (
-

Homepage

-

Controls the hero, share section, PRISM video block, and "Where to Next" navigation cards.

-
- - {renderSectionJumpNav('homepage')} - -
-

Hero

-

Primary landing page headline, tag line, and hero call-to-action labels.

+

Hero

+

Primary landing page headline, tagline, podcast art, and call-to-action button labels.

{FIELDS.filter(f => f.section === 'hero').map(({ key, label, multiline }) => (
@@ -2999,9 +3056,14 @@ async function handleAssetUpload(event: ChangeEvent) { {(key.includes('ArtUrl') || key.includes('ImageUrl')) && renderImagePreview(form[key] as string, `${label} preview`)}
))} + {renderSaveStatus()} +
+ )} -
-

Share Section

+ {adminView === 'homepage:share' && ( +
+
+

Share Section

Controls the share block that points visitors toward the next step.

{FIELDS.filter(f => f.section === 'share').map(({ key, label, multiline }) => ( @@ -3014,9 +3076,14 @@ async function handleAssetUpload(event: ChangeEvent) { {key.includes('ImageUrl') && renderImagePreview(form[key] as string, `${label} preview`)}
))} + {renderSaveStatus()} + + )} -
-

PRISM Block

+ {adminView === 'homepage:prism' && ( +
+
+

PRISM Block

Video URL, intro text, pattern steps, and closing copy for the PRISM section.

{FIELDS.filter(f => f.section === 'prism').map(({ key, label, multiline }) => ( @@ -3027,23 +3094,25 @@ async function handleAssetUpload(event: ChangeEvent) { : handleChange(key, e.target.value)} />}
))} + {renderSaveStatus()} + + )} -
-

Where to Next — Section Labels

+ {adminView === 'homepage:where-to-next' && ( +
+
+

Where to Next

+

Section heading, eyebrow, and navigation cards shown at the bottom of the homepage.

- + setForm(f => ({ ...f, whereToNextEyebrow: e.target.value }))} />
- + setForm(f => ({ ...f, whereToNextHeading: e.target.value }))} />
- -
-

Where to Next — Navigation Cards

-

Edit the title, description, and link path for each card.

-
+

Navigation Cards

Title, description, and path for each card.

{(form.whereToNextCards ?? []).map((card, idx) => (
@@ -3063,11 +3132,7 @@ async function handleAssetUpload(event: ChangeEvent) {
))} - {renderSaveStatus()} @@ -3136,21 +3201,14 @@ async function handleAssetUpload(event: ChangeEvent) {
)} - {/* ABOUT */} - {adminView === 'about' && ( + {/* ABOUT — SHOW COPY */} + {adminView === 'about:show-copy' && (
-

About

-

Manage the main show description, Nate bio, and about images.

+

Show Copy

+

Top section of the About page — eyebrow, heading, show description paragraphs, and listen button label.

- - {renderSectionJumpNav('about')} - -
-

Show Copy

-

Top section content that explains the show and listening invitation.

-
- {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 }) => (
{multiline ? ( @@ -3160,58 +3218,57 @@ async function handleAssetUpload(event: ChangeEvent) { )}
))} +
+ + handleChange('aboutVerseArtUrl', e.target.value)} /> + {renderImageAssetSelector(form.aboutVerseArtUrl ?? '', value => handleChange('aboutVerseArtUrl', value), 'field-aboutVerseArtUrl-asset')} +

Scripture artwork shown under the "About the Show" text.

+ {renderImagePreview(form.aboutVerseArtUrl ?? '', 'Scripture artwork preview')} +
+ {renderSaveStatus()} +
+ )} -
-

Nate Bio

-

Profile eyebrow and biography copy for the About Nate block.

+ {/* ABOUT — BIO */} + {adminView === 'about:bio' && ( +
+
+

Nate Bio

+

Profile eyebrow, biography copy, and portrait image for the About Nate block.

{FIELDS.filter(f => ['aboutEyebrow', 'aboutNate'].includes(f.key)).map(({ key, label, multiline }) => (
{multiline ? ( -