Compare commits
2
Commits
8ff01f8e48
...
1ddb8ee7be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ddb8ee7be | ||
|
|
590885f6b1 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.24",
|
"version": "2.0.26",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -358,6 +358,21 @@ export function register(app) {
|
|||||||
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const { archived } = req.body ?? {}
|
||||||
|
if (typeof archived !== 'boolean') {
|
||||||
|
res.status(400).json({ message: 'archived must be a boolean.' }); return
|
||||||
|
}
|
||||||
|
const idx = state.contactSubmissions.findIndex(s => s.id === id)
|
||||||
|
if (idx === -1) {
|
||||||
|
res.status(404).json({ message: 'Submission not found.' }); return
|
||||||
|
}
|
||||||
|
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], archived }
|
||||||
|
queueContactSubmissionsWrite()
|
||||||
|
res.json({ ok: true, submission: state.contactSubmissions[idx] })
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
const subscribers = state.contactSubmissions
|
const subscribers = state.contactSubmissions
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function feedUrl() {
|
|||||||
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
|
return state.cachedSiteContent?.nuggets?.rssFeedUrl?.trim() ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchNuggetsEpisodes() {
|
export async function fetchNuggetsEpisodes() {
|
||||||
const url = feedUrl()
|
const url = feedUrl()
|
||||||
if (!url) { cache = null; return [] }
|
if (!url) { cache = null; return [] }
|
||||||
|
|
||||||
|
|||||||
+54
-1
@@ -16,6 +16,33 @@ import { state } from '../state.js'
|
|||||||
import { loadSiteContentFile } from '../data.js'
|
import { loadSiteContentFile } from '../data.js'
|
||||||
import { DATA_FILE } from '../config.js'
|
import { DATA_FILE } from '../config.js'
|
||||||
import { sanitizeRedirectRules } from '../study-helpers.js'
|
import { sanitizeRedirectRules } from '../study-helpers.js'
|
||||||
|
import { fetchAllEpisodes } from './episodes.js'
|
||||||
|
import { fetchNuggetsEpisodes } from './nuggets.js'
|
||||||
|
import { BIBLE_BOOKS } from '../../shared/bible-books.js'
|
||||||
|
|
||||||
|
// Build a regex that matches scripture references like "Titus 1:5-9" or "1 Cor 13:4-7"
|
||||||
|
// out of plain text. Sorted longest-first so "1 Corinthians" beats "1 Cor".
|
||||||
|
const _refPatterns = []
|
||||||
|
for (const b of BIBLE_BOOKS) {
|
||||||
|
_refPatterns.push(b.name, ...(b.aliases ?? []))
|
||||||
|
}
|
||||||
|
_refPatterns.sort((a, z) => z.length - a.length)
|
||||||
|
const _bookPart = _refPatterns.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
||||||
|
const SCRIPTURE_REF_RE = new RegExp(
|
||||||
|
`(${_bookPart})\\s+(\\d+):(\\d+)(?:-((?:\\d+):)?(\\d+))?`,
|
||||||
|
'gi',
|
||||||
|
)
|
||||||
|
|
||||||
|
function extractScriptureRefs(text) {
|
||||||
|
const refs = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const m of String(text || '').matchAll(SCRIPTURE_REF_RE)) {
|
||||||
|
const ref = m[0].trim()
|
||||||
|
const key = ref.toLowerCase()
|
||||||
|
if (!seen.has(key)) { seen.add(key); refs.push(ref) }
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
|
const SALVATION_INDEX_FILE = path.join(DIST_DIR, 'salvation', 'index.html')
|
||||||
@@ -160,7 +187,7 @@ export function register(app) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Scripture index — every passage covered across released study lessons and podcast episodes
|
// Scripture index — every passage covered across released study lessons and podcast episodes
|
||||||
app.get('/api/public/scripture-index', (_req, res) => {
|
app.get('/api/public/scripture-index', async (_req, res) => {
|
||||||
const content = state.cachedSiteContent
|
const content = state.cachedSiteContent
|
||||||
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
const allStudies = Array.isArray(content?.studies) ? content.studies : []
|
||||||
const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||||
@@ -196,6 +223,32 @@ export function register(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract scripture refs from main podcast episode descriptions
|
||||||
|
try {
|
||||||
|
const mainEps = await fetchAllEpisodes()
|
||||||
|
for (const ep of mainEps) {
|
||||||
|
if (!ep.description) continue
|
||||||
|
const refs = extractScriptureRefs(ep.description)
|
||||||
|
for (const ref of refs) {
|
||||||
|
const title = ep.episode ? `Episode ${ep.episode}: ${ep.title}` : ep.title
|
||||||
|
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: ep.link || '/episodes' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep going if RSS is down */ }
|
||||||
|
|
||||||
|
// Extract scripture refs from Nuggets episode descriptions
|
||||||
|
try {
|
||||||
|
const nuggetsEps = await fetchNuggetsEpisodes()
|
||||||
|
for (const ep of nuggetsEps) {
|
||||||
|
if (!ep.description) continue
|
||||||
|
const refs = extractScriptureRefs(ep.description)
|
||||||
|
for (const ref of refs) {
|
||||||
|
const title = ep.episode ? `Nuggets Ep. ${ep.episode}: ${ep.title}` : ep.title
|
||||||
|
entries.push({ reference: ref, sourceType: 'episode', sourceTitle: title, url: '/nuggets' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep going if RSS is down */ }
|
||||||
|
|
||||||
res.json({ entries })
|
res.json({ entries })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function parseRssItems(xml, limit = Infinity) {
|
|||||||
items.push({
|
items.push({
|
||||||
title, pubDate, link,
|
title, pubDate, link,
|
||||||
audioUrl: enclosureUrl,
|
audioUrl: enclosureUrl,
|
||||||
description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''),
|
description: descText.slice(0, 800) + (descText.length > 800 ? '…' : ''),
|
||||||
duration, episode, season,
|
duration, episode, season,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,6 +668,7 @@ type AdminView =
|
|||||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history'
|
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
|
| 'inbox'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
||||||
@@ -737,6 +738,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
group: 'Audience',
|
group: 'Audience',
|
||||||
items: [
|
items: [
|
||||||
|
{ kind: 'leaf', view: 'inbox', label: 'Inbox' },
|
||||||
{ kind: 'leaf', view: 'questions', label: 'Questions' },
|
{ kind: 'leaf', view: 'questions', label: 'Questions' },
|
||||||
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
||||||
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
||||||
@@ -1568,6 +1570,10 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||||
const [navSearch, setNavSearch] = useState('')
|
const [navSearch, setNavSearch] = useState('')
|
||||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||||||
|
const [inboxFilter, setInboxFilter] = useState<'unread' | 'all' | 'archived'>('unread')
|
||||||
|
const [inboxSearch, setInboxSearch] = useState('')
|
||||||
|
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null)
|
||||||
|
const [archivingId, setArchivingId] = useState<string | null>(null)
|
||||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||||||
// Study comments moderation state
|
// Study comments moderation state
|
||||||
@@ -2545,12 +2551,31 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
if (view === 'questions' && unansweredCount > 0) {
|
if (view === 'questions' && unansweredCount > 0) {
|
||||||
return { count: unansweredCount, neutral: false }
|
return { count: unansweredCount, neutral: false }
|
||||||
}
|
}
|
||||||
|
if (view === 'inbox' && unreadEmailCount > 0) {
|
||||||
|
return { count: unreadEmailCount, neutral: false }
|
||||||
|
}
|
||||||
if (view === 'subscribers' && subscribers.length > 0) {
|
if (view === 'subscribers' && subscribers.length > 0) {
|
||||||
return { count: subscribers.length, neutral: true }
|
return { count: subscribers.length, neutral: true }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleArchiveSubmission(id: string, archived: boolean) {
|
||||||
|
setArchivingId(id)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin-contact-submissions/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ archived }),
|
||||||
|
})
|
||||||
|
if (!res.ok) return
|
||||||
|
setContactSubmissions(prev => prev.map(s => s.id === id ? { ...s, archived } : s))
|
||||||
|
if (archived && selectedSubmissionId === id) setSelectedSubmissionId(null)
|
||||||
|
} finally {
|
||||||
|
setArchivingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderAdminNavItem(view: AdminView, label: string) {
|
function renderAdminNavItem(view: AdminView, label: string) {
|
||||||
const badge = getAdminViewBadge(view)
|
const badge = getAdminViewBadge(view)
|
||||||
|
|
||||||
@@ -3319,6 +3344,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
<div className="admin-dashboard-card-value">{unreadEmailCount}</div>
|
||||||
<div className="admin-dashboard-card-label">Inbox Messages</div>
|
<div className="admin-dashboard-card-label">Inbox Messages</div>
|
||||||
<div className="admin-dashboard-card-sub">{contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</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('inbox')}>View Inbox →</button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-dashboard-card">
|
<div className="admin-dashboard-card">
|
||||||
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
<div className="admin-dashboard-card-value">{thisWeekReal.toLocaleString()}</div>
|
||||||
@@ -3430,6 +3456,126 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* INBOX */}
|
||||||
|
{adminView === 'inbox' && (() => {
|
||||||
|
const messageTypeLabel: Record<string, string> = {
|
||||||
|
question: 'Bible Question',
|
||||||
|
community: 'Community Question',
|
||||||
|
testimony: 'Testimony',
|
||||||
|
topic: 'Topic Request',
|
||||||
|
general: 'General Message',
|
||||||
|
}
|
||||||
|
const filtered = contactSubmissions.filter(s => {
|
||||||
|
if (inboxFilter === 'unread') return !s.archived
|
||||||
|
if (inboxFilter === 'archived') return s.archived === true
|
||||||
|
return true
|
||||||
|
}).filter(s => {
|
||||||
|
if (!inboxSearch.trim()) return true
|
||||||
|
const q = inboxSearch.toLowerCase()
|
||||||
|
return s.name.toLowerCase().includes(q) || s.email.toLowerCase().includes(q) || s.message.toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
const selected = selectedSubmissionId ? contactSubmissions.find(s => s.id === selectedSubmissionId) ?? null : null
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Contact Form Inbox">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Inbox</h2>
|
||||||
|
<p>{unreadEmailCount} unread · {contactSubmissions.filter(s => s.archived).length} archived · {contactSubmissions.length} total</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-actions admin-actions--maintenance" style={{ alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={inboxSearch}
|
||||||
|
onChange={e => setInboxSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, email, or message…"
|
||||||
|
style={{ minWidth: '240px', maxWidth: '420px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
<button type="button" className={`btn-admin-reset${inboxFilter === 'unread' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('unread')}>Unread</button>
|
||||||
|
<button type="button" className={`btn-admin-reset${inboxFilter === 'all' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('all')}>All</button>
|
||||||
|
<button type="button" className={`btn-admin-reset${inboxFilter === 'archived' ? ' btn-admin-reset--active' : ''}`} onClick={() => setInboxFilter('archived')}>Archived</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contactSubmissions.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No messages yet.</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">No messages match this filter.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: '1rem', alignItems: 'start' }}>
|
||||||
|
<div className="admin-questions-list" style={{ gap: '0.5rem' }}>
|
||||||
|
{filtered.map(sub => (
|
||||||
|
<div
|
||||||
|
key={sub.id}
|
||||||
|
className={`admin-question-card${selectedSubmissionId === sub.id ? ' admin-question-card--selected' : ''}`}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setSelectedSubmissionId(prev => prev === sub.id ? null : sub.id)}
|
||||||
|
>
|
||||||
|
<div className="admin-question-header">
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<p className="admin-question-meta">
|
||||||
|
<strong>{sub.name}</strong>
|
||||||
|
{' · '}
|
||||||
|
<a href={`mailto:${sub.email}`} onClick={e => e.stopPropagation()}>{sub.email}</a>
|
||||||
|
{' · '}
|
||||||
|
{formatDate(sub.submittedAt)}
|
||||||
|
</p>
|
||||||
|
<p className="admin-question-text" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{sub.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-question-status" style={{ flexShrink: 0 }}>
|
||||||
|
<span className="admin-badge admin-badge--pending" style={{ textTransform: 'capitalize' }}>
|
||||||
|
{messageTypeLabel[sub.messageType] ?? sub.messageType}
|
||||||
|
</span>
|
||||||
|
{sub.archived && <span className="admin-badge" style={{ background: 'var(--clr-surface-2)' }}>Archived</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div className="admin-visits-table-wrap" style={{ position: 'sticky', top: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.75rem' }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, margin: 0 }}>{selected.name}</p>
|
||||||
|
<p style={{ margin: '0.2rem 0 0', fontSize: '0.875rem', opacity: 0.7 }}>
|
||||||
|
<a href={`mailto:${selected.email}`}>{selected.email}</a>
|
||||||
|
{' · '}
|
||||||
|
{formatDate(selected.submittedAt)}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.8rem', opacity: 0.6 }}>
|
||||||
|
{messageTypeLabel[selected.messageType] ?? selected.messageType}
|
||||||
|
{selected.subscribe ? ' · subscribed' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-admin-reset"
|
||||||
|
disabled={archivingId === selected.id}
|
||||||
|
onClick={() => handleArchiveSubmission(selected.id, !selected.archived)}
|
||||||
|
>
|
||||||
|
{archivingId === selected.id ? '…' : selected.archived ? 'Unarchive' : 'Archive'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={() => setSelectedSubmissionId(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, borderTop: '1px solid var(--clr-border)', paddingTop: '0.75rem' }}>
|
||||||
|
{selected.message}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<a href={`mailto:${selected.email}?subject=Re: Your message`} className="btn-admin-save" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||||
|
Reply via Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* SUBSCRIBERS */}
|
{/* SUBSCRIBERS */}
|
||||||
{adminView === 'subscribers' && (() => {
|
{adminView === 'subscribers' && (() => {
|
||||||
const filteredSubs = subscribers.filter(s =>
|
const filteredSubs = subscribers.filter(s =>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||||
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
|
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
|
||||||
|
const [nuggetsItems, setNuggetsItems] = useState<SearchResult[]>([])
|
||||||
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
|
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
|
||||||
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
@@ -75,6 +76,24 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [content.podcastFeaturedLinks])
|
}, [content.podcastFeaturedLinks])
|
||||||
|
|
||||||
|
// Fetch nuggets episodes once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/nuggets/episodes/all')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then((data: { episodes?: Array<{ title: string; link: string; description?: string; episode?: string }> }) => {
|
||||||
|
const eps: SearchResult[] = (data.episodes ?? []).map(ep => ({
|
||||||
|
id: `nuggets-${ep.episode ?? ep.title}`,
|
||||||
|
type: 'episode' as const,
|
||||||
|
title: ep.title,
|
||||||
|
subtitle: ep.episode ? `Nuggets Ep. ${ep.episode}` : 'Nuggets',
|
||||||
|
description: ep.description,
|
||||||
|
href: '/nuggets',
|
||||||
|
}))
|
||||||
|
setNuggetsItems(eps)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Debounced server-side script search — runs whenever query changes
|
// Debounced server-side script search — runs whenever query changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
|
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
|
||||||
@@ -132,7 +151,7 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
return results
|
return results
|
||||||
}, [content, questions])
|
}, [content, questions])
|
||||||
|
|
||||||
const allFuseItems = useMemo(() => [...episodeItems, ...staticItems], [episodeItems, staticItems])
|
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...staticItems], [episodeItems, nuggetsItems, staticItems])
|
||||||
|
|
||||||
const fuse = useMemo(() => new Fuse(allFuseItems, {
|
const fuse = useMemo(() => new Fuse(allFuseItems, {
|
||||||
keys: [
|
keys: [
|
||||||
|
|||||||
Reference in new issue
Block a user