Add social share buttons and per-question OG share stubs
This commit is contained in:
@@ -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
@@ -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) {
|
||||||
|
|||||||
+613
-108
@@ -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()) ||
|
|
||||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
return matchesTopic && matchesSearch
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => {
|
||||||
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
setEngagement(prev => {
|
||||||
|
const current = prev[id] ?? { shares: 0, related: 0, expands: 0 }
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[id]: {
|
||||||
|
...current,
|
||||||
|
[metric]: current[metric] + 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleExpanded = (id: string) => {
|
const normalizedSearch = searchQuery.trim().toLowerCase()
|
||||||
setExpanded(state => ({ ...state, [id]: !state[id] }))
|
|
||||||
|
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) => {
|
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,108 +517,244 @@ 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-cards">
|
<div className="qa-meta-row">
|
||||||
{pagedQuestions.map(question => {
|
<p className="qa-result-count">
|
||||||
const relatedQuestions = getRelatedQuestions(question)
|
{filteredQuestions.length} result{filteredQuestions.length === 1 ? '' : 's'}
|
||||||
return (
|
{normalizedSearch && <span> for "{normalizedSearch}"</span>}
|
||||||
<div key={question.id} className="qa-card-scene">
|
{filteredQuestions.length > 0 && <span> · Showing {pageStart + 1}-{pageEnd}</span>}
|
||||||
<div
|
</p>
|
||||||
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
<label className="qa-sort-select" htmlFor="qa-sort-mode">
|
||||||
onClick={() => toggleExpanded(question.id)}
|
Sort
|
||||||
role="button"
|
<select id="qa-sort-mode" value={sortMode} onChange={e => setSortMode(e.target.value as SortMode)}>
|
||||||
tabIndex={0}
|
<option value="relevance">Best match</option>
|
||||||
aria-expanded={!!expanded[question.id]}
|
<option value="helpful">Most helpful</option>
|
||||||
aria-label={question.question}
|
<option value="newest">Newest first</option>
|
||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
<option value="oldest">Oldest first</option>
|
||||||
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
|
</select>
|
||||||
}}
|
</label>
|
||||||
>
|
|
||||||
<div className="qa-card-face qa-card-front">
|
|
||||||
<span className="qa-face-label">Q</span>
|
|
||||||
<p className="qa-question-text">{question.question}</p>
|
|
||||||
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="qa-card-face qa-card-back">
|
|
||||||
<span className="qa-face-label">A</span>
|
|
||||||
<div className="qa-answer-text">{renderTextWithLinks(question.answer)}</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={e => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleTopic(null)
|
|
||||||
handleSearch(related.question)
|
|
||||||
setExpanded({ [related.id]: true })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{related.question}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
|
|
||||||
— Answered by Nate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
<div className="qa-layout">
|
||||||
<div className="qa-pagination">
|
<aside className="qa-sidebar" aria-label="Question topics">
|
||||||
|
<p className="qa-sidebar-label">Browse by topic</p>
|
||||||
<button
|
<button
|
||||||
className="qa-page-btn"
|
type="button"
|
||||||
onClick={() => setPage(p => p - 1)}
|
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
||||||
disabled={page === 0}
|
onClick={() => handleTopic(null)}
|
||||||
aria-label="Previous page"
|
|
||||||
>
|
>
|
||||||
‹ Prev
|
<span>All topics</span>
|
||||||
</button>
|
<span>{questions.length}</span>
|
||||||
<span className="qa-page-info">
|
|
||||||
{page + 1} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="qa-page-btn"
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
disabled={page >= totalPages - 1}
|
|
||||||
aria-label="Next page"
|
|
||||||
>
|
|
||||||
Next ›
|
|
||||||
</button>
|
</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 ref={resultsTopRef} aria-hidden="true" />
|
||||||
|
{pagedQuestions.map(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 (
|
||||||
|
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}`}>
|
||||||
|
<div className="qa-question-header">
|
||||||
|
<span className="qa-face-label">Q</span>
|
||||||
|
<span className="qa-question-main">
|
||||||
|
<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 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>
|
||||||
|
<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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user