Make homepage study card admin-editable with NEW tag controls

This commit is contained in:
nmemmert
2026-05-12 12:47:13 -04:00
parent 853e56bd91
commit affd903f40
13 changed files with 3701 additions and 18 deletions
+311 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent } from 'react'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
@@ -156,13 +156,13 @@ interface ContactReplyConfig {
note: string
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'current-series' | 'episode-highlights' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets'
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts'
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
@@ -287,9 +287,68 @@ const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; sect
{ key: 'welcomeEmailSignoff', label: 'Welcome Email — Signoff HTML', multiline: true, section: 'global' },
]
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
if (Array.isArray(siteContent.studies) && siteContent.studies.length > 0) {
return siteContent.studies.map(study => ({
...study,
homepageEyebrow: typeof study.homepageEyebrow === 'string' ? study.homepageEyebrow : '',
showOnHomepage: study.showOnHomepage === true,
showNewTag: study.showNewTag === true,
newTagLabel: typeof study.newTagLabel === 'string' ? study.newTagLabel : 'NEW',
numberOfChapters: Number.isInteger(study.numberOfChapters) && study.numberOfChapters >= 1 && study.numberOfChapters <= 999 ? study.numberOfChapters : 1,
}))
}
const fallbackSections = siteContent.colossiansStudySections?.length
? siteContent.colossiansStudySections
: DEFAULTS.colossiansStudySections
return [
{
id: 'study-colossians',
slug: 'colossians',
title: 'Colossians: Rooted in Christ',
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
homepageEyebrow: 'New Study',
showOnHomepage: true,
showNewTag: true,
newTagLabel: 'NEW',
status: 'active',
difficulty: 'intermediate',
estimatedHours: 12,
completionBadge: 'Colossians Completion',
numberOfChapters: 4,
sections: fallbackSections,
},
]
}
function normalizeSiteContentForAdmin(siteContent: SiteContent): SiteContent {
const studies = normalizeStudies(siteContent)
const colossians = studies.find(study => study.slug === 'colossians')
return {
...siteContent,
studies,
colossiansStudySections: colossians?.sections?.length
? colossians.sections
: (siteContent.colossiansStudySections?.length ? siteContent.colossiansStudySections : DEFAULTS.colossiansStudySections),
}
}
function buildSiteContentForSave(siteContent: SiteContent): SiteContent {
const normalized = normalizeSiteContentForAdmin(siteContent)
const colossians = normalized.studies.find(study => study.slug === 'colossians')
return {
...normalized,
colossiansStudySections: colossians?.sections ?? normalized.colossiansStudySections,
}
}
export default function AdminPage({ content, onSave, onLogout }: Props) {
const [form, setForm] = useState<SiteContent>(content)
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent)
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(normalizedContent))
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [adminView, setAdminView] = useState<AdminView>('dashboard')
@@ -368,8 +427,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}, [form, adminView, previewOpen])
useEffect(() => {
const nextSnapshot = JSON.stringify(content)
setForm(content)
const normalized = normalizeSiteContentForAdmin(content)
const nextSnapshot = JSON.stringify(normalized)
setForm(normalized)
setLastSavedSnapshot(nextSnapshot)
}, [content])
@@ -575,7 +635,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (!r.ok) return
const data = await r.json() as { siteContent?: Partial<SiteContent> }
if (data?.siteContent && typeof data.siteContent === 'object') {
const next = { ...DEFAULTS, ...data.siteContent }
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent })
setForm(next)
onSave(next)
}
@@ -601,13 +661,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
async function handleSaveDraft() {
const payload = buildSiteContentForSave(form)
setStatus('saving')
setErrorMsg('')
try {
const res = await fetch('/api/admin-content-draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteContent: form }),
body: JSON.stringify({ siteContent: payload }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
@@ -615,7 +676,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}
const data = await res.json() as { updatedAt?: string }
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
setLastSavedSnapshot(JSON.stringify(form))
setLastSavedSnapshot(JSON.stringify(payload))
setStatus('saved')
setTimeout(() => setStatus('idle'), 3500)
} catch (err) {
@@ -626,6 +687,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
async function handlePublishDraft() {
if (!confirm('Publish current changes to the live site now?')) return
const payload = buildSiteContentForSave(form)
setStatus('saving')
setErrorMsg('')
try {
@@ -633,7 +695,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const draftRes = await fetch('/api/admin-content-draft', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteContent: form }),
body: JSON.stringify({ siteContent: payload }),
})
if (!draftRes.ok) {
const data = await draftRes.json().catch(() => ({}))
@@ -653,7 +715,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
if (!latestRes.ok) throw new Error('Failed to refresh published content')
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
if (latest?.siteContent) {
const next = { ...DEFAULTS, ...latest.siteContent }
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...latest.siteContent })
setForm(next)
onSave(next)
setLastSavedSnapshot(JSON.stringify(next))
@@ -1326,6 +1388,92 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
}))
}
function addStudyProgram() {
setForm(f => ({
...f,
studies: [
...(f.studies ?? []),
{
id: `study-${Date.now().toString(36)}`,
slug: `new-study-${Date.now().toString(36)}`,
title: 'New Study',
description: 'Add study description',
homepageEyebrow: 'New Study',
showOnHomepage: false,
showNewTag: false,
newTagLabel: 'NEW',
status: 'planned',
difficulty: 'beginner',
estimatedHours: 8,
completionBadge: 'Study Completion',
numberOfChapters: 4,
sections: [],
},
],
}))
}
function updateStudyProgram(studyId: string, field: keyof Omit<StudyProgram, 'sections'>, value: string | number | boolean) {
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id === studyId ? { ...study, [field]: value } : study),
}))
}
function removeStudyProgram(studyId: string) {
setForm(f => ({
...f,
studies: (f.studies ?? []).filter(study => study.id !== studyId),
}))
}
function addStudySection(studyId: string) {
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id === studyId
? {
...study,
sections: [
...(study.sections ?? []),
{
id: `${study.slug || 'section'}-${Date.now().toString(36)}`,
chapter: 1,
reference: '',
title: '',
audioEmbedUrl: '',
passageText: '',
summary: '',
commentary: '',
greekNotes: [],
studyQuestions: [],
},
],
}
: study),
}))
}
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id === studyId
? {
...study,
sections: (study.sections ?? []).map(section => section.id === sectionId ? { ...section, [field]: value } : section),
}
: study),
}))
}
function removeStudySection(studyId: string, sectionId: string) {
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id === studyId
? { ...study, sections: (study.sections ?? []).filter(section => section.id !== sectionId) }
: study),
}))
}
function archiveCurrentSeriesSnapshot() {
const currentTitle = form.seriesTitle.trim()
if (!currentTitle) {
@@ -1738,6 +1886,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-nav-group">
<span className="admin-nav-label">Content</span>
<button type="button" className={`admin-nav-item${adminView === 'colossians-study' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('colossians-study')}>Studies</button>
<button type="button" className={`admin-nav-item${adminView === 'downloads' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('downloads')}>Downloads</button>
<button type="button" className={`admin-nav-item${adminView === 'custom-links' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('custom-links')}>Custom Links</button>
<button type="button" className={`admin-nav-item${adminView === 'content-blocks' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('content-blocks')}>Content Blocks</button>
@@ -2724,6 +2873,156 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section>
)}
{/* STUDIES */}
{adminView === 'colossians-study' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Studies</h2>
<p>Manage multiple study tracks and their lesson sections (text, commentary, Greek notes, audio, and questions).</p>
</div>
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
{(form.studies ?? []).map(study => (
<details key={study.id} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary">
<div>
<strong>{study.title || 'Untitled study'} · /study/{study.slug || 'slug'}</strong>
<p>{study.description || 'Study description'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row" style={{ marginBottom: '1rem' }}>
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`study-title-${study.id}`}>Study Title</label>
<input id={`study-title-${study.id}`} type="text" value={study.title} placeholder="Colossians: Rooted in Christ" onChange={e => updateStudyProgram(study.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
</div>
<div className="admin-field">
<label htmlFor={`study-status-${study.id}`}>Status</label>
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
<option value="active">Active</option>
<option value="planned">Planned</option>
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-description-${study.id}`}>Description</label>
<textarea id={`study-description-${study.id}`} rows={3} value={study.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(study.id, 'description', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-home-eyebrow-${study.id}`}>Homepage Card Eyebrow</label>
<input id={`study-home-eyebrow-${study.id}`} type="text" value={study.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(study.id, 'homepageEyebrow', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-new-tag-label-${study.id}`}>NEW Tag Label</label>
<input id={`study-new-tag-label-${study.id}`} type="text" value={study.newTagLabel ?? 'NEW'} placeholder="NEW" onChange={e => updateStudyProgram(study.id, 'newTagLabel', e.target.value)} />
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id={`study-home-feature-${study.id}`} type="checkbox" checked={study.showOnHomepage === true} onChange={e => updateStudyProgram(study.id, 'showOnHomepage', e.target.checked)} />
<label htmlFor={`study-home-feature-${study.id}`}>Feature this study card on homepage</label>
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id={`study-show-new-tag-${study.id}`} type="checkbox" checked={study.showNewTag === true} onChange={e => updateStudyProgram(study.id, 'showNewTag', e.target.checked)} />
<label htmlFor={`study-show-new-tag-${study.id}`}>Show NEW tag on homepage card</label>
</div>
</div>
<div className="admin-field">
<label htmlFor={`study-chapters-${study.id}`}>Number of Chapters</label>
<input id={`study-chapters-${study.id}`} type="number" min="1" max="999" value={study.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(study.id, "numberOfChapters", Number(e.target.value) || 1)} />
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudyProgram(study.id)}>Remove Study</button>
</div>
{(study.sections ?? []).length === 0 && (
<p className="admin-stats-note">No sections yet for this study.</p>
)}
{(study.sections ?? []).map(section => (
<details key={section.id} className="admin-collapsible-card" open={false}>
<summary className="admin-collapsible-summary">
<div>
<strong>Lesson {section.reference || section.id}</strong>
<p>{section.title || 'Untitled section'}</p>
</div>
<span className="admin-collapsible-hint">Expand to edit lesson</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-row">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
))}
</select>
</div>
<div className="admin-field">
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
const val = e.target.value;
if (val) {
const date = new Date(val + ':00Z');
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
} else {
updateStudySection(study.id, section.id, 'releasedAt', '');
}
}} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={5} value={section.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
<div className="admin-field">
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
</div>
</div>
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
</div>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
</div>
</details>
))}
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</button>
{renderSaveStatus()}
</section>
)}
{/* GLOBAL / FOOTER & PLATFORM */}
{adminView === 'global' && (
<section className="admin-panel-section">