diff --git a/index.html b/index.html index e4b1551..90213fa 100644 --- a/index.html +++ b/index.html @@ -21,7 +21,7 @@ - +
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 1684059..40efb2f 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -73,30 +73,108 @@ interface Question { } type StringField = Exclude -const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [ - { key: 'eyebrow', label: 'Hero Eyebrow Text' }, - { key: 'heroTagline', label: 'Hero Tagline' }, - { key: 'aboutShowHeading', label: 'About Show — Heading' }, - { key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true }, - { key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true }, - { key: 'aboutNate', label: 'About Nate', multiline: true }, - { key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")' }, - { key: 'seriesTitle', label: 'Series Title' }, - { key: 'seriesDescription', label: 'Series Description', multiline: true }, - { key: 'seriesImageUrl', label: 'Series Cover Image URL' }, - { key: 'seriesListenUrl', label: 'Series Listen URL' }, - { key: 'studyGuideTitle', label: 'Study Guide Title' }, - { key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true }, - { key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)' }, - { key: 'shareHeading', label: 'Share Section — Heading' }, - { key: 'shareP', label: 'Share Section — Paragraph', multiline: true }, +type MainContentSection = 'hero' | 'start-here' | 'about' | 'series' | 'share' + +const MAIN_CONTENT_SECTIONS: Array<{ id: MainContentSection; title: string; description: string }> = [ + { + id: 'hero', + title: 'Hero Section', + description: 'Controls the top intro copy on the homepage.', + }, + { + id: 'start-here', + title: 'Start Here Page', + description: 'Edit the separate Start Here page heading, intro, and step cards.', + }, + { + id: 'about', + title: 'About Section', + description: 'Manage the main show description and Nate bio content.', + }, + { + id: 'series', + title: 'Current Series', + description: 'Update the active study card and companion guide content.', + }, + { + id: 'share', + title: 'Share Section', + description: 'Controls the copy shown near the QR/share area.', + }, +] + +const BRAND_KIT_SWATCHES = [ + { name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' }, + { name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' }, + { name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' }, + { name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' }, + { name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' }, + { name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' }, + { name: 'Warm White', hex: '#f0ead8', role: 'Primary text' }, + { name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' }, +] as const + +const BRAND_KIT_TYPE = [ + { + label: 'Brand Font', + spec: 'Cormorant Garamond · 300/400/600/700 · italic supported', + sample: 'Verse by Verse with Nate', + }, + { + label: 'Subtitle / Small Caps', + spec: 'Uppercase with tracking for labels, nav, and support text', + sample: 'A Journey Through Scripture', + }, + { + label: 'Body / Quote', + spec: 'Cormorant Garamond light/italic for long-form text and pull copy', + sample: 'Verse by verse. Nugget by nugget.', + }, +] as const + +const BRAND_KIT_VERIFICATION = [ + 'Global font import updated to Cormorant Garamond.', + 'Shared brand variables now define black, gold, border, warm white, and muted text colors.', + 'Live site CSS now references the brand variables for core text and accent styling.', + 'Admin surfaces inherit the same typography and palette tokens used by the public site.', +] as const + +const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [ + { key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' }, + { key: 'heroTagline', label: 'Hero Tagline', section: 'hero' }, + { key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' }, + { key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' }, + { key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' }, + { key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' }, + { key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' }, + { key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' }, + { key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' }, + { key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' }, + { key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' }, + { key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' }, + { key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' }, + { key: 'aboutShowHeading', label: 'About Show — Heading', section: 'about' }, + { key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' }, + { key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' }, + { key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' }, + { key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' }, + { key: 'seriesTitle', label: 'Series Title', section: 'series' }, + { key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' }, + { key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' }, + { key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' }, + { key: 'studyGuideTitle', label: 'Study Guide Title', section: 'series' }, + { key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true, section: 'series' }, + { key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)', section: 'series' }, + { key: 'shareHeading', label: 'Share Section — Heading', section: 'share' }, + { key: 'shareP', label: 'Share Section — Paragraph', multiline: true, section: 'share' }, ] export default function AdminPage({ content, onSave, onLogout }: Props) { const [form, setForm] = useState(content) + const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content)) const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [errorMsg, setErrorMsg] = useState('') - const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions'>('content') + const [adminTab, setAdminTab] = useState<'content' | 'stats' | 'questions' | 'brand'>('content') const [contentTab, setContentTab] = useState<'main' | 'custom'>('main') const [stats, setStats] = useState(null) const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') @@ -110,6 +188,25 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [editingQuestionId, setEditingQuestionId] = useState(null) const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({}) + const isDirty = JSON.stringify(form) !== lastSavedSnapshot + + useEffect(() => { + const nextSnapshot = JSON.stringify(content) + setForm(content) + setLastSavedSnapshot(nextSnapshot) + }, [content]) + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isDirty) return + event.preventDefault() + event.returnValue = '' + } + + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [isDirty]) + useEffect(() => { fetch('/api/admin-stats') .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats')))) @@ -300,6 +397,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { setForm(f => ({ ...f, [key]: value })) } + function confirmLeaveUnsavedChanges() { + if (!isDirty) return true + return confirm('You have unsaved changes. Leave this section without saving?') + } + + function handleAdminTabChange(nextTab: 'content' | 'stats' | 'questions' | 'brand') { + if (nextTab === adminTab) return + if (!confirmLeaveUnsavedChanges()) return + setAdminTab(nextTab) + } + + function handleContentTabChange(nextTab: 'main' | 'custom') { + if (nextTab === contentTab) return + if (!confirmLeaveUnsavedChanges()) return + setContentTab(nextTab) + } + function addLink() { setForm(f => ({ ...f, @@ -581,6 +695,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { throw new Error((data as { message?: string }).message ?? 'Save failed') } onSave(form) + setLastSavedSnapshot(JSON.stringify(form)) setStatus('saved') setTimeout(() => setStatus('idle'), 3500) } catch (err) { @@ -656,8 +771,23 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {

Site Admin

Verse by Verse with Nate

- ← Back to site -
@@ -670,7 +800,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { role="tab" aria-selected={adminTab === 'content'} className={`admin-tab ${adminTab === 'content' ? 'admin-tab--active' : ''}`} - onClick={() => setAdminTab('content')} + onClick={() => handleAdminTabChange('content')} > Content @@ -679,7 +809,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { role="tab" aria-selected={adminTab === 'stats'} className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`} - onClick={() => setAdminTab('stats')} + onClick={() => handleAdminTabChange('stats')} > Site Stats @@ -688,12 +818,82 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { role="tab" aria-selected={adminTab === 'questions'} className={`admin-tab ${adminTab === 'questions' ? 'admin-tab--active' : ''}`} - onClick={() => setAdminTab('questions')} + onClick={() => handleAdminTabChange('questions')} > Questions ({questions.length}) + + {adminTab === 'brand' && ( +
+
+

Brand Kit

+

Reference palette, typography, and implementation status for the live site.

+
+ +
+ Verse by Verse with Nate artwork +
+

Verse by Verse with Nate

+

A Journey Through Scripture

+

Verse by verse. Nugget by nugget.

+
+
+ +
+
+

Color Palette

+
+ {BRAND_KIT_SWATCHES.map(swatch => ( +
+
+
+ {swatch.name} + {swatch.hex} +

{swatch.role}

+
+
+ ))} +
+
+ +
+

Typography

+
+ {BRAND_KIT_TYPE.map(item => ( +
+

{item.label}

+

{item.spec}

+

{item.sample}

+
+ ))} +
+
+
+ +
+

Verified On Site

+
    + {BRAND_KIT_VERIFICATION.map(item => ( +
  • {item}
  • + ))} +
+

+ This admin panel reflects the live implementation now loaded from the shared font import and brand tokens. +

+
+
+ )} + {adminTab === 'stats' && (
@@ -967,7 +1167,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { role="tab" aria-selected={contentTab === 'main'} className={`admin-tab ${contentTab === 'main' ? 'admin-tab--active' : ''}`} - onClick={() => setContentTab('main')} + onClick={() => handleContentTabChange('main')} > Main Content @@ -976,12 +1176,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { role="tab" aria-selected={contentTab === 'custom'} className={`admin-tab ${contentTab === 'custom' ? 'admin-tab--active' : ''}`} - onClick={() => setContentTab('custom')} + onClick={() => handleContentTabChange('custom')} > Custom Content
+ {isDirty &&

Unsaved changes

} + {contentTab === 'main' && ( <>
@@ -995,26 +1197,41 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
- {FIELDS.map(({ key, label, multiline }) => ( -
- - {multiline ? ( -