Add Finished Books feature with episode playlists
- New FinishedBook type in content.ts + finishedBooks[] field on SiteContent - RSS parser now extracts itunes:season into each episode object - /finished grid page and /finished/:id playlist page (filters episodes by season) - Episodes nav link replaced with dropdown: Current Series / Finished Books - Finished Books link added to footer - Admin: "End Current Series & Start New Book" wizard on Current Series tab - Admin: Finished Books management tab (add/edit/remove entries) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+115
-437
@@ -4,7 +4,7 @@ import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable }
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
|
||||
import { DEFAULTS } from './content'
|
||||
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
||||
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
|
||||
@@ -692,11 +692,11 @@ interface PodcastChecklistData {
|
||||
episodes: PodcastChecklistEpisode[]
|
||||
}
|
||||
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'redirects' | 'podcastFeaturedLinks' | 'finishedBooks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||
|
||||
type AdminView =
|
||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
|
||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'emails' | 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||||
@@ -804,7 +804,7 @@ const ADMIN_SECTION_LINKS: Partial<Record<AdminView, AdminSectionLink[]>> = {
|
||||
],
|
||||
}
|
||||
|
||||
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'episode-scripts'
|
||||
type PodcastTab = 'current-series' | 'episode-highlights' | 'finished-books' | 'podcast-checklist' | 'episode-scripts'
|
||||
|
||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' | 'email-templates'
|
||||
|
||||
@@ -1071,6 +1071,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
||||
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
||||
|
||||
const [endSeriesOpen, setEndSeriesOpen] = useState(false)
|
||||
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
|
||||
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
|
||||
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
|
||||
|
||||
const [questions, setQuestions] = useState<Question[]>([])
|
||||
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
||||
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
@@ -1142,7 +1147,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
})
|
||||
const [manualQuestionStatus, setManualQuestionStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [manualQuestionMsg, setManualQuestionMsg] = useState('')
|
||||
const [archiveLinkSelectionBySeries, setArchiveLinkSelectionBySeries] = useState<{ [key: string]: string }>({})
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const previewIframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const dragSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||
@@ -2321,184 +2325,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setForm(f => ({ ...f, customBlocks: (f.customBlocks ?? []).filter(b => b.id !== id) }))
|
||||
}
|
||||
|
||||
function addArchivedSeries() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: [
|
||||
...(f.archivedSeries ?? []),
|
||||
{
|
||||
id: Date.now().toString(36),
|
||||
label: 'Archived Study',
|
||||
title: '',
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
listenUrl: '',
|
||||
studyGuideTitle: '',
|
||||
studyGuideDescription: '',
|
||||
studyGuideUrl: '',
|
||||
resourceLinks: [],
|
||||
notes: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function updateArchivedSeries(id: string, field: keyof ArchivedSeries, value: string | ArchivedSeriesResourceLink[] | ArchivedSeriesNote[] | { from: number; to: number } | undefined) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === id ? { ...series, [field]: value } : series),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeArchivedSeries(id: string) {
|
||||
setForm(f => ({ ...f, archivedSeries: (f.archivedSeries ?? []).filter(series => series.id !== id) }))
|
||||
}
|
||||
|
||||
function addArchivedSeriesLink(seriesId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||||
? {
|
||||
...series,
|
||||
resourceLinks: [
|
||||
...(series.resourceLinks ?? []),
|
||||
{ id: `${seriesId}-${Date.now().toString(36)}`, label: '', description: '', url: '', amazonUrl: '', amazonLabel: '' },
|
||||
],
|
||||
}
|
||||
: series),
|
||||
}))
|
||||
}
|
||||
|
||||
function updateArchivedSeriesLink(seriesId: string, linkId: string, field: keyof ArchivedSeriesResourceLink, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||||
? {
|
||||
...series,
|
||||
resourceLinks: (series.resourceLinks ?? []).map(link => link.id === linkId ? { ...link, [field]: value } : link),
|
||||
}
|
||||
: series),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeArchivedSeriesLink(seriesId: string, linkId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||||
? { ...series, resourceLinks: (series.resourceLinks ?? []).filter(link => link.id !== linkId) }
|
||||
: series),
|
||||
}))
|
||||
}
|
||||
|
||||
function addExistingCustomLinkToArchivedSeries(seriesId: string) {
|
||||
const selectedLinkId = archiveLinkSelectionBySeries[seriesId]
|
||||
if (!selectedLinkId) return
|
||||
|
||||
const source = (form.customLinks ?? []).find(link => link.id === selectedLinkId)
|
||||
if (!source) return
|
||||
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => {
|
||||
if (series.id !== seriesId) return series
|
||||
|
||||
const alreadyExists = (series.resourceLinks ?? []).some(link =>
|
||||
link.url.trim().toLowerCase() === source.url.trim().toLowerCase(),
|
||||
)
|
||||
|
||||
if (alreadyExists) return series
|
||||
|
||||
return {
|
||||
...series,
|
||||
resourceLinks: [
|
||||
...(series.resourceLinks ?? []),
|
||||
{
|
||||
id: `${seriesId}-${Date.now().toString(36)}`,
|
||||
label: source.label,
|
||||
description: source.description ?? '',
|
||||
url: source.url,
|
||||
amazonUrl: source.amazonUrl ?? '',
|
||||
amazonLabel: source.amazonLabel ?? '',
|
||||
},
|
||||
],
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
function addAllExistingCustomLinksToArchivedSeries(seriesId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => {
|
||||
if (series.id !== seriesId) return series
|
||||
|
||||
const existingUrls = new Set(
|
||||
(series.resourceLinks ?? [])
|
||||
.map(link => link.url.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
const toAdd = (f.customLinks ?? [])
|
||||
.filter(link => link.url.trim().length > 0)
|
||||
.filter(link => !existingUrls.has(link.url.trim().toLowerCase()))
|
||||
.map(link => ({
|
||||
id: `${seriesId}-${Date.now().toString(36)}-${link.id}`,
|
||||
label: link.label,
|
||||
description: link.description ?? '',
|
||||
url: link.url,
|
||||
amazonUrl: link.amazonUrl ?? '',
|
||||
amazonLabel: link.amazonLabel ?? '',
|
||||
}))
|
||||
|
||||
if (toAdd.length === 0) return series
|
||||
|
||||
return {
|
||||
...series,
|
||||
resourceLinks: [
|
||||
...(series.resourceLinks ?? []),
|
||||
...toAdd,
|
||||
],
|
||||
}
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
function addArchivedSeriesNote(seriesId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||||
? {
|
||||
...series,
|
||||
notes: [
|
||||
...(series.notes ?? []),
|
||||
{ id: `${seriesId}-note-${Date.now().toString(36)}`, heading: '', body: '' },
|
||||
],
|
||||
}
|
||||
: series),
|
||||
}))
|
||||
}
|
||||
|
||||
function updateArchivedSeriesNote(seriesId: string, noteId: string, field: keyof ArchivedSeriesNote, value: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||||
? {
|
||||
...series,
|
||||
notes: (series.notes ?? []).map(note => note.id === noteId ? { ...note, [field]: value } : note),
|
||||
}
|
||||
: series),
|
||||
}))
|
||||
}
|
||||
|
||||
function removeArchivedSeriesNote(seriesId: string, noteId: string) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: (f.archivedSeries ?? []).map(series => series.id === seriesId
|
||||
? { ...series, notes: (series.notes ?? []).filter(note => note.id !== noteId) }
|
||||
: series),
|
||||
}))
|
||||
}
|
||||
|
||||
function addStudyProgram() {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
@@ -2587,59 +2413,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}))
|
||||
}
|
||||
|
||||
function archiveCurrentSeriesSnapshot() {
|
||||
const currentTitle = form.seriesTitle.trim()
|
||||
if (!currentTitle) {
|
||||
alert('Set a current series title first, then archive it.')
|
||||
return
|
||||
}
|
||||
|
||||
const existing = (form.archivedSeries ?? []).some(
|
||||
series => series.title.trim().toLowerCase() === currentTitle.toLowerCase(),
|
||||
)
|
||||
|
||||
if (existing && !confirm(`An archived series named "${currentTitle}" already exists. Create another snapshot anyway?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const resourceLinks = (form.customLinks ?? [])
|
||||
.filter(link => link.placement === 'resources')
|
||||
.filter(link => link.label.trim().length > 0 || link.url.trim().length > 0)
|
||||
.map(link => ({
|
||||
id: `archive-link-${Date.now().toString(36)}-${link.id}`,
|
||||
label: link.label,
|
||||
url: link.url,
|
||||
}))
|
||||
|
||||
const notes = (form.customBlocks ?? [])
|
||||
.filter(block => block.heading.trim().length > 0 || block.body.trim().length > 0)
|
||||
.map(block => ({
|
||||
id: `archive-note-${Date.now().toString(36)}-${block.id}`,
|
||||
heading: block.heading,
|
||||
body: block.body,
|
||||
}))
|
||||
|
||||
const archived: ArchivedSeries = {
|
||||
id: `archive-${Date.now().toString(36)}`,
|
||||
label: form.seriesLabel?.trim() || 'Archived Study',
|
||||
title: form.seriesTitle,
|
||||
description: form.seriesDescription,
|
||||
imageUrl: form.seriesImageUrl,
|
||||
listenUrl: form.seriesListenUrl,
|
||||
studyGuideTitle: form.studyGuideTitle,
|
||||
studyGuideDescription: form.studyGuideDescription,
|
||||
studyGuideUrl: form.studyGuideUrl,
|
||||
resourceLinks,
|
||||
notes,
|
||||
}
|
||||
|
||||
setForm(f => ({
|
||||
...f,
|
||||
archivedSeries: [archived, ...(f.archivedSeries ?? [])],
|
||||
}))
|
||||
navigateTo('archived-series')
|
||||
}
|
||||
|
||||
async function handleAnswerQuestion(questionId: string, answer: string) {
|
||||
if (!answer.trim()) return
|
||||
try {
|
||||
@@ -3045,9 +2818,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
}
|
||||
|
||||
const resourceLinks = (form.customLinks ?? []).filter(link => link.placement === 'resources')
|
||||
const archivedResourceCount = (form.archivedSeries ?? []).reduce((count, series) => {
|
||||
return count + (series.resourceLinks ?? []).length
|
||||
}, 0)
|
||||
|
||||
const filteredAdminQuestions = questions.filter(question => {
|
||||
const search = questionSearch.trim().toLowerCase()
|
||||
@@ -3881,8 +3651,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<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 === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
|
||||
<button type="button" className={`admin-tab${podcastTab === 'episode-scripts' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-scripts')}>Episode Scripts</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -3911,6 +3681,64 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
))}
|
||||
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Downloads.</p>
|
||||
{renderSaveStatus()}
|
||||
<div className="admin-end-series-wrap">
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', margin: '2rem 0 1.5rem' }} />
|
||||
{!endSeriesOpen ? (
|
||||
<button type="button" className="btn-danger-outline" onClick={() => setEndSeriesOpen(true)}>
|
||||
End Current Series & Start New Book
|
||||
</button>
|
||||
) : (
|
||||
<div className="admin-end-series-panel">
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>Archive “{form.seriesTitle}” to Finished Books</h3>
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||
This will save the current series as a finished book and clear Episode Highlights. Fill in the new series details below, then confirm.
|
||||
</p>
|
||||
<div className="admin-field">
|
||||
<label>New Series Title</label>
|
||||
<input type="text" placeholder="e.g. Study of Romans" value={endSeriesNewTitle} onChange={e => setEndSeriesNewTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>New Series Image URL</label>
|
||||
<input type="text" placeholder="/images/romans-cover.png" value={endSeriesNewImage} onChange={e => setEndSeriesNewImage(e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>New Season Number</label>
|
||||
<input type="number" placeholder="2" value={endSeriesNewSeason} onChange={e => setEndSeriesNewSeason(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={!endSeriesNewTitle.trim() || !endSeriesNewSeason.trim()}
|
||||
onClick={() => {
|
||||
const archivedBook = {
|
||||
id: Date.now().toString(36),
|
||||
title: form.seriesTitle,
|
||||
description: form.seriesDescription,
|
||||
imageUrl: form.seriesImageUrl,
|
||||
season: parseInt(endSeriesNewSeason) - 1 || 1,
|
||||
}
|
||||
setForm(f => ({
|
||||
...f,
|
||||
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
|
||||
podcastFeaturedLinks: [],
|
||||
seriesTitle: endSeriesNewTitle.trim(),
|
||||
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
|
||||
seriesLabel: 'Now Playing',
|
||||
}))
|
||||
setEndSeriesOpen(false)
|
||||
setEndSeriesNewTitle('')
|
||||
setEndSeriesNewImage('')
|
||||
setEndSeriesNewSeason('')
|
||||
}}
|
||||
>
|
||||
Confirm & Archive
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" onClick={() => setEndSeriesOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -3978,6 +3806,53 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* FINISHED BOOKS */}
|
||||
{(adminView === 'podcast' && podcastTab === 'finished-books') && (
|
||||
<section className="admin-panel-section" aria-label="Finished books">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Finished Books</h2>
|
||||
<p>Series that have been completed. Each one gets a playlist page at <code>/finished/:id</code>.</p>
|
||||
</div>
|
||||
{(form.finishedBooks ?? []).length === 0 && (
|
||||
<p className="admin-stats-note">No finished books yet. Use “End Current Series” on the Current Series tab to archive one, or add one manually below.</p>
|
||||
)}
|
||||
{(form.finishedBooks ?? []).map(book => (
|
||||
<AdminCollapsibleCard key={book.id} title={book.title || 'Untitled'} subtitle={`Season ${book.season}`}>
|
||||
<div className="admin-array-row">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label>Title</label>
|
||||
<input type="text" value={book.title} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, title: e.target.value } : b) }))} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Description</label>
|
||||
<textarea rows={3} value={book.description} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, description: e.target.value } : b) }))} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Cover Image URL</label>
|
||||
<input type="text" value={book.imageUrl} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, imageUrl: e.target.value } : b) }))} />
|
||||
{renderImagePreview(book.imageUrl, `${book.title} cover`)}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Season Number</label>
|
||||
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).filter(b => b.id !== book.id) }))}>Remove</button>
|
||||
</div>
|
||||
</AdminCollapsibleCard>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-add"
|
||||
onClick={() => setForm(f => ({ ...f, finishedBooks: [...(f.finishedBooks ?? []), { id: Date.now().toString(36), title: '', description: '', imageUrl: '', season: 1 }] }))}
|
||||
>
|
||||
+ Add Finished Book
|
||||
</button>
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* PODCAST CHECKLIST */}
|
||||
{(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && (
|
||||
<section className="admin-panel-section" aria-label="Podcast production checklist">
|
||||
@@ -4178,143 +4053,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ARCHIVED SERIES */}
|
||||
{(adminView === 'archived-series' || (adminView === 'podcast' && podcastTab === 'archived-series')) && (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Archived Series</h2>
|
||||
<p>Move finished studies here so users can still access old resources after you switch the current series.</p>
|
||||
</div>
|
||||
<div className="admin-archive-helper">
|
||||
<h3>Archive Current Series</h3>
|
||||
<p>Use this when you move from one study to the next. It creates a pre-filled archived entry from the current series, study guide, custom resource links, and custom content blocks.</p>
|
||||
<button type="button" className="btn-admin-add" onClick={archiveCurrentSeriesSnapshot}>+ Archive Current Series Snapshot</button>
|
||||
</div>
|
||||
{(form.archivedSeries ?? []).length === 0 && (
|
||||
<p className="admin-stats-note">No archived series yet.</p>
|
||||
)}
|
||||
{(form.archivedSeries ?? []).map(series => (
|
||||
<div key={series.id} className="admin-archive-card">
|
||||
<div className="admin-archive-card-head">
|
||||
<div>
|
||||
<h4>{series.title || 'Untitled archived series'}</h4>
|
||||
<p>{series.label || 'Archived Study'}</p>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeries(series.id)}>Remove Series</button>
|
||||
</div>
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-label-${series.id}`}>Label</label>
|
||||
<input id={`archive-label-${series.id}`} type="text" value={series.label} placeholder="Archived Study" onChange={e => updateArchivedSeries(series.id, 'label', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-title-${series.id}`}>Series Title</label>
|
||||
<input id={`archive-title-${series.id}`} type="text" value={series.title} placeholder="Study of Titus: Sound Doctrine" onChange={e => updateArchivedSeries(series.id, 'title', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-description-${series.id}`}>Description</label>
|
||||
<textarea id={`archive-description-${series.id}`} value={series.description} rows={4} placeholder="Describe the archived study and why it still matters." onChange={e => updateArchivedSeries(series.id, 'description', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-image-${series.id}`}>Cover Image URL</label>
|
||||
<input id={`archive-image-${series.id}`} type="text" value={series.imageUrl} placeholder="/images/titus-cover.png" onChange={e => updateArchivedSeries(series.id, 'imageUrl', e.target.value)} />
|
||||
{renderImageAssetSelector(series.imageUrl, value => updateArchivedSeries(series.id, 'imageUrl', value), `archive-image-${series.id}-asset`)}
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-listen-${series.id}`}>Listen URL</label>
|
||||
<input id={`archive-listen-${series.id}`} type="url" value={series.listenUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'listenUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-guide-title-${series.id}`}>Study Guide Title</label>
|
||||
<input id={`archive-guide-title-${series.id}`} type="text" value={series.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => updateArchivedSeries(series.id, 'studyGuideTitle', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-guide-description-${series.id}`}>Study Guide Description</label>
|
||||
<textarea id={`archive-guide-description-${series.id}`} value={series.studyGuideDescription} rows={3} placeholder="Describe the archived guide or workbook." onChange={e => updateArchivedSeries(series.id, 'studyGuideDescription', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-guide-url-${series.id}`}>Study Guide URL</label>
|
||||
<input id={`archive-guide-url-${series.id}`} type="url" value={series.studyGuideUrl} placeholder="https://..." onChange={e => updateArchivedSeries(series.id, 'studyGuideUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Episode Range (for archive grouping on Episodes page)</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<input type="number" value={series.episodeRange?.from ?? ''} placeholder="First ep #" min={1} style={{ width: '7rem' }} onChange={e => { const from = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: isNaN(from) ? 0 : from, to: series.episodeRange?.to ?? 0 }) }} />
|
||||
<span style={{ color: 'var(--brand-gold)' }}>to</span>
|
||||
<input type="number" value={series.episodeRange?.to ?? ''} placeholder="Last ep #" min={1} style={{ width: '7rem' }} onChange={e => { const to = parseInt(e.target.value, 10); updateArchivedSeries(series.id, 'episodeRange', { from: series.episodeRange?.from ?? 0, to: isNaN(to) ? 0 : to }) }} />
|
||||
</div>
|
||||
<p className="admin-stats-note" style={{ marginTop: '0.35rem' }}>Episodes in this range will be grouped under this series on the public Episodes page.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-archive-subsection">
|
||||
<div className="admin-archive-subsection-head">
|
||||
<h5>Archived Resource Links</h5>
|
||||
<div className="admin-archive-subsection-actions">
|
||||
<select value={archiveLinkSelectionBySeries[series.id] ?? ''} onChange={e => setArchiveLinkSelectionBySeries(prev => ({ ...prev, [series.id]: e.target.value }))}>
|
||||
<option value="">Pick existing custom link…</option>
|
||||
{(form.customLinks ?? []).filter(link => link.url.trim().length > 0).map(link => (
|
||||
<option key={`pick-${series.id}-${link.id}`} value={link.id}>{link.label || link.url}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addExistingCustomLinkToArchivedSeries(series.id)} disabled={!archiveLinkSelectionBySeries[series.id]}>+ Add Picked Link</button>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addAllExistingCustomLinksToArchivedSeries(series.id)} disabled={(form.customLinks ?? []).filter(link => link.url.trim().length > 0).length === 0}>+ Add All Custom Links</button>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Blank Link</button>
|
||||
</div>
|
||||
</div>
|
||||
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
|
||||
{(series.resourceLinks ?? []).map(link => (
|
||||
<div key={link.id} className="admin-array-row admin-array-row--nested">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-link-label-${link.id}`}>Label</label>
|
||||
<input id={`archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-link-url-${link.id}`}>URL</label>
|
||||
<input id={`archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||||
<input id={`archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||||
<input id={`archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-archive-subsection">
|
||||
<div className="admin-archive-subsection-head">
|
||||
<h5>Archived Notes / Blocks</h5>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesNote(series.id)}>+ Add Note Block</button>
|
||||
</div>
|
||||
{(series.notes ?? []).length === 0 && <p className="admin-stats-note">No archived note blocks yet.</p>}
|
||||
{(series.notes ?? []).map(note => (
|
||||
<div key={note.id} className="admin-array-row admin-array-row--nested">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-note-heading-${note.id}`}>Heading</label>
|
||||
<input id={`archive-note-heading-${note.id}`} type="text" value={note.heading} placeholder="Titus overview" onChange={e => updateArchivedSeriesNote(series.id, note.id, 'heading', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`archive-note-body-${note.id}`}>Body</label>
|
||||
<textarea id={`archive-note-body-${note.id}`} value={note.body} rows={3} placeholder="Add archived notes, explanation, or links context." onChange={e => updateArchivedSeriesNote(series.id, note.id, 'body', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesNote(series.id, note.id)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addArchivedSeries}>+ Add Archived Series</button>
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* DOWNLOADS */}
|
||||
{adminView === 'downloads' && (
|
||||
<section className="admin-panel-section">
|
||||
@@ -4328,8 +4066,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
<div className="admin-content-summary">
|
||||
<div className="admin-summary-card"><h3>Companion Study Guide</h3><p>Included</p></div>
|
||||
<div className="admin-summary-card"><h3>Current Downloads</h3><p>{resourceLinks.length}</p></div>
|
||||
<div className="admin-summary-card"><h3>Previous Study Downloads</h3><p>{archivedResourceCount}</p></div>
|
||||
<div className="admin-summary-card"><h3>Total Library Items</h3><p>{resourceLinks.length + archivedResourceCount}</p></div>
|
||||
</div>
|
||||
|
||||
<div className="admin-section-header" id="downloads-study-guide">
|
||||
@@ -4412,64 +4148,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
))}
|
||||
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button>
|
||||
|
||||
<div className="admin-section-header" id="downloads-previous-studies">
|
||||
<h3>Previous Study Downloads</h3>
|
||||
<p>These downloads appear in the Previous Studies area of the download library.</p>
|
||||
</div>
|
||||
{(form.archivedSeries ?? []).length === 0 && (
|
||||
<p className="admin-stats-note">No archived series yet. Add one in Archived Series, then manage its downloads here.</p>
|
||||
)}
|
||||
{(form.archivedSeries ?? []).map(series => (
|
||||
<AdminCollapsibleCard
|
||||
key={series.id}
|
||||
title={series.title || 'Untitled archived series'}
|
||||
subtitle={`${(series.resourceLinks ?? []).length} download${(series.resourceLinks ?? []).length === 1 ? '' : 's'}`}
|
||||
hint="Expand to manage"
|
||||
className="admin-collapsible-card--group"
|
||||
>
|
||||
<div className="admin-archive-subsection">
|
||||
<div className="admin-archive-subsection-head">
|
||||
<h5>{series.title || 'Untitled archived series'}</h5>
|
||||
<button type="button" className="btn-admin-add" onClick={() => addArchivedSeriesLink(series.id)}>+ Add Link</button>
|
||||
</div>
|
||||
{(series.resourceLinks ?? []).length === 0 && <p className="admin-stats-note">No archived resource links yet.</p>}
|
||||
{(series.resourceLinks ?? []).map(link => (
|
||||
<AdminCollapsibleCard
|
||||
key={link.id}
|
||||
title={link.label || 'Untitled archived download'}
|
||||
subtitle={link.description || 'Previous study download'}
|
||||
className="admin-collapsible-card--nested"
|
||||
>
|
||||
<div className="admin-array-row admin-array-row--nested">
|
||||
<div className="admin-array-fields">
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`resources-archive-link-label-${link.id}`}>Label</label>
|
||||
<input id={`resources-archive-link-label-${link.id}`} type="text" value={link.label} placeholder="Episode guide" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'label', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`resources-archive-link-url-${link.id}`}>URL</label>
|
||||
<input id={`resources-archive-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'url', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`resources-archive-link-description-${link.id}`}>Description</label>
|
||||
<textarea id={`resources-archive-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'description', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`resources-archive-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
||||
<input id={`resources-archive-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonUrl', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`resources-archive-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
||||
<input id={`resources-archive-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateArchivedSeriesLink(series.id, link.id, 'amazonLabel', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => removeArchivedSeriesLink(series.id, link.id)}>Remove</button>
|
||||
</div>
|
||||
</AdminCollapsibleCard>
|
||||
))}
|
||||
</div>
|
||||
</AdminCollapsibleCard>
|
||||
))}
|
||||
{renderSaveStatus()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user