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 (
⠿ {section.episodeNumber || '—'} {section.reference || '—'} {section.title || Untitled} onReleaseChange(fromLocalInputValue(e.target.value))} /> {isLive ? 'Live' : 'Draft'}
e.stopPropagation()}>Preview
) } function BsbFetchButton({ study, section, updateStudySection }: { study: StudyProgram; section: ColossiansStudySection; updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void }) { const [busy, setBusy] = useState(false) const [msg, setMsg] = useState('') async function fetch_() { const bookKey = (study.bibleBook?.trim() || study.slug).toLowerCase() const reference = section.reference?.trim() if (!bookKey || !reference) { setMsg('Set the passage reference first.'); return } setBusy(true); setMsg('') try { const res = await fetch(`/api/bible-passage?${new URLSearchParams({ book: bookKey, reference })}`) const data = await res.json() as { text?: string; message?: string } if (!res.ok || !data.text) { setMsg(data.message ?? 'Passage not found.'); return } updateStudySection(study.id, section.id, 'passageText', data.text) setMsg('Passage filled from BSB.') } catch { setMsg('Could not reach the BSB API.') } finally { setBusy(false) } } return ( <> {msg && {msg}} ) } interface StudyUserRecord { id: string username: string displayName: string createdAt: string | null lastLoginAt: string | null lastStudiedDate: string | null currentStreak: number longestStreak: number enrolledStudies: Array<{ slug: string; title: string; completedLessons?: number; totalLessons?: number }> noteCount: number subscribeNewsletter: boolean studyRemindersEnabled: boolean reminderFrequency: 'immediate' | 'weekly' remindersPausedUntil: string | null reengagementSentAt: Record } function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) { const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [expandedId, setExpandedId] = useState(null) const [editDisplayName, setEditDisplayName] = useState>({}) const [newPassword, setNewPassword] = useState>({}) const [msg, setMsg] = useState>({}) const [confirmDeleteId, setConfirmDeleteId] = useState(null) const [reengagementTier, setReengagementTier] = useState>({}) function flashMsg(id: string, text: string) { setMsg(prev => ({ ...prev, [id]: text })) setTimeout(() => setMsg(prev => { const n = { ...prev }; delete n[id]; return n }), 4000) } useEffect(() => { fetch('/api/admin/study-users') .then(r => r.json()) .then((d: { users: StudyUserRecord[] }) => { setUsers(d.users ?? []); setLoading(false) }) .catch(() => setLoading(false)) }, []) async function patchUser(id: string, body: Record) { const res = await fetch(`/api/admin/study-users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) const data = await res.json() if (!res.ok) throw new Error(data.message ?? 'Error') return data } async function handleSaveDisplayName(user: StudyUserRecord) { try { const name = editDisplayName[user.id] ?? user.displayName await patchUser(user.id, { displayName: name }) setUsers(prev => prev.map(u => u.id === user.id ? { ...u, displayName: name } : u)) flashMsg(user.id, 'Display name updated.') } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } async function handleResetPassword(user: StudyUserRecord) { const pw = newPassword[user.id] ?? '' if (pw.length < 8) { flashMsg(user.id, 'Password must be at least 8 characters.'); return } try { await patchUser(user.id, { newPassword: pw }) setNewPassword(prev => { const n = { ...prev }; delete n[user.id]; return n }) flashMsg(user.id, 'Password reset. Their active sessions have been signed out.') } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } async function handleEnroll(user: StudyUserRecord, slug: string) { try { const data = await patchUser(user.id, { addEnrollment: slug }) setUsers(prev => prev.map(u => { if (u.id !== user.id) return u const study = studies.find(s => s.slug === slug) const already = u.enrolledStudies.find(e => e.slug === slug) return { ...u, enrolledStudies: already ? u.enrolledStudies : [...u.enrolledStudies, { slug, title: study?.title ?? slug }] } })) flashMsg(user.id, `Enrolled in ${slug}.`) return data } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } async function handleUnenroll(user: StudyUserRecord, slug: string) { try { await patchUser(user.id, { removeEnrollment: slug }) setUsers(prev => prev.map(u => u.id === user.id ? { ...u, enrolledStudies: u.enrolledStudies.filter(e => e.slug !== slug) } : u)) flashMsg(user.id, `Removed from ${slug}.`) } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } async function handleSendReengagement(user: StudyUserRecord) { const tier = reengagementTier[user.id] ?? 't1' try { const res = await fetch(`/api/admin/study-users/${user.id}/send-reengagement`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tier }), }) const data = await res.json() if (!res.ok) throw new Error(data.message ?? 'Error') flashMsg(user.id, `Tier ${tier.slice(1)} re-engagement email sent.`) } catch (e) { flashMsg(user.id, e instanceof Error ? e.message : 'Error') } } async function handleDelete(id: string) { try { const res = await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' }) if (!res.ok) { flashMsg(id, 'Failed to delete user.'); return } setUsers(prev => prev.filter(u => u.id !== id)) setConfirmDeleteId(null) setExpandedId(null) } catch { flashMsg(id, 'Failed to delete user.') } } const filtered = users.filter(u => u.username.toLowerCase().includes(search.toLowerCase()) || u.displayName.toLowerCase().includes(search.toLowerCase()) ) if (loading) return

Loading study users…

return (

Study Users

Manage student accounts — reset passwords, update display names, adjust enrollments, or delete accounts.

setSearch(e.target.value)} />
{filtered.length === 0 &&

{users.length === 0 ? 'No study users yet.' : 'No users match your search.'}

}
{filtered.map(user => { const isExpanded = expandedId === user.id const unenrolledStudies = studies.filter(s => !user.enrolledStudies.find(e => e.slug === s.slug)) return (
{isExpanded && (
Joined: {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'} Last login: {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : '—'} Last studied: {user.lastStudiedDate ?? '—'} Streak: {user.currentStreak ?? 0} 🔥 (best: {user.longestStreak ?? 0}) Newsletter: {user.subscribeNewsletter ? 'Yes' : 'No'} Reminders: {user.studyRemindersEnabled ? 'On' : 'Off'} · {user.reminderFrequency === 'weekly' ? 'Weekly digest' : 'Per-lesson'} {user.remindersPausedUntil && new Date(user.remindersPausedUntil) > new Date() && ( Paused until {new Date(user.remindersPausedUntil).toLocaleDateString()} )} {user.reengagementSentAt && Object.keys(user.reengagementSentAt).length > 0 && ( Re-engagement sent: {Object.keys(user.reengagementSentAt).sort().join(', ')} )}
{/* Display Name */}

Display Name

setEditDisplayName(prev => ({ ...prev, [user.id]: e.target.value }))} placeholder="Display name" maxLength={80} />
{/* Password Reset */}

Reset Password

setNewPassword(prev => ({ ...prev, [user.id]: e.target.value }))} placeholder="New password (min 8 chars)" autoComplete="off" />

This immediately signs them out of all active sessions.

{/* Enrollments */}

Enrollments

{user.enrolledStudies.length === 0 ?

Not enrolled in any studies.

: (
    {user.enrolledStudies.map(e => (
  • {e.title} {e.totalLessons != null && ( {e.completedLessons ?? 0}/{e.totalLessons} lessons )}
  • ))}
) } {unenrolledStudies.length > 0 && (
)}
{/* Re-engagement */} {user.enrolledStudies.length > 0 && (

Send Re-engagement Email

Sends the selected tier email immediately, regardless of inactivity threshold.

)} {msg[user.id] &&

{msg[user.id]}

} {/* Delete */}
{confirmDeleteId === user.id ? (
Delete this account permanently?
) : ( )}
)}
) })}
) } // Module-level (not nested in EmailTemplatesPanel) so its identity is stable across // renders — otherwise every keystroke remounts the card and the focused input loses focus. function EmailTemplateCard({ type, icon, title, trigger, autoVars, fieldKeys, openKey, form, handleChange, renderImageAssetSelector, renderImagePreview, expandedEmail, setExpandedEmail, }: { form: SiteContent handleChange: (key: StringField, value: string) => void renderImageAssetSelector: (value: string | null | undefined, onChange: (value: string) => void, fieldId: string) => React.ReactNode renderImagePreview: (value: string | null | undefined, alt: string) => React.ReactNode expandedEmail: string | null setExpandedEmail: (key: string | null) => void type: 'newsletter' | 'study' | 'transactional' icon: string title: string trigger: string autoVars?: string fieldKeys: string[] openKey: string }) { const isOpen = expandedEmail === openKey return (
{isOpen && (
{autoVars && (

Auto-inserted: {autoVars}

)} {fieldKeys.map(key => { const field = FIELDS.find(f => f.key === key && f.section === 'email-templates') if (!field) return null return (
{field.multiline ?