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:
nmemmert
2026-06-30 10:22:37 -04:00
parent a8f6024234
commit 2eb98b66d0
5 changed files with 576 additions and 470 deletions
+115 -437
View File
@@ -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 &amp; Start New Book
</button>
) : (
<div className="admin-end-series-panel">
<h3 style={{ marginBottom: '0.5rem' }}>Archive &ldquo;{form.seriesTitle}&rdquo; 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 &amp; 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 &ldquo;End Current Series&rdquo; 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>
)}