Enhance admin dashboard and scripture linking
This commit is contained in:
+375
-6
@@ -141,6 +141,13 @@ interface ContactReplyHistoryItem {
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
interface Subscriber {
|
||||
name: string
|
||||
email: string
|
||||
subscribedAt: string
|
||||
source: 'contact-form' | 'download'
|
||||
}
|
||||
|
||||
interface ContactReplyConfig {
|
||||
fromEmail: string
|
||||
fromIdentity: string
|
||||
@@ -152,11 +159,11 @@ interface ContactReplyConfig {
|
||||
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | 'archivedSeries' | 'redirects' | 'podcastFeaturedLinks' | 'seo' | 'legal' | 'whereToNextCards'>
|
||||
|
||||
type AdminView =
|
||||
| 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
|
||||
| 'current-series' | 'episode-highlights' | 'archived-series'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'analytics' | 'assets'
|
||||
| 'emails'
|
||||
| 'emails' | 'subscribers' | 'contacts'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'global'
|
||||
@@ -285,7 +292,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [lastSavedSnapshot, setLastSavedSnapshot] = useState(() => JSON.stringify(content))
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [adminView, setAdminView] = useState<AdminView>('homepage')
|
||||
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('')
|
||||
@@ -324,7 +331,13 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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: '',
|
||||
@@ -339,6 +352,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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(() => {
|
||||
@@ -358,6 +373,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setLastSavedSnapshot(nextSnapshot)
|
||||
}, [content])
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setDashboardNow(new Date())
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!isDirty) return
|
||||
@@ -457,6 +480,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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(() => {
|
||||
@@ -1383,11 +1416,32 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
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')
|
||||
@@ -1650,6 +1704,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</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>
|
||||
@@ -1674,8 +1733,18 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
|
||||
<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})</button>
|
||||
<button type="button" className={`admin-nav-item${adminView === 'emails' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('emails')}>Emails</button>
|
||||
<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>
|
||||
@@ -1695,6 +1764,284 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
{/* ── 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">
|
||||
@@ -2441,6 +2788,14 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
</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">
|
||||
@@ -2520,8 +2875,22 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
) : (
|
||||
<div className="admin-questions-list">
|
||||
{visibleAdminQuestions.map(question => (
|
||||
<div key={question.id} className="admin-question-card">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user