Add admin podcast checklist UX and persistence updates
This commit is contained in:
@@ -57,6 +57,7 @@ const STUDY_USERS_FILE = path.join(DATA_DIR, 'study-users.json')
|
|||||||
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json')
|
const STUDY_NOTES_FILE = path.join(DATA_DIR, 'study-notes.json')
|
||||||
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
const REPLY_TEMPLATES_FILE = path.join(DATA_DIR, 'admin-reply-templates.json')
|
||||||
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
const REPLY_HISTORY_FILE = path.join(DATA_DIR, 'admin-reply-history.json')
|
||||||
|
const PODCAST_CHECKLIST_FILE = path.join(DATA_DIR, 'podcast-checklist.json')
|
||||||
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
|
||||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||||
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
const UPLOADS_META_FILE = path.join(DATA_DIR, 'uploads-meta.json')
|
||||||
@@ -208,6 +209,122 @@ const DEFAULT_PUBLISH_STATE = {
|
|||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PODCAST_CHECKLIST_TASKS = [
|
||||||
|
{ id: 'verify_script', label: 'Verify Script', phase: 'pre' },
|
||||||
|
{ id: 'read_script', label: 'Read Script', phase: 'pre' },
|
||||||
|
{ id: 'record', label: 'Record', phase: 'pre' },
|
||||||
|
{ id: 'edit', label: 'Edit', phase: 'pre' },
|
||||||
|
{ id: 'mix', label: 'Mix', phase: 'pre' },
|
||||||
|
{ id: 'video_script', label: 'Run Video Conversion Script', phase: 'pre' },
|
||||||
|
{ id: 'post_spotify', label: 'Post on Spotify', phase: 'pre' },
|
||||||
|
{ id: 'update_website', label: 'Update Website', phase: 'post' },
|
||||||
|
{ id: 'send_email', label: 'Send Email', phase: 'post' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildChecklistEpisode(series, number) {
|
||||||
|
const tasks = {}
|
||||||
|
for (const task of DEFAULT_PODCAST_CHECKLIST_TASKS) {
|
||||||
|
tasks[task.id] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${series.toLowerCase()}-${number}`,
|
||||||
|
series,
|
||||||
|
episodeNumber: number,
|
||||||
|
title: '',
|
||||||
|
datePublished: '',
|
||||||
|
expanded: false,
|
||||||
|
tasks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultPodcastChecklist() {
|
||||||
|
const titusEpisodes = [11, 12, 13, 14, 15].map(number => buildChecklistEpisode('Titus', number))
|
||||||
|
const colossiansEpisodes = Array.from({ length: 27 }, (_, index) => buildChecklistEpisode('Colossians', index + 1))
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: DEFAULT_PODCAST_CHECKLIST_TASKS,
|
||||||
|
episodes: [...titusEpisodes, ...colossiansEpisodes],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeChecklistTask(task) {
|
||||||
|
const label = typeof task?.label === 'string' ? task.label.trim().slice(0, 120) : ''
|
||||||
|
if (!label) return null
|
||||||
|
|
||||||
|
const phase = task?.phase === 'post' ? 'post' : 'pre'
|
||||||
|
const id = typeof task?.id === 'string' && task.id.trim() ? task.id.trim() : randomUUID()
|
||||||
|
return { id, label, phase }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePodcastChecklist(value) {
|
||||||
|
const fallback = buildDefaultPodcastChecklist()
|
||||||
|
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||||
|
|
||||||
|
const taskInput = Array.isArray(source.tasks) ? source.tasks : fallback.tasks
|
||||||
|
const seenTaskIds = new Set()
|
||||||
|
const tasks = []
|
||||||
|
|
||||||
|
for (const item of taskInput) {
|
||||||
|
const safeTask = sanitizeChecklistTask(item)
|
||||||
|
if (!safeTask) continue
|
||||||
|
if (seenTaskIds.has(safeTask.id)) continue
|
||||||
|
seenTaskIds.add(safeTask.id)
|
||||||
|
tasks.push(safeTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tasks.length === 0) {
|
||||||
|
for (const task of fallback.tasks) {
|
||||||
|
tasks.push({ ...task })
|
||||||
|
seenTaskIds.add(task.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskIds = tasks.map(task => task.id)
|
||||||
|
const episodesInput = Array.isArray(source.episodes) ? source.episodes : fallback.episodes
|
||||||
|
const episodes = []
|
||||||
|
|
||||||
|
for (const item of episodesInput) {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
|
||||||
|
|
||||||
|
const id = typeof item.id === 'string' && item.id.trim() ? item.id.trim() : randomUUID()
|
||||||
|
const series = typeof item.series === 'string' ? item.series.trim().slice(0, 80) : ''
|
||||||
|
const title = typeof item.title === 'string' ? item.title.trim().slice(0, 180) : ''
|
||||||
|
const rawEpisodeNumber = Number(item.episodeNumber)
|
||||||
|
const episodeNumber = Number.isFinite(rawEpisodeNumber) && rawEpisodeNumber >= 0
|
||||||
|
? Math.round(rawEpisodeNumber)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const datePublished = typeof item.datePublished === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(item.datePublished.trim())
|
||||||
|
? item.datePublished.trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const sourceTasks = item.tasks && typeof item.tasks === 'object' && !Array.isArray(item.tasks)
|
||||||
|
? item.tasks
|
||||||
|
: {}
|
||||||
|
const taskState = {}
|
||||||
|
for (const taskId of taskIds) {
|
||||||
|
taskState[taskId] = sourceTasks[taskId] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes.push({
|
||||||
|
id,
|
||||||
|
series,
|
||||||
|
episodeNumber,
|
||||||
|
title,
|
||||||
|
datePublished,
|
||||||
|
expanded: item.expanded === true,
|
||||||
|
tasks: taskState,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tasks, episodes }
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_REPLY_TEMPLATES = [
|
const DEFAULT_REPLY_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: 'thanks-for-reaching-out',
|
id: 'thanks-for-reaching-out',
|
||||||
@@ -238,6 +355,8 @@ let replyTemplates = [...DEFAULT_REPLY_TEMPLATES]
|
|||||||
let replyTemplatesWritePromise = Promise.resolve()
|
let replyTemplatesWritePromise = Promise.resolve()
|
||||||
let replyHistory = []
|
let replyHistory = []
|
||||||
let replyHistoryWritePromise = Promise.resolve()
|
let replyHistoryWritePromise = Promise.resolve()
|
||||||
|
let podcastChecklist = buildDefaultPodcastChecklist()
|
||||||
|
let podcastChecklistWritePromise = Promise.resolve()
|
||||||
|
|
||||||
async function loadSiteContentFile(filePath) {
|
async function loadSiteContentFile(filePath) {
|
||||||
const raw = await readFile(filePath, 'utf8')
|
const raw = await readFile(filePath, 'utf8')
|
||||||
@@ -620,6 +739,31 @@ function loadReplyHistoryFromDisk() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadPodcastChecklistFromDisk() {
|
||||||
|
return readFile(PODCAST_CHECKLIST_FILE, 'utf8')
|
||||||
|
.then(raw => {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
podcastChecklist = sanitizePodcastChecklist(parsed?.checklist)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
podcastChecklist = buildDefaultPodcastChecklist()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePodcastChecklistWrite() {
|
||||||
|
const updatedAt = new Date().toISOString()
|
||||||
|
podcastChecklistWritePromise = podcastChecklistWritePromise.then(async () => {
|
||||||
|
await mkdir(DATA_DIR, { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
PODCAST_CHECKLIST_FILE,
|
||||||
|
JSON.stringify({ checklist: podcastChecklist, updatedAt }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return podcastChecklistWritePromise
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMessageType(value) {
|
function normalizeMessageType(value) {
|
||||||
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
if (value === 'question' || value === 'testimony' || value === 'topic') return value
|
||||||
return 'general'
|
return 'general'
|
||||||
@@ -1027,6 +1171,7 @@ async function createBackupSnapshot(reason = 'scheduled') {
|
|||||||
reason,
|
reason,
|
||||||
adminContent: null,
|
adminContent: null,
|
||||||
draftContent: null,
|
draftContent: null,
|
||||||
|
podcastChecklist,
|
||||||
publishState,
|
publishState,
|
||||||
hitStats,
|
hitStats,
|
||||||
visitorStats,
|
visitorStats,
|
||||||
@@ -1187,14 +1332,23 @@ async function restoreFromBackup(filename) {
|
|||||||
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
contactSubmissions = sanitizeLoadedContactSubmissions(parsed?.contactSubmissions)
|
||||||
replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
|
replyTemplates = sanitizeReplyTemplates(parsed?.replyTemplates)
|
||||||
replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
|
replyHistory = sanitizeReplyHistory(parsed?.replyHistory)
|
||||||
|
podcastChecklist = sanitizePodcastChecklist(parsed?.podcastChecklist)
|
||||||
|
|
||||||
queueHitStatsWrite()
|
queueHitStatsWrite()
|
||||||
queueVisitorStatsWrite()
|
queueVisitorStatsWrite()
|
||||||
queueContactSubmissionsWrite()
|
queueContactSubmissionsWrite()
|
||||||
queueReplyTemplatesWrite()
|
queueReplyTemplatesWrite()
|
||||||
queueReplyHistoryWrite()
|
queueReplyHistoryWrite()
|
||||||
|
queuePodcastChecklistWrite()
|
||||||
|
|
||||||
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise, contactSubmissionsWritePromise, replyTemplatesWritePromise, replyHistoryWritePromise])
|
await Promise.all([
|
||||||
|
hitStatsWritePromise,
|
||||||
|
visitorStatsWritePromise,
|
||||||
|
contactSubmissionsWritePromise,
|
||||||
|
replyTemplatesWritePromise,
|
||||||
|
replyHistoryWritePromise,
|
||||||
|
podcastChecklistWritePromise,
|
||||||
|
])
|
||||||
await refreshContentCaches()
|
await refreshContentCaches()
|
||||||
await createBackupSnapshot('post-restore')
|
await createBackupSnapshot('post-restore')
|
||||||
}
|
}
|
||||||
@@ -1346,6 +1500,21 @@ app.get('/api/admin-content-state', requireAdminAuth, (_req, res) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/admin-podcast-checklist', requireAdminAuth, (_req, res) => {
|
||||||
|
res.json({ checklist: podcastChecklist })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/admin-podcast-checklist', requireAdminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const safeChecklist = sanitizePodcastChecklist(req.body?.checklist)
|
||||||
|
podcastChecklist = safeChecklist
|
||||||
|
await queuePodcastChecklistWrite()
|
||||||
|
res.json({ ok: true, checklist: safeChecklist })
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ message: 'Failed to save podcast checklist.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/site-config', async (_req, res) => {
|
app.get('/api/site-config', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const parsed = await loadSiteContentFile(DATA_FILE)
|
const parsed = await loadSiteContentFile(DATA_FILE)
|
||||||
@@ -3447,6 +3616,7 @@ Promise.all([
|
|||||||
loadStudyUsersFromDisk(),
|
loadStudyUsersFromDisk(),
|
||||||
loadStudyNotesFromDisk(),
|
loadStudyNotesFromDisk(),
|
||||||
loadDownloadCountsFromDisk(),
|
loadDownloadCountsFromDisk(),
|
||||||
|
loadPodcastChecklistFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|||||||
+435
-1
@@ -156,11 +156,34 @@ interface ContactReplyConfig {
|
|||||||
note: string
|
note: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChecklistPhase = 'pre' | 'post'
|
||||||
|
|
||||||
|
interface PodcastChecklistTask {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
phase: ChecklistPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PodcastChecklistEpisode {
|
||||||
|
id: string
|
||||||
|
series: string
|
||||||
|
episodeNumber: number | null
|
||||||
|
title: string
|
||||||
|
datePublished: string
|
||||||
|
expanded: boolean
|
||||||
|
tasks: Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PodcastChecklistData {
|
||||||
|
tasks: PodcastChecklistTask[]
|
||||||
|
episodes: PodcastChecklistEpisode[]
|
||||||
|
}
|
||||||
|
|
||||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
||||||
|
|
||||||
type AdminView =
|
type AdminView =
|
||||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||||
| 'current-series' | 'episode-highlights' | 'archived-series'
|
| 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||||
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
|
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
|
||||||
| 'emails' | 'subscribers' | 'contacts'
|
| 'emails' | 'subscribers' | 'contacts'
|
||||||
@@ -396,6 +419,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||||
const [contactSearch, setContactSearch] = useState('')
|
const [contactSearch, setContactSearch] = useState('')
|
||||||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||||||
|
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ tasks: [], episodes: [] })
|
||||||
|
const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
|
const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('')
|
||||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||||
const [manualQuestion, setManualQuestion] = useState({
|
const [manualQuestion, setManualQuestion] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -550,6 +576,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
.then(r => (r.ok ? r.json() : Promise.reject()))
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
||||||
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
|
fetch('/api/admin-podcast-checklist')
|
||||||
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load podcast checklist'))))
|
||||||
|
.then(data => {
|
||||||
|
const checklist = (data as { checklist?: PodcastChecklistData }).checklist
|
||||||
|
if (checklist?.tasks && checklist?.episodes) {
|
||||||
|
setPodcastChecklist(checklist)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -869,6 +905,149 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addChecklistTask(phase: ChecklistPhase) {
|
||||||
|
const id = `task-${Date.now().toString(36)}`
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
tasks: [...prev.tasks, { id, label: '', phase }],
|
||||||
|
episodes: prev.episodes.map(episode => ({
|
||||||
|
...episode,
|
||||||
|
tasks: {
|
||||||
|
...episode.tasks,
|
||||||
|
[id]: false,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChecklistTask(id: string, field: 'label' | 'phase', value: string) {
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
...prev,
|
||||||
|
tasks: prev.tasks.map(task => task.id === id
|
||||||
|
? {
|
||||||
|
...task,
|
||||||
|
[field]: field === 'phase' ? (value === 'post' ? 'post' : 'pre') : value,
|
||||||
|
}
|
||||||
|
: task),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChecklistTask(id: string) {
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
tasks: prev.tasks.filter(task => task.id !== id),
|
||||||
|
episodes: prev.episodes.map(episode => {
|
||||||
|
const nextTasks = { ...episode.tasks }
|
||||||
|
delete nextTasks[id]
|
||||||
|
return {
|
||||||
|
...episode,
|
||||||
|
tasks: nextTasks,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChecklistEpisode() {
|
||||||
|
const id = `episode-${Date.now().toString(36)}`
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
...prev,
|
||||||
|
episodes: [
|
||||||
|
...prev.episodes,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
series: 'Colossians',
|
||||||
|
episodeNumber: null,
|
||||||
|
title: '',
|
||||||
|
datePublished: '',
|
||||||
|
expanded: false,
|
||||||
|
tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChecklistEpisode(id: string, field: 'series' | 'episodeNumber' | 'title' | 'datePublished', value: string) {
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
...prev,
|
||||||
|
episodes: prev.episodes.map(episode => {
|
||||||
|
if (episode.id !== id) return episode
|
||||||
|
if (field === 'episodeNumber') {
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
return {
|
||||||
|
...episode,
|
||||||
|
episodeNumber: Number.isNaN(parsed) ? null : parsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...episode,
|
||||||
|
[field]: value,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChecklistEpisodeTask(episodeId: string, taskId: string) {
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
...prev,
|
||||||
|
episodes: prev.episodes.map(episode => {
|
||||||
|
if (episode.id !== episodeId) return episode
|
||||||
|
return {
|
||||||
|
...episode,
|
||||||
|
tasks: {
|
||||||
|
...episode.tasks,
|
||||||
|
[taskId]: !episode.tasks[taskId],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetChecklistEpisode(episodeId: string) {
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
...prev,
|
||||||
|
episodes: prev.episodes.map(episode => {
|
||||||
|
if (episode.id !== episodeId) return episode
|
||||||
|
return {
|
||||||
|
...episode,
|
||||||
|
datePublished: '',
|
||||||
|
tasks: Object.fromEntries(prev.tasks.map(task => [task.id, false])),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChecklistEpisode(id: string) {
|
||||||
|
setPodcastChecklist(prev => ({
|
||||||
|
...prev,
|
||||||
|
episodes: prev.episodes.filter(episode => episode.id !== id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePodcastChecklist() {
|
||||||
|
setPodcastChecklistStatus('saving')
|
||||||
|
setPodcastChecklistMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin-podcast-checklist', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ checklist: podcastChecklist }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({})) as { message?: string }
|
||||||
|
throw new Error(data.message ?? 'Failed to save checklist')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json() as { checklist?: PodcastChecklistData }
|
||||||
|
if (data.checklist?.tasks && data.checklist?.episodes) {
|
||||||
|
setPodcastChecklist(data.checklist)
|
||||||
|
}
|
||||||
|
setPodcastChecklistStatus('saved')
|
||||||
|
setTimeout(() => setPodcastChecklistStatus('idle'), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
setPodcastChecklistMsg(err instanceof Error ? err.message : 'Failed to save checklist')
|
||||||
|
setPodcastChecklistStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -1795,6 +1974,44 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
questionPage * QUESTION_PAGE_SIZE,
|
questionPage * QUESTION_PAGE_SIZE,
|
||||||
(questionPage + 1) * QUESTION_PAGE_SIZE,
|
(questionPage + 1) * QUESTION_PAGE_SIZE,
|
||||||
)
|
)
|
||||||
|
const checklistTasksSorted = [...podcastChecklist.tasks].sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }))
|
||||||
|
const checklistPreTasks = checklistTasksSorted.filter(task => task.phase === 'pre')
|
||||||
|
const checklistPostTasks = checklistTasksSorted.filter(task => task.phase === 'post')
|
||||||
|
const checklistEpisodesSorted = [...podcastChecklist.episodes].sort((a, b) => {
|
||||||
|
const isDraft = (episode: PodcastChecklistEpisode) => {
|
||||||
|
const hasNumber = episode.episodeNumber !== null
|
||||||
|
const hasTitle = Boolean(episode.title?.trim())
|
||||||
|
const hasDate = Boolean(episode.datePublished?.trim())
|
||||||
|
return !hasNumber && !hasTitle && !hasDate
|
||||||
|
}
|
||||||
|
|
||||||
|
const aDraft = isDraft(a)
|
||||||
|
const bDraft = isDraft(b)
|
||||||
|
if (aDraft && !bDraft) return -1
|
||||||
|
if (!aDraft && bDraft) return 1
|
||||||
|
|
||||||
|
const seriesOrder = (series: string) => {
|
||||||
|
const key = series.trim().toLowerCase()
|
||||||
|
if (key === 'titus') return 0
|
||||||
|
if (key === 'colossians') return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const bySeries = seriesOrder(a.series) - seriesOrder(b.series)
|
||||||
|
if (bySeries !== 0) return bySeries
|
||||||
|
|
||||||
|
const nameSort = a.series.localeCompare(b.series, undefined, { sensitivity: 'base' })
|
||||||
|
if (nameSort !== 0) return nameSort
|
||||||
|
|
||||||
|
const aNum = a.episodeNumber
|
||||||
|
const bNum = b.episodeNumber
|
||||||
|
if (aNum === null && bNum === null) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
|
||||||
|
if (aNum === null) return 1
|
||||||
|
if (bNum === null) return -1
|
||||||
|
if (aNum !== bNum) return aNum - bNum
|
||||||
|
|
||||||
|
return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
|
||||||
function renderSaveStatus() {
|
function renderSaveStatus() {
|
||||||
return (
|
return (
|
||||||
@@ -1805,6 +2022,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderPodcastChecklistStatus() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{podcastChecklistStatus === 'saved' && <p className="admin-status admin-status--ok">✓ Checklist saved.</p>}
|
||||||
|
{podcastChecklistStatus === 'error' && <p className="admin-status admin-status--err">✗ {podcastChecklistMsg}</p>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
{/* ── Top bar ── */}
|
{/* ── Top bar ── */}
|
||||||
@@ -1881,6 +2107,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<span className="admin-nav-label">Podcast</span>
|
<span className="admin-nav-label">Podcast</span>
|
||||||
<button type="button" className={`admin-nav-item${adminView === 'current-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('current-series')}>Current Series</button>
|
<button type="button" className={`admin-nav-item${adminView === 'current-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('current-series')}>Current Series</button>
|
||||||
<button type="button" className={`admin-nav-item${adminView === 'episode-highlights' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('episode-highlights')}>Ep. Highlights</button>
|
<button type="button" className={`admin-nav-item${adminView === 'episode-highlights' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('episode-highlights')}>Ep. Highlights</button>
|
||||||
|
<button type="button" className={`admin-nav-item${adminView === 'podcast-checklist' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('podcast-checklist')}>Production Checklist</button>
|
||||||
<button type="button" className={`admin-nav-item${adminView === 'archived-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('archived-series')}>Archived Series</button>
|
<button type="button" className={`admin-nav-item${adminView === 'archived-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('archived-series')}>Archived Series</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2422,6 +2649,213 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PODCAST CHECKLIST */}
|
||||||
|
{adminView === 'podcast-checklist' && (
|
||||||
|
<section className="admin-panel-section" aria-label="Podcast production checklist">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Podcast Production Checklist</h2>
|
||||||
|
<p>Track production progress for each episode. Add as many tasks and episodes as you need, then save.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-content-summary">
|
||||||
|
<article className="admin-summary-card">
|
||||||
|
<h3>Total Episodes</h3>
|
||||||
|
<p>{podcastChecklist.episodes.length}</p>
|
||||||
|
</article>
|
||||||
|
<article className="admin-summary-card">
|
||||||
|
<h3>Pre-Publish Tasks</h3>
|
||||||
|
<p>{checklistPreTasks.length}</p>
|
||||||
|
</article>
|
||||||
|
<article className="admin-summary-card">
|
||||||
|
<h3>Post-Publish Tasks</h3>
|
||||||
|
<p>{checklistPostTasks.length}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details className="admin-collapsible-card admin-collapsible-card--group">
|
||||||
|
<summary className="admin-collapsible-summary">
|
||||||
|
<div>
|
||||||
|
<strong>Task Template</strong>
|
||||||
|
<p>{podcastChecklist.tasks.length} tasks configured</p>
|
||||||
|
</div>
|
||||||
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||||
|
</summary>
|
||||||
|
<div className="admin-collapsible-body">
|
||||||
|
<div className="admin-archive-subsection-actions" style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<button type="button" className="btn-admin-add" onClick={() => addChecklistTask('pre')}>+ Add Pre-Publish Task</button>
|
||||||
|
<button type="button" className="btn-admin-add" onClick={() => addChecklistTask('post')}>+ Add Post-Publish Task</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{podcastChecklist.tasks.length === 0 && (
|
||||||
|
<p className="admin-stats-note">No tasks yet. Add a pre-publish or post-publish task above.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checklistTasksSorted.map(task => (
|
||||||
|
<div key={task.id} className="admin-array-row admin-array-row--nested">
|
||||||
|
<div className="admin-array-fields" style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: '1fr minmax(11rem, 15rem)' }}>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`checklist-task-label-${task.id}`}>Task Name</label>
|
||||||
|
<input
|
||||||
|
id={`checklist-task-label-${task.id}`}
|
||||||
|
type="text"
|
||||||
|
value={task.label}
|
||||||
|
onChange={e => updateChecklistTask(task.id, 'label', e.target.value)}
|
||||||
|
placeholder="e.g. Upload transcript"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`checklist-task-phase-${task.id}`}>Phase</label>
|
||||||
|
<select
|
||||||
|
id={`checklist-task-phase-${task.id}`}
|
||||||
|
value={task.phase}
|
||||||
|
onChange={e => updateChecklistTask(task.id, 'phase', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="pre">Pre-Publish</option>
|
||||||
|
<option value="post">Post-Publish</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={() => removeChecklistTask(task.id)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="admin-archive-subsection">
|
||||||
|
<div className="admin-archive-subsection-head">
|
||||||
|
<h5>Episodes</h5>
|
||||||
|
<div className="admin-archive-subsection-actions">
|
||||||
|
<button type="button" className="btn-admin-add" onClick={addChecklistEpisode}>+ Add Episode</button>
|
||||||
|
<button type="button" className="btn-admin-save" onClick={handleSavePodcastChecklist} disabled={podcastChecklistStatus === 'saving'}>
|
||||||
|
{podcastChecklistStatus === 'saving' ? 'Saving Checklist…' : 'Save Checklist'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{podcastChecklist.episodes.length === 0 && (
|
||||||
|
<p className="admin-stats-note">No episodes yet. Add one above to start tracking progress.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checklistEpisodesSorted.map(episode => {
|
||||||
|
const doneCount = checklistTasksSorted.reduce((count, task) => count + (episode.tasks[task.id] ? 1 : 0), 0)
|
||||||
|
const totalCount = checklistTasksSorted.length
|
||||||
|
const episodeLabelParts = [episode.series?.trim()]
|
||||||
|
if (episode.episodeNumber !== null) {
|
||||||
|
episodeLabelParts.push(String(episode.episodeNumber))
|
||||||
|
}
|
||||||
|
if (episode.title?.trim()) {
|
||||||
|
episodeLabelParts.push(episode.title.trim())
|
||||||
|
}
|
||||||
|
const episodeLabel = episodeLabelParts.filter(Boolean).join(' - ') || 'Untitled'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details key={episode.id} className="admin-collapsible-card">
|
||||||
|
<summary className="admin-collapsible-summary">
|
||||||
|
<div>
|
||||||
|
<strong>{episodeLabel}</strong>
|
||||||
|
<p>{totalCount > 0 ? `${doneCount}/${totalCount} tasks completed` : 'No tasks assigned yet'}</p>
|
||||||
|
</div>
|
||||||
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
||||||
|
</summary>
|
||||||
|
<div className="admin-collapsible-body">
|
||||||
|
<div className="admin-array-row admin-array-row--nested">
|
||||||
|
<div className="admin-array-fields" style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))' }}>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`checklist-series-${episode.id}`}>Series</label>
|
||||||
|
<input
|
||||||
|
id={`checklist-series-${episode.id}`}
|
||||||
|
type="text"
|
||||||
|
value={episode.series}
|
||||||
|
onChange={e => updateChecklistEpisode(episode.id, 'series', e.target.value)}
|
||||||
|
placeholder="Colossians"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`checklist-episode-number-${episode.id}`}>Episode Number</label>
|
||||||
|
<input
|
||||||
|
id={`checklist-episode-number-${episode.id}`}
|
||||||
|
type="number"
|
||||||
|
value={episode.episodeNumber ?? ''}
|
||||||
|
onChange={e => updateChecklistEpisode(episode.id, 'episodeNumber', e.target.value)}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`checklist-title-${episode.id}`}>Title (optional)</label>
|
||||||
|
<input
|
||||||
|
id={`checklist-title-${episode.id}`}
|
||||||
|
type="text"
|
||||||
|
value={episode.title}
|
||||||
|
onChange={e => updateChecklistEpisode(episode.id, 'title', e.target.value)}
|
||||||
|
placeholder="Grace that Trains Us"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`checklist-date-${episode.id}`}>Date Published</label>
|
||||||
|
<input
|
||||||
|
id={`checklist-date-${episode.id}`}
|
||||||
|
type="date"
|
||||||
|
value={episode.datePublished}
|
||||||
|
onChange={e => updateChecklistEpisode(episode.id, 'datePublished', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checklistPreTasks.length > 0 && (
|
||||||
|
<div className="admin-archive-subsection">
|
||||||
|
<div className="admin-archive-subsection-head">
|
||||||
|
<h5>Pre-Publish Tasks</h5>
|
||||||
|
</div>
|
||||||
|
<div className="admin-array-fields">
|
||||||
|
{checklistPreTasks.map(task => (
|
||||||
|
<label key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={episode.tasks[task.id] === true}
|
||||||
|
onChange={() => toggleChecklistEpisodeTask(episode.id, task.id)}
|
||||||
|
/>
|
||||||
|
<span>{task.label || 'Untitled task'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{checklistPostTasks.length > 0 && (
|
||||||
|
<div className="admin-archive-subsection">
|
||||||
|
<div className="admin-archive-subsection-head">
|
||||||
|
<h5>Post-Publish Tasks</h5>
|
||||||
|
</div>
|
||||||
|
<div className="admin-array-fields">
|
||||||
|
{checklistPostTasks.map(task => (
|
||||||
|
<label key={task.id} style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={episode.tasks[task.id] === true}
|
||||||
|
onChange={() => toggleChecklistEpisodeTask(episode.id, task.id)}
|
||||||
|
/>
|
||||||
|
<span>{task.label || 'Untitled task'}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.6rem', marginTop: '0.8rem' }}>
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={() => resetChecklistEpisode(episode.id)}>Reset Progress</button>
|
||||||
|
<button type="button" className="btn-admin-remove" onClick={() => removeChecklistEpisode(episode.id)}>Remove Episode</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderPodcastChecklistStatus()}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ARCHIVED SERIES */}
|
{/* ARCHIVED SERIES */}
|
||||||
{adminView === 'archived-series' && (
|
{adminView === 'archived-series' && (
|
||||||
<section className="admin-panel-section">
|
<section className="admin-panel-section">
|
||||||
|
|||||||
Reference in New Issue
Block a user