import DOMPurify from 'dompurify' import React, { useEffect, useRef, useState } from 'react' import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { Link } from 'react-router-dom' import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost, SecondSeries, StudyGuide } from './content' import { DEFAULTS } from './content' import { AnalyticsPanel } from './components/AnalyticsPanel' import { AdminCollapsibleCard } from './components/AdminCollapsibleCard' import { AdminDashboardExtras } from './components/AdminDashboardExtras' import { useAutosave } from './hooks/useAutosave' import QRCode from 'qrcode' import InboxPanel, { type InboxSubmission } from './InboxPanel' import AssetManager, { type AdminAsset } from './AssetManager' type HighlightLane = 'primary' | 'second' | 'general' function highlightLaneOf(link: PodcastFeaturedLink): HighlightLane { return link.lane === 'second' || link.lane === 'general' ? link.lane : 'primary' } // Splits highlights into the ones belonging to a series (moved onto its Finished Book) and the rest (kept). function splitHighlightsForLane(links: PodcastFeaturedLink[] | undefined, lane: HighlightLane) { const all = links ?? [] const ending = all.filter(link => highlightLaneOf(link) === lane).map(link => { const copy = { ...link }; delete copy.lane; return copy }) const kept = all.filter(link => highlightLaneOf(link) !== lane) return { ending, kept } } function toLocalInputValue(iso?: string): string { if (!iso) return '' const d = new Date(iso) if (isNaN(d.getTime())) return '' const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` } function fromLocalInputValue(val: string): string { return val ? new Date(val).toISOString() : '' } interface SortableLessonRowProps { section: ColossiansStudySection study: StudyProgram onEdit: () => void removeStudySection: (studyId: string, sectionId: string) => void onReleaseChange: (value: string) => void } function SortableLessonRow({ section, study, onEdit, removeStudySection, onReleaseChange }: SortableLessonRowProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } const isLive = section.releasedAt ? new Date(section.releasedAt) <= new Date() : false return (
Loading study users…
return (Manage student accounts — reset passwords, update display names, adjust enrollments, or delete accounts.
{users.length === 0 ? 'No study users yet.' : 'No users match your search.'}
}This immediately signs them out of all active sessions.
Not enrolled in any studies.
: (Sends the selected tier email immediately, regardless of inactivity threshold.
{msg[user.id]}
} {/* Delete */}Auto-inserted: {autoVars}
)} {fieldKeys.map(key => { const field = FIELDS.find(f => f.key === key && f.section === 'email-templates') if (!field) return null return (Every automated email the site sends — edit subject lines, body copy, and sign-offs. Publish to apply changes.
Manage host login accounts for the upload portal at{' '} /host. Each host gets a username and password to upload episode files to Seafile.
Make sure SEAFILE_URL, SEAFILE_TOKEN, and SEAFILE_NUGGETS_REPO_ID are set in your environment.
{error}
} {loading &&Loading…
} {!loading && hosts.length > 0 && (| Name | Email (login) | Status | |
|---|---|---|---|
| {h.name} | {h.email} | {h.active ? 'Active' : 'Disabled'} |
No upload hosts yet. Add one below.
} {resetId && ( )} {resetEmailSent && !resetId &&✓ New password emailed to host.
}This text appears as a banner at the top of the host upload portal. Leave blank to hide.
Loading…
: ( )}One guideline per line. These appear in the collapsible "Recording Guidelines" panel on the host upload portal. Max 20 lines.
Loading…
: ( )}All episodes submitted through the host portal, newest first.
Loading…
: ( <> {entries.length > 0 && ({entries.length === 0 ? 'No uploads yet.' : 'No results match that filter.'}
) : (| Date | Host | Episode | Passage | File |
|---|---|---|---|---|
| {new Date(e.uploadedAt).toLocaleDateString()} | {e.hostName} | {e.episodeName} | {e.passage} | {e.filename} |
Walk each portal upload through: listen → cold open → record cold open → edit → Spotify.
{error}
} {loading ?Loading…
: ({items.length === 0 ? 'No uploads yet.' : 'All caught up.'}
} {visible.map(i => { const done = WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length return ( ) })}Select an upload to work on.
: ( <>
{selected.entry.passage} · uploaded by {selected.entry.hostName} · {selected.entry.filename}
{selected.entry.description}
}Saving…
} > )}Every episode for each series: what’s still in progress, and what’s already out (dates come from your Spotify feed).
Couldn’t reach the podcast feed, so released episodes may be missing right now.
} {error &&{error}
}Episodes from other seasons (finished or not assigned to a lane).
)} {loading ?Loading…
: (Nothing in progress.
} {upcoming.map(row)}Select an episode to work on.
: ( <>Released {selected.releaseDate} {selected.spotifyUrl && <> · Listen>}
) : (This moves to Released on its own once season {selected.season} episode {selected.episodeNumber} shows up in your Spotify feed.
)}✓ Changes saved.
} {status === 'error' &&✗ {errorMsg}
} > ) } return (