Fix contact form crash, analytics range filter, Scripture Index parsing, and unsafe-delete UI bugs
Publish Container / docker (push) Successful in 13m12s
Publish Container / docker (push) Successful in 13m12s
- server/routes/contact.js: import fireWebhooks (was undefined, throwing on every submission) - server/routes/analytics.js: filter admin-stats by row.at, the field recordVisitor actually sets, instead of the never-populated row.visitedAt - src/App.tsx: parseSIRef now supports chapter-spanning verse ranges (e.g. "5:15-6:2"); SI_BOOK_MAP indexes both spaced and unspaced book names so multi-word Bible Book values from the admin field resolve correctly - src/App.css / src/App.tsx: fix Episodes/Studies nav dropdown menus being unclickable — a positioning gap between the trigger and menu created a hover dead zone; menu is now flush with spacing moved into padding so hover carries through - src/components/StudySectionComments.tsx: check response status before removing a comment locally, so a rejected delete no longer appears to succeed - src/components/QASection.tsx: don't bail out of search scoring for 1-2 character queries, so short substrings like "OT" or "AI" can still match - src/AdminPage.tsx: check response status in study-user, QR code, and episode-script delete handlers before updating local state Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
This commit is contained in:
1 parent
6ed29ea527
commit
c158fb6317
7 files changed
+72
-23
No files matched your search
@@ -222,8 +222,8 @@ export function register(app) {
|
|||||||
|
|
||||||
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
|
// Filter per-path stats by range — approximate using recent visitor rows scoped to range
|
||||||
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
|
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => {
|
||||||
if (!row.visitedAt) return true // include if no timestamp
|
if (!row.at) return true // include if no timestamp
|
||||||
return row.visitedAt >= cutoffDate
|
return row.at >= cutoffDate
|
||||||
})
|
})
|
||||||
|
|
||||||
const rangePathCountsReal = {}
|
const rangePathCountsReal = {}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
queueDraftQuestionsWrite,
|
queueDraftQuestionsWrite,
|
||||||
normalizeContactEmailStatus,
|
normalizeContactEmailStatus,
|
||||||
normalizeMessageType,
|
normalizeMessageType,
|
||||||
|
fireWebhooks,
|
||||||
} from '../data.js'
|
} from '../data.js'
|
||||||
import {
|
import {
|
||||||
noteContactEmailCooldown,
|
noteContactEmailCooldown,
|
||||||
|
|||||||
+11
-7
@@ -174,11 +174,12 @@ function StudyUsersPanel({ studies }: { studies: StudyProgram[] }) {
|
|||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/admin/study-users/${id}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) { flashMsg(id, 'Failed to delete user.'); return }
|
||||||
setUsers(prev => prev.filter(u => u.id !== id))
|
setUsers(prev => prev.filter(u => u.id !== id))
|
||||||
setConfirmDeleteId(null)
|
setConfirmDeleteId(null)
|
||||||
setExpandedId(null)
|
setExpandedId(null)
|
||||||
} catch { /* ignore */ }
|
} catch { flashMsg(id, 'Failed to delete user.') }
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = users.filter(u =>
|
const filtered = users.filter(u =>
|
||||||
@@ -2479,19 +2480,21 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
async function handleDeleteQrCode(id: string) {
|
async function handleDeleteQrCode(id: string) {
|
||||||
if (!confirm('Delete this QR code and all its scan history?')) return
|
if (!confirm('Delete this QR code and all its scan history?')) return
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/admin/qr-codes/${id}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) { alert('Failed to delete QR code.'); return }
|
||||||
setQrCodes(prev => prev.filter(c => c.id !== id))
|
setQrCodes(prev => prev.filter(c => c.id !== id))
|
||||||
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
||||||
} catch { /* ignore */ }
|
} catch { alert('Failed to delete QR code.') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClearQrScans(id: string) {
|
async function handleClearQrScans(id: string) {
|
||||||
if (!confirm('Clear all scan history for this QR code?')) return
|
if (!confirm('Clear all scan history for this QR code?')) return
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
|
const res = await fetch(`/api/admin/qr-codes/${id}/scans`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) { alert('Failed to clear scan history.'); return }
|
||||||
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
setQrScans(prev => prev.filter(s => s.qrId !== id))
|
||||||
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
|
setQrCodes(prev => prev.map(c => c.id === id ? { ...c, scanCount: 0 } : c))
|
||||||
} catch { /* ignore */ }
|
} catch { alert('Failed to clear scan history.') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStudyComments() {
|
async function loadStudyComments() {
|
||||||
@@ -2568,7 +2571,8 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
async function handleDeleteScript(episodeNumber: string) {
|
async function handleDeleteScript(episodeNumber: string) {
|
||||||
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
|
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
|
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(episodeNumber)}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) { alert('Failed to delete script.'); return }
|
||||||
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
|
setScriptsList(prev => prev.filter(s => s.episodeNumber !== episodeNumber))
|
||||||
} catch { alert('Failed to delete script.') }
|
} catch { alert('Failed to delete script.') }
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-4
@@ -9665,15 +9665,24 @@
|
|||||||
.nav-dropdown-menu {
|
.nav-dropdown-menu {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.5rem);
|
/* Flush against the trigger (no gap) so the hover state carries continuously
|
||||||
|
from the trigger into the menu — a visual gap here left a dead zone where
|
||||||
|
the mouse exits .nav-dropdown before reaching the menu, closing it before
|
||||||
|
it could be clicked. The 0.5rem of breathing room is added as padding-top
|
||||||
|
(still inside the hoverable box) instead of a position offset. */
|
||||||
|
top: 100%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu-inner {
|
||||||
background: rgba(15, 15, 12, 0.98);
|
background: rgba(15, 15, 12, 0.98);
|
||||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.4rem 0;
|
padding: 0.4rem 0;
|
||||||
min-width: 160px;
|
|
||||||
z-index: 200;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9923,12 +9932,16 @@
|
|||||||
display: none;
|
display: none;
|
||||||
position: static;
|
position: static;
|
||||||
transform: none;
|
transform: none;
|
||||||
|
padding-top: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dropdown-menu-inner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-dropdown:hover .nav-dropdown-menu {
|
.nav-dropdown:hover .nav-dropdown-menu {
|
||||||
|
|||||||
+32
-7
@@ -423,10 +423,12 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-dropdown-menu">
|
<div className="nav-dropdown-menu">
|
||||||
|
<div className="nav-dropdown-menu-inner">
|
||||||
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
|
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
|
||||||
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
|
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
<NavLink to="/questions" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Q&A</NavLink>
|
||||||
{/* Studies dropdown */}
|
{/* Studies dropdown */}
|
||||||
<div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}>
|
<div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}>
|
||||||
@@ -437,10 +439,12 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-dropdown-menu">
|
<div className="nav-dropdown-menu">
|
||||||
|
<div className="nav-dropdown-menu-inner">
|
||||||
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
|
<NavLink to="/study" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>All Studies</NavLink>
|
||||||
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
|
<NavLink to="/scripture" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Scripture Index</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
|
<NavLink to="/study/account" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>My Account</NavLink>
|
||||||
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
|
<NavLink to="/resources" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Resources</NavLink>
|
||||||
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
|
||||||
@@ -2052,18 +2056,31 @@ const SI_BOOK_MAP: Record<string, string> = {}
|
|||||||
const SI_BOOK_NAME: Record<string, string> = {}
|
const SI_BOOK_NAME: Record<string, string> = {}
|
||||||
const SI_BOOK_ORDER: string[] = []
|
const SI_BOOK_ORDER: string[] = []
|
||||||
for (const b of BIBLE_BOOKS) {
|
for (const b of BIBLE_BOOKS) {
|
||||||
SI_BOOK_MAP[b.name.toLowerCase()] = b.id
|
// Admin's "Bible Book" field strips all whitespace (e.g. "1corinthians"), so
|
||||||
for (const alias of b.aliases ?? []) SI_BOOK_MAP[alias] = b.id
|
// index both the spaced and unspaced forms of every name/alias.
|
||||||
|
for (const key of [b.name.toLowerCase(), ...(b.aliases ?? [])]) {
|
||||||
|
SI_BOOK_MAP[key] = b.id
|
||||||
|
SI_BOOK_MAP[key.replace(/\s+/g, '')] = b.id
|
||||||
|
}
|
||||||
SI_BOOK_NAME[b.id] = b.name
|
SI_BOOK_NAME[b.id] = b.name
|
||||||
SI_BOOK_ORDER.push(b.id)
|
SI_BOOK_ORDER.push(b.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSIRef(ref: string) {
|
function parseSIRef(ref: string) {
|
||||||
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
|
// Supports same-chapter ranges ("5:15-20") and chapter-spanning ranges ("5:15-6:2").
|
||||||
|
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(?:(\d+):)?(\d+))?$/)
|
||||||
if (!m) return null
|
if (!m) return null
|
||||||
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
|
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
|
||||||
if (!bookId) return null
|
if (!bookId) return null
|
||||||
return { bookId, chapter: parseInt(m[2], 10), verseStart: parseInt(m[3], 10), verseEnd: m[4] ? parseInt(m[4], 10) : parseInt(m[3], 10) }
|
const chapter = parseInt(m[2], 10)
|
||||||
|
const endChapter = m[4] ? parseInt(m[4], 10) : chapter
|
||||||
|
return {
|
||||||
|
bookId,
|
||||||
|
chapter,
|
||||||
|
endChapter,
|
||||||
|
verseStart: parseInt(m[3], 10),
|
||||||
|
verseEnd: m[5] ? parseInt(m[5], 10) : parseInt(m[3], 10),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }>
|
type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }>
|
||||||
@@ -2094,6 +2111,7 @@ interface ScriptureRow {
|
|||||||
displayRef: string
|
displayRef: string
|
||||||
bookId: string | null
|
bookId: string | null
|
||||||
chapter: number
|
chapter: number
|
||||||
|
endChapter: number
|
||||||
verseStart: number
|
verseStart: number
|
||||||
verseEnd: number
|
verseEnd: number
|
||||||
sources: ScriptureSource[]
|
sources: ScriptureSource[]
|
||||||
@@ -2134,17 +2152,21 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
|
|||||||
: entry.sourceTitle,
|
: entry.sourceTitle,
|
||||||
}
|
}
|
||||||
const key = parsed
|
const key = parsed
|
||||||
? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.verseEnd}`
|
? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.endChapter}-${parsed.verseEnd}`
|
||||||
: `other-${entry.reference.trim().toLowerCase()}`
|
: `other-${entry.reference.trim().toLowerCase()}`
|
||||||
let row = rowMap.get(key)
|
let row = rowMap.get(key)
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
const spansChapters = parsed && parsed.endChapter !== parsed.chapter
|
||||||
row = {
|
row = {
|
||||||
key,
|
key,
|
||||||
displayRef: parsed
|
displayRef: parsed
|
||||||
? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`
|
? spansChapters
|
||||||
|
? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}-${parsed.endChapter}:${parsed.verseEnd}`
|
||||||
|
: `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}`
|
||||||
: entry.reference.trim(),
|
: entry.reference.trim(),
|
||||||
bookId: parsed?.bookId ?? null,
|
bookId: parsed?.bookId ?? null,
|
||||||
chapter: parsed?.chapter ?? 0,
|
chapter: parsed?.chapter ?? 0,
|
||||||
|
endChapter: parsed?.endChapter ?? parsed?.chapter ?? 0,
|
||||||
verseStart: parsed?.verseStart ?? 0,
|
verseStart: parsed?.verseStart ?? 0,
|
||||||
verseEnd: parsed?.verseEnd ?? 0,
|
verseEnd: parsed?.verseEnd ?? 0,
|
||||||
sources: [],
|
sources: [],
|
||||||
@@ -2182,7 +2204,10 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
|
|||||||
for (const row of group.rows) {
|
for (const row of group.rows) {
|
||||||
const ck = `${group.bookId}/${row.chapter}`
|
const ck = `${group.bookId}/${row.chapter}`
|
||||||
if (!chapterMap.has(ck)) chapterMap.set(ck, [])
|
if (!chapterMap.has(ck)) chapterMap.set(ck, [])
|
||||||
chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd: row.verseEnd })
|
// Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's
|
||||||
|
// verses, since extractVerseRange operates on a single fetched chapter.
|
||||||
|
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
|
||||||
|
chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [ck, rows] of chapterMap) {
|
for (const [ck, rows] of chapterMap) {
|
||||||
|
|||||||
@@ -349,8 +349,10 @@ function semanticScoreForQuestion(question: PublicQuestion, tags: string[], quer
|
|||||||
const normalized = query.trim().toLowerCase()
|
const normalized = query.trim().toLowerCase()
|
||||||
if (!normalized) return 0
|
if (!normalized) return 0
|
||||||
|
|
||||||
|
// Note: tokenize() drops tokens of length <= 2 (e.g. "OT", "AI"), so queryTokens can be
|
||||||
|
// empty for short queries — fall through to the substring checks below rather than
|
||||||
|
// bailing out, so short queries can still match via direct substring inclusion.
|
||||||
const queryTokens = tokenize(normalized)
|
const queryTokens = tokenize(normalized)
|
||||||
if (queryTokens.length === 0) return 0
|
|
||||||
|
|
||||||
const expanded = expandQueryTokens(queryTokens)
|
const expanded = expandQueryTokens(queryTokens)
|
||||||
const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`))
|
const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`))
|
||||||
|
|||||||
@@ -71,10 +71,14 @@ export function StudySectionComments({ studySlug, sectionId, isEnrolled }: Props
|
|||||||
async function handleDelete(commentId: string) {
|
async function handleDelete(commentId: string) {
|
||||||
setDeletingId(commentId)
|
setDeletingId(commentId)
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
|
const res = await fetch(`/api/study-comments/${commentId}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Could not delete that comment.')
|
||||||
|
return
|
||||||
|
}
|
||||||
setComments(prev => prev.filter(c => c.id !== commentId))
|
setComments(prev => prev.filter(c => c.id !== commentId))
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
setError('Network error. Please try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setDeletingId(null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user