Add social share buttons and per-question OG share stubs

This commit is contained in:
nmemmert
2026-05-11 11:53:01 -04:00
parent f4adeb2844
commit 7a48130ffb
3 changed files with 1032 additions and 195 deletions
+55 -1
View File
@@ -2531,7 +2531,8 @@ app.get('/api/admin-questions', requireAdminAuth, (_req, res) => {
// Get only approved public questions (for homepage) // Get only approved public questions (for homepage)
app.get('/api/questions', (_req, res) => { app.get('/api/questions', (_req, res) => {
const publicQuestions = questions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0) const sourceQuestions = draftQuestions ?? questions
const publicQuestions = sourceQuestions.filter(q => q.isApproved === true && q.answer && q.answer.trim().length > 0)
res.json({ questions: publicQuestions }) res.json({ questions: publicQuestions })
}) })
@@ -2917,6 +2918,59 @@ app.use('/images', express.static(DIST_IMAGES_DIR))
app.use('/images', express.static(PUBLIC_IMAGES_DIR)) app.use('/images', express.static(PUBLIC_IMAGES_DIR))
app.use('/uploads', express.static(UPLOADS_DIR)) app.use('/uploads', express.static(UPLOADS_DIR))
// Per-question social share stub — serves question-specific OG tags so
// platforms (X, Facebook) render a rich preview card. After the crawl
// delay the page immediately redirects the human visitor to the real SPA URL.
app.get('/questions/share/:id', (req, res) => {
const id = req.params.id
if (!id || !/^[\w-]{1,120}$/.test(id)) {
res.redirect(302, '/questions')
return
}
const sourceQuestions = draftQuestions ?? questions
const question = sourceQuestions.find(q => q.id === id && q.isApproved === true && q.answer)
if (!question) {
res.redirect(302, '/questions')
return
}
const BASE = 'https://versebyversewithnate.us'
const canonicalUrl = `${BASE}/questions#qa-${encodeURIComponent(id)}`
const shareUrl = `${BASE}/questions/share/${encodeURIComponent(id)}`
const ogTitle = escapeHtml(question.question.length > 100
? `${question.question.slice(0, 97)}\u2026`
: question.question)
const answerSnippet = question.answer.replace(/\n+/g, ' ').trim()
const ogDescription = escapeHtml(answerSnippet.length > 200
? `${answerSnippet.slice(0, 197)}\u2026`
: answerSnippet)
const ogImage = `${BASE}/images/banner.png`
res.type('html').send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>${ogTitle} — Verse by Verse with Nate</title>
<meta name="description" content="${ogDescription}"/>
<meta property="og:type" content="article"/>
<meta property="og:site_name" content="Verse by Verse with Nate"/>
<meta property="og:url" content="${escapeHtml(shareUrl)}"/>
<meta property="og:title" content="${ogTitle}"/>
<meta property="og:description" content="${ogDescription}"/>
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
<meta property="og:image:alt" content="${ogTitle}"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:title" content="${ogTitle}"/>
<meta name="twitter:description" content="${ogDescription}"/>
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
<link rel="canonical" href="${escapeHtml(shareUrl)}"/>
<meta http-equiv="refresh" content="0;url=${escapeHtml(canonicalUrl)}"/>
<script>location.replace(${JSON.stringify(canonicalUrl)})</script>
</head>
<body></body>
</html>`)
})
app.use(express.static(DIST_DIR)) app.use(express.static(DIST_DIR))
app.use(async (_req, res) => { app.use(async (_req, res) => {
+364 -86
View File
@@ -3268,21 +3268,73 @@
/* ── Q&A Section ── */ /* ── Q&A Section ── */
.section-qa { .section-qa {
background: rgba(201, 168, 76, 0.05); background:
border-top: 1px solid rgba(201, 168, 76, 0.15); radial-gradient(90% 120% at 100% 0%, rgba(201, 168, 76, 0.12) 0%, rgba(201, 168, 76, 0.02) 50%, rgba(16, 15, 12, 0) 100%),
linear-gradient(180deg, rgba(20, 18, 14, 0.96), rgba(13, 12, 10, 0.97));
border-top: 1px solid rgba(201, 168, 76, 0.2);
} }
.qa-filters { .qa-filters {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.85rem;
margin-bottom: 2rem; margin-bottom: 1rem;
}
.qa-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 0.7rem;
}
.qa-view-toggle {
border: 1px solid rgba(201, 168, 76, 0.34);
background: rgba(201, 168, 76, 0.05);
color: #cfba8d;
border-radius: 999px;
padding: 0.3rem 0.72rem;
font-size: 0.76rem;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.qa-view-toggle--active {
background: rgba(201, 168, 76, 0.16);
color: #f4ddb0;
} }
.qa-topics { .qa-topics {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.15rem;
}
.qa-tags {
display: flex;
flex-wrap: nowrap;
gap: 0.45rem;
overflow-x: auto;
padding-bottom: 0.1rem;
}
.qa-tag-btn {
border: 1px solid rgba(201, 168, 76, 0.26);
background: rgba(201, 168, 76, 0.04);
color: #bfa982;
border-radius: 999px;
padding: 0.28rem 0.62rem;
font-size: 0.75rem;
cursor: pointer;
white-space: nowrap;
}
.qa-tag-btn--active {
background: rgba(201, 168, 76, 0.18);
color: #f0d9ab;
border-color: rgba(201, 168, 76, 0.5);
} }
.qa-topics::-webkit-scrollbar { .qa-topics::-webkit-scrollbar {
@@ -3297,11 +3349,11 @@
.qa-topic-btn { .qa-topic-btn {
background: none; background: none;
border: 1px solid rgba(201, 168, 76, 0.35); border: 1px solid rgba(201, 168, 76, 0.35);
color: var(--brand-muted); color: #cab483;
padding: 0.35rem 0.9rem; padding: 0.42rem 0.92rem;
border-radius: 2rem; border-radius: 999px;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.85rem;
font-family: 'Barlow', sans-serif; font-family: 'Barlow', sans-serif;
transition: background 0.18s, border-color 0.18s, color 0.18s; transition: background 0.18s, border-color 0.18s, color 0.18s;
} }
@@ -3313,16 +3365,22 @@
} }
.qa-topic-btn--active { .qa-topic-btn--active {
background: rgba(201, 168, 76, 0.18); background: rgba(201, 168, 76, 0.2);
border-color: var(--brand-gold); border-color: var(--brand-gold);
color: var(--brand-gold); color: #f2ddb0;
font-weight: 600; font-weight: 600;
} }
.qa-search { .qa-topic-btn--full {
width: 100%;
display: flex; display: flex;
gap: 1rem; justify-content: space-between;
align-items: flex-end; align-items: center;
border-radius: 0.6rem;
}
.qa-search {
display: block;
} }
.qa-search label { .qa-search label {
@@ -3345,18 +3403,20 @@
} }
.qa-search input { .qa-search input {
padding: 0.75rem 1rem; width: 100%;
background: rgba(30, 30, 30, 0.8); padding: 0.78rem 1rem;
border: 1px solid rgba(201, 168, 76, 0.3); background: rgba(22, 21, 17, 0.95);
color: var(--brand-warm-white); border: 1px solid rgba(201, 168, 76, 0.33);
color: #f7f0df;
font-family: 'Barlow', sans-serif; font-family: 'Barlow', sans-serif;
border-radius: 0.375rem; border-radius: 0.55rem;
transition: border-color 0.2s ease; transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
.qa-search input:focus { .qa-search input:focus {
outline: none; outline: none;
border-color: var(--brand-gold); border-color: var(--brand-gold);
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18);
} }
.qa-filter-actions { .qa-filter-actions {
@@ -3364,6 +3424,28 @@
justify-content: flex-end; justify-content: flex-end;
} }
.qa-filter-chips {
margin: 0 0 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.qa-chip {
border: 1px solid rgba(201, 168, 76, 0.26);
background: rgba(201, 168, 76, 0.06);
color: #d2bf95;
border-radius: 999px;
padding: 0.24rem 0.6rem;
font-size: 0.74rem;
cursor: pointer;
}
.qa-chip--clear {
color: #ead6ac;
border-color: rgba(201, 168, 76, 0.5);
}
.qa-clear-btn { .qa-clear-btn {
background: transparent; background: transparent;
border: 1px solid rgba(201, 168, 76, 0.38); border: 1px solid rgba(201, 168, 76, 0.38);
@@ -3381,14 +3463,72 @@
.qa-cards { .qa-cards {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.7rem;
}
.qa-meta-row {
margin: 0 0 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
} }
/* ── Accordion card ── */ .qa-result-count {
margin: 0;
color: #d7c59d;
font-size: 0.92rem;
}
.qa-sort-select {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
color: #b8a37a;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.qa-sort-select select {
border: 1px solid rgba(201, 168, 76, 0.35);
background: rgba(24, 22, 18, 0.95);
color: #f1e4c2;
border-radius: 0.45rem;
padding: 0.33rem 0.58rem;
}
.qa-layout {
display: grid;
grid-template-columns: minmax(210px, 250px) minmax(0, 1fr);
gap: 1rem;
align-items: flex-start;
}
.qa-sidebar {
border: 1px solid rgba(201, 168, 76, 0.26);
background: linear-gradient(180deg, rgba(39, 34, 24, 0.76), rgba(25, 22, 17, 0.9));
border-radius: 0.75rem;
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
position: sticky;
top: 94px;
}
.qa-sidebar-label {
margin: 0 0 0.1rem;
color: #ad9971;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.qa-card-scene { .qa-card-scene {
border: 1px solid rgba(201, 168, 76, 0.25); border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 0.5rem; border-radius: 0.68rem;
background: rgba(35, 30, 20, 0.85); background: linear-gradient(180deg, rgba(32, 28, 20, 0.96), rgba(20, 19, 15, 0.98));
transition: border-color 0.2s ease; transition: border-color 0.2s ease;
overflow: hidden; overflow: hidden;
} }
@@ -3397,56 +3537,87 @@
border-color: rgba(201, 168, 76, 0.55); border-color: rgba(201, 168, 76, 0.55);
} }
.qa-card-inner { .qa-card-scene--focused {
border-color: rgba(201, 168, 76, 0.72);
box-shadow: 0 0 0 1px rgba(201, 168, 76, 0.24);
}
.qa-question-header {
width: 100%; width: 100%;
cursor: pointer; display: flex;
align-items: flex-start;
gap: 0.8rem;
padding: 1rem 1rem 0.9rem;
} }
.qa-card-face { .qa-card-face {
padding: 1.25rem 1.5rem; padding: 0.25rem 1rem 1rem;
}
.qa-card-front {
display: flex;
align-items: center;
gap: 1rem;
user-select: none;
} }
.qa-card-back { .qa-card-back {
border-top: 1px solid rgba(201, 168, 76, 0.2); border-top: 1px solid rgba(201, 168, 76, 0.16);
background: rgba(20, 28, 20, 0.7); background: rgba(17, 24, 17, 0.58);
display: none; display: grid;
} grid-template-columns: auto 1fr;
column-gap: 0.8rem;
.qa-card-inner.flipped .qa-card-back { align-items: flex-start;
display: block; text-align: left;
} }
.qa-face-label { .qa-face-label {
font-size: 1.3rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
color: var(--brand-gold); color: var(--brand-gold);
flex-shrink: 0; flex: 0 0 1.4rem;
width: 1.75rem; }
.qa-question-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.42rem;
} }
.qa-question-text { .qa-question-text {
margin: 0; margin: 0;
font-size: 1.14rem; font-size: 1.06rem;
line-height: 1.6; line-height: 1.5;
color: var(--brand-warm-white); color: #f6efe0;
font-weight: 600; font-weight: 600;
flex: 1; }
.qa-highlight {
background: rgba(201, 168, 76, 0.26);
color: #f7e9c8;
padding: 0.02em 0.18em;
border-radius: 0.24em;
}
.qa-question-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
color: #bca67f;
font-size: 0.78rem;
}
.qa-topic-pill {
border: 1px solid rgba(201, 168, 76, 0.35);
border-radius: 999px;
padding: 0.16rem 0.5rem;
color: #e8d0a0;
} }
.qa-answer-text { .qa-answer-text {
margin: 0; margin: 0;
font-size: 1.12rem; font-size: 1.01rem;
line-height: 1.75; line-height: 1.58;
color: var(--brand-warm-white); color: #efe7d4;
opacity: 0.92; opacity: 0.92;
white-space: pre-wrap; white-space: pre-wrap;
text-align: left;
justify-self: stretch;
} }
.qa-answer-text a { .qa-answer-text a {
@@ -3459,12 +3630,76 @@
color: #f0ead8; color: #f0ead8;
} }
.qa-flip-hint { .qa-read-more-btn {
font-size: 0.75rem; margin-top: 0.55rem;
color: var(--brand-muted); border: 1px solid rgba(201, 168, 76, 0.35);
margin-left: auto; background: rgba(201, 168, 76, 0.07);
flex-shrink: 0; color: #d8c395;
font-style: italic; border-radius: 999px;
padding: 0.2rem 0.58rem;
font-size: 0.74rem;
cursor: pointer;
}
.qa-card-actions {
display: flex;
align-items: center;
gap: 0.45rem;
justify-content: flex-end;
padding: 0 1rem 0.65rem;
}
.qa-social-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.28rem 0.62rem;
font-size: 0.73rem;
font-family: inherit;
letter-spacing: 0.04em;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.qa-social-btn--x {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.15);
color: #e7e7e7;
}
.qa-social-btn--x:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.28);
}
.qa-social-btn--fb {
background: rgba(24,119,242,0.12);
border-color: rgba(24,119,242,0.32);
color: #7eaaff;
}
.qa-social-btn--fb:hover {
background: rgba(24,119,242,0.22);
border-color: rgba(24,119,242,0.5);
}
.qa-share-btn {
border: 1px solid rgba(201, 168, 76, 0.35);
background: rgba(201, 168, 76, 0.06);
color: #d6be8f;
border-radius: 999px;
padding: 0.28rem 0.66rem;
font-size: 0.73rem;
font-family: inherit;
text-transform: uppercase;
letter-spacing: 0.06em;
cursor: pointer;
}
.qa-share-btn:hover {
background: rgba(201, 168, 76, 0.14);
} }
.qa-no-results { .qa-no-results {
@@ -3473,6 +3708,26 @@
color: var(--brand-muted); color: var(--brand-muted);
} }
.qa-no-results--smart {
text-align: left;
border: 1px solid rgba(201, 168, 76, 0.24);
border-radius: 0.7rem;
background: rgba(23, 22, 17, 0.82);
}
.qa-no-results--smart p {
margin: 0 0 0.6rem;
}
.qa-inline-link {
border: 0;
background: transparent;
color: #e7cf9c;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.qa-pagination { .qa-pagination {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3509,18 +3764,23 @@
text-align: center; text-align: center;
} }
.qa-answer-byline {
grid-column: 2;
margin: 0.55rem 0 0;
font-size: 0.8rem;
color: #a89060;
font-style: italic;
text-align: left;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.qa-filters { .qa-filters {
gap: 0.65rem; gap: 0.65rem;
margin-bottom: 1.25rem; margin-bottom: 1rem;
} }
.qa-topics { .qa-topics {
flex-wrap: nowrap; gap: 0.4rem;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.2rem;
-webkit-overflow-scrolling: touch;
} }
.qa-topic-btn { .qa-topic-btn {
@@ -3535,28 +3795,43 @@
font-size: 0.95rem; font-size: 0.95rem;
} }
.qa-card-face { .qa-meta-row {
padding: 1rem 0.9rem; flex-direction: column;
}
.qa-card-front {
align-items: flex-start; align-items: flex-start;
gap: 0.65rem; gap: 0.5rem;
} }
.qa-face-label { .qa-layout {
width: 1.2rem; grid-template-columns: 1fr;
font-size: 1.1rem; }
line-height: 1;
.qa-sidebar {
position: static;
padding: 0.65rem;
max-height: 220px;
overflow-y: auto;
}
.qa-question-header {
padding: 0.86rem 0.82rem;
gap: 0.62rem;
}
.qa-card-face {
padding: 0.15rem 0.82rem 0.86rem;
}
.qa-card-actions {
padding: 0 0.82rem 0.62rem;
} }
.qa-question-text { .qa-question-text {
font-size: 1rem; font-size: 0.97rem;
line-height: 1.5; line-height: 1.45;
} }
.qa-answer-text { .qa-answer-text {
font-size: 0.98rem; font-size: 0.95rem;
line-height: 1.6; line-height: 1.6;
} }
@@ -4321,15 +4596,17 @@
/* Q&A related question buttons */ /* Q&A related question buttons */
.qa-related-wrap { .qa-related-wrap {
margin-top: 1rem; margin-top: 0.7rem;
border-top: 1px solid rgba(201, 168, 76, 0.18); border-top: 1px solid rgba(201, 168, 76, 0.12);
padding-top: 0.85rem; padding-top: 0.6rem;
grid-column: 2;
text-align: left;
} }
.qa-related-label { .qa-related-label {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
color: #b9a783; color: #a9956c;
font-size: 0.88rem; font-size: 0.76rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -4338,20 +4615,21 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; gap: 0.45rem;
justify-content: flex-start;
} }
.qa-related-btn { .qa-related-btn {
border: 1px solid rgba(201, 168, 76, 0.35); border: 1px solid rgba(201, 168, 76, 0.22);
background: rgba(201, 168, 76, 0.08); background: rgba(201, 168, 76, 0.03);
color: #d9be86; color: #bca57b;
border-radius: 999px; border-radius: 999px;
padding: 0.3rem 0.75rem; padding: 0.22rem 0.62rem;
font-size: 0.82rem; font-size: 0.74rem;
cursor: pointer; cursor: pointer;
} }
.qa-related-btn:hover { .qa-related-btn:hover {
background: rgba(201, 168, 76, 0.16); background: rgba(201, 168, 76, 0.09);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
+580 -75
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent } from 'react'
interface PublicQuestion { interface PublicQuestion {
id: string id: string
@@ -7,24 +6,114 @@ interface PublicQuestion {
question: string question: string
answer: string answer: string
topic?: string topic?: string
submittedAt?: string
answeredAt?: string
} }
const QA_PAGE_SIZE = 6 interface DecoratedQuestion extends PublicQuestion {
_topic: string
_tags: string[]
_helpful: number
_semanticScore: number
}
function tokenizeForRelated(text: string) { 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 return text
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ') .replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/) .split(/\s+/)
.filter(token => token.length > 3) .map(normalizeToken)
.filter(token => token.length > 2)
} }
function renderTextWithLinks(text: string) { 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 parts = text.split(/(https?:\/\/[^\s]+)/g) const parts = text.split(/(https?:\/\/[^\s]+)/g)
return parts.map((part, index) => { return parts.map((part, index) => {
if (!/^https?:\/\//i.test(part)) { if (!/^https?:\/\//i.test(part)) {
return <span key={`text-${index}`}>{part}</span> return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
} }
const safeHref = part.replace(/[),.;!?]+$/g, '') const safeHref = part.replace(/[),.;!?]+$/g, '')
@@ -41,13 +130,93 @@ function renderTextWithLinks(text: string) {
}) })
} }
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() { export default function QASection() {
const [questions, setQuestions] = useState<PublicQuestion[]>([]) const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedTopic, setSelectedTopic] = useState<string | null>(null) const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({}) 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 [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 [loading, setLoading] = useState(true)
const resultsTopRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { useEffect(() => {
fetch('/api/questions') fetch('/api/questions')
@@ -61,49 +230,236 @@ export default function QASection() {
}) })
}, []) }, [])
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[] useEffect(() => {
setEngagement(readEngagementFromStorage())
}, [])
const filteredQuestions = questions.filter(q => { useEffect(() => {
const matchesTopic = !selectedTopic || q.topic === selectedTopic window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement))
const matchesSearch = }, [engagement])
!searchQuery ||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) || const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => {
q.answer.toLowerCase().includes(searchQuery.toLowerCase()) setEngagement(prev => {
return matchesTopic && matchesSearch 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 totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE) const timestamp = (question: PublicQuestion) => {
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE) const value = question.answeredAt ?? question.submittedAt
return value ? new Date(value).getTime() : 0
}
const toggleExpanded = (id: string) => { output.sort((a, b) => {
setExpanded(state => ({ ...state, [id]: !state[id] })) 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) => { const handleSearch = (value: string) => {
setSearchQuery(value) setSearchQuery(value)
setPage(0)
} }
const handleTopic = (topic: string | null) => { const handleTopic = (topic: string | null) => {
setSelectedTopic(topic) setSelectedTopic(topic)
setPage(0) }
const handleTag = (tag: string | null) => {
setSelectedTag(tag)
} }
const clearFilters = () => { const clearFilters = () => {
setSelectedTopic(null) setSelectedTopic(null)
setSelectedTag(null)
setSearchQuery('') setSearchQuery('')
setPage(0) setPage(0)
} }
const getRelatedQuestions = (current: PublicQuestion) => { 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 shareToX = (question: DecoratedQuestion) => {
const url = getSocialUrl(question.id)
const text = question.question.length > 200
? `${question.question.slice(0, 197)}`
: question.question
window.open(
`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
'_blank',
'noopener,noreferrer,width=600,height=420'
)
incrementEngagement(question.id, 'shares')
}
const shareToFacebook = (id: string) => {
const url = getSocialUrl(id)
window.open(
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
'_blank',
'noopener,noreferrer,width=600,height=500'
)
incrementEngagement(id, 'shares')
}
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}`)) const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
return questions return questionSet
.filter(candidate => candidate.id !== current.id) .filter(candidate => candidate.id !== current.id)
.map(candidate => { .map(candidate => {
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`) const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic) const sameTopic = current._topic === candidate._topic
const score = overlap + (sameTopic ? 5 : 0) const score = overlap + (sameTopic ? 5 : 0)
return { candidate, score } return { candidate, score }
}) })
@@ -113,6 +469,9 @@ export default function QASection() {
.map(item => item.candidate) .map(item => item.candidate)
} }
const topSuggestedTopic = topicCounts[0]?.topic ?? null
const mostHelpfulQuestion = [...questionSet].sort((a, b) => b._helpful - a._helpful)[0] ?? null
return ( return (
<section id="qa" className="section-qa" aria-label="Questions and answers"> <section id="qa" className="section-qa" aria-label="Questions and answers">
<div className="section-inner"> <div className="section-inner">
@@ -121,13 +480,23 @@ export default function QASection() {
</h2> </h2>
{loading ? ( {loading ? (
<p className="qa-no-results">Loading questions</p> <p className="qa-no-results">Loading questions...</p>
) : questions.length === 0 ? ( ) : questions.length === 0 ? (
<div className="qa-no-results"> <div className="qa-no-results">
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p> <p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
</div> </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-filters">
<div className="qa-topics"> <div className="qa-topics">
<button <button
@@ -137,7 +506,7 @@ export default function QASection() {
> >
All All
</button> </button>
{topics.map(topic => ( {topicCounts.map(({ topic }) => (
<button <button
type="button" type="button"
key={topic} key={topic}
@@ -148,54 +517,185 @@ export default function QASection() {
</button> </button>
))} ))}
</div> </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"> <div className="qa-search">
<label> <label>
<span className="visually-hidden">Search Questions</span> <span className="visually-hidden">Search Questions</span>
<input <input
type="text" type="text"
placeholder="Search questions…" placeholder="Search naturally (example: how to stay consistent in Bible reading)"
value={searchQuery} value={searchQuery}
onChange={e => handleSearch(e.target.value)} onChange={e => handleSearch(e.target.value)}
/> />
</label> </label>
</div> </div>
{(searchQuery || selectedTopic) && (
<div className="qa-filter-actions">
<button type="button" className="qa-clear-btn" onClick={clearFilters}>Clear filters</button>
</div>
)}
</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 ? ( {filteredQuestions.length === 0 ? (
<div className="qa-no-results"> <div className="qa-no-results qa-no-results--smart">
<p>No matching questions found. <a href="#contact">Submit your question</a></p> <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>
) : ( ) : (
<> <>
<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"> <div className="qa-cards">
<div ref={resultsTopRef} aria-hidden="true" />
{pagedQuestions.map(question => { {pagedQuestions.map(question => {
const relatedQuestions = getRelatedQuestions(question) const relatedQuestions = getRelatedQuestions(question)
const isFocused = focusedQuestionId === question.id
const isExpandedInCompact = expandedCompactAnswers[question.id] === true
const answerText = compactMode && !isExpandedInCompact
? truncateAnswer(question.answer)
: question.answer
return ( return (
<div key={question.id} className="qa-card-scene"> <article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}`}>
<div <div className="qa-question-header">
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
onClick={() => toggleExpanded(question.id)}
role="button"
tabIndex={0}
aria-expanded={!!expanded[question.id]}
aria-label={question.question}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
}}
>
<div className="qa-card-face qa-card-front">
<span className="qa-face-label">Q</span> <span className="qa-face-label">Q</span>
<p className="qa-question-text">{question.question}</p> <span className="qa-question-main">
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span> <span className="qa-question-text">{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>}
</span>
</span>
</div> </div>
<div className="qa-card-face qa-card-back">
<div className="qa-card-actions">
<button
type="button"
aria-label="Share on X"
className="qa-social-btn qa-social-btn--x"
onClick={() => shareToX(question)}
>
<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={() => shareToFacebook(question.id)}
>
<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)}>
{copiedQuestionId === question.id ? '✓ Copied' : 'Copy link'}
</button>
</div>
<div id={`qa-answer-${question.id}`} className="qa-card-face qa-card-back">
<span className="qa-face-label">A</span> <span className="qa-face-label">A</span>
<div className="qa-answer-text">{renderTextWithLinks(question.answer)}</div> <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 && ( {relatedQuestions.length > 0 && (
<div className="qa-related-wrap"> <div className="qa-related-wrap">
<p className="qa-related-label">Related questions</p> <p className="qa-related-label">Related questions</p>
@@ -205,11 +705,17 @@ export default function QASection() {
key={related.id} key={related.id}
type="button" type="button"
className="qa-related-btn" className="qa-related-btn"
onClick={e => { onClick={() => {
e.stopPropagation() incrementEngagement(question.id, 'related')
handleTopic(null) setSelectedTopic(related._topic)
handleSearch(related.question) setSelectedTag(null)
setExpanded({ [related.id]: true }) 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} {related.question}
@@ -218,38 +724,37 @@ export default function QASection() {
</div> </div>
</div> </div>
)} )}
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}> <p className="qa-answer-byline">Answered by Nate</p>
Answered by Nate
</p>
</div>
</div>
</div>
)})}
</div> </div>
</article>
)
})}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="qa-pagination"> <div className="qa-pagination">
<button <button
type="button"
className="qa-page-btn" className="qa-page-btn"
onClick={() => setPage(p => p - 1)} onClick={() => goToPage(Math.max(0, safePage - 1))}
disabled={page === 0} disabled={safePage === 0}
aria-label="Previous page" aria-label="Previous questions page"
> >
Prev Prev
</button> </button>
<span className="qa-page-info"> <span className="qa-page-info">Page {safePage + 1} / {totalPages}</span>
{page + 1} / {totalPages}
</span>
<button <button
type="button"
className="qa-page-btn" className="qa-page-btn"
onClick={() => setPage(p => p + 1)} onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={page >= totalPages - 1} disabled={safePage >= totalPages - 1}
aria-label="Next page" aria-label="Next questions page"
> >
Next Next
</button> </button>
</div> </div>
)} )}
</div>
</div>
</> </>
)} )}
</> </>