3807 lines
197 KiB
TypeScript
3807 lines
197 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
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 { DEFAULTS } from './content'
|
|
import { AnalyticsPanel } from './components/AnalyticsPanel'
|
|
|
|
interface Props {
|
|
content: SiteContent
|
|
onSave: (c: SiteContent) => void
|
|
onLogout: () => void | Promise<void>
|
|
}
|
|
|
|
interface BackupPreview {
|
|
filename: string
|
|
sizeBytes: number
|
|
createdAt: string | null
|
|
reason: string
|
|
adminUpdatedAt: string | null
|
|
totalHits: number
|
|
totalVisits: number
|
|
}
|
|
|
|
export interface AdminStats {
|
|
totalHits: number
|
|
realHits: number
|
|
botHits: number
|
|
firstHitAt: string | null
|
|
lastHitAt: string | null
|
|
topPaths: Array<{ path: string; hits: number }>
|
|
topPathsReal: Array<{ path: string; hits: number }>
|
|
topPathsBot: Array<{ path: string; hits: number }>
|
|
last7Days: Array<{ day: string; hits: number }>
|
|
last7DaysReal: Array<{ day: string; hits: number }>
|
|
last7DaysBot: Array<{ day: string; hits: number }>
|
|
last30DaysTotal: number
|
|
last30DaysRealTotal: number
|
|
last30DaysBotTotal: number
|
|
botReasons: Array<{ reason: string; count: number }>
|
|
visitors: {
|
|
totalVisits: number
|
|
uniqueVisitors: number
|
|
returningVisits: number
|
|
firstVisitAt: string | null
|
|
lastVisitAt: string | null
|
|
topCountries: Array<{ name: string; hits: number }>
|
|
topStates: Array<{ name: string; hits: number }>
|
|
topCounties: Array<{ name: string; hits: number }>
|
|
topCities: Array<{ name: string; hits: number }>
|
|
recentVisits: Array<{
|
|
at: string
|
|
visitorId: string
|
|
ip: string
|
|
path: string
|
|
country: string
|
|
state: string
|
|
county: string
|
|
city: string
|
|
returningVisitor: boolean
|
|
visitCount: number
|
|
}>
|
|
}
|
|
writeStatus: {
|
|
hitStats: { ok: boolean; at: string | null; error: string | null }
|
|
visitorStats: { ok: boolean; at: string | null; error: string | null }
|
|
backups: { ok: boolean; at: string | null; error: string | null; file: string | null }
|
|
}
|
|
contactTotals: {
|
|
totalSubmissions: number
|
|
totalQuestions: number
|
|
}
|
|
}
|
|
|
|
interface AdminAsset {
|
|
filename: string
|
|
url: string
|
|
sizeBytes: number
|
|
updatedAt: string
|
|
tags?: string[]
|
|
}
|
|
|
|
interface PublishState {
|
|
draftUpdatedAt: string | null
|
|
publishedAt: string | null
|
|
}
|
|
|
|
interface OpsStatus {
|
|
buildCommit: string | null
|
|
buildNumber: string | null
|
|
deployedAt: string | null
|
|
cachePurge: { ok: boolean; at: string | null; error: string | null }
|
|
deployHook: { ok: boolean; at: string | null; error: string | null }
|
|
}
|
|
|
|
interface Question {
|
|
id: string
|
|
submittedAt: string
|
|
firstName: string
|
|
email: string
|
|
question: string
|
|
answer: string
|
|
answeredAt: string | null
|
|
isApproved: boolean
|
|
approvedAt: string | null
|
|
}
|
|
|
|
interface ContactSubmission {
|
|
id: string
|
|
submittedAt: string
|
|
name: string
|
|
email: string
|
|
message: string
|
|
messageType: 'question' | 'testimony' | 'topic' | 'general'
|
|
subscribe: boolean
|
|
archived?: boolean
|
|
}
|
|
|
|
interface ContactReplyDraft {
|
|
submissionId: string
|
|
recipientName: string
|
|
recipientEmail: string
|
|
subject: string
|
|
message: string
|
|
}
|
|
|
|
interface ContactReplyTemplate {
|
|
id: string
|
|
label: string
|
|
subject: string
|
|
message: string
|
|
}
|
|
|
|
interface ContactReplyHistoryItem {
|
|
id: string
|
|
submissionId: string
|
|
toEmail: string
|
|
toName: string
|
|
fromEmail: string
|
|
subject: string
|
|
preview: string
|
|
sentAt: string
|
|
}
|
|
|
|
interface Subscriber {
|
|
name: string
|
|
email: string
|
|
subscribedAt: string
|
|
source: 'contact-form' | 'download'
|
|
}
|
|
|
|
interface ContactReplyConfig {
|
|
fromEmail: string
|
|
fromIdentity: string
|
|
resendApiConfigured: boolean
|
|
canSendReplies: boolean
|
|
note: string
|
|
}
|
|
|
|
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards' | 'colossiansStudySections' | 'studies'>
|
|
|
|
type AdminView =
|
|
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
|
| 'current-series' | 'episode-highlights' | 'archived-series'
|
|
| 'downloads' | 'custom-links' | 'content-blocks'
|
|
| 'questions' | 'analytics' | 'assets' | 'colossians-study'
|
|
| 'emails' | 'subscribers' | 'contacts'
|
|
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
|
|
|
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global'
|
|
|
|
const BRAND_KIT_SWATCHES = [
|
|
{ name: 'Rich Black', hex: '#0a0a08', role: 'Primary background' },
|
|
{ name: 'Soft Black', hex: '#0f0f0c', role: 'Secondary surfaces' },
|
|
{ name: 'Panel Black', hex: '#1a1a15', role: 'Cards and panels' },
|
|
{ name: 'Deep Brown', hex: '#2a2518', role: 'Borders and dividers' },
|
|
{ name: 'Antique Gold', hex: '#c9a84c', role: 'Primary accent' },
|
|
{ name: 'Light Gold', hex: '#e0c070', role: 'Hover and highlight' },
|
|
{ name: 'Warm White', hex: '#f0ead8', role: 'Primary text' },
|
|
{ name: 'Warm Gray', hex: '#7a7060', role: 'Secondary text' },
|
|
] as const
|
|
|
|
const BRAND_KIT_TYPE = [
|
|
{
|
|
label: 'Brand Font',
|
|
spec: 'Cormorant Garamond · 300/400/600/700 · italic supported',
|
|
sample: 'Verse by Verse with Nate',
|
|
},
|
|
{
|
|
label: 'Subtitle / Small Caps',
|
|
spec: 'Uppercase with tracking for labels, nav, and support text',
|
|
sample: 'A Journey Through Scripture',
|
|
},
|
|
{
|
|
label: 'Body / Quote',
|
|
spec: 'Cormorant Garamond light/italic for long-form text and pull copy',
|
|
sample: 'Verse by verse. Nugget by nugget.',
|
|
},
|
|
] as const
|
|
|
|
const BRAND_KIT_VERIFICATION = [
|
|
'Global font import updated to Cormorant Garamond.',
|
|
'Shared brand variables now define black, gold, border, warm white, and muted text colors.',
|
|
'Live site CSS now references the brand variables for core text and accent styling.',
|
|
'Admin surfaces inherit the same typography and palette tokens used by the public site.',
|
|
] as const
|
|
|
|
const FIELDS: Array<{ key: StringField; label: string; multiline?: boolean; section: MainContentSection }> = [
|
|
{ key: 'eyebrow', label: 'Hero Eyebrow Text', section: 'hero' },
|
|
{ key: 'heroTagline', label: 'Hero Tagline', section: 'hero' },
|
|
{ key: 'heroBtnSpotify', label: 'Hero — Spotify Button Label', section: 'hero' },
|
|
{ key: 'heroBtnEpisodes', label: 'Hero — Episodes Button Label', section: 'hero' },
|
|
{ key: 'heroBtnStartHere', label: 'Hero — Start Here Button Label', section: 'hero' },
|
|
{ key: 'startHereHeading', label: 'Start Here — Heading', section: 'start-here' },
|
|
{ key: 'startHereIntro', label: 'Start Here — Intro', multiline: true, section: 'start-here' },
|
|
{ key: 'startHereStep1Title', label: 'Start Here — Step 1 Title', section: 'start-here' },
|
|
{ key: 'startHereStep1Body', label: 'Start Here — Step 1 Body', multiline: true, section: 'start-here' },
|
|
{ key: 'startHereStep1Cta', label: 'Start Here — Step 1 Button Text', section: 'start-here' },
|
|
{ key: 'startHereStep2Title', label: 'Start Here — Step 2 Title', section: 'start-here' },
|
|
{ key: 'startHereStep2Body', label: 'Start Here — Step 2 Body', multiline: true, section: 'start-here' },
|
|
{ key: 'startHereStep2Cta', label: 'Start Here — Step 2 Button Text', section: 'start-here' },
|
|
{ key: 'startHereStep3Title', label: 'Start Here — Step 3 Title', section: 'start-here' },
|
|
{ key: 'startHereStep3Body', label: 'Start Here — Step 3 Body', multiline: true, section: 'start-here' },
|
|
{ key: 'startHereStep3Cta', label: 'Start Here — Step 3 Button Text', section: 'start-here' },
|
|
{ key: 'aboutShowHeading', label: 'About Show — Heading', section: 'about' },
|
|
{ key: 'aboutShowP1', label: 'About Show — Paragraph 1', multiline: true, section: 'about' },
|
|
{ key: 'aboutShowP2', label: 'About Show — Paragraph 2', multiline: true, section: 'about' },
|
|
{ key: 'aboutNate', label: 'About Nate', multiline: true, section: 'about' },
|
|
{ key: 'aboutPhotoUrl', label: 'About — Nate Portrait Image URL', section: 'about' },
|
|
{ key: 'aboutVerseArtUrl', label: 'About — Scripture Artwork Image URL', section: 'about' },
|
|
{ key: 'aboutEyebrow', label: 'About — "About Nate" Eyebrow', section: 'about' },
|
|
{ key: 'aboutListenBtnLabel', label: 'About — Listen Button Label', section: 'about' },
|
|
{ key: 'aboutShowEyebrow', label: 'About — "About the Show" Eyebrow', section: 'about' },
|
|
{ key: 'contactPhotoUrl', label: 'Contact — Profile Photo URL', section: 'contact' },
|
|
{ key: 'contactEyebrow', label: 'Contact — Section Eyebrow', section: 'contact' },
|
|
{ key: 'contactHeading', label: 'Contact — Heading', section: 'contact' },
|
|
{ key: 'contactName', label: 'Contact — Profile Name', section: 'contact' },
|
|
{ key: 'contactRole', label: 'Contact — Profile Role', section: 'contact' },
|
|
{ key: 'contactQuote', label: 'Contact — Quote', multiline: true, section: 'contact' },
|
|
{ key: 'contactIntro', label: 'Contact — Intro Text', multiline: true, section: 'contact' },
|
|
{ key: 'contactPoint1', label: 'Contact — Point 1', section: 'contact' },
|
|
{ key: 'contactPoint2', label: 'Contact — Point 2', section: 'contact' },
|
|
{ key: 'contactVerse', label: 'Contact — Scripture Text', section: 'contact' },
|
|
{ key: 'contactVerseRef', label: 'Contact — Scripture Reference', section: 'contact' },
|
|
{ key: 'seriesLabel', label: 'Series Label (e.g. "Now Playing")', section: 'series' },
|
|
{ key: 'seriesTitle', label: 'Series Title', section: 'series' },
|
|
{ key: 'seriesDescription', label: 'Series Description', multiline: true, section: 'series' },
|
|
{ key: 'seriesImageUrl', label: 'Series Cover Image URL', section: 'series' },
|
|
{ key: 'seriesListenUrl', label: 'Series Listen URL', section: 'series' },
|
|
{ key: 'seriesListenBtnLabel', label: 'Series — Listen Button Label', section: 'series' },
|
|
{ key: 'seriesDownloadsBtnLabel', label: 'Series — Downloads Button Label', section: 'series' },
|
|
{ key: 'studyGuideTitle', label: 'Study Guide Title', section: 'series' },
|
|
{ key: 'studyGuideDescription', label: 'Study Guide Description', multiline: true, section: 'series' },
|
|
{ key: 'studyGuideUrl', label: 'Study Guide URL (Amazon link)', section: 'series' },
|
|
{ key: 'shareHeading', label: 'Share Section — Heading', section: 'share' },
|
|
{ key: 'shareP', label: 'Share Section — Paragraph', multiline: true, section: 'share' },
|
|
// Global / Footer / Platform
|
|
{ key: 'footerTitle', label: 'Footer — Brand Title', section: 'global' },
|
|
{ key: 'footerSubtitle', label: 'Footer — Subtitle', section: 'global' },
|
|
{ key: 'footerEmail', label: 'Footer — Contact Email', section: 'global' },
|
|
{ key: 'footerCopyright', label: 'Footer — Copyright Line', section: 'global' },
|
|
{ key: 'footerPrivacyNote', label: 'Footer — Privacy Note', multiline: true, section: 'global' },
|
|
{ key: 'headerFollowLabel', label: 'Header — Follow Button Label', section: 'global' },
|
|
{ key: 'cookieBannerText', label: 'Analytics Cookie Banner Text', multiline: true, section: 'global' },
|
|
{ key: 'platformSpotifyUrl', label: 'Platform — Spotify URL', section: 'global' },
|
|
{ key: 'platformAppleUrl', label: 'Platform — Apple Podcasts URL', section: 'global' },
|
|
{ key: 'platformYoutubeUrl', label: 'Platform — YouTube URL', section: 'global' },
|
|
{ key: 'platformAmazonUrl', label: 'Platform — Amazon Music URL', section: 'global' },
|
|
{ key: 'platformFacebookUrl', label: 'Platform — Facebook URL', section: 'global' },
|
|
{ key: 'platformCreatorUrl', label: 'Platform — Creator Profile URL', section: 'global' },
|
|
{ key: 'welcomeEmailSubject', label: 'Welcome Email — Subject', section: 'global' },
|
|
{ key: 'welcomeEmailGreetingPrefix', label: 'Welcome Email — Greeting Prefix', section: 'global' },
|
|
{ key: 'welcomeEmailIntro', label: 'Welcome Email — Intro Paragraph', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailCurrentSeries', label: 'Welcome Email — Current Series Paragraph', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailStartHereTitle', label: 'Welcome Email — Start Here Title', section: 'global' },
|
|
{ key: 'welcomeEmailStartHereSummary', label: 'Welcome Email — Start Here Summary', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailStartHereUrl', label: 'Welcome Email — Start Here URL', section: 'global' },
|
|
{ key: 'welcomeEmailSpotifyUrl', label: 'Welcome Email — Spotify URL', section: 'global' },
|
|
{ key: 'welcomeEmailAppleUrl', label: 'Welcome Email — Apple URL', section: 'global' },
|
|
{ key: 'welcomeEmailAmazonUrl', label: 'Welcome Email — Amazon URL', section: 'global' },
|
|
{ key: 'welcomeEmailWebsiteUrl', label: 'Welcome Email — Website URL', section: 'global' },
|
|
{ key: 'welcomeEmailImageUrl', label: 'Welcome Email — Image URL', section: 'global' },
|
|
{ key: 'welcomeEmailWhatToExpect1', label: 'Welcome Email — What to Expect 1', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailWhatToExpect2', label: 'Welcome Email — What to Expect 2', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailWhatToExpect3', label: 'Welcome Email — What to Expect 3', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailScripture', label: 'Welcome Email — Scripture Text', multiline: true, section: 'global' },
|
|
{ key: 'welcomeEmailScriptureRef', label: 'Welcome Email — Scripture Reference', section: 'global' },
|
|
{ key: 'welcomeEmailSignoff', label: 'Welcome Email — Signoff HTML', multiline: true, section: 'global' },
|
|
]
|
|
|
|
function normalizeStudies(siteContent: SiteContent): StudyProgram[] {
|
|
if (Array.isArray(siteContent.studies) && siteContent.studies.length > 0) {
|
|
return siteContent.studies.map(study => ({
|
|
...study,
|
|
homepageEyebrow: typeof study.homepageEyebrow === 'string' ? study.homepageEyebrow : '',
|
|
showOnHomepage: study.showOnHomepage === true,
|
|
showNewTag: study.showNewTag === true,
|
|
newTagLabel: typeof study.newTagLabel === 'string' ? study.newTagLabel : 'NEW',
|
|
numberOfChapters: Number.isInteger(study.numberOfChapters) && study.numberOfChapters >= 1 && study.numberOfChapters <= 999 ? study.numberOfChapters : 1,
|
|
}))
|
|
}
|
|
|
|
const fallbackSections = siteContent.colossiansStudySections?.length
|
|
? siteContent.colossiansStudySections
|
|
: DEFAULTS.colossiansStudySections
|
|
|
|
return [
|
|
{
|
|
id: 'study-colossians',
|
|
slug: 'colossians',
|
|
title: 'Colossians: Rooted in Christ',
|
|
description: 'Walk through Colossians in guided lessons with commentary, Greek notes, and discussion prompts.',
|
|
homepageEyebrow: 'New Study',
|
|
showOnHomepage: true,
|
|
showNewTag: true,
|
|
newTagLabel: 'NEW',
|
|
status: 'active',
|
|
difficulty: 'intermediate',
|
|
estimatedHours: 12,
|
|
completionBadge: 'Colossians Completion',
|
|
numberOfChapters: 4,
|
|
sections: fallbackSections,
|
|
},
|
|
]
|
|
}
|
|
|
|
function normalizeSiteContentForAdmin(siteContent: SiteContent): SiteContent {
|
|
const studies = normalizeStudies(siteContent)
|
|
const colossians = studies.find(study => study.slug === 'colossians')
|
|
|
|
return {
|
|
...siteContent,
|
|
studies,
|
|
colossiansStudySections: colossians?.sections?.length
|
|
? colossians.sections
|
|
: (siteContent.colossiansStudySections?.length ? siteContent.colossiansStudySections : DEFAULTS.colossiansStudySections),
|
|
}
|
|
}
|
|
|
|
function buildSiteContentForSave(siteContent: SiteContent): SiteContent {
|
|
const normalized = normalizeSiteContentForAdmin(siteContent)
|
|
const colossians = normalized.studies.find(study => study.slug === 'colossians')
|
|
return {
|
|
...normalized,
|
|
colossiansStudySections: colossians?.sections ?? normalized.colossiansStudySections,
|
|
}
|
|
}
|
|
|
|
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|
const normalizedContent = normalizeSiteContentForAdmin(content)
|
|
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
|
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(normalizedContent))
|
|
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
|
const [errorMsg, setErrorMsg] = useState('')
|
|
const [adminView, setAdminView] = useState<AdminView>('dashboard')
|
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
|
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
const [maintenanceMsg, setMaintenanceMsg] = useState('')
|
|
const [opsMsg, setOpsMsg] = useState('')
|
|
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
|
|
const [selectedBackup, setSelectedBackup] = useState('')
|
|
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
|
|
|
|
// TOTP management state
|
|
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null)
|
|
const [totpSetupQr, setTotpSetupQr] = useState<string | null>(null)
|
|
const [totpSetupSecret, setTotpSetupSecret] = useState<string | null>(null)
|
|
const [totpConfirmCode, setTotpConfirmCode] = useState('')
|
|
const [totpMsg, setTotpMsg] = useState('')
|
|
const [totpRecoveryCodes, setTotpRecoveryCodes] = useState<string[] | null>(null)
|
|
const [publishState, setPublishState] = useState<PublishState>({ draftUpdatedAt: null, publishedAt: null })
|
|
const [assets, setAssets] = useState<AdminAsset[]>([])
|
|
const [assetTagEdits, setAssetTagEdits] = useState<Record<string, string>>({})
|
|
const [opsStatus, setOpsStatus] = useState<OpsStatus | null>(null)
|
|
const [assetUploadPending, setAssetUploadPending] = useState(false)
|
|
|
|
const [questions, setQuestions] = useState<Question[]>([])
|
|
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
|
|
const [contactStatus, setContactStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
const [contactReplyDraft, setContactReplyDraft] = useState<ContactReplyDraft | null>(null)
|
|
const [contactReplyStatus, setContactReplyStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
|
const [contactReplyMsg, setContactReplyMsg] = useState('')
|
|
const [contactReplyTemplates, setContactReplyTemplates] = useState<ContactReplyTemplate[]>([])
|
|
const [contactReplyHistory, setContactReplyHistory] = useState<ContactReplyHistoryItem[]>([])
|
|
const [contactReplyConfig, setContactReplyConfig] = useState<ContactReplyConfig | null>(null)
|
|
const [contactTemplateStatus, setContactTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
|
const [emailMailboxView, setEmailMailboxView] = useState<'inbox' | 'archived'>('inbox')
|
|
const [selectedEmailId, setSelectedEmailId] = useState<string | null>(null)
|
|
const [answeredQuestions, setAnsweredQuestions] = useState<{ [key: string]: string }>({})
|
|
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null)
|
|
const [questionSearch, setQuestionSearch] = useState('')
|
|
const [questionFilter, setQuestionFilter] = useState<'all' | 'pending' | 'approved' | 'answered' | 'unanswered'>('all')
|
|
const [questionPage, setQuestionPage] = useState(0)
|
|
const [selectedQuestionIds, setSelectedQuestionIds] = useState<Set<string>>(new Set())
|
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
|
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
|
const [subscriberSearch, setSubscriberSearch] = useState('')
|
|
const [contactSearch, setContactSearch] = useState('')
|
|
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
|
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|
const [manualQuestion, setManualQuestion] = useState({
|
|
firstName: '',
|
|
email: '',
|
|
question: '',
|
|
answer: '',
|
|
approve: false,
|
|
})
|
|
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 isDirty = JSON.stringify(form) !== lastSavedSnapshot
|
|
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
|
|
const unansweredCount = questions.filter(q => !q.answer?.trim()).length
|
|
|
|
// Broadcast live form state + active view into the preview iframe whenever they change
|
|
useEffect(() => {
|
|
if (!previewOpen) return
|
|
const timer = setTimeout(() => {
|
|
previewIframeRef.current?.contentWindow?.postMessage(
|
|
{ type: 'admin-preview-content', content: form, view: adminView },
|
|
window.location.origin
|
|
)
|
|
}, 150)
|
|
return () => clearTimeout(timer)
|
|
}, [form, adminView, previewOpen])
|
|
|
|
useEffect(() => {
|
|
const normalized = normalizeSiteContentForAdmin(content)
|
|
const nextSnapshot = JSON.stringify(normalized)
|
|
setForm(normalized)
|
|
setLastSavedSnapshot(nextSnapshot)
|
|
}, [content])
|
|
|
|
useEffect(() => {
|
|
const intervalId = window.setInterval(() => {
|
|
setDashboardNow(new Date())
|
|
}, 1000)
|
|
|
|
return () => window.clearInterval(intervalId)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!isDirty) return
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
}, [isDirty])
|
|
|
|
useEffect(() => {
|
|
fetch('/api/admin-auth/status')
|
|
.then(r => r.ok ? r.json() : Promise.reject())
|
|
.then(data => setTotpEnabled(!!(data as { totpEnabled?: boolean }).totpEnabled))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
fetch('/api/admin-stats')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
|
|
.then(data => {
|
|
setStats(data as AdminStats)
|
|
setStatsStatus('ready')
|
|
})
|
|
.catch(() => {
|
|
setStatsStatus('error')
|
|
})
|
|
|
|
fetch('/api/admin-questions')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
|
.then(data => {
|
|
setQuestions((data as { questions: Question[] }).questions ?? [])
|
|
})
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-contact-submissions')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load contact submissions'))))
|
|
.then(data => {
|
|
setContactSubmissions((data as { submissions: ContactSubmission[] }).submissions ?? [])
|
|
setContactStatus('ready')
|
|
})
|
|
.catch(() => {
|
|
setContactStatus('error')
|
|
})
|
|
|
|
fetch('/api/admin-contact-reply-templates')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply templates'))))
|
|
.then(data => {
|
|
setContactReplyTemplates((data as { templates: ContactReplyTemplate[] }).templates ?? [])
|
|
})
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-contact-reply-history')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply history'))))
|
|
.then(data => {
|
|
setContactReplyHistory((data as { items: ContactReplyHistoryItem[] }).items ?? [])
|
|
})
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-reply-config')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load reply config'))))
|
|
.then(data => {
|
|
setContactReplyConfig(data as ContactReplyConfig)
|
|
})
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-stats/backups')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
|
|
.then(data => {
|
|
const files = Array.isArray((data as { backups?: unknown }).backups) ? (data as { backups: BackupPreview[] }).backups : []
|
|
setBackupFiles(files)
|
|
if (files.length > 0) {
|
|
setSelectedBackup(files[0].filename)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-content-state')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load publish state'))))
|
|
.then(data => {
|
|
const state = (data as { publishState?: PublishState }).publishState
|
|
if (state) {
|
|
setPublishState({
|
|
draftUpdatedAt: state.draftUpdatedAt ?? null,
|
|
publishedAt: state.publishedAt ?? null,
|
|
})
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
|
|
void reloadAssets()
|
|
|
|
fetch('/api/admin-ops/status')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load operations status'))))
|
|
.then(data => {
|
|
setOpsStatus(data as OpsStatus)
|
|
})
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-subscribers')
|
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
|
.then(data => setSubscribers((data as { subscribers: Subscriber[] }).subscribers ?? []))
|
|
.catch(() => {})
|
|
|
|
fetch('/api/admin-download-stats')
|
|
.then(r => (r.ok ? r.json() : Promise.reject()))
|
|
.then(data => setDownloadStats((data as { counts: Record<string, number> }).counts ?? {}))
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!selectedBackup) {
|
|
setSelectedBackupPreview(null)
|
|
return
|
|
}
|
|
|
|
fetch('/api/admin-stats/backup-preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: selectedBackup }),
|
|
})
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
|
|
.then(data => {
|
|
const preview = (data as { preview?: BackupPreview }).preview ?? null
|
|
setSelectedBackupPreview(preview)
|
|
})
|
|
.catch(() => {
|
|
setSelectedBackupPreview(null)
|
|
})
|
|
}, [selectedBackup])
|
|
|
|
async function reloadStats() {
|
|
const r = await fetch('/api/admin-stats')
|
|
if (!r.ok) throw new Error('Could not refresh stats')
|
|
const data = await r.json()
|
|
setStats(data as AdminStats)
|
|
setStatsStatus('ready')
|
|
}
|
|
|
|
async function reloadContactSubmissions() {
|
|
const r = await fetch('/api/admin-contact-submissions')
|
|
if (!r.ok) throw new Error('Could not refresh contact submissions')
|
|
const data = await r.json() as { submissions?: ContactSubmission[] }
|
|
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
|
|
setContactStatus('ready')
|
|
}
|
|
|
|
useEffect(() => {
|
|
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
|
if (visible.length === 0) {
|
|
setSelectedEmailId(null)
|
|
return
|
|
}
|
|
if (!selectedEmailId || !visible.some(item => item.id === selectedEmailId)) {
|
|
setSelectedEmailId(visible[0].id)
|
|
}
|
|
}, [contactSubmissions, emailMailboxView, selectedEmailId])
|
|
|
|
useEffect(() => {
|
|
setQuestionPage(0)
|
|
}, [questionSearch, questionFilter])
|
|
|
|
async function reloadContactReplyHistory() {
|
|
const r = await fetch('/api/admin-contact-reply-history')
|
|
if (!r.ok) throw new Error('Could not refresh reply history')
|
|
const data = await r.json() as { items?: ContactReplyHistoryItem[] }
|
|
setContactReplyHistory(Array.isArray(data.items) ? data.items : [])
|
|
}
|
|
|
|
async function reloadContactReplyTemplates() {
|
|
const r = await fetch('/api/admin-contact-reply-templates')
|
|
if (!r.ok) throw new Error('Could not refresh reply templates')
|
|
const data = await r.json() as { templates?: ContactReplyTemplate[] }
|
|
setContactReplyTemplates(Array.isArray(data.templates) ? data.templates : [])
|
|
}
|
|
|
|
async function reloadBackups() {
|
|
const r = await fetch('/api/admin-stats/backups')
|
|
if (!r.ok) throw new Error('Could not refresh backups')
|
|
const data = await r.json() as { backups?: BackupPreview[] }
|
|
const files = Array.isArray(data.backups) ? data.backups : []
|
|
setBackupFiles(files)
|
|
const names = files.map(f => f.filename)
|
|
if (files.length > 0 && !names.includes(selectedBackup)) {
|
|
setSelectedBackup(files[0].filename)
|
|
}
|
|
}
|
|
|
|
async function reloadContentFromServer() {
|
|
const r = await fetch('/api/admin-content')
|
|
if (!r.ok) return
|
|
const data = await r.json() as { siteContent?: Partial<SiteContent> }
|
|
if (data?.siteContent && typeof data.siteContent === 'object') {
|
|
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...data.siteContent })
|
|
setForm(next)
|
|
onSave(next)
|
|
}
|
|
}
|
|
|
|
async function reloadAssets() {
|
|
const r = await fetch('/api/admin-assets')
|
|
if (!r.ok) throw new Error('Could not refresh assets')
|
|
const data = await r.json() as { assets?: AdminAsset[] }
|
|
const incomingAssets = Array.isArray(data.assets) ? data.assets : []
|
|
setAssets(incomingAssets)
|
|
setAssetTagEdits(incomingAssets.reduce<Record<string, string>>((memo, asset) => {
|
|
memo[asset.filename] = (asset.tags ?? []).join(', ')
|
|
return memo
|
|
}, {}))
|
|
}
|
|
|
|
async function reloadOpsStatus() {
|
|
const r = await fetch('/api/admin-ops/status')
|
|
if (!r.ok) throw new Error('Could not refresh ops status')
|
|
const data = await r.json() as OpsStatus
|
|
setOpsStatus(data)
|
|
}
|
|
|
|
async function handleSaveDraft() {
|
|
const payload = buildSiteContentForSave(form)
|
|
setStatus('saving')
|
|
setErrorMsg('')
|
|
try {
|
|
const res = await fetch('/api/admin-content-draft', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ siteContent: payload }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
|
}
|
|
const data = await res.json() as { updatedAt?: string }
|
|
setPublishState(prev => ({ ...prev, draftUpdatedAt: data.updatedAt ?? new Date().toISOString() }))
|
|
setLastSavedSnapshot(JSON.stringify(payload))
|
|
setStatus('saved')
|
|
setTimeout(() => setStatus('idle'), 3500)
|
|
} catch (err) {
|
|
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
|
setStatus('error')
|
|
}
|
|
}
|
|
|
|
async function handlePublishDraft() {
|
|
if (!confirm('Publish current changes to the live site now?')) return
|
|
const payload = buildSiteContentForSave(form)
|
|
setStatus('saving')
|
|
setErrorMsg('')
|
|
try {
|
|
// Always save the current form to draft first so publish never fails from a missing draft
|
|
const draftRes = await fetch('/api/admin-content-draft', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ siteContent: payload }),
|
|
})
|
|
if (!draftRes.ok) {
|
|
const data = await draftRes.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Draft save failed')
|
|
}
|
|
const draftData = await draftRes.json() as { updatedAt?: string }
|
|
setPublishState(prev => ({ ...prev, draftUpdatedAt: draftData.updatedAt ?? new Date().toISOString() }))
|
|
|
|
const publishRes = await fetch('/api/admin-content/publish', { method: 'POST' })
|
|
if (!publishRes.ok) {
|
|
const data = await publishRes.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Publish failed')
|
|
}
|
|
|
|
const published = await publishRes.json() as { publishedAt?: string }
|
|
const latestRes = await fetch('/api/admin-content')
|
|
if (!latestRes.ok) throw new Error('Failed to refresh published content')
|
|
const latest = await latestRes.json() as { siteContent?: Partial<SiteContent> }
|
|
if (latest?.siteContent) {
|
|
const next = normalizeSiteContentForAdmin({ ...DEFAULTS, ...latest.siteContent })
|
|
setForm(next)
|
|
onSave(next)
|
|
setLastSavedSnapshot(JSON.stringify(next))
|
|
}
|
|
|
|
setPublishState(prev => ({ ...prev, publishedAt: published.publishedAt ?? new Date().toISOString() }))
|
|
await reloadStats()
|
|
setStatus('saved')
|
|
setTimeout(() => setStatus('idle'), 3500)
|
|
} catch (err) {
|
|
setErrorMsg(err instanceof Error ? err.message : 'Unknown error')
|
|
setStatus('error')
|
|
}
|
|
}
|
|
|
|
function updateSeoField(field: keyof SeoSettings, value: string | string[]) {
|
|
setForm(f => ({
|
|
...f,
|
|
seo: {
|
|
...f.seo,
|
|
[field]: value,
|
|
},
|
|
}))
|
|
}
|
|
|
|
function updateLegalField(field: keyof LegalSettings, value: string | string[]) {
|
|
setForm(f => ({
|
|
...f,
|
|
legal: {
|
|
...f.legal,
|
|
[field]: value,
|
|
},
|
|
}))
|
|
}
|
|
|
|
async function handleTotpSetupInit() {
|
|
setTotpMsg('')
|
|
setTotpRecoveryCodes(null)
|
|
const res = await fetch('/api/admin-auth/totp-setup-init', { method: 'POST' })
|
|
const data = await res.json().catch(() => ({})) as { qrDataUrl?: string; secret?: string; message?: string }
|
|
if (!res.ok) { setTotpMsg(data.message ?? 'Setup failed.'); return }
|
|
setTotpSetupQr(data.qrDataUrl ?? null)
|
|
setTotpSetupSecret(data.secret ?? null)
|
|
setTotpConfirmCode('')
|
|
}
|
|
|
|
async function handleTotpSetupConfirm(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setTotpMsg('')
|
|
const res = await fetch('/api/admin-auth/totp-setup-confirm', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: totpConfirmCode }),
|
|
})
|
|
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
|
|
if (!res.ok) { setTotpMsg(data.message ?? 'Confirmation failed.'); return }
|
|
setTotpEnabled(true)
|
|
setTotpSetupQr(null)
|
|
setTotpSetupSecret(null)
|
|
setTotpConfirmCode('')
|
|
setTotpRecoveryCodes(data.recoveryCodes ?? null)
|
|
setTotpMsg('Two-factor authentication enabled.')
|
|
}
|
|
|
|
async function handleTotpDisable() {
|
|
if (!confirm('Disable two-factor authentication? This will make your admin less secure.')) return
|
|
setTotpMsg('')
|
|
const res = await fetch('/api/admin-auth/totp-disable', { method: 'POST' })
|
|
if (res.ok) { setTotpEnabled(false); setTotpRecoveryCodes(null); setTotpMsg('Two-factor authentication disabled.') }
|
|
else { const d = await res.json().catch(() => ({})) as { message?: string }; setTotpMsg(d.message ?? 'Failed to disable TOTP.') }
|
|
}
|
|
|
|
async function handleTotpRegenRecovery() {
|
|
if (!confirm('Regenerate recovery codes? Your old codes will stop working immediately.')) return
|
|
setTotpMsg('')
|
|
const res = await fetch('/api/admin-auth/totp-regen-recovery', { method: 'POST' })
|
|
const data = await res.json().catch(() => ({})) as { ok?: boolean; recoveryCodes?: string[]; message?: string }
|
|
if (!res.ok) { setTotpMsg(data.message ?? 'Failed.'); return }
|
|
setTotpRecoveryCodes(data.recoveryCodes ?? null)
|
|
setTotpMsg('New recovery codes generated. Save these now.')
|
|
}
|
|
|
|
function addRedirectRule() {
|
|
setForm(f => ({
|
|
...f,
|
|
redirects: [
|
|
...(f.redirects ?? []),
|
|
{
|
|
id: Date.now().toString(36),
|
|
path: '/new-short-link',
|
|
target: 'https://',
|
|
statusCode: 301,
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
function updateRedirectRule(id: string, field: keyof RedirectRule, value: string | number) {
|
|
setForm(f => ({
|
|
...f,
|
|
redirects: (f.redirects ?? []).map(rule => rule.id === id
|
|
? {
|
|
...rule,
|
|
[field]: field === 'statusCode' ? (Number(value) === 302 ? 302 : 301) : value,
|
|
}
|
|
: rule),
|
|
}))
|
|
}
|
|
|
|
function removeRedirectRule(id: string) {
|
|
setForm(f => ({ ...f, redirects: (f.redirects ?? []).filter(rule => rule.id !== id) }))
|
|
}
|
|
|
|
function addPodcastLink() {
|
|
setForm(f => ({
|
|
...f,
|
|
podcastFeaturedLinks: [
|
|
...(f.podcastFeaturedLinks ?? []),
|
|
{
|
|
id: Date.now().toString(36),
|
|
title: '',
|
|
episodeNumber: '',
|
|
summary: '',
|
|
url: '',
|
|
embedUrl: '',
|
|
showNotes: '',
|
|
discussionQuestions: [],
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
function updatePodcastLink(id: string, field: keyof PodcastFeaturedLink, value: string) {
|
|
setForm(f => ({
|
|
...f,
|
|
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link => link.id === id ? { ...link, [field]: value } : link),
|
|
}))
|
|
}
|
|
|
|
function updatePodcastLinkQuestions(id: string, raw: string) {
|
|
const questions = raw.split('\n').map(q => q.trim()).filter(Boolean)
|
|
setForm(f => ({
|
|
...f,
|
|
podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).map(link =>
|
|
link.id === id ? { ...link, discussionQuestions: questions } : link
|
|
),
|
|
}))
|
|
}
|
|
|
|
function removePodcastLink(id: string) {
|
|
setForm(f => ({ ...f, podcastFeaturedLinks: (f.podcastFeaturedLinks ?? []).filter(link => link.id !== id) }))
|
|
}
|
|
|
|
async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0]
|
|
if (!file) return
|
|
|
|
setAssetUploadPending(true)
|
|
setOpsMsg('')
|
|
try {
|
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => resolve(String(reader.result ?? ''))
|
|
reader.onerror = () => reject(new Error('Failed to read file'))
|
|
reader.readAsDataURL(file)
|
|
})
|
|
|
|
const res = await fetch('/api/admin-assets', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: file.name, dataUrl }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Upload failed')
|
|
}
|
|
await reloadAssets()
|
|
setOpsMsg('Asset uploaded successfully.')
|
|
} catch (err) {
|
|
setOpsMsg(err instanceof Error ? err.message : 'Upload failed.')
|
|
} finally {
|
|
setAssetUploadPending(false)
|
|
event.target.value = ''
|
|
}
|
|
}
|
|
|
|
async function handleDeleteAsset(filename: string) {
|
|
if (!confirm(`Delete asset ${filename}?`)) return
|
|
try {
|
|
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, { method: 'DELETE' })
|
|
if (!res.ok) throw new Error('Delete failed')
|
|
await reloadAssets()
|
|
setOpsMsg('Asset deleted.')
|
|
} catch {
|
|
setOpsMsg('Asset delete failed.')
|
|
}
|
|
}
|
|
|
|
async function handleSaveAssetTags(filename: string) {
|
|
const tagsText = assetTagEdits[filename] ?? ''
|
|
const tags = tagsText.split(',').map(tag => tag.trim()).filter(Boolean)
|
|
try {
|
|
const res = await fetch(`/api/admin-assets/${encodeURIComponent(filename)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tags }),
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Save failed')
|
|
}
|
|
await reloadAssets()
|
|
setOpsMsg('Asset tags saved.')
|
|
} catch (err) {
|
|
setOpsMsg(err instanceof Error ? err.message : 'Save failed.')
|
|
}
|
|
}
|
|
|
|
async function handlePurgeCache() {
|
|
try {
|
|
const res = await fetch('/api/admin-ops/purge-cache', { method: 'POST' })
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Cache purge failed')
|
|
}
|
|
await reloadOpsStatus()
|
|
setOpsMsg('Cache purge triggered.')
|
|
} catch (err) {
|
|
setOpsMsg(err instanceof Error ? err.message : 'Cache purge failed.')
|
|
}
|
|
}
|
|
|
|
async function handleDeployHook() {
|
|
try {
|
|
const res = await fetch('/api/admin-ops/deploy', { method: 'POST' })
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Deploy hook failed')
|
|
}
|
|
await reloadOpsStatus()
|
|
setOpsMsg('Deploy hook triggered.')
|
|
} catch (err) {
|
|
setOpsMsg(err instanceof Error ? err.message : 'Deploy hook failed.')
|
|
}
|
|
}
|
|
|
|
function maskIp(ip: string) {
|
|
if (!ip || ip === 'unknown') return 'unknown'
|
|
if (ip.includes('.')) {
|
|
const parts = ip.split('.')
|
|
if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x`
|
|
}
|
|
if (ip.includes(':')) {
|
|
const parts = ip.split(':')
|
|
return `${parts.slice(0, 3).join(':')}:x:x`
|
|
}
|
|
return ip
|
|
}
|
|
|
|
async function handleExport() {
|
|
try {
|
|
const r = await fetch('/api/admin-stats/export')
|
|
if (!r.ok) throw new Error('Export failed')
|
|
const data = await r.json()
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `siteforge-admin-export-${new Date().toISOString().slice(0, 10)}.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
setMaintenanceMsg('Export downloaded.')
|
|
} catch {
|
|
setMaintenanceMsg('Export failed.')
|
|
}
|
|
}
|
|
|
|
async function handlePrune() {
|
|
const input = prompt('Keep how many days of analytics data?', '180')
|
|
if (input === null) return
|
|
const days = Number(input)
|
|
if (!Number.isFinite(days) || days <= 0) {
|
|
setMaintenanceMsg('Invalid retention days.')
|
|
return
|
|
}
|
|
try {
|
|
const r = await fetch('/api/admin-stats/prune', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ days }),
|
|
})
|
|
if (!r.ok) throw new Error('Prune failed')
|
|
await reloadStats()
|
|
setMaintenanceMsg(`Pruned analytics to ${Math.floor(days)} days.`)
|
|
} catch {
|
|
setMaintenanceMsg('Prune failed.')
|
|
}
|
|
}
|
|
|
|
async function handleClear() {
|
|
if (!confirm('Clear ALL analytics data now? This cannot be undone.')) return
|
|
try {
|
|
const r = await fetch('/api/admin-stats/clear', { method: 'POST' })
|
|
if (!r.ok) throw new Error('Clear failed')
|
|
await reloadStats()
|
|
setMaintenanceMsg('All analytics data cleared.')
|
|
} catch {
|
|
setMaintenanceMsg('Clear failed.')
|
|
}
|
|
}
|
|
|
|
async function handleBackupNow() {
|
|
try {
|
|
const r = await fetch('/api/admin-stats/backup', { method: 'POST' })
|
|
if (!r.ok) throw new Error('Backup failed')
|
|
await reloadStats()
|
|
await reloadBackups()
|
|
setMaintenanceMsg('Backup snapshot created.')
|
|
} catch {
|
|
setMaintenanceMsg('Backup failed.')
|
|
}
|
|
}
|
|
|
|
async function handleRestoreBackup() {
|
|
if (!selectedBackup) {
|
|
setMaintenanceMsg('Select a backup first.')
|
|
return
|
|
}
|
|
if (!confirm(`Restore backup ${selectedBackup}? This will overwrite current admin data and analytics.`)) return
|
|
try {
|
|
const r = await fetch('/api/admin-stats/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: selectedBackup }),
|
|
})
|
|
if (!r.ok) throw new Error('Restore failed')
|
|
await reloadContentFromServer()
|
|
await reloadStats()
|
|
await reloadBackups()
|
|
setMaintenanceMsg(`Restored from ${selectedBackup}. Content fields were refreshed from backup.`)
|
|
} catch {
|
|
setMaintenanceMsg('Restore failed.')
|
|
}
|
|
}
|
|
|
|
function formatDate(value: string | null) {
|
|
if (!value) return 'Not available yet'
|
|
const d = new Date(value)
|
|
return Number.isNaN(d.getTime()) ? 'Not available yet' : d.toLocaleString()
|
|
}
|
|
|
|
function handleChange(key: StringField, value: string) {
|
|
setForm(f => ({ ...f, [key]: value }))
|
|
}
|
|
|
|
function renderImageAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
|
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
|
const currentValue = value?.trim() ?? ''
|
|
|
|
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
|
assetOptions.unshift({ label: `Current image (${currentValue})`, value: currentValue })
|
|
}
|
|
|
|
if (assetOptions.length === 0) return null
|
|
|
|
return (
|
|
<div className="admin-field-asset-picker">
|
|
<label htmlFor={fieldId}>Choose an existing image</label>
|
|
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
|
<option value="">Select image</option>
|
|
{assetOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderImagePreview(value: string | null | undefined, alt: string) {
|
|
const src = value?.trim() ?? ''
|
|
if (!src) return null
|
|
|
|
return (
|
|
<div className="admin-field-image-preview">
|
|
<span className="admin-field-image-preview-label">Preview</span>
|
|
<img src={src} alt={alt} className="admin-field-image-preview-img" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderFileAssetSelector(value: string | null | undefined, onChange: (value: string) => void, fieldId: string) {
|
|
const assetOptions = [...assets.map(asset => ({ label: asset.filename, value: asset.url }))]
|
|
const currentValue = value?.trim() ?? ''
|
|
|
|
if (currentValue && !assetOptions.some(item => item.value === currentValue)) {
|
|
assetOptions.unshift({ label: `Current file (${currentValue})`, value: currentValue })
|
|
}
|
|
|
|
if (assetOptions.length === 0) return null
|
|
|
|
return (
|
|
<div className="admin-field-asset-picker">
|
|
<label htmlFor={fieldId}>Choose an existing file</label>
|
|
<select id={fieldId} value={assetOptions.some(a => a.value === currentValue) ? currentValue : ''} onChange={e => onChange(e.target.value)}>
|
|
<option value="">Select file</option>
|
|
{assetOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function isImageAsset(filename: string) {
|
|
return /\.(png|jpe?g|webp|gif)$/i.test(filename)
|
|
}
|
|
|
|
function confirmLeaveUnsavedChanges() {
|
|
if (!isDirty) return true
|
|
return confirm('You have unsaved changes. Leave this section without saving?')
|
|
}
|
|
|
|
function navigateTo(view: AdminView) {
|
|
if (view === adminView) return
|
|
if (!confirmLeaveUnsavedChanges()) return
|
|
setAdminView(view)
|
|
setMobileNavOpen(false)
|
|
}
|
|
|
|
function addLink() {
|
|
setForm(f => ({
|
|
...f,
|
|
customLinks: [
|
|
...(f.customLinks ?? []),
|
|
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', placement: 'platforms' as const },
|
|
],
|
|
}))
|
|
}
|
|
|
|
function addResource() {
|
|
setForm(f => ({
|
|
...f,
|
|
customLinks: [
|
|
...(f.customLinks ?? []),
|
|
{ id: Date.now().toString(36), label: '', url: '', imageUrl: '', description: '', placement: 'resources' as const },
|
|
],
|
|
}))
|
|
}
|
|
|
|
function updateLink(id: string, field: keyof CustomLink, value: string | string[]) {
|
|
setForm(f => ({
|
|
...f,
|
|
customLinks: (f.customLinks ?? []).map(l => l.id === id ? { ...l, [field]: value } : l),
|
|
}))
|
|
}
|
|
|
|
function removeLink(id: string) {
|
|
setForm(f => ({ ...f, customLinks: (f.customLinks ?? []).filter(l => l.id !== id) }))
|
|
}
|
|
|
|
function moveLinkToResources(id: string) {
|
|
setForm(f => ({
|
|
...f,
|
|
customLinks: (f.customLinks ?? []).map(link => (
|
|
link.id === id ? { ...link, placement: 'resources' as const } : link
|
|
)),
|
|
}))
|
|
}
|
|
|
|
function addBlock() {
|
|
setForm(f => ({
|
|
...f,
|
|
customBlocks: [
|
|
...(f.customBlocks ?? []),
|
|
{ id: Date.now().toString(36), heading: '', body: '', page: 'homepage' as const },
|
|
],
|
|
}))
|
|
}
|
|
|
|
function updateBlock(id: string, field: keyof CustomBlock, value: string) {
|
|
setForm(f => ({
|
|
...f,
|
|
customBlocks: (f.customBlocks ?? []).map(b => b.id === id ? { ...b, [field]: value } : b),
|
|
}))
|
|
}
|
|
|
|
function removeBlock(id: string) {
|
|
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,
|
|
studies: [
|
|
...(f.studies ?? []),
|
|
{
|
|
id: `study-${Date.now().toString(36)}`,
|
|
slug: `new-study-${Date.now().toString(36)}`,
|
|
title: 'New Study',
|
|
description: 'Add study description',
|
|
homepageEyebrow: 'New Study',
|
|
showOnHomepage: false,
|
|
showNewTag: false,
|
|
newTagLabel: 'NEW',
|
|
status: 'planned',
|
|
difficulty: 'beginner',
|
|
estimatedHours: 8,
|
|
completionBadge: 'Study Completion',
|
|
numberOfChapters: 4,
|
|
sections: [],
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
|
|
function updateStudyProgram(studyId: string, field: keyof Omit<StudyProgram, 'sections'>, value: string | number | boolean) {
|
|
setForm(f => ({
|
|
...f,
|
|
studies: (f.studies ?? []).map(study => study.id === studyId ? { ...study, [field]: value } : study),
|
|
}))
|
|
}
|
|
|
|
function removeStudyProgram(studyId: string) {
|
|
setForm(f => ({
|
|
...f,
|
|
studies: (f.studies ?? []).filter(study => study.id !== studyId),
|
|
}))
|
|
}
|
|
|
|
function addStudySection(studyId: string) {
|
|
setForm(f => ({
|
|
...f,
|
|
studies: (f.studies ?? []).map(study => study.id === studyId
|
|
? {
|
|
...study,
|
|
sections: [
|
|
...(study.sections ?? []),
|
|
{
|
|
id: `${study.slug || 'section'}-${Date.now().toString(36)}`,
|
|
chapter: 1,
|
|
reference: '',
|
|
title: '',
|
|
audioEmbedUrl: '',
|
|
passageText: '',
|
|
summary: '',
|
|
commentary: '',
|
|
greekNotes: [],
|
|
studyQuestions: [],
|
|
},
|
|
],
|
|
}
|
|
: study),
|
|
}))
|
|
}
|
|
|
|
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
|
|
setForm(f => ({
|
|
...f,
|
|
studies: (f.studies ?? []).map(study => study.id === studyId
|
|
? {
|
|
...study,
|
|
sections: (study.sections ?? []).map(section => section.id === sectionId ? { ...section, [field]: value } : section),
|
|
}
|
|
: study),
|
|
}))
|
|
}
|
|
|
|
function removeStudySection(studyId: string, sectionId: string) {
|
|
setForm(f => ({
|
|
...f,
|
|
studies: (f.studies ?? []).map(study => study.id === studyId
|
|
? { ...study, sections: (study.sections ?? []).filter(section => section.id !== sectionId) }
|
|
: study),
|
|
}))
|
|
}
|
|
|
|
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 {
|
|
const res = await fetch(`/api/admin-questions/${questionId}/answer`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ answer: answer.trim() }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to answer question')
|
|
setQuestions(qs =>
|
|
qs.map(q =>
|
|
q.id === questionId
|
|
? { ...q, answer: answer.trim(), answeredAt: new Date().toISOString() }
|
|
: q
|
|
)
|
|
)
|
|
setEditingQuestionId(null)
|
|
setAnsweredQuestions(a => ({ ...a, [questionId]: '' }))
|
|
} catch {
|
|
alert('Failed to save answer')
|
|
}
|
|
}
|
|
|
|
async function handleApproveQuestion(questionId: string, approved: boolean) {
|
|
try {
|
|
const res = await fetch(`/api/admin-questions/${questionId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ approved }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to update question')
|
|
setQuestions(qs =>
|
|
qs.map(q =>
|
|
q.id === questionId
|
|
? { ...q, isApproved: approved, approvedAt: approved ? new Date().toISOString() : null }
|
|
: q
|
|
)
|
|
)
|
|
} catch {
|
|
alert('Failed to update question')
|
|
}
|
|
}
|
|
|
|
async function handleDeleteQuestion(questionId: string) {
|
|
if (!confirm('Delete this question permanently?')) return
|
|
try {
|
|
const res = await fetch(`/api/admin-questions/${questionId}`, { method: 'DELETE' })
|
|
if (!res.ok) throw new Error('Failed to delete question')
|
|
setQuestions(qs => qs.filter(q => q.id !== questionId))
|
|
setSelectedQuestionIds(prev => { const next = new Set(prev); next.delete(questionId); return next })
|
|
} catch {
|
|
alert('Failed to delete question')
|
|
}
|
|
}
|
|
|
|
async function handleBulkDeleteQuestions() {
|
|
if (selectedQuestionIds.size === 0) return
|
|
if (!confirm(`Delete ${selectedQuestionIds.size} question${selectedQuestionIds.size === 1 ? '' : 's'} permanently?`)) return
|
|
const ids = Array.from(selectedQuestionIds)
|
|
let deletedCount = 0
|
|
for (const id of ids) {
|
|
try {
|
|
const res = await fetch(`/api/admin-questions/${id}`, { method: 'DELETE' })
|
|
if (res.ok) {
|
|
deletedCount++
|
|
setQuestions(qs => qs.filter(q => q.id !== id))
|
|
}
|
|
} catch {
|
|
// continue with remaining
|
|
}
|
|
}
|
|
setSelectedQuestionIds(new Set())
|
|
if (deletedCount < ids.length) alert(`Deleted ${deletedCount} of ${ids.length} questions.`)
|
|
}
|
|
|
|
async function handleCreateManualQuestion() {
|
|
if (!manualQuestion.firstName.trim() || !manualQuestion.question.trim()) {
|
|
setManualQuestionStatus('error')
|
|
setManualQuestionMsg('First name and question are required.')
|
|
return
|
|
}
|
|
|
|
setManualQuestionStatus('saving')
|
|
setManualQuestionMsg('')
|
|
|
|
try {
|
|
const res = await fetch('/api/admin-questions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
firstName: manualQuestion.firstName,
|
|
email: manualQuestion.email,
|
|
question: manualQuestion.question,
|
|
answer: manualQuestion.answer,
|
|
approve: manualQuestion.approve,
|
|
}),
|
|
})
|
|
|
|
const data = await res.json().catch(() => ({})) as { question?: Question; message?: string }
|
|
if (!res.ok || !data.question) {
|
|
throw new Error(data.message ?? 'Failed to add question.')
|
|
}
|
|
|
|
setQuestions(items => [data.question as Question, ...items])
|
|
setManualQuestion({ firstName: '', email: '', question: '', answer: '', approve: false })
|
|
setManualQuestionStatus('saved')
|
|
setManualQuestionMsg('Question added to draft Q&A list.')
|
|
} catch (err) {
|
|
setManualQuestionStatus('error')
|
|
setManualQuestionMsg(err instanceof Error ? err.message : 'Failed to add question.')
|
|
}
|
|
}
|
|
|
|
async function handleDeleteContactSubmission(submissionId: string) {
|
|
if (!confirm('Delete this contact submission permanently?')) return
|
|
try {
|
|
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, { method: 'DELETE' })
|
|
if (!res.ok) throw new Error('Failed to delete submission')
|
|
await reloadContactSubmissions()
|
|
await reloadStats()
|
|
setMaintenanceMsg('Contact submission deleted.')
|
|
} catch {
|
|
setMaintenanceMsg('Failed to delete contact submission.')
|
|
}
|
|
}
|
|
|
|
async function handleArchiveContactSubmission(submissionId: string, archived: boolean) {
|
|
try {
|
|
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(submissionId)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ archived }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to update submission')
|
|
await reloadContactSubmissions()
|
|
await reloadStats()
|
|
setContactReplyMsg(archived ? 'Message archived.' : 'Message moved back to inbox.')
|
|
} catch {
|
|
setContactReplyMsg('Failed to update archive status.')
|
|
}
|
|
}
|
|
|
|
function openContactReplyComposer(submission: ContactSubmission) {
|
|
const firstName = submission.name?.trim().split(/\s+/)[0] || 'there'
|
|
setContactReplyDraft({
|
|
submissionId: submission.id,
|
|
recipientName: submission.name,
|
|
recipientEmail: submission.email,
|
|
subject: 'Thanks for reaching out to Verse by Verse with Nate',
|
|
message: `Thank you for reaching out.\n\nI appreciate your message and wanted to follow up personally.`,
|
|
})
|
|
setContactReplyStatus('idle')
|
|
setContactReplyMsg(`Composing a reply to ${firstName}.`)
|
|
}
|
|
|
|
function applyContactReplyTemplate(templateId: string) {
|
|
if (!contactReplyDraft) return
|
|
const template = contactReplyTemplates.find(item => item.id === templateId)
|
|
if (!template) return
|
|
|
|
setContactReplyDraft({
|
|
...contactReplyDraft,
|
|
subject: template.subject,
|
|
message: template.message,
|
|
})
|
|
setContactReplyMsg(`Applied template: ${template.label}.`)
|
|
}
|
|
|
|
function addContactReplyTemplate() {
|
|
setContactReplyTemplates(items => ([
|
|
...items,
|
|
{
|
|
id: Date.now().toString(36),
|
|
label: '',
|
|
subject: '',
|
|
message: '',
|
|
},
|
|
]))
|
|
setContactTemplateStatus('idle')
|
|
}
|
|
|
|
function updateContactReplyTemplate(id: string, field: keyof ContactReplyTemplate, value: string) {
|
|
setContactReplyTemplates(items => items.map(item => item.id === id ? { ...item, [field]: value } : item))
|
|
setContactTemplateStatus('idle')
|
|
}
|
|
|
|
function removeContactReplyTemplate(id: string) {
|
|
setContactReplyTemplates(items => items.filter(item => item.id !== id))
|
|
setContactTemplateStatus('idle')
|
|
}
|
|
|
|
async function handleSaveContactReplyTemplates() {
|
|
setContactTemplateStatus('saving')
|
|
try {
|
|
const res = await fetch('/api/admin-contact-reply-templates', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ templates: contactReplyTemplates }),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to save templates')
|
|
await reloadContactReplyTemplates()
|
|
setContactTemplateStatus('saved')
|
|
setContactReplyMsg('Reply templates saved.')
|
|
} catch {
|
|
setContactTemplateStatus('error')
|
|
setContactReplyMsg('Failed to save reply templates.')
|
|
}
|
|
}
|
|
|
|
async function handleSendContactReply() {
|
|
if (!contactReplyDraft) return
|
|
setContactReplyStatus('sending')
|
|
setContactReplyMsg('')
|
|
|
|
try {
|
|
const res = await fetch(`/api/admin-contact-submissions/${encodeURIComponent(contactReplyDraft.submissionId)}/reply`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
subject: contactReplyDraft.subject,
|
|
message: contactReplyDraft.message,
|
|
}),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error((data as { message?: string }).message ?? 'Failed to send email.')
|
|
}
|
|
|
|
setContactReplyStatus('sent')
|
|
setContactReplyMsg(`Reply sent to ${contactReplyDraft.recipientEmail} from hello@versebyversewithnate.us.`)
|
|
await reloadContactReplyHistory()
|
|
setContactReplyDraft(null)
|
|
} catch (err) {
|
|
setContactReplyStatus('error')
|
|
setContactReplyMsg(err instanceof Error ? err.message : 'Failed to send email.')
|
|
}
|
|
}
|
|
|
|
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()
|
|
const matchesSearch = !search
|
|
|| question.firstName.toLowerCase().includes(search)
|
|
|| question.question.toLowerCase().includes(search)
|
|
|| question.answer.toLowerCase().includes(search)
|
|
|
|
const matchesFilter = (
|
|
questionFilter === 'all'
|
|
|| (questionFilter === 'pending' && !question.isApproved)
|
|
|| (questionFilter === 'approved' && question.isApproved)
|
|
|| (questionFilter === 'answered' && Boolean(question.answer?.trim()))
|
|
|| (questionFilter === 'unanswered' && !question.answer?.trim())
|
|
)
|
|
|
|
return matchesSearch && matchesFilter
|
|
})
|
|
|
|
const QUESTION_PAGE_SIZE = 20
|
|
const totalQuestionPages = Math.max(1, Math.ceil(filteredAdminQuestions.length / QUESTION_PAGE_SIZE))
|
|
const visibleAdminQuestions = filteredAdminQuestions.slice(
|
|
questionPage * QUESTION_PAGE_SIZE,
|
|
(questionPage + 1) * QUESTION_PAGE_SIZE,
|
|
)
|
|
|
|
function renderSaveStatus() {
|
|
return (
|
|
<>
|
|
{status === 'saved' && <p className="admin-status admin-status--ok">✓ Changes saved.</p>}
|
|
{status === 'error' && <p className="admin-status admin-status--err">✗ {errorMsg}</p>}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="admin-shell">
|
|
{/* ── Top bar ── */}
|
|
<header className="admin-topbar">
|
|
<div className="admin-topbar-brand">
|
|
<span className="admin-topbar-ornament">✦</span>
|
|
<span className="admin-topbar-title">Site Admin</span>
|
|
<span className="admin-topbar-sub">Verse by Verse with Nate</span>
|
|
</div>
|
|
<div className="admin-topbar-actions">
|
|
<button
|
|
type="button"
|
|
className={`admin-mobile-menu-btn${mobileNavOpen ? ' admin-mobile-menu-btn--open' : ''}`}
|
|
onClick={() => setMobileNavOpen(open => !open)}
|
|
aria-expanded={mobileNavOpen}
|
|
aria-controls="admin-sidebar-nav"
|
|
>
|
|
{mobileNavOpen ? 'Close Menu' : 'Menu'}
|
|
</button>
|
|
{isDirty && <span className="admin-status admin-status--warn">● Unsaved</span>}
|
|
<span className="admin-topbar-meta">Draft: {formatDate(publishState.draftUpdatedAt)}</span>
|
|
<span className="admin-topbar-meta">Published: {formatDate(publishState.publishedAt)}</span>
|
|
<button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}>
|
|
{status === 'saving' ? 'Saving…' : 'Save Draft'}
|
|
</button>
|
|
<button type="button" className={`btn-admin-reset${previewOpen ? ' btn-admin-reset--active' : ''}`} onClick={() => setPreviewOpen(o => !o)}>
|
|
{previewOpen ? 'Close Preview' : 'Preview'}
|
|
</button>
|
|
<button type="button" className="btn-admin-reset" onClick={handlePublishDraft}>Publish</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className={`admin-layout${previewOpen ? ' admin-layout--split' : ''}`}>
|
|
{/* ── Sidebar ── */}
|
|
<nav id="admin-sidebar-nav" className={`admin-sidebar${mobileNavOpen ? ' admin-sidebar--open' : ''}`} aria-label="Admin navigation">
|
|
<div className="admin-sidebar-meta-links">
|
|
<Link
|
|
to="/"
|
|
className="admin-meta-link"
|
|
onClick={e => {
|
|
if (!confirmLeaveUnsavedChanges()) e.preventDefault()
|
|
else setMobileNavOpen(false)
|
|
}}
|
|
>
|
|
← Back to site
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="admin-meta-link"
|
|
onClick={() => {
|
|
if (!confirmLeaveUnsavedChanges()) return
|
|
setMobileNavOpen(false)
|
|
void onLogout()
|
|
}}
|
|
>
|
|
Log Out
|
|
</button>
|
|
</div>
|
|
|
|
<div className="admin-nav-group">
|
|
<span className="admin-nav-label">Overview</span>
|
|
<button type="button" className={`admin-nav-item${adminView === 'dashboard' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('dashboard')}>Dashboard</button>
|
|
</div>
|
|
|
|
<div className="admin-nav-group">
|
|
<span className="admin-nav-label">Site</span>
|
|
<button type="button" className={`admin-nav-item${adminView === 'homepage' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('homepage')}>Homepage</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'start-here' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('start-here')}>Start Here</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'about' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('about')}>About</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'contact' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('contact')}>Contact</button>
|
|
</div>
|
|
|
|
<div className="admin-nav-group">
|
|
<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 === 'episode-highlights' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('episode-highlights')}>Ep. Highlights</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'archived-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('archived-series')}>Archived Series</button>
|
|
</div>
|
|
|
|
<div className="admin-nav-group">
|
|
<span className="admin-nav-label">Content</span>
|
|
<button type="button" className={`admin-nav-item${adminView === 'colossians-study' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('colossians-study')}>Studies</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'downloads' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('downloads')}>Downloads</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'custom-links' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('custom-links')}>Custom Links</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'content-blocks' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('content-blocks')}>Content Blocks</button>
|
|
</div>
|
|
|
|
<div className="admin-nav-group">
|
|
<span className="admin-nav-label">Manage</span>
|
|
<button type="button" className={`admin-nav-item${adminView === 'questions' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('questions')}>
|
|
Questions ({questions.length}){unansweredCount > 0 && <span className="admin-nav-badge">{unansweredCount}</span>}
|
|
</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>
|
|
Emails{unreadEmailCount > 0 && <span className="admin-nav-badge">{unreadEmailCount}</span>}
|
|
</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'contacts' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('contacts')}>
|
|
Contacts{contactSubmissions.length > 0 && <span className="admin-nav-badge admin-nav-badge--neutral">{contactSubmissions.length}</span>}
|
|
</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'subscribers' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('subscribers')}>
|
|
Subscribers{subscribers.length > 0 && <span className="admin-nav-badge admin-nav-badge--neutral">{subscribers.length}</span>}
|
|
</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'analytics' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('analytics')}>Analytics</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'assets' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('assets')}>Asset Manager</button>
|
|
</div>
|
|
|
|
<div className="admin-nav-group">
|
|
<span className="admin-nav-label">Configure</span>
|
|
<button type="button" className={`admin-nav-item${adminView === 'global' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('global')}>Footer & Global</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'seo' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('seo')}>SEO & Redirects</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'legal' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('legal')}>Legal Pages</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'security' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('security')}>Security</button>
|
|
<button type="button" className={`admin-nav-item${adminView === 'brand' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('brand')}>Brand Kit</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{mobileNavOpen && <button type="button" className="admin-sidebar-backdrop" aria-label="Close navigation menu" onClick={() => setMobileNavOpen(false)} />}
|
|
|
|
{/* ── Content panel ── */}
|
|
<main className="admin-panel">
|
|
|
|
{/* DASHBOARD */}
|
|
{adminView === 'dashboard' && (() => {
|
|
const thisWeekHits = stats?.last7Days?.reduce((s, d) => s + d.hits, 0) ?? 0
|
|
const thisWeekReal = stats?.last7DaysReal?.reduce((s, d) => s + d.hits, 0) ?? 0
|
|
const dashboardHour = dashboardNow.getHours()
|
|
const welcomeMessage = dashboardHour < 12
|
|
? 'Good morning.'
|
|
: dashboardHour < 18
|
|
? 'Good afternoon.'
|
|
: 'Good evening.'
|
|
const dashboardDateLabel = dashboardNow.toLocaleDateString(undefined, {
|
|
weekday: 'long',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
})
|
|
const dashboardTimeLabel = dashboardNow.toLocaleTimeString(undefined, {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
})
|
|
const recentContacts = [...contactSubmissions]
|
|
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
|
.slice(0, 5)
|
|
const recentQuestions = [...questions]
|
|
.sort((a, b) => new Date(b.submittedAt ?? '').getTime() - new Date(a.submittedAt ?? '').getTime())
|
|
.slice(0, 5)
|
|
const approvedCount = questions.filter(q => q.isApproved).length
|
|
const answeredCount = questions.filter(q => !!q.answer?.trim()).length
|
|
return (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Dashboard</h2>
|
|
<p>Quick overview of your ministry site.</p>
|
|
</div>
|
|
|
|
<div className="admin-dashboard-welcome">
|
|
<div>
|
|
<h3 className="admin-dashboard-welcome-title">{welcomeMessage}</h3>
|
|
<p className="admin-dashboard-welcome-copy">Here's what needs your attention and how the site is performing today.</p>
|
|
</div>
|
|
<div className="admin-dashboard-clock" aria-label={`Current time ${dashboardTimeLabel} on ${dashboardDateLabel}`}>
|
|
<span className="admin-dashboard-clock-time">{dashboardTimeLabel}</span>
|
|
<span className="admin-dashboard-clock-date">{dashboardDateLabel}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-dashboard-grid">
|
|
<div className={`admin-dashboard-card${unansweredCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
|
<div className="admin-dashboard-card-value">{unansweredCount}</div>
|
|
<div className="admin-dashboard-card-label">Unanswered Questions</div>
|
|
<div className="admin-dashboard-card-sub">{answeredCount} answered · {approvedCount} approved of {questions.length}</div>
|
|
{unansweredCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => { setQuestionFilter('unanswered'); navigateTo('questions') }}>Answer Now →</button>}
|
|
</div>
|
|
<div className={`admin-dashboard-card${unreadEmailCount > 0 ? ' admin-dashboard-card--alert' : ''}`}>
|
|
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
|
<div className="admin-dashboard-card-label">Unread Emails</div>
|
|
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</div>
|
|
{unreadEmailCount > 0 && <button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('emails')}>Open Inbox →</button>}
|
|
</div>
|
|
<div className="admin-dashboard-card">
|
|
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
|
<div className="admin-dashboard-card-label">Real Visits (7 Days)</div>
|
|
<div className="admin-dashboard-card-sub">{thisWeekHits.toLocaleString()} total · {stats?.visitors?.uniqueVisitors?.toLocaleString() ?? '—'} unique all time</div>
|
|
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('analytics')}>Full Analytics →</button>
|
|
</div>
|
|
<div className="admin-dashboard-card">
|
|
<div className="admin-dashboard-card-value">{subscribers.length}</div>
|
|
<div className="admin-dashboard-card-label">Email Subscribers</div>
|
|
<div className="admin-dashboard-card-sub">{contactSubmissions.length} total contact submissions</div>
|
|
<button type="button" className="admin-dashboard-card-action" onClick={() => navigateTo('subscribers')}>View List →</button>
|
|
</div>
|
|
<div className="admin-dashboard-card">
|
|
<div className="admin-dashboard-card-value">{downloadStats['titus-study'] ?? 0}</div>
|
|
<div className="admin-dashboard-card-label">Titus Study Downloads</div>
|
|
<div className="admin-dashboard-card-sub">
|
|
{Object.values(downloadStats).reduce((a, b) => a + b, 0)} total resource downloads
|
|
</div>
|
|
</div>
|
|
{publishState?.publishedAt && (
|
|
<div className="admin-dashboard-card">
|
|
<div className="admin-dashboard-card-value" style={{ fontSize: '1rem', marginTop: '0.25rem' }}>{formatDate(publishState.publishedAt)}</div>
|
|
<div className="admin-dashboard-card-label">Last Published</div>
|
|
{publishState.draftUpdatedAt && <div className="admin-dashboard-card-sub">Draft updated {formatDate(publishState.draftUpdatedAt)}</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="admin-dashboard-activity">
|
|
<div className="admin-dashboard-activity-col">
|
|
<h3 className="admin-dashboard-activity-heading">Recent Contacts</h3>
|
|
{recentContacts.length === 0
|
|
? <p className="admin-stats-note">No contacts yet.</p>
|
|
: (
|
|
<div className="admin-dashboard-feed">
|
|
{recentContacts.map(c => (
|
|
<div key={c.id} className="admin-dashboard-feed-item">
|
|
<div className="admin-dashboard-feed-meta">
|
|
<strong>{c.name}</strong>
|
|
<span className="admin-dashboard-feed-date">{formatDate(c.submittedAt)}</span>
|
|
</div>
|
|
<div className="admin-dashboard-feed-email">{c.email}</div>
|
|
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
|
|
</div>
|
|
))}
|
|
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('contacts')}>View All Contacts →</button>
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
<div className="admin-dashboard-activity-col">
|
|
<h3 className="admin-dashboard-activity-heading">Recent Questions</h3>
|
|
{recentQuestions.length === 0
|
|
? <p className="admin-stats-note">No questions yet.</p>
|
|
: (
|
|
<div className="admin-dashboard-feed">
|
|
{recentQuestions.map(q => (
|
|
<div key={q.id} className="admin-dashboard-feed-item">
|
|
<div className="admin-dashboard-feed-meta">
|
|
<strong>{q.firstName}</strong>
|
|
<span className={`admin-badge ${q.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`} style={{ fontSize: '0.65rem' }}>{q.isApproved ? 'Approved' : 'Pending'}</span>
|
|
<span className="admin-dashboard-feed-date">{formatDate(q.submittedAt ?? '')}</span>
|
|
</div>
|
|
<div className="admin-dashboard-feed-preview">{q.question.slice(0, 100)}{q.question.length > 100 ? '…' : ''}</div>
|
|
</div>
|
|
))}
|
|
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('questions')}>View All Questions →</button>
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
})()}
|
|
|
|
{/* CONTACTS */}
|
|
{adminView === 'contacts' && (() => {
|
|
const searchTerm = contactSearch.trim().toLowerCase()
|
|
const grouped = new Map<string, ContactSubmission[]>()
|
|
|
|
for (const submission of contactSubmissions) {
|
|
const emailKey = submission.email.trim().toLowerCase()
|
|
const nameKey = submission.name.trim().toLowerCase()
|
|
const key = emailKey || nameKey || submission.id
|
|
const entries = grouped.get(key)
|
|
if (entries) entries.push(submission)
|
|
else grouped.set(key, [submission])
|
|
}
|
|
|
|
const rolledUp = Array.from(grouped.values())
|
|
.map(entries => {
|
|
const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
|
const latest = sortedEntries[0]
|
|
return {
|
|
...latest,
|
|
archived: sortedEntries.every(entry => entry.archived === true),
|
|
subscribe: sortedEntries.some(entry => entry.subscribe),
|
|
message: latest.message || sortedEntries.find(entry => entry.message)?.message || '',
|
|
submissionCount: sortedEntries.length,
|
|
}
|
|
})
|
|
.filter(contact => {
|
|
if (!searchTerm) return true
|
|
return contact.name.toLowerCase().includes(searchTerm)
|
|
|| contact.email.toLowerCase().includes(searchTerm)
|
|
|| contact.message.toLowerCase().includes(searchTerm)
|
|
})
|
|
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
|
|
|
return (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Contacts</h2>
|
|
<p>{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.</p>
|
|
</div>
|
|
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="search"
|
|
placeholder="Search name, email, or message…"
|
|
value={contactSearch}
|
|
onChange={e => setContactSearch(e.target.value)}
|
|
style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
|
|
/>
|
|
<span className="admin-stats-note" style={{ margin: 0 }}>{rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
{rolledUp.length === 0
|
|
? <p className="admin-stats-note">No contacts{contactSearch ? ' match your search' : ' yet'}.</p>
|
|
: (
|
|
<div className="admin-visits-table-wrap">
|
|
<table className="admin-visits-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Type</th>
|
|
<th>Subscriber</th>
|
|
<th>Date</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rolledUp.map(c => (
|
|
<tr key={c.id} className={c.archived ? 'admin-contacts-row--archived' : ''}>
|
|
<td>
|
|
<div>{c.name}</div>
|
|
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
|
|
</td>
|
|
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
|
|
<td><span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span></td>
|
|
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
|
|
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
|
|
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
</section>
|
|
)
|
|
})()}
|
|
|
|
{/* SUBSCRIBERS */}
|
|
{adminView === 'subscribers' && (() => {
|
|
const filteredSubs = subscribers.filter(s =>
|
|
!subscriberSearch.trim() ||
|
|
s.name.toLowerCase().includes(subscriberSearch.toLowerCase()) ||
|
|
s.email.toLowerCase().includes(subscriberSearch.toLowerCase())
|
|
)
|
|
return (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Subscribers</h2>
|
|
<p>{subscribers.length} people have opted in to email updates.</p>
|
|
</div>
|
|
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<input
|
|
type="search"
|
|
placeholder="Search by name or email…"
|
|
value={subscriberSearch}
|
|
onChange={e => setSubscriberSearch(e.target.value)}
|
|
style={{ minWidth: '240px', maxWidth: '400px', width: '100%' }}
|
|
/>
|
|
<form method="post" action="/api/admin-subscribers/export" style={{ display: 'inline' }}>
|
|
<button type="submit" className="btn-admin-reset">Export CSV</button>
|
|
</form>
|
|
</div>
|
|
{filteredSubs.length === 0 ? (
|
|
<p className="admin-stats-note">No subscribers{subscriberSearch ? ' match your search' : ' yet'}.</p>
|
|
) : (
|
|
<div className="admin-visits-table-wrap">
|
|
<table className="admin-visits-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Source</th>
|
|
<th>Subscribed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredSubs.map((sub, i) => (
|
|
<tr key={`${sub.email}-${i}`}>
|
|
<td>{sub.name}</td>
|
|
<td><a href={`mailto:${sub.email}`}>{sub.email}</a></td>
|
|
<td>{sub.source === 'download' ? 'Download' : 'Contact Form'}</td>
|
|
<td>{formatDate(sub.subscribedAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
})()}
|
|
|
|
{/* HOMEPAGE */}
|
|
{adminView === 'homepage' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Homepage</h2>
|
|
<p>Controls the hero, button labels, share section, and "Where to Next" navigation cards.</p>
|
|
</div>
|
|
{FIELDS.filter(f => f.section === 'hero' || f.section === 'share').map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline
|
|
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
|
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
|
</div>
|
|
))}
|
|
|
|
<div className="admin-panel-subhead">
|
|
<h3>Where to Next — Section Labels</h3>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="field-whereToNextEyebrow">Eyebrow</label>
|
|
<input id="field-whereToNextEyebrow" type="text" value={form.whereToNextEyebrow ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextEyebrow: e.target.value }))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="field-whereToNextHeading">Heading</label>
|
|
<input id="field-whereToNextHeading" type="text" value={form.whereToNextHeading ?? ''} onChange={e => setForm(f => ({ ...f, whereToNextHeading: e.target.value }))} />
|
|
</div>
|
|
|
|
<div className="admin-panel-subhead">
|
|
<h3>Where to Next — Navigation Cards</h3>
|
|
<p>Edit the title, description, and link path for each card.</p>
|
|
</div>
|
|
{(form.whereToNextCards ?? []).map((card, idx) => (
|
|
<div key={card.id} className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`wtn-title-${card.id}`}>Card {idx + 1} — Title</label>
|
|
<input id={`wtn-title-${card.id}`} type="text" value={card.title} onChange={e => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).map(c => c.id === card.id ? { ...c, title: e.target.value } : c) }))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`wtn-desc-${card.id}`}>Card {idx + 1} — Description</label>
|
|
<input id={`wtn-desc-${card.id}`} type="text" value={card.description} onChange={e => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).map(c => c.id === card.id ? { ...c, description: e.target.value } : c) }))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`wtn-path-${card.id}`}>Card {idx + 1} — Link Path</label>
|
|
<input id={`wtn-path-${card.id}`} type="text" value={card.path} onChange={e => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).map(c => c.id === card.id ? { ...c, path: e.target.value } : c) }))} />
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => setForm(f => ({ ...f, whereToNextCards: (f.whereToNextCards ?? []).filter(c => c.id !== card.id) }))}>Remove</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="btn-admin-add"
|
|
onClick={() => setForm(f => ({ ...f, whereToNextCards: [...(f.whereToNextCards ?? []), { id: Date.now().toString(36), title: '', description: '', path: '/' }] }))}
|
|
>
|
|
+ Add Card
|
|
</button>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* START HERE */}
|
|
{adminView === 'start-here' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Start Here Page</h2>
|
|
<p>Edit the /start-here page — heading, intro, and the three step cards.</p>
|
|
</div>
|
|
{FIELDS.filter(f => f.section === 'start-here').map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline
|
|
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
|
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
|
</div>
|
|
))}
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* ABOUT */}
|
|
{adminView === 'about' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>About</h2>
|
|
<p>Manage the main show description, Nate bio, and about images.</p>
|
|
</div>
|
|
{FIELDS.filter(f => f.section === 'about').map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline ? (
|
|
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
|
) : (
|
|
<>
|
|
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
|
{(key.includes('ImageUrl') || key.includes('PhotoUrl') || key.includes('ArtUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
|
{key === 'aboutPhotoUrl' && <p className="admin-field-note">This controls the portrait shown on the left side of the About page.</p>}
|
|
{key === 'aboutVerseArtUrl' && <p className="admin-field-note">This controls the scripture artwork shown under the “About the Show” text. It is separate from the Contact page image.</p>}
|
|
{key === 'aboutPhotoUrl' && renderImagePreview(form[key] as string, 'About page portrait preview')}
|
|
{key === 'aboutVerseArtUrl' && renderImagePreview(form[key] as string, 'About page scripture artwork preview')}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* CONTACT */}
|
|
{adminView === 'contact' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Contact</h2>
|
|
<p>Manage the contact page profile image and contact copy.</p>
|
|
</div>
|
|
{FIELDS.filter(f => f.section === 'contact').map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline ? (
|
|
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
|
) : (
|
|
<>
|
|
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
|
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* CURRENT SERIES */}
|
|
{adminView === 'current-series' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Current Series</h2>
|
|
<p>Update the active series card shown on the homepage and episodes page.</p>
|
|
</div>
|
|
{FIELDS.filter(f => f.section === 'series' && !['studyGuideTitle', 'studyGuideDescription', 'studyGuideUrl'].includes(f.key)).map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline ? (
|
|
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={4} />
|
|
) : (
|
|
<>
|
|
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
|
{(key.includes('ImageUrl') || key.includes('PhotoUrl')) && renderImageAssetSelector(form[key] as string, value => handleChange(key, value), `field-${key}-asset`)}
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Downloads.</p>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* EPISODE HIGHLIGHTS */}
|
|
{adminView === 'episode-highlights' && (
|
|
<section className="admin-panel-section" aria-label="Episode highlights">
|
|
<div className="admin-panel-head">
|
|
<h2>Episode Highlights</h2>
|
|
<p>Featured episodes shown on the Episodes page. Fill in discussion questions, show notes, or an embed URL and the highlight automatically gets its own detail page at <code>/episodes/[id]</code>.</p>
|
|
</div>
|
|
{(form.podcastFeaturedLinks ?? []).length === 0 && (
|
|
<p className="admin-stats-note">No episode highlights yet. Add one below.</p>
|
|
)}
|
|
{(form.podcastFeaturedLinks ?? []).map(item => (
|
|
<details key={item.id} className="admin-collapsible-card">
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>{item.title || 'Untitled episode highlight'}</strong>
|
|
<p>{item.episodeNumber ? `Episode ${item.episodeNumber}` : 'No episode number yet'}</p>
|
|
</div>
|
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
|
</summary>
|
|
<div className="admin-collapsible-body">
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-ep-${item.id}`}>Episode Number</label>
|
|
<input id={`podcast-ep-${item.id}`} type="text" placeholder="e.g. 42" value={item.episodeNumber ?? ''} onChange={e => updatePodcastLink(item.id, 'episodeNumber', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-title-${item.id}`}>Title</label>
|
|
<input id={`podcast-title-${item.id}`} type="text" value={item.title} onChange={e => updatePodcastLink(item.id, 'title', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-summary-${item.id}`}>Summary</label>
|
|
<textarea id={`podcast-summary-${item.id}`} rows={2} value={item.summary} onChange={e => updatePodcastLink(item.id, 'summary', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-url-${item.id}`}>Platform URL (external link)</label>
|
|
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional — enables in-page player)</label>
|
|
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
|
|
<textarea id={`podcast-notes-${item.id}`} rows={4} placeholder="Key points, scripture references, timestamps..." value={item.showNotes ?? ''} onChange={e => updatePodcastLink(item.id, 'showNotes', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`podcast-dq-${item.id}`}>Discussion Questions (one per line)</label>
|
|
<textarea id={`podcast-dq-${item.id}`} rows={5} placeholder="What stood out to you in this passage?" value={(item.discussionQuestions ?? []).join('\n')} onChange={e => updatePodcastLinkQuestions(item.id, e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removePodcastLink(item.id)}>Remove</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
))}
|
|
<button type="button" className="btn-admin-add" onClick={addPodcastLink}>+ Add Episode Highlight</button>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* ARCHIVED SERIES */}
|
|
{adminView === '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">
|
|
<div className="admin-panel-head">
|
|
<h2>Downloads</h2>
|
|
<p>Manage the featured study guide, current download library, and previous series downloads.</p>
|
|
</div>
|
|
|
|
<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">
|
|
<h3>Companion Study Guide</h3>
|
|
<p>This is the featured primary download at the top of the Downloads page.</p>
|
|
</div>
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor="resources-study-guide-title">Title</label>
|
|
<input id="resources-study-guide-title" type="text" value={form.studyGuideTitle} placeholder="Companion Study Guide" onChange={e => handleChange('studyGuideTitle', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="resources-study-guide-description">Description</label>
|
|
<textarea id="resources-study-guide-description" rows={3} value={form.studyGuideDescription} placeholder="Describe the study guide download." onChange={e => handleChange('studyGuideDescription', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="resources-study-guide-download-url">Primary Download URL</label>
|
|
<input id="resources-study-guide-download-url" type="url" value={form.studyGuideDownloadUrl} placeholder="/uploads/new-guide.pdf or https://..." onChange={e => handleChange('studyGuideDownloadUrl', e.target.value)} />
|
|
{renderFileAssetSelector(form.studyGuideDownloadUrl, value => handleChange('studyGuideDownloadUrl', value), 'resources-study-guide-download-url-asset')}
|
|
<p className="admin-stats-note">This is the file URL the main guide download form will deliver after submission.</p>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="resources-study-guide-url">Printed Copy URL (Amazon button)</label>
|
|
<input id="resources-study-guide-url" type="url" value={form.studyGuideUrl} placeholder="https://..." onChange={e => handleChange('studyGuideUrl', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="resources-study-guide-amazon-label">Amazon Button Label</label>
|
|
<input id="resources-study-guide-amazon-label" type="text" value={form.studyGuideAmazonButtonLabel} placeholder="Get it on Amazon" onChange={e => handleChange('studyGuideAmazonButtonLabel', e.target.value)} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="admin-section-header">
|
|
<h3>Download Library</h3>
|
|
<p>Manage the current downloads shown below the featured guide.</p>
|
|
</div>
|
|
{resourceLinks.length === 0 && <p className="admin-stats-note">No resources yet.</p>}
|
|
{resourceLinks.map(link => (
|
|
<details key={link.id} className="admin-collapsible-card">
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>{link.label || 'Untitled download'}</strong>
|
|
<p>{link.description || 'Current download item'}</p>
|
|
</div>
|
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
|
</summary>
|
|
<div className="admin-collapsible-body">
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-label-${link.id}`}>Label</label>
|
|
<input id={`resource-label-${link.id}`} type="text" value={link.label} placeholder="Resource title" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-url-${link.id}`}>URL</label>
|
|
<input id={`resource-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-description-${link.id}`}>Description</label>
|
|
<textarea id={`resource-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
|
<input id={`resource-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(link.id, 'amazonUrl', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-amazon-label-${link.id}`}>Amazon Button Label</label>
|
|
<input id={`resource-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(link.id, 'amazonLabel', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-image-${link.id}`}>Image URL</label>
|
|
<input id={`resource-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
|
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resource-image-${link.id}-asset`)}
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resource-tags-${link.id}`}>Tags</label>
|
|
<input id={`resource-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="sermon, bible study, faith" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
))}
|
|
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Download</button>
|
|
|
|
<div className="admin-section-header">
|
|
<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 => (
|
|
<details key={series.id} className="admin-collapsible-card admin-collapsible-card--group">
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>{series.title || 'Untitled archived series'}</strong>
|
|
<p>{(series.resourceLinks ?? []).length} download{(series.resourceLinks ?? []).length === 1 ? '' : 's'}</p>
|
|
</div>
|
|
<span className="admin-collapsible-hint">Expand to manage</span>
|
|
</summary>
|
|
<div className="admin-collapsible-body">
|
|
<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 => (
|
|
<details key={link.id} className="admin-collapsible-card admin-collapsible-card--nested">
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>{link.label || 'Untitled archived download'}</strong>
|
|
<p>{link.description || 'Previous study download'}</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">
|
|
<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>
|
|
</div>
|
|
</details>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
))}
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* CUSTOM LINKS */}
|
|
{adminView === 'custom-links' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Custom Links</h2>
|
|
<p>Add links to show in platform buttons, footer navigation, or the More Resources download library.</p>
|
|
</div>
|
|
|
|
<div className="admin-panel-subhead">
|
|
<h3>Platform + Footer Links</h3>
|
|
<p>Links that appear on the listen buttons row or footer nav.</p>
|
|
</div>
|
|
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').length === 0 && (
|
|
<p className="admin-stats-note">No custom links yet.</p>
|
|
)}
|
|
{(form.customLinks ?? []).filter(link => link.placement !== 'resources').map(link => (
|
|
<div key={link.id} className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`link-label-${link.id}`}>Label</label>
|
|
<input id={`link-label-${link.id}`} type="text" value={link.label} placeholder="e.g. iHeart Radio" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`link-url-${link.id}`}>URL</label>
|
|
<input id={`link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`link-image-${link.id}`}>Image URL</label>
|
|
<input id={`link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
|
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `link-image-${link.id}-asset`)}
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`link-tags-${link.id}`}>Tags</label>
|
|
<input id={`link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="listen, podcast, study" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`link-placement-${link.id}`}>Show in</label>
|
|
<select id={`link-placement-${link.id}`} value={link.placement} onChange={e => updateLink(link.id, 'placement', e.target.value)}>
|
|
<option value="platforms">Platform Buttons (Listen section)</option>
|
|
<option value="footer">Footer Nav</option>
|
|
<option value="resources">More Resources Section</option>
|
|
</select>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>Quick Action</label>
|
|
<button type="button" className="btn-admin-apply" onClick={() => moveLinkToResources(link.id)}>Move to Resources</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
|
</div>
|
|
))}
|
|
<button type="button" className="btn-admin-add" onClick={addLink}>+ Add Link</button>
|
|
|
|
<div className="admin-panel-subhead">
|
|
<h3>More Resources Links</h3>
|
|
<p>These create cards in Downloads → More guides, worksheets, and past study downloads.</p>
|
|
</div>
|
|
{(form.customLinks ?? []).filter(link => link.placement === 'resources').length === 0 && (
|
|
<p className="admin-stats-note">No resource links yet.</p>
|
|
)}
|
|
{(form.customLinks ?? []).filter(link => link.placement === 'resources').map(link => (
|
|
<div key={link.id} className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-label-${link.id}`}>Label</label>
|
|
<input id={`resources-link-label-${link.id}`} type="text" value={link.label} placeholder="Resource title" onChange={e => updateLink(link.id, 'label', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-url-${link.id}`}>Download URL</label>
|
|
<input id={`resources-link-url-${link.id}`} type="url" value={link.url} placeholder="https://..." onChange={e => updateLink(link.id, 'url', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-description-${link.id}`}>Description</label>
|
|
<textarea id={`resources-link-description-${link.id}`} rows={3} value={link.description ?? ''} placeholder="Description shown on the download page" onChange={e => updateLink(link.id, 'description', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-image-${link.id}`}>Image URL</label>
|
|
<input id={`resources-link-image-${link.id}`} type="text" value={link.imageUrl ?? ''} placeholder="/uploads/example.png" onChange={e => updateLink(link.id, 'imageUrl', e.target.value)} />
|
|
{renderImageAssetSelector(link.imageUrl, value => updateLink(link.id, 'imageUrl', value), `resources-link-image-${link.id}-asset`)}
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-amazon-url-${link.id}`}>Amazon URL (optional)</label>
|
|
<input id={`resources-link-amazon-url-${link.id}`} type="url" value={link.amazonUrl ?? ''} placeholder="https://amazon.com/..." onChange={e => updateLink(link.id, 'amazonUrl', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-amazon-label-${link.id}`}>Amazon Button Label</label>
|
|
<input id={`resources-link-amazon-label-${link.id}`} type="text" value={link.amazonLabel ?? ''} placeholder="Get it on Amazon" onChange={e => updateLink(link.id, 'amazonLabel', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`resources-link-tags-${link.id}`}>Tags</label>
|
|
<input id={`resources-link-tags-${link.id}`} type="text" value={(link.tags ?? []).join(', ')} placeholder="worksheet, study, pdf" onChange={e => updateLink(link.id, 'tags', e.target.value.split(',').map(tag => tag.trim()).filter(Boolean))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label>Quick Action</label>
|
|
<button type="button" className="btn-admin-apply" onClick={() => updateLink(link.id, 'placement', 'platforms')}>Move to Platform Buttons</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeLink(link.id)}>Remove</button>
|
|
</div>
|
|
))}
|
|
<button type="button" className="btn-admin-add" onClick={addResource}>+ Add Resource Link</button>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* CONTENT BLOCKS */}
|
|
{adminView === 'content-blocks' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Content Blocks</h2>
|
|
<p>Add extra text sections to any page. Choose which page each block appears on.</p>
|
|
</div>
|
|
{(form.customBlocks ?? []).length === 0 && <p className="admin-stats-note">No custom content blocks yet.</p>}
|
|
{(form.customBlocks ?? []).map(block => (
|
|
<div key={block.id} className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`block-page-${block.id}`}>Page</label>
|
|
<select id={`block-page-${block.id}`} value={block.page ?? 'downloads'} onChange={e => updateBlock(block.id, 'page', e.target.value)}>
|
|
<option value="homepage">Homepage</option>
|
|
<option value="start-here">Start Here</option>
|
|
<option value="episodes">Episodes</option>
|
|
<option value="downloads">Downloads</option>
|
|
<option value="about">About</option>
|
|
<option value="contact">Contact</option>
|
|
<option value="questions">Q&A</option>
|
|
</select>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`block-heading-${block.id}`}>Heading</label>
|
|
<input id={`block-heading-${block.id}`} type="text" value={block.heading} placeholder="Section heading" onChange={e => updateBlock(block.id, 'heading', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`block-body-${block.id}`}>Body Text</label>
|
|
<textarea id={`block-body-${block.id}`} value={block.body} rows={3} placeholder="Write your content here…" onChange={e => updateBlock(block.id, 'body', e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeBlock(block.id)}>Remove</button>
|
|
</div>
|
|
))}
|
|
<button type="button" className="btn-admin-add" onClick={addBlock}>+ Add Content Block</button>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* STUDIES */}
|
|
{adminView === 'colossians-study' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Studies</h2>
|
|
<p>Manage multiple study tracks and their lesson sections (text, commentary, Greek notes, audio, and questions).</p>
|
|
</div>
|
|
|
|
{(form.studies ?? []).length === 0 && <p className="admin-stats-note">No studies yet.</p>}
|
|
|
|
{(form.studies ?? []).map(study => (
|
|
<details key={study.id} className="admin-collapsible-card" open={false}>
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>{study.title || 'Untitled study'} · /study/{study.slug || 'slug'}</strong>
|
|
<p>{study.description || 'Study description'}</p>
|
|
</div>
|
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
|
</summary>
|
|
<div className="admin-collapsible-body">
|
|
<div className="admin-array-row" style={{ marginBottom: '1rem' }}>
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-title-${study.id}`}>Study Title</label>
|
|
<input id={`study-title-${study.id}`} type="text" value={study.title} placeholder="Colossians: Rooted in Christ" onChange={e => updateStudyProgram(study.id, 'title', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
|
|
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-status-${study.id}`}>Status</label>
|
|
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
|
|
<option value="active">Active</option>
|
|
<option value="planned">Planned</option>
|
|
</select>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-description-${study.id}`}>Description</label>
|
|
<textarea id={`study-description-${study.id}`} rows={3} value={study.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(study.id, 'description', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-home-eyebrow-${study.id}`}>Homepage Card Eyebrow</label>
|
|
<input id={`study-home-eyebrow-${study.id}`} type="text" value={study.homepageEyebrow ?? ''} placeholder="New Study" onChange={e => updateStudyProgram(study.id, 'homepageEyebrow', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-new-tag-label-${study.id}`}>NEW Tag Label</label>
|
|
<input id={`study-new-tag-label-${study.id}`} type="text" value={study.newTagLabel ?? 'NEW'} placeholder="NEW" onChange={e => updateStudyProgram(study.id, 'newTagLabel', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
|
<input id={`study-home-feature-${study.id}`} type="checkbox" checked={study.showOnHomepage === true} onChange={e => updateStudyProgram(study.id, 'showOnHomepage', e.target.checked)} />
|
|
<label htmlFor={`study-home-feature-${study.id}`}>Feature this study card on homepage</label>
|
|
</div>
|
|
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
|
<input id={`study-show-new-tag-${study.id}`} type="checkbox" checked={study.showNewTag === true} onChange={e => updateStudyProgram(study.id, 'showNewTag', e.target.checked)} />
|
|
<label htmlFor={`study-show-new-tag-${study.id}`}>Show NEW tag on homepage card</label>
|
|
</div>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-chapters-${study.id}`}>Number of Chapters</label>
|
|
<input id={`study-chapters-${study.id}`} type="number" min="1" max="999" value={study.numberOfChapters} placeholder="4" onChange={e => updateStudyProgram(study.id, "numberOfChapters", Number(e.target.value) || 1)} />
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeStudyProgram(study.id)}>Remove Study</button>
|
|
</div>
|
|
|
|
{(study.sections ?? []).length === 0 && (
|
|
<p className="admin-stats-note">No sections yet for this study.</p>
|
|
)}
|
|
|
|
{(study.sections ?? []).map(section => (
|
|
<details key={section.id} className="admin-collapsible-card" open={false}>
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>Lesson {section.reference || section.id}</strong>
|
|
<p>{section.title || 'Untitled section'}</p>
|
|
</div>
|
|
<span className="admin-collapsible-hint">Expand to edit lesson</span>
|
|
</summary>
|
|
<div className="admin-collapsible-body">
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-chapter-${study.id}-${section.id}`}>Chapter</label>
|
|
<select id={`study-section-chapter-${study.id}-${section.id}`} value={section.chapter} onChange={e => updateStudySection(study.id, section.id, 'chapter', Number(e.target.value) || 1)}>
|
|
{Array.from({ length: Math.max(study.numberOfChapters, section.chapter, 1) }, (_, index) => index + 1).map(chapterNumber => (
|
|
<option key={chapterNumber} value={chapterNumber}>Chapter {chapterNumber}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-reference-${study.id}-${section.id}`}>Passage Reference</label>
|
|
<input id={`study-section-reference-${study.id}-${section.id}`} type="text" value={section.reference} placeholder="1:1-2" onChange={e => updateStudySection(study.id, section.id, 'reference', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-title-${study.id}-${section.id}`}>Section Title</label>
|
|
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
|
|
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
|
|
<input id={`study-section-released-${study.id}-${section.id}`} type="datetime-local" value={section.releasedAt ? new Date(section.releasedAt).toISOString().slice(0, 16) : ''} onChange={e => {
|
|
const val = e.target.value;
|
|
if (val) {
|
|
const date = new Date(val + ':00Z');
|
|
updateStudySection(study.id, section.id, 'releasedAt', date.toISOString());
|
|
} else {
|
|
updateStudySection(study.id, section.id, 'releasedAt', '');
|
|
}
|
|
}} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-summary-${study.id}-${section.id}`}>Short Summary</label>
|
|
<textarea id={`study-section-summary-${study.id}-${section.id}`} rows={3} value={section.summary} placeholder="One to two sentences that summarize the section." onChange={e => updateStudySection(study.id, section.id, 'summary', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-passage-${study.id}-${section.id}`}>Passage Text</label>
|
|
<textarea id={`study-section-passage-${study.id}-${section.id}`} rows={4} value={section.passageText} placeholder="Paste the passage text here." onChange={e => updateStudySection(study.id, section.id, 'passageText', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-commentary-${study.id}-${section.id}`}>Commentary</label>
|
|
<textarea id={`study-section-commentary-${study.id}-${section.id}`} rows={5} value={section.commentary} placeholder="Add your teaching notes and explanation here." onChange={e => updateStudySection(study.id, section.id, 'commentary', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-greek-${study.id}-${section.id}`}>Greek Words</label>
|
|
<textarea id={`study-section-greek-${study.id}-${section.id}`} rows={4} value={(section.greekNotes ?? []).join('\n')} placeholder="One note per line" onChange={e => updateStudySection(study.id, section.id, 'greekNotes', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`study-section-questions-${study.id}-${section.id}`}>Study Questions</label>
|
|
<textarea id={`study-section-questions-${study.id}-${section.id}`} rows={5} value={(section.studyQuestions ?? []).join('\n')} placeholder="One question per line" onChange={e => updateStudySection(study.id, section.id, 'studyQuestions', e.target.value.split('\n').map(line => line.trim()).filter(Boolean))} />
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeStudySection(study.id, section.id)}>Remove Lesson</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
))}
|
|
|
|
<button type="button" className="btn-admin-add" onClick={() => addStudySection(study.id)}>+ Add Lesson Section</button>
|
|
</div>
|
|
</details>
|
|
))}
|
|
|
|
<button type="button" className="btn-admin-add" onClick={addStudyProgram}>+ Add Study Program</button>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* GLOBAL / FOOTER & PLATFORM */}
|
|
{adminView === 'global' && (
|
|
<section className="admin-panel-section">
|
|
<div className="admin-panel-head">
|
|
<h2>Footer & Global Settings</h2>
|
|
<p>Edit the site footer text, platform links, header label, and analytics cookie banner.</p>
|
|
</div>
|
|
|
|
<div className="admin-panel-subhead"><h3>Footer</h3></div>
|
|
{FIELDS.filter(f => f.section === 'global' && ['footerTitle','footerSubtitle','footerEmail','footerCopyright','footerPrivacyNote'].includes(f.key)).map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline
|
|
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
|
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
|
</div>
|
|
))}
|
|
|
|
<div className="admin-panel-subhead"><h3>Header</h3></div>
|
|
{FIELDS.filter(f => f.section === 'global' && f.key === 'headerFollowLabel').map(({ key, label }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
|
</div>
|
|
))}
|
|
|
|
<div className="admin-panel-subhead"><h3>Platform URLs</h3></div>
|
|
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('platform')).map(({ key, label }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
<input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />
|
|
</div>
|
|
))}
|
|
|
|
<div className="admin-panel-subhead"><h3>Analytics Banner</h3></div>
|
|
{FIELDS.filter(f => f.section === 'global' && f.key === 'cookieBannerText').map(({ key, label }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
<textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
|
</div>
|
|
))}
|
|
|
|
<div className="admin-panel-subhead"><h3>Welcome Email</h3></div>
|
|
{FIELDS.filter(f => f.section === 'global' && f.key.startsWith('welcomeEmail')).map(({ key, label, multiline }) => (
|
|
<div className="admin-field" key={key}>
|
|
<label htmlFor={`field-${key}`}>{label}</label>
|
|
{multiline
|
|
? <textarea id={`field-${key}`} value={form[key] as string} onChange={e => handleChange(key, e.target.value)} rows={3} />
|
|
: <input id={`field-${key}`} type="text" value={form[key] as string} onChange={e => handleChange(key, e.target.value)} />}
|
|
</div>
|
|
))}
|
|
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* QUESTIONS */}
|
|
{adminView === 'questions' && (
|
|
<section className="admin-panel-section" aria-label="Q&A Management">
|
|
<div className="admin-panel-head">
|
|
<h2>Bible Questions & Answers</h2>
|
|
<p>Manage submitted questions, add manual questions, provide answers, and approve for public display.</p>
|
|
</div>
|
|
|
|
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
|
|
<input
|
|
type="text"
|
|
value={questionSearch}
|
|
onChange={e => setQuestionSearch(e.target.value)}
|
|
placeholder="Search by name, question, or answer..."
|
|
style={{ minWidth: '320px', maxWidth: '520px', width: '100%' }}
|
|
/>
|
|
<button type="button" className={`btn-admin-reset${questionFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('all')}>All</button>
|
|
<button type="button" className={`btn-admin-reset${questionFilter === 'pending' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('pending')}>Pending</button>
|
|
<button type="button" className={`btn-admin-reset${questionFilter === 'approved' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('approved')}>Approved</button>
|
|
<button type="button" className={`btn-admin-reset${questionFilter === 'answered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('answered')}>Answered</button>
|
|
<button type="button" className={`btn-admin-reset${questionFilter === 'unanswered' ? ' btn-admin-reset--active' : ''}`} onClick={() => setQuestionFilter('unanswered')}>Unanswered</button>
|
|
</div>
|
|
<p className="admin-stats-note">Showing {filteredAdminQuestions.length} of {questions.length} questions.</p>
|
|
|
|
{selectedQuestionIds.size > 0 && (
|
|
<div className="admin-bulk-toolbar">
|
|
<span>{selectedQuestionIds.size} selected</span>
|
|
<button type="button" className="btn-admin-remove" onClick={handleBulkDeleteQuestions}>Delete Selected</button>
|
|
<button type="button" className="btn-admin-reset" onClick={() => setSelectedQuestionIds(new Set())}>Deselect All</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="admin-visits-table-wrap">
|
|
<h3>Add Question Manually</h3>
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field">
|
|
<label htmlFor="manual-question-first-name">First Name</label>
|
|
<input
|
|
id="manual-question-first-name"
|
|
type="text"
|
|
value={manualQuestion.firstName}
|
|
onChange={e => setManualQuestion(curr => ({ ...curr, firstName: e.target.value }))}
|
|
placeholder="Nate"
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="manual-question-email">Email (optional)</label>
|
|
<input
|
|
id="manual-question-email"
|
|
type="email"
|
|
value={manualQuestion.email}
|
|
onChange={e => setManualQuestion(curr => ({ ...curr, email: e.target.value }))}
|
|
placeholder="optional@email.com"
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="manual-question-question">Question</label>
|
|
<textarea
|
|
id="manual-question-question"
|
|
rows={3}
|
|
value={manualQuestion.question}
|
|
onChange={e => setManualQuestion(curr => ({ ...curr, question: e.target.value }))}
|
|
placeholder="Type the question you want to add to the Q&A list..."
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="manual-question-answer">Answer (optional)</label>
|
|
<textarea
|
|
id="manual-question-answer"
|
|
rows={3}
|
|
value={manualQuestion.answer}
|
|
onChange={e => setManualQuestion(curr => ({ ...curr, answer: e.target.value }))}
|
|
placeholder="Optional: add an answer now"
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="manual-question-approve">Approve Immediately</label>
|
|
<select
|
|
id="manual-question-approve"
|
|
value={manualQuestion.approve ? 'yes' : 'no'}
|
|
onChange={e => setManualQuestion(curr => ({ ...curr, approve: e.target.value === 'yes' }))}
|
|
>
|
|
<option value="no">No</option>
|
|
<option value="yes">Yes</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn-admin-save"
|
|
onClick={handleCreateManualQuestion}
|
|
disabled={manualQuestionStatus === 'saving'}
|
|
>
|
|
{manualQuestionStatus === 'saving' ? 'Adding…' : 'Add Question'}
|
|
</button>
|
|
</div>
|
|
{manualQuestionMsg && (
|
|
<p className={`admin-status ${manualQuestionStatus === 'error' ? 'admin-status--err' : 'admin-status--ok'}`}>
|
|
{manualQuestionMsg}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{questions.length === 0 ? (
|
|
<p className="admin-stats-note">No questions submitted yet.</p>
|
|
) : filteredAdminQuestions.length === 0 ? (
|
|
<p className="admin-stats-note">No questions match this filter.</p>
|
|
) : (
|
|
<div className="admin-questions-list">
|
|
{visibleAdminQuestions.map(question => (
|
|
<div key={question.id} className={`admin-question-card${selectedQuestionIds.has(question.id) ? ' admin-question-card--selected' : ''}`}>
|
|
<div className="admin-question-header">
|
|
<label className="admin-question-checkbox" title="Select question">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedQuestionIds.has(question.id)}
|
|
onChange={e => {
|
|
setSelectedQuestionIds(prev => {
|
|
const next = new Set(prev)
|
|
if (e.target.checked) next.add(question.id)
|
|
else next.delete(question.id)
|
|
return next
|
|
})
|
|
}}
|
|
/>
|
|
</label>
|
|
<div>
|
|
<p className="admin-question-meta"><strong>{question.firstName}</strong> • {formatDate(question.submittedAt)}</p>
|
|
<p className="admin-question-text"><strong>Q:</strong> {question.question}</p>
|
|
</div>
|
|
<div className="admin-question-status">
|
|
<span className={`admin-badge ${question.isApproved ? 'admin-badge--approved' : 'admin-badge--pending'}`}>
|
|
{question.isApproved ? 'Approved' : 'Pending'}
|
|
</span>
|
|
{question.answer && <span className="admin-badge admin-badge--answered">Answered</span>}
|
|
</div>
|
|
</div>
|
|
{question.answer && (
|
|
<div className="admin-question-answer">
|
|
<p><strong>A:</strong> {question.answer}</p>
|
|
</div>
|
|
)}
|
|
{editingQuestionId === question.id ? (
|
|
<div className="admin-question-editor">
|
|
<textarea
|
|
value={answeredQuestions[question.id] ?? question.answer ?? ''}
|
|
onChange={e => setAnsweredQuestions(a => ({ ...a, [question.id]: e.target.value }))}
|
|
rows={4}
|
|
placeholder="Type your answer here..."
|
|
/>
|
|
<div className="admin-question-editor-actions">
|
|
<button type="button" className="btn-admin-save" onClick={() => handleAnswerQuestion(question.id, answeredQuestions[question.id] ?? '')}>Save Answer</button>
|
|
<button type="button" className="btn-admin-reset" onClick={() => setEditingQuestionId(null)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="admin-question-actions">
|
|
<button type="button" className="btn-admin-reset" onClick={() => { setEditingQuestionId(question.id); setAnsweredQuestions(a => ({ ...a, [question.id]: question.answer ?? '' })) }}>
|
|
{question.answer ? 'Edit Answer' : 'Add Answer'}
|
|
</button>
|
|
<button type="button" className={`btn-admin-${question.isApproved ? 'remove' : 'reset'}`} onClick={() => handleApproveQuestion(question.id, !question.isApproved)}>
|
|
{question.isApproved ? 'Unapprove' : 'Approve'}
|
|
</button>
|
|
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteQuestion(question.id)}>Delete</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{filteredAdminQuestions.length > QUESTION_PAGE_SIZE && (
|
|
<div className="qa-pagination" style={{ marginTop: '1rem', justifyContent: 'flex-start' }}>
|
|
<button
|
|
type="button"
|
|
className="qa-page-btn"
|
|
onClick={() => setQuestionPage(p => Math.max(0, p - 1))}
|
|
disabled={questionPage === 0}
|
|
>
|
|
Prev
|
|
</button>
|
|
<span className="qa-page-info">Page {questionPage + 1} / {totalQuestionPages}</span>
|
|
<button
|
|
type="button"
|
|
className="qa-page-btn"
|
|
onClick={() => setQuestionPage(p => Math.min(totalQuestionPages - 1, p + 1))}
|
|
disabled={questionPage >= totalQuestionPages - 1}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* ANALYTICS */}
|
|
{adminView === 'analytics' && (
|
|
<AnalyticsPanel
|
|
stats={stats}
|
|
statsStatus={statsStatus}
|
|
opsStatus={opsStatus}
|
|
formatDate={formatDate}
|
|
maskIp={maskIp}
|
|
maintenanceMsg={maintenanceMsg}
|
|
selectedBackup={selectedBackup}
|
|
backupFiles={backupFiles}
|
|
selectedBackupPreview={selectedBackupPreview}
|
|
onBackupSelect={setSelectedBackup}
|
|
onRestore={handleRestoreBackup}
|
|
onExport={handleExport}
|
|
onBackupNow={handleBackupNow}
|
|
onPrune={handlePrune}
|
|
onClear={handleClear}
|
|
onPurgeCache={handlePurgeCache}
|
|
onDeployHook={handleDeployHook}
|
|
onRefreshStatus={() => { void reloadOpsStatus() }}
|
|
/>
|
|
)}
|
|
|
|
{/* EMAILS */}
|
|
{adminView === 'emails' && (
|
|
<section className="admin-panel-section" aria-label="Email center">
|
|
<div className="admin-panel-head">
|
|
<h2>Email Center</h2>
|
|
<p>Manage inbound contact emails, archive threads, reply with templates, and review sent history.</p>
|
|
</div>
|
|
|
|
{contactReplyConfig && (
|
|
<div className="admin-restore-preview" style={{ marginBottom: '0.9rem' }}>
|
|
<h3>Sender Status</h3>
|
|
<p><strong>From:</strong> {contactReplyConfig.fromIdentity}</p>
|
|
<p><strong>Resend Configured:</strong> {contactReplyConfig.resendApiConfigured ? 'Yes' : 'No'}</p>
|
|
<p><strong>Can Send Replies:</strong> {contactReplyConfig.canSendReplies ? 'Yes' : 'No'}</p>
|
|
<p>{contactReplyConfig.note}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '0.75rem' }}>
|
|
<button type="button" className={`btn-admin-reset${emailMailboxView === 'inbox' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('inbox')}>Inbox</button>
|
|
<button type="button" className={`btn-admin-reset${emailMailboxView === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setEmailMailboxView('archived')}>Archived</button>
|
|
</div>
|
|
|
|
{contactStatus === 'loading' && <p className="admin-stats-note">Loading emails…</p>}
|
|
{contactStatus === 'error' && <p className="admin-stats-note">Could not load contact submissions.</p>}
|
|
|
|
{contactStatus === 'ready' && (
|
|
<div className="admin-email-layout">
|
|
<aside className="admin-email-list">
|
|
{contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true)).length === 0 && (
|
|
<p className="admin-stats-note">No messages in this mailbox.</p>
|
|
)}
|
|
{contactSubmissions
|
|
.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
|
.map(item => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className={`admin-email-list-item${selectedEmailId === item.id ? ' admin-email-list-item--active' : ''}`}
|
|
onClick={() => setSelectedEmailId(item.id)}
|
|
>
|
|
<div className="admin-email-list-head">
|
|
<strong>{item.name}</strong>
|
|
<span>{formatDate(item.submittedAt)}</span>
|
|
</div>
|
|
<p>{item.message}</p>
|
|
</button>
|
|
))}
|
|
</aside>
|
|
|
|
<div className="admin-email-detail">
|
|
{(() => {
|
|
const visible = contactSubmissions.filter(item => (emailMailboxView === 'archived' ? item.archived === true : item.archived !== true))
|
|
const selected = visible.find(item => item.id === selectedEmailId) ?? null
|
|
if (!selected) return <p className="admin-stats-note">Select an email to view details.</p>
|
|
|
|
return (
|
|
<>
|
|
<div className="admin-email-meta">
|
|
<p><strong>From:</strong> {selected.name} <{selected.email}></p>
|
|
<p><strong>Type:</strong> {selected.messageType}</p>
|
|
<p><strong>Subscribed:</strong> {selected.subscribe ? 'Yes' : 'No'}</p>
|
|
<p><strong>Received:</strong> {formatDate(selected.submittedAt)}</p>
|
|
</div>
|
|
|
|
<div className="admin-email-body">
|
|
<p>{selected.message}</p>
|
|
</div>
|
|
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-save admin-email-action-btn" onClick={() => openContactReplyComposer(selected)}>Reply</button>
|
|
<button
|
|
type="button"
|
|
className="btn-admin-reset admin-email-action-btn"
|
|
onClick={() => handleArchiveContactSubmission(selected.id, !(selected.archived === true))}
|
|
>
|
|
{selected.archived === true ? 'Move to Inbox' : 'Archive'}
|
|
</button>
|
|
<button type="button" className="btn-admin-remove admin-email-action-btn" onClick={() => handleDeleteContactSubmission(selected.id)}>Delete</button>
|
|
</div>
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{contactReplyDraft && (
|
|
<div className="admin-array-row" style={{ marginTop: '1rem' }}>
|
|
<div className="admin-array-fields">
|
|
{contactReplyTemplates.length > 0 && (
|
|
<div className="admin-field">
|
|
<label htmlFor="reply-template">Saved Template</label>
|
|
<select id="reply-template" defaultValue="" onChange={e => applyContactReplyTemplate(e.target.value)}>
|
|
<option value="">Choose a template…</option>
|
|
{contactReplyTemplates.map(template => (
|
|
<option key={template.id} value={template.id}>{template.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div className="admin-field">
|
|
<label htmlFor="reply-to">To</label>
|
|
<input id="reply-to" type="text" value={`${contactReplyDraft.recipientName} <${contactReplyDraft.recipientEmail}>`} readOnly />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="reply-from">From</label>
|
|
<input id="reply-from" type="text" value="hello@versebyversewithnate.us" readOnly />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="reply-subject">Subject</label>
|
|
<input
|
|
id="reply-subject"
|
|
type="text"
|
|
value={contactReplyDraft.subject}
|
|
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, subject: e.target.value } : draft)}
|
|
/>
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor="reply-message">Message</label>
|
|
<textarea
|
|
id="reply-message"
|
|
rows={8}
|
|
value={contactReplyDraft.message}
|
|
onChange={e => setContactReplyDraft(draft => draft ? { ...draft, message: e.target.value } : draft)}
|
|
/>
|
|
<p className="admin-stats-note">This will be wrapped in a professional HTML email template automatically.</p>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<button type="button" className="btn-admin-save" onClick={handleSendContactReply} disabled={contactReplyStatus === 'sending'}>
|
|
{contactReplyStatus === 'sending' ? 'Sending…' : 'Send Reply'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-admin-reset"
|
|
onClick={() => {
|
|
setContactReplyDraft(null)
|
|
setContactReplyStatus('idle')
|
|
setContactReplyMsg('')
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{contactReplyMsg && <p className="admin-stats-note">{contactReplyMsg}</p>}
|
|
|
|
<details className="admin-collapsible-card">
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>Saved Reply Templates</strong>
|
|
<p>{contactReplyTemplates.length} template{contactReplyTemplates.length === 1 ? '' : 's'} configured</p>
|
|
</div>
|
|
<span className="admin-collapsible-hint">Expand to edit</span>
|
|
</summary>
|
|
<div className="admin-collapsible-body">
|
|
{contactReplyTemplates.length === 0 && <p className="admin-stats-note">No saved templates yet.</p>}
|
|
{contactReplyTemplates.map(template => (
|
|
<details key={template.id} className="admin-collapsible-card admin-collapsible-card--nested">
|
|
<summary className="admin-collapsible-summary">
|
|
<div>
|
|
<strong>{template.label || 'Untitled template'}</strong>
|
|
<p>{template.subject || 'No subject set'}</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">
|
|
<div className="admin-field">
|
|
<label htmlFor={`reply-template-label-${template.id}`}>Label</label>
|
|
<input id={`reply-template-label-${template.id}`} type="text" value={template.label} onChange={e => updateContactReplyTemplate(template.id, 'label', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`reply-template-subject-${template.id}`}>Subject</label>
|
|
<input id={`reply-template-subject-${template.id}`} type="text" value={template.subject} onChange={e => updateContactReplyTemplate(template.id, 'subject', e.target.value)} />
|
|
</div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`reply-template-message-${template.id}`}>Message</label>
|
|
<textarea id={`reply-template-message-${template.id}`} rows={5} value={template.message} onChange={e => updateContactReplyTemplate(template.id, 'message', e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeContactReplyTemplate(template.id)}>Remove</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
))}
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<button type="button" className="btn-admin-reset" onClick={addContactReplyTemplate}>Add Template</button>
|
|
<button type="button" className="btn-admin-save" onClick={handleSaveContactReplyTemplates} disabled={contactTemplateStatus === 'saving'}>
|
|
{contactTemplateStatus === 'saving' ? 'Saving…' : 'Save Templates'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<div className="admin-visits-table-wrap">
|
|
<h3>Reply History</h3>
|
|
{contactReplyHistory.length === 0 ? <p className="admin-stats-note">No admin replies have been sent yet.</p> : (
|
|
<div className="admin-visits-table-scroll">
|
|
<table className="admin-visits-table">
|
|
<thead>
|
|
<tr><th>Sent</th><th>To</th><th>From</th><th>Subject</th><th>Preview</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{contactReplyHistory.map(item => (
|
|
<tr key={item.id}>
|
|
<td>{formatDate(item.sentAt)}</td>
|
|
<td>{item.toName} ({item.toEmail})</td>
|
|
<td>{item.fromEmail}</td>
|
|
<td>{item.subject}</td>
|
|
<td>{item.preview}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* ASSETS */}
|
|
{adminView === 'assets' && (
|
|
<section className="admin-panel-section" aria-label="Asset manager">
|
|
<div className="admin-panel-head">
|
|
<h2>Asset Manager</h2>
|
|
<p>Upload, tag, and reuse hosted images from this domain.</p>
|
|
</div>
|
|
<div className="admin-actions admin-actions--maintenance">
|
|
<label className="btn-admin-reset" style={{ cursor: 'pointer' }}>
|
|
{assetUploadPending ? 'Uploading…' : 'Upload Image or PDF'}
|
|
<input type="file" accept="image/*,.pdf" style={{ display: 'none' }} onChange={handleAssetUpload} disabled={assetUploadPending} />
|
|
</label>
|
|
</div>
|
|
{assets.length === 0 && <p className="admin-stats-note">No assets uploaded yet.</p>}
|
|
{assets.length > 0 && (
|
|
<div className="admin-visits-table-scroll">
|
|
<table className="admin-visits-table admin-assets-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Preview</th>
|
|
<th>Filename</th>
|
|
<th>Size</th>
|
|
<th>Updated</th>
|
|
<th>Tags</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{assets.map(asset => (
|
|
<tr key={asset.filename}>
|
|
<td>
|
|
{isImageAsset(asset.filename)
|
|
? <img src={asset.url} alt={asset.filename} className="admin-asset-thumb" style={{ width: '72px', height: '52px', objectFit: 'cover' }} />
|
|
: <span>PDF/File</span>}
|
|
</td>
|
|
<td>{asset.filename}</td>
|
|
<td>{(asset.sizeBytes / 1024).toFixed(1)} KB</td>
|
|
<td>{formatDate(asset.updatedAt)}</td>
|
|
<td>
|
|
<input
|
|
type="text"
|
|
className="admin-asset-tags-input"
|
|
placeholder="Add tags…"
|
|
value={assetTagEdits[asset.filename] ?? (asset.tags ?? []).join(', ')}
|
|
onChange={e => setAssetTagEdits(t => ({ ...t, [asset.filename]: e.target.value }))}
|
|
onBlur={() => handleSaveAssetTags(asset.filename)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<div className="admin-asset-actions-inline">
|
|
<button type="button" className="btn-admin-reset btn-admin-reset--compact" onClick={() => { navigator.clipboard.writeText(asset.url).catch(() => {}) }}>Copy URL</button>
|
|
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteAsset(asset.filename)}>Delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{opsMsg && <p className="admin-stats-note">{opsMsg}</p>}
|
|
</section>
|
|
)}
|
|
|
|
{/* SEO & REDIRECTS */}
|
|
{adminView === 'seo' && (
|
|
<section className="admin-panel-section" aria-label="SEO & Redirects">
|
|
<div className="admin-panel-head">
|
|
<h2>SEO & Redirects</h2>
|
|
<p>Control metadata, canonical URL, robots policy, sitemap paths, and short-link redirects.</p>
|
|
</div>
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field"><label htmlFor="seo-title">SEO Title</label><input id="seo-title" type="text" value={form.seo?.title ?? ''} onChange={e => updateSeoField('title', e.target.value)} /></div>
|
|
<div className="admin-field"><label htmlFor="seo-description">SEO Description</label><textarea id="seo-description" rows={3} value={form.seo?.description ?? ''} onChange={e => updateSeoField('description', e.target.value)} /></div>
|
|
<div className="admin-field"><label htmlFor="seo-og-title">Open Graph Title</label><input id="seo-og-title" type="text" value={form.seo?.ogTitle ?? ''} onChange={e => updateSeoField('ogTitle', e.target.value)} /></div>
|
|
<div className="admin-field"><label htmlFor="seo-og-description">Open Graph Description</label><textarea id="seo-og-description" rows={3} value={form.seo?.ogDescription ?? ''} onChange={e => updateSeoField('ogDescription', e.target.value)} /></div>
|
|
<div className="admin-field">
|
|
<label htmlFor="seo-og-image">Open Graph Image URL</label>
|
|
<input id="seo-og-image" type="text" value={form.seo?.ogImage ?? ''} onChange={e => updateSeoField('ogImage', e.target.value)} />
|
|
{renderImageAssetSelector(form.seo?.ogImage, value => updateSeoField('ogImage', value), 'seo-og-image-asset')}
|
|
</div>
|
|
<div className="admin-field"><label htmlFor="seo-canonical">Canonical URL</label><input id="seo-canonical" type="text" value={form.seo?.canonicalUrl ?? ''} onChange={e => updateSeoField('canonicalUrl', e.target.value)} /></div>
|
|
<div className="admin-field"><label htmlFor="seo-robots">Robots Policy</label><input id="seo-robots" type="text" value={form.seo?.robotsPolicy ?? ''} onChange={e => updateSeoField('robotsPolicy', e.target.value)} /></div>
|
|
<div className="admin-field">
|
|
<label htmlFor="seo-sitemap">Sitemap Paths (one per line)</label>
|
|
<textarea id="seo-sitemap" rows={4} value={(form.seo?.sitemapPaths ?? []).join('\n')} onChange={e => updateSeoField('sitemapPaths', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="admin-section-header">
|
|
<h3>Redirect Manager</h3>
|
|
<p>Maintain first-party short links like /spotify without code changes.</p>
|
|
</div>
|
|
{(form.redirects ?? []).map(rule => (
|
|
<div key={rule.id} className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field"><label htmlFor={`redirect-path-${rule.id}`}>Path</label><input id={`redirect-path-${rule.id}`} type="text" value={rule.path} onChange={e => updateRedirectRule(rule.id, 'path', e.target.value)} /></div>
|
|
<div className="admin-field"><label htmlFor={`redirect-target-${rule.id}`}>Target URL</label><input id={`redirect-target-${rule.id}`} type="text" value={rule.target} onChange={e => updateRedirectRule(rule.id, 'target', e.target.value)} /></div>
|
|
<div className="admin-field">
|
|
<label htmlFor={`redirect-status-${rule.id}`}>Status</label>
|
|
<select id={`redirect-status-${rule.id}`} value={rule.statusCode} onChange={e => updateRedirectRule(rule.id, 'statusCode', Number(e.target.value))}>
|
|
<option value={301}>301 Permanent</option>
|
|
<option value={302}>302 Temporary</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-admin-remove" onClick={() => removeRedirectRule(rule.id)}>Remove</button>
|
|
</div>
|
|
))}
|
|
<button type="button" className="btn-admin-add" onClick={addRedirectRule}>+ Add Redirect</button>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* LEGAL */}
|
|
{adminView === 'legal' && (
|
|
<section className="admin-panel-section" aria-label="Legal Pages">
|
|
<div className="admin-panel-head">
|
|
<h2>Legal Pages</h2>
|
|
<p>Edit Privacy and Terms pages from admin.</p>
|
|
</div>
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<div className="admin-field"><label htmlFor="legal-privacy-title">Privacy Title</label><input id="legal-privacy-title" type="text" value={form.legal?.privacyTitle ?? ''} onChange={e => updateLegalField('privacyTitle', e.target.value)} /></div>
|
|
<div className="admin-field">
|
|
<label htmlFor="legal-privacy-body">Privacy Body (one paragraph per line)</label>
|
|
<textarea id="legal-privacy-body" rows={4} value={(form.legal?.privacyBody ?? []).join('\n')} onChange={e => updateLegalField('privacyBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
|
</div>
|
|
<div className="admin-field"><label htmlFor="legal-terms-title">Terms Title</label><input id="legal-terms-title" type="text" value={form.legal?.termsTitle ?? ''} onChange={e => updateLegalField('termsTitle', e.target.value)} /></div>
|
|
<div className="admin-field">
|
|
<label htmlFor="legal-terms-body">Terms Body (one paragraph per line)</label>
|
|
<textarea id="legal-terms-body" rows={4} value={(form.legal?.termsBody ?? []).join('\n')} onChange={e => updateLegalField('termsBody', e.target.value.split('\n').map(v => v.trim()).filter(Boolean))} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{renderSaveStatus()}
|
|
</section>
|
|
)}
|
|
|
|
{/* SECURITY */}
|
|
{adminView === 'security' && (
|
|
<section className="admin-panel-section" aria-label="Security">
|
|
<div className="admin-panel-head">
|
|
<h2>Security</h2>
|
|
<p>Manage two-factor authentication for admin login.</p>
|
|
</div>
|
|
{totpEnabled === null && <p className="admin-stats-note">Loading…</p>}
|
|
{totpEnabled === false && !totpSetupQr && (
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<p className="admin-stats-note">2FA is currently <strong>off</strong>. Enable it to require an authenticator app at every login.</p>
|
|
<button type="button" className="btn-primary" onClick={handleTotpSetupInit}>Enable 2FA</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{totpSetupQr && (
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<p className="admin-stats-note">Scan this QR code with your authenticator app, then enter the 6-digit code below to confirm.</p>
|
|
<img src={totpSetupQr} alt="TOTP QR code" style={{ width: 200, height: 200, display: 'block', margin: '0.5rem 0' }} />
|
|
{totpSetupSecret && <p className="admin-stats-note" style={{ wordBreak: 'break-all' }}>Manual entry key: <code>{totpSetupSecret}</code></p>}
|
|
<form onSubmit={handleTotpSetupConfirm} style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
<div className="admin-field" style={{ flex: 1, minWidth: 160 }}>
|
|
<label htmlFor="totp-confirm-code">Confirmation Code</label>
|
|
<input id="totp-confirm-code" type="text" inputMode="numeric" value={totpConfirmCode} onChange={e => setTotpConfirmCode(e.target.value)} placeholder="000000" autoFocus required />
|
|
</div>
|
|
<button type="submit" className="btn-primary">Confirm & Enable</button>
|
|
<button type="button" className="btn-secondary" onClick={() => { setTotpSetupQr(null); setTotpSetupSecret(null) }}>Cancel</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{totpEnabled === true && !totpSetupQr && (
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<p className="admin-stats-note">2FA is currently <strong>on</strong>. A code from your authenticator app is required at every login.</p>
|
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
|
<button type="button" className="btn-admin-reset" onClick={handleTotpRegenRecovery}>Regenerate Recovery Codes</button>
|
|
<button type="button" className="btn-admin-remove" onClick={handleTotpDisable}>Disable 2FA</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{totpRecoveryCodes && (
|
|
<div className="admin-array-row">
|
|
<div className="admin-array-fields">
|
|
<p className="admin-stats-note"><strong>Save these recovery codes somewhere safe.</strong> Each can be used once instead of the 6-digit code if you lose access to your authenticator app. They will not be shown again.</p>
|
|
<ul style={{ fontFamily: 'monospace', lineHeight: 2 }}>{totpRecoveryCodes.map(c => <li key={c}>{c}</li>)}</ul>
|
|
<button type="button" className="btn-secondary" onClick={() => setTotpRecoveryCodes(null)}>I've saved these</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{totpMsg && <p className="admin-stats-note">{totpMsg}</p>}
|
|
</section>
|
|
)}
|
|
|
|
{/* BRAND KIT */}
|
|
{adminView === 'brand' && (
|
|
<section className="admin-panel-section admin-brand-kit" aria-label="Brand kit">
|
|
<div className="admin-panel-head">
|
|
<h2>Brand Kit</h2>
|
|
<p>Reference palette, typography, and implementation status for the live site.</p>
|
|
</div>
|
|
<div className="admin-brand-hero">
|
|
<img src="/images/podcast-art.jpeg" alt="Verse by Verse with Nate artwork" />
|
|
<div>
|
|
<p className="admin-brand-kicker">Verse by Verse with Nate</p>
|
|
<h3>A Journey Through Scripture</h3>
|
|
<p className="admin-brand-tagline">Verse by verse. Nugget by nugget.</p>
|
|
</div>
|
|
</div>
|
|
<div className="admin-brand-grid">
|
|
<article className="admin-brand-card">
|
|
<h3>Color Palette</h3>
|
|
<div className="admin-brand-swatches">
|
|
{BRAND_KIT_SWATCHES.map(swatch => (
|
|
<div key={swatch.hex} className="admin-brand-swatch">
|
|
<div className="admin-brand-swatch-block" style={{ background: swatch.hex }} />
|
|
<div className="admin-brand-swatch-copy">
|
|
<strong>{swatch.name}</strong>
|
|
<span>{swatch.hex}</span>
|
|
<p>{swatch.role}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
<article className="admin-brand-card">
|
|
<h3>Typography</h3>
|
|
<div className="admin-brand-type-list">
|
|
{BRAND_KIT_TYPE.map(item => (
|
|
<div key={item.label} className="admin-brand-type-row">
|
|
<p className="admin-brand-type-label">{item.label}</p>
|
|
<p className="admin-brand-type-spec">{item.spec}</p>
|
|
<p className="admin-brand-type-sample">{item.sample}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<article className="admin-brand-card admin-brand-card--verify">
|
|
<h3>Verified On Site</h3>
|
|
<ul className="admin-brand-checklist">
|
|
{BRAND_KIT_VERIFICATION.map(item => <li key={item}>{item}</li>)}
|
|
</ul>
|
|
<p className="admin-brand-note">This admin panel reflects the live implementation now loaded from the shared font import and brand tokens.</p>
|
|
</article>
|
|
</section>
|
|
)}
|
|
|
|
</main>
|
|
|
|
{/* ── Live preview pane ── */}
|
|
{previewOpen && (
|
|
<div className="admin-preview-pane">
|
|
<div className="admin-preview-toolbar">
|
|
<span className="admin-preview-label">Live Preview</span>
|
|
<span className="admin-preview-hint">Updates as you type</span>
|
|
</div>
|
|
<iframe
|
|
ref={previewIframeRef}
|
|
src="/preview"
|
|
title="Site preview"
|
|
className="admin-preview-iframe"
|
|
onLoad={() => {
|
|
// Once the iframe loads, immediately push the current form state and view
|
|
previewIframeRef.current?.contentWindow?.postMessage(
|
|
{ type: 'admin-preview-content', content: form, view: adminView },
|
|
window.location.origin
|
|
)
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|