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:
@@ -29,11 +29,12 @@ function parseRssItems(xml, limit = Infinity) {
|
||||
const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim()
|
||||
const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim()
|
||||
const season = (/<itunes:season>([\s\S]*?)<\/itunes:season>/.exec(block)?.[1] ?? '').trim()
|
||||
items.push({
|
||||
title, pubDate, link,
|
||||
audioUrl: enclosureUrl,
|
||||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
||||
duration, episode,
|
||||
duration, episode, season,
|
||||
})
|
||||
}
|
||||
return items
|
||||
|
||||
+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>
|
||||
)}
|
||||
|
||||
+306
@@ -8687,3 +8687,309 @@
|
||||
color: #8a7f5a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Nav Dropdown (Episodes) ── */
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-dropdown-arrow {
|
||||
font-size: 0.7em;
|
||||
opacity: 0.7;
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(15, 15, 12, 0.98);
|
||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0;
|
||||
min-width: 160px;
|
||||
z-index: 200;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .nav-dropdown-menu,
|
||||
.nav-dropdown:focus-within .nav-dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
display: block;
|
||||
padding: 0.55rem 1.1rem;
|
||||
color: var(--brand-muted);
|
||||
text-decoration: none;
|
||||
font-family: var(--brand-font-body);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.88rem;
|
||||
transition: color 200ms, background 200ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-dropdown-item:hover,
|
||||
.nav-dropdown-item--active {
|
||||
color: var(--brand-gold);
|
||||
background: rgba(201, 168, 76, 0.07);
|
||||
}
|
||||
|
||||
/* ── Finished Books & Series Pages ── */
|
||||
.finished-books-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.finished-book-card {
|
||||
background: rgba(26, 26, 21, 0.9);
|
||||
border: 1px solid rgba(201, 168, 76, 0.18);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 200ms, transform 200ms;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.finished-book-card:hover {
|
||||
border-color: rgba(201, 168, 76, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.finished-book-card-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.finished-book-card-body {
|
||||
padding: 1.1rem 1.2rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.finished-book-card-season {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--brand-gold);
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.finished-book-card-title {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: 1.25rem;
|
||||
color: #e8d9b5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.finished-book-card-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
|
||||
.finished-book-card-cta {
|
||||
display: block;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--brand-gold);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.finished-books-empty {
|
||||
color: var(--brand-muted);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.finished-series-hero {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.finished-series-hero-img {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.finished-series-title {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: 2rem;
|
||||
color: #e8d9b5;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.finished-series-desc {
|
||||
color: var(--brand-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.finished-series-playlist-heading {
|
||||
font-family: var(--brand-font-heading);
|
||||
font-size: 1.3rem;
|
||||
color: #e8d9b5;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.2);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.finished-series-playlist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.finished-series-episode {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: 1.1rem 1.2rem;
|
||||
background: rgba(26, 26, 21, 0.7);
|
||||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.finished-series-ep-num {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--brand-gold);
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
padding-top: 0.15rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.finished-series-ep-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.finished-series-ep-title {
|
||||
font-weight: 600;
|
||||
color: #d4c49a;
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
|
||||
.finished-series-ep-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--brand-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
color: var(--brand-muted);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--brand-gold);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Admin: End Series panel ── */
|
||||
.btn-danger-outline {
|
||||
background: none;
|
||||
border: 1px solid rgba(200, 80, 60, 0.5);
|
||||
color: #e87070;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: var(--brand-font-body);
|
||||
transition: background 200ms, border-color 200ms;
|
||||
}
|
||||
|
||||
.btn-danger-outline:hover {
|
||||
background: rgba(200, 80, 60, 0.08);
|
||||
border-color: rgba(200, 80, 60, 0.8);
|
||||
}
|
||||
|
||||
.admin-end-series-panel {
|
||||
background: rgba(20, 15, 10, 0.6);
|
||||
border: 1px solid rgba(200, 80, 60, 0.25);
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.header-nav--open {
|
||||
max-height: 660px;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.8rem 0.25rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.12);
|
||||
}
|
||||
|
||||
.nav-dropdown-menu {
|
||||
display: none;
|
||||
position: static;
|
||||
transform: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-dropdown--open .nav-dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
padding: 0.65rem 0.25rem 0.65rem 1.5rem;
|
||||
font-size: 0.88rem;
|
||||
border-bottom: 1px solid rgba(201, 168, 76, 0.08);
|
||||
}
|
||||
|
||||
.finished-series-hero {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.finished-series-hero-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
+142
-1
@@ -354,7 +354,10 @@ function SiteSearchBar({ content }: { content: SiteContent }) {
|
||||
|
||||
function SiteHeader({ content }: { content: SiteContent }) {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [episodesOpen, setEpisodesOpen] = useState(false)
|
||||
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
|
||||
const location = useLocation()
|
||||
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
@@ -372,7 +375,21 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
||||
{menuOpen ? 'Close' : 'Menu'}
|
||||
</button>
|
||||
<nav id="site-nav" className={`header-nav ${menuOpen ? 'header-nav--open' : ''}`}>
|
||||
<NavLink to="/episodes" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Episodes</NavLink>
|
||||
{/* Episodes dropdown */}
|
||||
<div className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`header-nav-link nav-dropdown-trigger${episodesActive ? ' header-nav-link--active' : ''}`}
|
||||
onClick={() => setEpisodesOpen(o => !o)}
|
||||
aria-expanded={episodesOpen}
|
||||
>
|
||||
Episodes <span className="nav-dropdown-arrow" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div className="nav-dropdown-menu">
|
||||
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
|
||||
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
||||
<NavLink to="/study" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Studies</NavLink>
|
||||
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
|
||||
@@ -442,6 +459,8 @@ function SiteFooter({ content }: { content: SiteContent }) {
|
||||
)}
|
||||
</nav>
|
||||
<nav className="footer-links footer-links--legal" aria-label="Legal links">
|
||||
<Link to="/finished">Finished Books</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link to="/privacy">Privacy Policy</Link>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Link to="/terms">Terms</Link>
|
||||
@@ -1378,6 +1397,126 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useEpisodesForBook(season: number) {
|
||||
const [episodes, setEpisodes] = useState<{ title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
fetch('/api/episodes/all')
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then((data: { episodes?: { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[] }) => {
|
||||
const filtered = (data.episodes ?? [])
|
||||
.filter(ep => ep.season === String(season))
|
||||
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
|
||||
setEpisodes(filtered)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [season])
|
||||
return { episodes, loading }
|
||||
}
|
||||
|
||||
function FinishedBooksPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(`Finished Books | ${siteTitle}`, 'Completed series from Verse by Verse with Nate — browse episode playlists for past book studies.')
|
||||
const books = content.finishedBooks ?? []
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<main className="page-inner">
|
||||
<h1 className="page-heading">Finished Books</h1>
|
||||
<p className="page-sub">Completed series — browse the full episode playlist for each book.</p>
|
||||
{books.length === 0 ? (
|
||||
<p className="finished-books-empty">No finished series yet. Check back after the current study wraps up.</p>
|
||||
) : (
|
||||
<div className="finished-books-grid">
|
||||
{books.map(book => (
|
||||
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
|
||||
{book.imageUrl && <img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />}
|
||||
<div className="finished-book-card-body">
|
||||
<p className="finished-book-card-season">Season {book.season}</p>
|
||||
<h2 className="finished-book-card-title">{book.title}</h2>
|
||||
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
|
||||
<span className="finished-book-card-cta">View Playlist →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FinishedSeriesPage({ content }: { content: SiteContent }) {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const book = (content.finishedBooks ?? []).find(b => b.id === id)
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
book ? `${book.title} | ${siteTitle}` : `Finished Series | ${siteTitle}`,
|
||||
book?.description || 'Episode playlist for a completed series.',
|
||||
)
|
||||
const { episodes, loading } = useEpisodesForBook(book?.season ?? 0)
|
||||
|
||||
if (!book) {
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<main className="page-inner">
|
||||
<p>Series not found. <Link to="/finished">← Back to Finished Books</Link></p>
|
||||
</main>
|
||||
<SiteFooter content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
<SiteHeader content={content} />
|
||||
<SiteSearchBar content={content} />
|
||||
<main className="page-inner">
|
||||
<nav className="breadcrumb" aria-label="Breadcrumb">
|
||||
<Link to="/finished">Finished Books</Link>
|
||||
<span aria-hidden="true"> › </span>
|
||||
<span>{book.title}</span>
|
||||
</nav>
|
||||
<div className="finished-series-hero">
|
||||
{book.imageUrl && <img src={book.imageUrl} alt={book.title} className="finished-series-hero-img" />}
|
||||
<div>
|
||||
<p className="finished-book-card-season">Season {book.season}</p>
|
||||
<h1 className="finished-series-title">{book.title}</h1>
|
||||
{book.description && <p className="finished-series-desc">{book.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="finished-series-playlist-heading">Episode Playlist</h2>
|
||||
{loading ? (
|
||||
<p className="finished-books-empty">Loading episodes…</p>
|
||||
) : episodes.length === 0 ? (
|
||||
<p className="finished-books-empty">No episodes found for this season.</p>
|
||||
) : (
|
||||
<ol className="finished-series-playlist">
|
||||
{episodes.map((ep, i) => (
|
||||
<li key={ep.audioUrl || i} className="finished-series-episode">
|
||||
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
|
||||
<div className="finished-series-ep-body">
|
||||
<p className="finished-series-ep-title">{ep.title}</p>
|
||||
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
||||
{ep.audioUrl && (
|
||||
<EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</main>
|
||||
<SiteFooter content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
@@ -2173,6 +2312,8 @@ export default function App() {
|
||||
<Route path="/study/:studySlug/notes" element={<StudyRouteFrame content={content} child={<ColossiansStudyNotesPage content={content} />} />} />
|
||||
<Route path="/study/:studySlug/:sectionId/quiz" element={<StudyRouteFrame content={content} child={<StudyQuizPage content={content} />} />} />
|
||||
<Route path="/study/:studySlug/:sectionId" element={<StudyRouteFrame content={content} child={<ColossiansStudySectionPage content={content} />} />} />
|
||||
<Route path="/finished" element={<FinishedBooksPage content={content} />} />
|
||||
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
|
||||
<Route path="/episodes" element={<EpisodesPage content={content} />} />
|
||||
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
|
||||
<Route path="/resources" element={<ResourcesPage content={content} />} />
|
||||
|
||||
+11
-31
@@ -47,35 +47,6 @@ export interface WhereToNextCard {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesResourceLink {
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
url: string
|
||||
amazonUrl?: string
|
||||
amazonLabel?: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeriesNote {
|
||||
id: string
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ArchivedSeries {
|
||||
id: string
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
listenUrl: string
|
||||
studyGuideTitle: string
|
||||
studyGuideDescription: string
|
||||
studyGuideUrl: string
|
||||
resourceLinks: ArchivedSeriesResourceLink[]
|
||||
notes: ArchivedSeriesNote[]
|
||||
episodeRange?: { from: number; to: number }
|
||||
}
|
||||
|
||||
export interface RedirectRule {
|
||||
id: string
|
||||
@@ -95,6 +66,14 @@ export interface PodcastFeaturedLink {
|
||||
discussionQuestions?: string[]
|
||||
}
|
||||
|
||||
export interface FinishedBook {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
imageUrl: string
|
||||
season: number
|
||||
}
|
||||
|
||||
export interface SeoSettings {
|
||||
title: string
|
||||
description: string
|
||||
@@ -260,13 +239,14 @@ export interface SiteContent {
|
||||
// ── Arrays ──
|
||||
customLinks: CustomLink[]
|
||||
customBlocks: CustomBlock[]
|
||||
archivedSeries: ArchivedSeries[]
|
||||
redirects: RedirectRule[]
|
||||
podcastFeaturedLinks: PodcastFeaturedLink[]
|
||||
seo: SeoSettings
|
||||
legal: LegalSettings
|
||||
// ── Episodes page ──
|
||||
episodesSeoIntro: string
|
||||
// ── Finished Books ──
|
||||
finishedBooks: FinishedBook[]
|
||||
}
|
||||
|
||||
export const DEFAULTS: SiteContent = {
|
||||
@@ -445,7 +425,6 @@ export const DEFAULTS: SiteContent = {
|
||||
cookieBannerText: 'We use optional analytics cookies to better understand how people find and engage with this content. By accepting, you allow us to measure how you interact with the site — no personal data is sold or shared.',
|
||||
customLinks: [],
|
||||
customBlocks: [],
|
||||
archivedSeries: [],
|
||||
redirects: [
|
||||
{
|
||||
id: 'spotify',
|
||||
@@ -467,6 +446,7 @@ export const DEFAULTS: SiteContent = {
|
||||
},
|
||||
],
|
||||
podcastFeaturedLinks: [],
|
||||
finishedBooks: [],
|
||||
episodesSeoIntro: 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.',
|
||||
seo: {
|
||||
title: 'Verse by Verse with Nate',
|
||||
|
||||
Reference in New Issue
Block a user