0512d40fc6
Publish Container / docker (push) Has been cancelled
- Replace hero study guide form with PageBanner + featured download card in library - Add PageBanner component used consistently across all interior pages (Downloads, Episodes, About, Contact, Q&A, Finished Books) - Remove duplicate headings/eyebrows that conflicted with banner titles on About, Contact, Q&A, Finished Books pages - Compact download card sizing (padding, border-radius, image size) - Add Books & Resources tabbed section with fallback to TruthForLife widget - Fix server sanitizer to pass through 'resources' placement items without URL - Add featured-badge and featured card styling for primary guide Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1110 lines
46 KiB
TypeScript
1110 lines
46 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
|
|
interface PublicQuestion {
|
|
id: string
|
|
firstName: string
|
|
question: string
|
|
answer: string
|
|
topic?: string
|
|
submittedAt?: string
|
|
answeredAt?: string
|
|
upvotes?: number
|
|
pinned?: boolean
|
|
}
|
|
|
|
interface DecoratedQuestion extends PublicQuestion {
|
|
_topic: string
|
|
_tags: string[]
|
|
_helpful: number
|
|
_semanticScore: number
|
|
}
|
|
|
|
type SortMode = 'relevance' | 'newest' | 'oldest' | 'helpful'
|
|
|
|
interface EngagementCounts {
|
|
shares: number
|
|
related: number
|
|
expands: number
|
|
}
|
|
|
|
type EngagementMap = Record<string, EngagementCounts>
|
|
|
|
const QA_PAGE_SIZE = 8
|
|
const ENGAGEMENT_STORAGE_KEY = 'qa-engagement-v1'
|
|
const MAX_TAGS_PER_QUESTION = 4
|
|
const MAX_TAGS_VISIBLE = 10
|
|
|
|
const STOP_WORDS = new Set([
|
|
'about', 'after', 'again', 'also', 'always', 'among', 'appears', 'around', 'been', 'before', 'being', 'between', 'both', 'could',
|
|
'does', 'each', 'every', 'from', 'have', 'into', 'just', 'like', 'many', 'more', 'most', 'much', 'must', 'other', 'over', 'same',
|
|
'some', 'such', 'than', 'that', 'their', 'there', 'these', 'they', 'this', 'those', 'through', 'very', 'what', 'when', 'where',
|
|
'which', 'while', 'will', 'with', 'would', 'your', 'you', 'the', 'and', 'for', 'are', 'but', 'not', 'too', 'can', 'how', 'why',
|
|
'who', 'was', 'were', 'into', 'upon', 'then', 'them', 'ours', 'ourselves', 'himself', 'herself', 'because', 'therefore', 'really',
|
|
'simply', 'right', 'still', 'even', 'today', 'episode', 'episodes', 'verse', 'verses',
|
|
])
|
|
|
|
const TAG_LEXICON = [
|
|
'bible', 'scripture', 'faith', 'grace', 'hope', 'salvation', 'gospel', 'mercy', 'obedience', 'leadership', 'elders', 'church',
|
|
'doctrine', 'discipleship', 'prayer', 'family', 'politics', 'translation', 'reading', 'study', 'titus', 'christian',
|
|
]
|
|
|
|
const SYNONYM_MAP: Record<string, string[]> = {
|
|
faith: ['belief', 'trust'],
|
|
trust: ['faith', 'belief'],
|
|
grace: ['mercy', 'favor'],
|
|
mercy: ['grace', 'compassion'],
|
|
hope: ['expectation', 'future'],
|
|
love: ['charity', 'care'],
|
|
sin: ['wrong', 'evil'],
|
|
prayer: ['pray'],
|
|
pray: ['prayer'],
|
|
bible: ['scripture', 'word'],
|
|
scripture: ['bible', 'word'],
|
|
church: ['fellowship', 'body'],
|
|
leadership: ['elder', 'pastor'],
|
|
politics: ['government', 'public'],
|
|
family: ['home', 'household'],
|
|
translation: ['version'],
|
|
salvation: ['saved', 'redeemed'],
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function normalizeToken(token: string) {
|
|
return token.toLowerCase().replace(/[^a-z0-9]/g, '').trim()
|
|
}
|
|
|
|
function tokenize(text: string) {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.split(/\s+/)
|
|
.map(normalizeToken)
|
|
.filter(token => token.length > 2)
|
|
}
|
|
|
|
function expandQueryTokens(tokens: string[]) {
|
|
const expanded = new Set<string>()
|
|
for (const token of tokens) {
|
|
expanded.add(token)
|
|
const synonyms = SYNONYM_MAP[token] ?? []
|
|
for (const synonym of synonyms) expanded.add(synonym)
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
function renderHighlightedText(text: string, query: string) {
|
|
const trimmed = query.trim()
|
|
if (!trimmed) return text
|
|
|
|
const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig')
|
|
const parts = text.split(regex)
|
|
|
|
return parts.map((part, index) => {
|
|
if (part.toLowerCase() === trimmed.toLowerCase()) {
|
|
return <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
|
|
}
|
|
return <span key={`plain-${index}`}>{part}</span>
|
|
})
|
|
}
|
|
|
|
function renderTextWithLinks(text: string, highlightQuery = '') {
|
|
|
|
const BOOK_ID_MAP: Record<string, string> = {
|
|
gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV',
|
|
num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS',
|
|
judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2',
|
|
'1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
|
|
'1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2',
|
|
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
|
|
job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO',
|
|
eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG',
|
|
isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM',
|
|
ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS',
|
|
joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC',
|
|
nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP',
|
|
hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL',
|
|
matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT',
|
|
rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2',
|
|
gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP',
|
|
col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2',
|
|
'1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2',
|
|
titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB',
|
|
jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2',
|
|
'1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV',
|
|
}
|
|
|
|
function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null {
|
|
const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
|
|
if (!match) return null
|
|
const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match
|
|
const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim()
|
|
const bookId = BOOK_ID_MAP[bookKey]
|
|
if (!bookId) return null
|
|
return {
|
|
bookId,
|
|
chapter: parseInt(chapterStr, 10),
|
|
verseStart: parseInt(verseStartStr, 10),
|
|
verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10),
|
|
}
|
|
}
|
|
|
|
// Split the text by both scripture refs and URLs
|
|
const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi
|
|
|
|
const parts: string[] = []
|
|
let lastIndex = 0
|
|
let m: RegExpExecArray | null
|
|
|
|
// reset lastIndex for combined
|
|
combined.lastIndex = 0
|
|
while ((m = combined.exec(text)) !== null) {
|
|
if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index))
|
|
parts.push(m[0])
|
|
lastIndex = m.index + m[0].length
|
|
}
|
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex))
|
|
|
|
return parts.map((part, index) => {
|
|
if (/^https?:\/\//i.test(part)) {
|
|
const safeHref = part.replace(/[),.;!?]+$/g, '')
|
|
const trailing = part.slice(safeHref.length)
|
|
return (
|
|
<span key={`link-${index}`}>
|
|
<a href={safeHref} target="_blank" rel="noopener noreferrer">{safeHref}</a>
|
|
{trailing}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const parsed = parseScriptureRef(part.replace(/\.$/, ''))
|
|
if (parsed) {
|
|
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
|
|
}
|
|
|
|
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
|
|
})
|
|
}
|
|
|
|
interface VerseData {
|
|
verse: number
|
|
value: string
|
|
}
|
|
|
|
// Maps helloao API book IDs → bible.com book codes
|
|
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
|
|
GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT',
|
|
SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH',
|
|
EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG',
|
|
ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO',
|
|
OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL',
|
|
MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM',
|
|
CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL',
|
|
TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB',
|
|
JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV',
|
|
}
|
|
|
|
function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: {
|
|
refText: string
|
|
bookId: string
|
|
chapter: number
|
|
verseStart: number
|
|
verseEnd: number
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const [verses, setVerses] = useState<VerseData[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState(false)
|
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
if (verses.length > 0) return
|
|
setLoading(true)
|
|
setError(false)
|
|
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
|
|
.then(r => {
|
|
if (!r.ok) throw new Error('Not found')
|
|
return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }>
|
|
})
|
|
.then(data => {
|
|
const content = data?.chapter?.content ?? []
|
|
const found: VerseData[] = []
|
|
for (const item of content) {
|
|
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
|
|
const text = (item.content ?? [])
|
|
.map(c => (typeof c === 'string' ? c : (c.text ?? '')))
|
|
.join('')
|
|
.trim()
|
|
if (text) found.push({ verse: item.number, value: text })
|
|
}
|
|
}
|
|
setVerses(found)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setError(true)
|
|
setLoading(false)
|
|
})
|
|
}, [open, bookId, chapter, verseStart, verseEnd, verses.length])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [open])
|
|
|
|
return (
|
|
<span ref={wrapRef} className="scripture-ref-wrap">
|
|
<button
|
|
type="button"
|
|
className="scripture-ref"
|
|
onClick={() => setOpen(o => !o)}
|
|
aria-expanded={open}
|
|
title={`View ${refText}`}
|
|
>
|
|
{refText}
|
|
</button>
|
|
{open && (
|
|
<span className="scripture-popup" role="tooltip">
|
|
<span className="scripture-popup-header">
|
|
<strong>{refText}</strong>
|
|
<button type="button" className="scripture-popup-close" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
|
</span>
|
|
{loading && <span className="scripture-popup-body">Loading…</span>}
|
|
{error && <span className="scripture-popup-body scripture-popup-error">Could not load verse.</span>}
|
|
{!loading && !error && verses.length === 0 && <span className="scripture-popup-body">Verse not found.</span>}
|
|
{!loading && !error && verses.map(v => (
|
|
<span key={v.verse} className="scripture-popup-body">
|
|
{verseStart !== verseEnd && <sup>{v.verse} </sup>}{v.value}
|
|
</span>
|
|
))}
|
|
<span className="scripture-popup-footer">
|
|
<a
|
|
href={`https://www.bible.com/bible/3034/${BIBLE_COM_BOOK_IDS[bookId] ?? bookId}.${chapter}.BSB`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="scripture-popup-link"
|
|
>
|
|
Read on Bible.com ↗
|
|
</a>
|
|
<span className="scripture-popup-attribution">Berean Standard Bible</span>
|
|
</span>
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── old renderTextWithLinks removed, replaced above ───
|
|
|
|
function readEngagementFromStorage(): EngagementMap {
|
|
try {
|
|
const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)
|
|
if (!raw) return {}
|
|
const parsed = JSON.parse(raw)
|
|
return typeof parsed === 'object' && parsed ? (parsed as EngagementMap) : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function truncateAnswer(text: string, limit = 260) {
|
|
if (text.length <= limit) return text
|
|
const sliced = text.slice(0, limit)
|
|
const safeCut = sliced.lastIndexOf(' ')
|
|
return `${sliced.slice(0, safeCut > 120 ? safeCut : limit).trim()}...`
|
|
}
|
|
|
|
function semanticScoreForQuestion(question: PublicQuestion, tags: string[], query: string) {
|
|
const normalized = query.trim().toLowerCase()
|
|
if (!normalized) return 0
|
|
|
|
const queryTokens = tokenize(normalized)
|
|
if (queryTokens.length === 0) return 0
|
|
|
|
const expanded = expandQueryTokens(queryTokens)
|
|
const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`))
|
|
|
|
let score = 0
|
|
for (const token of expanded) {
|
|
if (questionTokens.has(token)) score += 2
|
|
}
|
|
|
|
const lowerQuestion = question.question.toLowerCase()
|
|
const lowerAnswer = question.answer.toLowerCase()
|
|
const lowerTopic = (question.topic ?? '').toLowerCase()
|
|
|
|
if (lowerQuestion.includes(normalized)) score += 6
|
|
if (lowerAnswer.includes(normalized)) score += 3
|
|
if (lowerTopic.includes(normalized)) score += 2
|
|
|
|
for (const token of queryTokens) {
|
|
if (token.length < 4) continue
|
|
if (lowerQuestion.includes(token)) score += 1
|
|
if (lowerAnswer.includes(token)) score += 0.5
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
function tokenizeForRelated(text: string) {
|
|
return tokenize(text).filter(token => token.length > 3 && !STOP_WORDS.has(token))
|
|
}
|
|
|
|
function extractAutoTags(question: PublicQuestion) {
|
|
const source = `${question.question} ${question.answer}`.toLowerCase()
|
|
const topicToken = normalizeToken(question.topic ?? '')
|
|
|
|
const lexiconHits = TAG_LEXICON
|
|
.filter(tag => source.includes(tag))
|
|
.filter(tag => tag !== topicToken)
|
|
|
|
if (lexiconHits.length > 0) {
|
|
return Array.from(new Set(lexiconHits)).slice(0, MAX_TAGS_PER_QUESTION)
|
|
}
|
|
|
|
const questionOnlyTokens = tokenize(question.question)
|
|
.filter(token => token.length >= 5 && !STOP_WORDS.has(token) && token !== topicToken)
|
|
.slice(0, MAX_TAGS_PER_QUESTION)
|
|
|
|
return Array.from(new Set(questionOnlyTokens))
|
|
}
|
|
|
|
export default function QASection() {
|
|
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
|
const [selectedTag, setSelectedTag] = useState<string | null>(null)
|
|
const [focusedQuestionId, setFocusedQuestionId] = useState<string | null>(null)
|
|
const [sortMode, setSortMode] = useState<SortMode>('relevance')
|
|
const [compactMode, setCompactMode] = useState(false)
|
|
const [page, setPage] = useState(0)
|
|
const [engagement, setEngagement] = useState<EngagementMap>({})
|
|
const [expandedCompactAnswers, setExpandedCompactAnswers] = useState<Record<string, boolean>>({})
|
|
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [votedIds, setVotedIds] = useState<Set<string>>(() => {
|
|
try {
|
|
const raw = localStorage.getItem('qa-voted-ids-v1')
|
|
return new Set(raw ? JSON.parse(raw) as string[] : [])
|
|
} catch { return new Set() }
|
|
})
|
|
const resultsTopRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/questions')
|
|
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions'))))
|
|
.then(data => {
|
|
setQuestions((data as { questions: PublicQuestion[] }).questions ?? [])
|
|
setLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setLoading(false)
|
|
})
|
|
}, [])
|
|
|
|
async function handleUpvote(id: string) {
|
|
if (votedIds.has(id)) return
|
|
try {
|
|
const res = await fetch(`/api/questions/${encodeURIComponent(id)}/upvote`, { method: 'POST' })
|
|
if (!res.ok) return
|
|
const data = await res.json() as { upvotes: number }
|
|
setQuestions(prev => prev.map(q => q.id === id ? { ...q, upvotes: data.upvotes } : q))
|
|
setVotedIds(prev => {
|
|
const next = new Set(prev)
|
|
next.add(id)
|
|
localStorage.setItem('qa-voted-ids-v1', JSON.stringify([...next]))
|
|
return next
|
|
})
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
useEffect(() => {
|
|
setEngagement(readEngagementFromStorage())
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement))
|
|
}, [engagement])
|
|
|
|
const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => {
|
|
setEngagement(prev => {
|
|
const current = prev[id] ?? { shares: 0, related: 0, expands: 0 }
|
|
return {
|
|
...prev,
|
|
[id]: {
|
|
...current,
|
|
[metric]: current[metric] + 1,
|
|
},
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
const normalizedSearch = searchQuery.trim().toLowerCase()
|
|
|
|
const questionSet = useMemo(() => {
|
|
return questions.map(question => {
|
|
const topic = question.topic?.trim() || 'General'
|
|
const tags = extractAutoTags(question)
|
|
const counts = engagement[question.id] ?? { shares: 0, related: 0, expands: 0 }
|
|
const helpful = counts.shares * 3 + counts.related * 2 + counts.expands
|
|
const semantic = semanticScoreForQuestion(question, tags, normalizedSearch)
|
|
return {
|
|
...question,
|
|
_topic: topic,
|
|
_tags: tags,
|
|
_helpful: helpful,
|
|
_semanticScore: semantic,
|
|
}
|
|
})
|
|
}, [engagement, normalizedSearch, questions])
|
|
|
|
useEffect(() => {
|
|
const syncFromHash = () => {
|
|
const hash = window.location.hash
|
|
if (!hash.startsWith('#qa-')) return
|
|
const id = decodeURIComponent(hash.slice(4))
|
|
const matched = questionSet.find(item => item.id === id)
|
|
if (!matched) return
|
|
|
|
setFocusedQuestionId(id)
|
|
setSelectedTopic(matched._topic)
|
|
|
|
window.setTimeout(() => {
|
|
const element = document.getElementById(`qa-${id}`)
|
|
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}, 80)
|
|
}
|
|
|
|
syncFromHash()
|
|
window.addEventListener('hashchange', syncFromHash)
|
|
return () => window.removeEventListener('hashchange', syncFromHash)
|
|
}, [questionSet])
|
|
|
|
const topicCounts = useMemo(() => {
|
|
const counts = new Map<string, number>()
|
|
for (const question of questionSet) {
|
|
counts.set(question._topic, (counts.get(question._topic) ?? 0) + 1)
|
|
}
|
|
return Array.from(counts.entries())
|
|
.map(([topic, count]) => ({ topic, count }))
|
|
.sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic))
|
|
}, [questionSet])
|
|
|
|
const tagCounts = useMemo(() => {
|
|
const counts = new Map<string, number>()
|
|
for (const question of questionSet) {
|
|
for (const tag of question._tags) {
|
|
counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
|
}
|
|
}
|
|
return Array.from(counts.entries())
|
|
.map(([tag, count]) => ({ tag, count }))
|
|
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
|
|
.slice(0, MAX_TAGS_VISIBLE)
|
|
}, [questionSet])
|
|
|
|
const filteredQuestions = useMemo(() => {
|
|
const output = questionSet.filter(question => {
|
|
const matchesTopic = !selectedTopic || question._topic === selectedTopic
|
|
const matchesTag = !selectedTag || question._tags.includes(selectedTag)
|
|
const matchesSearch = !normalizedSearch || question._semanticScore > 0
|
|
return matchesTopic && matchesTag && matchesSearch
|
|
})
|
|
|
|
const timestamp = (question: PublicQuestion) => {
|
|
const value = question.answeredAt ?? question.submittedAt
|
|
return value ? new Date(value).getTime() : 0
|
|
}
|
|
|
|
output.sort((a, b) => {
|
|
if (sortMode === 'newest') return timestamp(b) - timestamp(a)
|
|
if (sortMode === 'oldest') return timestamp(a) - timestamp(b)
|
|
if (sortMode === 'helpful') {
|
|
if (b._helpful !== a._helpful) return b._helpful - a._helpful
|
|
return timestamp(b) - timestamp(a)
|
|
}
|
|
if (b._semanticScore !== a._semanticScore) return b._semanticScore - a._semanticScore
|
|
return timestamp(b) - timestamp(a)
|
|
})
|
|
|
|
return output
|
|
}, [normalizedSearch, questionSet, selectedTag, selectedTopic, sortMode])
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / QA_PAGE_SIZE))
|
|
const safePage = Math.min(page, totalPages - 1)
|
|
const pageStart = safePage * QA_PAGE_SIZE
|
|
const pageEnd = Math.min(pageStart + QA_PAGE_SIZE, filteredQuestions.length)
|
|
const pagedQuestions = filteredQuestions.slice(pageStart, pageEnd)
|
|
|
|
useEffect(() => {
|
|
setPage(0)
|
|
}, [normalizedSearch, selectedTag, selectedTopic, sortMode])
|
|
|
|
useEffect(() => {
|
|
if (page > totalPages - 1) setPage(totalPages - 1)
|
|
}, [page, totalPages])
|
|
|
|
useEffect(() => {
|
|
if (!focusedQuestionId) return
|
|
const timer = window.setTimeout(() => setFocusedQuestionId(null), 2200)
|
|
return () => window.clearTimeout(timer)
|
|
}, [focusedQuestionId])
|
|
|
|
useEffect(() => {
|
|
if (!focusedQuestionId) return
|
|
const index = filteredQuestions.findIndex(item => item.id === focusedQuestionId)
|
|
if (index === -1) return
|
|
const nextPage = Math.floor(index / QA_PAGE_SIZE)
|
|
if (nextPage !== safePage) setPage(nextPage)
|
|
}, [filteredQuestions, focusedQuestionId, safePage])
|
|
|
|
const goToPage = (nextPage: number) => {
|
|
setPage(nextPage)
|
|
window.setTimeout(() => {
|
|
resultsTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
}, 40)
|
|
}
|
|
|
|
const handleSearch = (value: string) => {
|
|
setSearchQuery(value)
|
|
}
|
|
|
|
const handleTopic = (topic: string | null) => {
|
|
setSelectedTopic(topic)
|
|
}
|
|
|
|
const handleTag = (tag: string | null) => {
|
|
setSelectedTag(tag)
|
|
}
|
|
|
|
const clearFilters = () => {
|
|
setSelectedTopic(null)
|
|
setSelectedTag(null)
|
|
setSearchQuery('')
|
|
setPage(0)
|
|
}
|
|
|
|
const buildQuoteCanvas = (question: DecoratedQuestion): HTMLCanvasElement => {
|
|
const SIZE = 1080
|
|
const PADDING = 72
|
|
const canvas = document.createElement('canvas')
|
|
canvas.width = SIZE
|
|
canvas.height = SIZE
|
|
const ctx = canvas.getContext('2d')!
|
|
|
|
ctx.fillStyle = '#1a1208'
|
|
ctx.fillRect(0, 0, SIZE, SIZE)
|
|
|
|
ctx.fillStyle = '#c9a84c'
|
|
ctx.fillRect(0, 0, SIZE, 6)
|
|
|
|
function wrapText(text: string, maxWidth: number, font: string): string[] {
|
|
ctx.font = font
|
|
const words = text.split(' ')
|
|
const lines: string[] = []
|
|
let line = ''
|
|
for (const word of words) {
|
|
const test = line ? `${line} ${word}` : word
|
|
if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word }
|
|
else { line = test }
|
|
}
|
|
if (line) lines.push(line)
|
|
return lines
|
|
}
|
|
|
|
let y = PADDING + 40
|
|
|
|
ctx.fillStyle = '#c9a84c'
|
|
ctx.font = 'bold 26px Georgia, serif'
|
|
ctx.textAlign = 'center'
|
|
ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y)
|
|
y += 50
|
|
|
|
ctx.fillStyle = '#c9a84c'
|
|
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
|
y += 36
|
|
|
|
ctx.fillStyle = '#c9a84c'
|
|
ctx.font = 'bold 22px Georgia, serif'
|
|
ctx.textAlign = 'left'
|
|
ctx.fillText('Q', PADDING, y)
|
|
y += 8
|
|
|
|
const qFont = 'italic 32px Georgia, serif'
|
|
const qLines = wrapText(`"${question.question}"`, SIZE - PADDING * 2 - 20, qFont)
|
|
ctx.fillStyle = '#f5ead2'
|
|
ctx.font = qFont
|
|
ctx.textAlign = 'left'
|
|
const qLineH = 44
|
|
for (const line of qLines.slice(0, 5)) { y += qLineH; ctx.fillText(line, PADDING + 20, y) }
|
|
y += 40
|
|
|
|
ctx.fillStyle = '#3a2d14'
|
|
ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1)
|
|
y += 36
|
|
|
|
ctx.fillStyle = '#c9a84c'
|
|
ctx.font = 'bold 22px Georgia, serif'
|
|
ctx.textAlign = 'left'
|
|
ctx.fillText('A', PADDING, y)
|
|
y += 8
|
|
|
|
const aFont = '28px Georgia, serif'
|
|
const maxAHeight = SIZE - y - PADDING - 60
|
|
const aLineH = 40
|
|
const maxALines = Math.floor(maxAHeight / aLineH)
|
|
const rawAnswer = question.answer.replace(/\*\*/g, '').replace(/\*/g, '').replace(/<[^>]+>/g, '')
|
|
const aLines = wrapText(rawAnswer, SIZE - PADDING * 2 - 20, aFont).slice(0, maxALines)
|
|
if (aLines.length > 0 && rawAnswer.split(' ').length > aLines.join(' ').split(' ').length) {
|
|
aLines[aLines.length - 1] = aLines[aLines.length - 1].replace(/\s*\w+$/, '…')
|
|
}
|
|
ctx.fillStyle = '#e8d9b5'
|
|
ctx.font = aFont
|
|
for (const line of aLines) { y += aLineH; ctx.fillText(line, PADDING + 20, y) }
|
|
|
|
ctx.fillStyle = '#8a7040'
|
|
ctx.font = '20px Georgia, serif'
|
|
ctx.textAlign = 'center'
|
|
ctx.fillText(window.location.host + '/questions', SIZE / 2, SIZE - PADDING + 8)
|
|
|
|
return canvas
|
|
}
|
|
|
|
const saveAsImage = (question: DecoratedQuestion) => {
|
|
const canvas = buildQuoteCanvas(question)
|
|
const link = document.createElement('a')
|
|
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
|
|
link.href = canvas.toDataURL('image/png')
|
|
link.click()
|
|
}
|
|
|
|
const shareImageTo = async (question: DecoratedQuestion, platform: 'x' | 'facebook') => {
|
|
const canvas = buildQuoteCanvas(question)
|
|
const shareUrl = getSocialUrl(question.id)
|
|
const shareText = `Q: ${question.question.slice(0, 120)}${question.question.length > 120 ? '…' : ''}`
|
|
|
|
// Try Web Share API with file (works on mobile — iOS Safari, Android Chrome)
|
|
if (typeof navigator.share === 'function') {
|
|
try {
|
|
const blob = await new Promise<Blob>((resolve, reject) =>
|
|
canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas toBlob failed')), 'image/png')
|
|
)
|
|
const file = new File([blob], `vbvn-qa-${question.id.slice(0, 8)}.png`, { type: 'image/png' })
|
|
if (navigator.canShare?.({ files: [file] })) {
|
|
await navigator.share({ files: [file], text: shareText, url: shareUrl })
|
|
incrementEngagement(question.id, 'shares')
|
|
return
|
|
}
|
|
} catch (err) {
|
|
if ((err as DOMException)?.name === 'AbortError') return
|
|
}
|
|
}
|
|
|
|
// Desktop fallback: download the image, then open the compose window
|
|
const link = document.createElement('a')
|
|
link.download = `vbvn-qa-${question.id.slice(0, 8)}.png`
|
|
link.href = canvas.toDataURL('image/png')
|
|
link.click()
|
|
|
|
if (platform === 'x') {
|
|
const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`
|
|
window.open(tweetUrl, '_blank', 'width=600,height=400,noopener')
|
|
} else {
|
|
const fbUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`
|
|
window.open(fbUrl, '_blank', 'width=600,height=400,noopener')
|
|
}
|
|
incrementEngagement(question.id, 'shares')
|
|
}
|
|
|
|
const getShareUrl = (id: string) =>
|
|
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
|
|
|
|
const getSocialUrl = (id: string) =>
|
|
`${window.location.origin}/questions/share/${encodeURIComponent(id)}`
|
|
|
|
const shareQuestion = async (id: string) => {
|
|
const shareUrl = getShareUrl(id)
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl)
|
|
incrementEngagement(id, 'shares')
|
|
setCopiedQuestionId(id)
|
|
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(id)}`)
|
|
window.setTimeout(() => setCopiedQuestionId(curr => (curr === id ? null : curr)), 1800)
|
|
} catch {
|
|
window.prompt('Copy this link:', shareUrl)
|
|
}
|
|
}
|
|
|
|
|
|
const formatDate = (value?: string) => {
|
|
if (!value) return null
|
|
const parsed = new Date(value)
|
|
if (Number.isNaN(parsed.getTime())) return null
|
|
return parsed.toLocaleDateString()
|
|
}
|
|
|
|
const toggleCompactAnswer = (id: string) => {
|
|
const next = !expandedCompactAnswers[id]
|
|
setExpandedCompactAnswers(prev => ({ ...prev, [id]: next }))
|
|
if (next) incrementEngagement(id, 'expands')
|
|
}
|
|
|
|
const getRelatedQuestions = (current: DecoratedQuestion) => {
|
|
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
|
|
|
return questionSet
|
|
.filter(candidate => candidate.id !== current.id)
|
|
.map(candidate => {
|
|
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
|
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
|
const sameTopic = current._topic === candidate._topic
|
|
const score = overlap + (sameTopic ? 5 : 0)
|
|
return { candidate, score }
|
|
})
|
|
.filter(item => item.score > 0)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 3)
|
|
.map(item => item.candidate)
|
|
}
|
|
|
|
const topSuggestedTopic = topicCounts[0]?.topic ?? null
|
|
const mostHelpfulQuestion = [...questionSet].sort((a, b) => b._helpful - a._helpful)[0] ?? null
|
|
|
|
return (
|
|
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
|
<div className="section-inner">
|
|
<div className="qa-submit-cta">
|
|
<p className="qa-submit-cta-text">Have a question about Scripture or the podcast?</p>
|
|
<Link to="/contact" className="btn-primary">Submit a Question →</Link>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="qa-skeleton-list" aria-busy="true" aria-label="Loading questions">
|
|
{[0, 1, 2].map(i => (
|
|
<div key={i} className="qa-skeleton-card">
|
|
<div className="qa-skeleton-line qa-skeleton-line--short" />
|
|
<div className="qa-skeleton-line" />
|
|
<div className="qa-skeleton-line qa-skeleton-line--medium" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : questions.length === 0 ? (
|
|
<div className="qa-no-results">
|
|
<p>No questions have been answered yet. <a href="/contact">Submit yours below!</a></p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="qa-toolbar">
|
|
<button
|
|
type="button"
|
|
className={`qa-view-toggle${compactMode ? ' qa-view-toggle--active' : ''}`}
|
|
onClick={() => setCompactMode(value => !value)}
|
|
>
|
|
{compactMode ? 'Compact view: On' : 'Compact view: Off'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="qa-filters">
|
|
<div className="qa-topics">
|
|
<button
|
|
type="button"
|
|
className={`qa-topic-btn${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
|
onClick={() => handleTopic(null)}
|
|
>
|
|
All
|
|
</button>
|
|
{topicCounts.map(({ topic }) => (
|
|
<button
|
|
type="button"
|
|
key={topic}
|
|
className={`qa-topic-btn${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
|
onClick={() => handleTopic(topic)}
|
|
>
|
|
{topic}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="qa-tags">
|
|
<button
|
|
type="button"
|
|
className={`qa-tag-btn${selectedTag === null ? ' qa-tag-btn--active' : ''}`}
|
|
onClick={() => handleTag(null)}
|
|
>
|
|
All tags
|
|
</button>
|
|
{tagCounts.map(({ tag }) => (
|
|
<button
|
|
type="button"
|
|
key={tag}
|
|
className={`qa-tag-btn${selectedTag === tag ? ' qa-tag-btn--active' : ''}`}
|
|
onClick={() => handleTag(tag)}
|
|
>
|
|
#{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="qa-search">
|
|
<label>
|
|
<span className="visually-hidden">Search Questions</span>
|
|
<input
|
|
type="text"
|
|
placeholder="Search naturally (example: how to stay consistent in Bible reading)"
|
|
value={searchQuery}
|
|
onChange={e => handleSearch(e.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{(searchQuery || selectedTopic || selectedTag) && (
|
|
<div className="qa-filter-chips" aria-label="Active filters">
|
|
{searchQuery && (
|
|
<button type="button" className="qa-chip" onClick={() => setSearchQuery('')}>
|
|
Search: {searchQuery} x
|
|
</button>
|
|
)}
|
|
{selectedTopic && (
|
|
<button type="button" className="qa-chip" onClick={() => setSelectedTopic(null)}>
|
|
Topic: {selectedTopic} x
|
|
</button>
|
|
)}
|
|
{selectedTag && (
|
|
<button type="button" className="qa-chip" onClick={() => setSelectedTag(null)}>
|
|
Tag: #{selectedTag} x
|
|
</button>
|
|
)}
|
|
<button type="button" className="qa-chip qa-chip--clear" onClick={clearFilters}>Clear all</button>
|
|
</div>
|
|
)}
|
|
|
|
{filteredQuestions.length === 0 ? (
|
|
<div className="qa-no-results qa-no-results--smart">
|
|
<p>No matches for this search yet.</p>
|
|
{topSuggestedTopic && (
|
|
<p>
|
|
Try browsing <button type="button" className="qa-inline-link" onClick={() => { clearFilters(); setSelectedTopic(topSuggestedTopic) }}>{topSuggestedTopic}</button> questions.
|
|
</p>
|
|
)}
|
|
{mostHelpfulQuestion && (
|
|
<p>
|
|
Or jump to <button type="button" className="qa-inline-link" onClick={() => {
|
|
clearFilters()
|
|
setFocusedQuestionId(mostHelpfulQuestion.id)
|
|
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(mostHelpfulQuestion.id)}`)
|
|
}}>{mostHelpfulQuestion.question}</button>.
|
|
</p>
|
|
)}
|
|
<p><a href="/contact">Submit your question</a> and Nate may add it here.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="qa-meta-row">
|
|
<p className="qa-result-count">
|
|
{filteredQuestions.length} result{filteredQuestions.length === 1 ? '' : 's'}
|
|
{normalizedSearch && <span> for "{normalizedSearch}"</span>}
|
|
{filteredQuestions.length > 0 && <span> · Showing {pageStart + 1}-{pageEnd}</span>}
|
|
</p>
|
|
<label className="qa-sort-select" htmlFor="qa-sort-mode">
|
|
Sort
|
|
<select id="qa-sort-mode" value={sortMode} onChange={e => setSortMode(e.target.value as SortMode)}>
|
|
<option value="relevance">Best match</option>
|
|
<option value="helpful">Most helpful</option>
|
|
<option value="newest">Newest first</option>
|
|
<option value="oldest">Oldest first</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="qa-layout">
|
|
<aside className="qa-sidebar" aria-label="Question topics">
|
|
<p className="qa-sidebar-label">Browse by topic</p>
|
|
<button
|
|
type="button"
|
|
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
|
onClick={() => handleTopic(null)}
|
|
>
|
|
<span>All topics</span>
|
|
<span>{questions.length}</span>
|
|
</button>
|
|
{topicCounts.map(({ topic, count }) => (
|
|
<button
|
|
type="button"
|
|
key={topic}
|
|
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
|
onClick={() => handleTopic(topic)}
|
|
>
|
|
<span>{topic}</span>
|
|
<span>{count}</span>
|
|
</button>
|
|
))}
|
|
</aside>
|
|
|
|
<div className={`qa-cards${compactMode ? ' qa-cards--compact' : ''}`}>
|
|
<div ref={resultsTopRef} aria-hidden="true" />
|
|
{pagedQuestions.map(question => {
|
|
const relatedQuestions = getRelatedQuestions(question)
|
|
const isFocused = focusedQuestionId === question.id
|
|
const isExpandedInCompact = expandedCompactAnswers[question.id] === true
|
|
const linkCopied = copiedQuestionId === question.id
|
|
const answerText = compactMode && !isExpandedInCompact
|
|
? truncateAnswer(question.answer)
|
|
: question.answer
|
|
|
|
return (
|
|
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}${question.pinned ? ' qa-card-scene--pinned' : ''}`}>
|
|
<div className="qa-question-header">
|
|
<span className="qa-face-label">Q</span>
|
|
<span className="qa-question-main">
|
|
<span className="qa-question-text">
|
|
{question.pinned && (
|
|
<span className="qa-pinned-badge" aria-label="Pinned question">📌 </span>
|
|
)}
|
|
{renderHighlightedText(question.question, normalizedSearch)}
|
|
</span>
|
|
<span className="qa-question-meta">
|
|
<span className="qa-topic-pill">{question._topic}</span>
|
|
{formatDate(question.answeredAt ?? question.submittedAt) && (
|
|
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
|
|
)}
|
|
{question._helpful > 0 && <span>{question._helpful} helpful</span>}
|
|
{(question.upvotes ?? 0) > 0 && (
|
|
<span>👍 {question.upvotes}</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="qa-card-actions">
|
|
<button
|
|
type="button"
|
|
aria-label={votedIds.has(question.id) ? 'Already upvoted' : 'Upvote this question'}
|
|
className={`qa-upvote-btn${votedIds.has(question.id) ? ' qa-upvote-btn--voted' : ''}`}
|
|
onClick={() => handleUpvote(question.id)}
|
|
disabled={votedIds.has(question.id)}
|
|
>
|
|
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" width="13" height="13"><path d="M8 2L2 9h4v5h4V9h4z"/></svg>
|
|
<span>{votedIds.has(question.id) ? 'Upvoted' : 'Upvote'}{(question.upvotes ?? 0) > 0 ? ` · ${question.upvotes}` : ''}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Share on X"
|
|
className="qa-social-btn qa-social-btn--x"
|
|
onClick={() => shareImageTo(question, 'x')}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
|
<span>Share</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Share on Facebook"
|
|
className="qa-social-btn qa-social-btn--fb"
|
|
onClick={() => shareImageTo(question, 'facebook')}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M24 12.073C24 5.404 18.627 0 12 0S0 5.404 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.236 2.686.236v2.97h-1.513c-1.491 0-1.956.93-1.956 1.884v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/></svg>
|
|
<span>Share</span>
|
|
</button>
|
|
<button type="button" className="qa-share-btn" onClick={() => shareQuestion(question.id)}>
|
|
{linkCopied ? '✓ Copied' : 'Copy link'}
|
|
</button>
|
|
<button type="button" className="qa-share-btn" onClick={() => saveAsImage(question)}>
|
|
Save image
|
|
</button>
|
|
</div>
|
|
{linkCopied && (
|
|
<p className="qa-share-feedback" role="status" aria-live="polite">
|
|
Direct link copied to clipboard.
|
|
</p>
|
|
)}
|
|
|
|
<div id={`qa-answer-${question.id}`} className="qa-card-face qa-card-back">
|
|
<span className="qa-face-label">A</span>
|
|
<div className="qa-answer-text">
|
|
{renderTextWithLinks(answerText, normalizedSearch)}
|
|
{compactMode && question.answer.length > 260 && (
|
|
<button
|
|
type="button"
|
|
className="qa-read-more-btn"
|
|
onClick={() => toggleCompactAnswer(question.id)}
|
|
>
|
|
{isExpandedInCompact ? 'Show less' : 'Read full answer'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{relatedQuestions.length > 0 && (
|
|
<div className="qa-related-wrap">
|
|
<p className="qa-related-label">Related questions</p>
|
|
<div className="qa-related-list">
|
|
{relatedQuestions.map(related => (
|
|
<button
|
|
key={related.id}
|
|
type="button"
|
|
className="qa-related-btn"
|
|
onClick={() => {
|
|
incrementEngagement(question.id, 'related')
|
|
setSelectedTopic(related._topic)
|
|
setSelectedTag(null)
|
|
setSearchQuery('')
|
|
setFocusedQuestionId(related.id)
|
|
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(related.id)}`)
|
|
window.setTimeout(() => {
|
|
const element = document.getElementById(`qa-${related.id}`)
|
|
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}, 30)
|
|
}}
|
|
>
|
|
{related.question}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<p className="qa-answer-byline">Answered by Nate</p>
|
|
</div>
|
|
</article>
|
|
)
|
|
})}
|
|
|
|
{totalPages > 1 && (
|
|
<div className="qa-pagination">
|
|
<button
|
|
type="button"
|
|
className="qa-page-btn"
|
|
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
|
disabled={safePage === 0}
|
|
aria-label="Previous questions page"
|
|
>
|
|
Prev
|
|
</button>
|
|
<span className="qa-page-info">Page {safePage + 1} / {totalPages}</span>
|
|
<button
|
|
type="button"
|
|
className="qa-page-btn"
|
|
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
|
disabled={safePage >= totalPages - 1}
|
|
aria-label="Next questions page"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|