From 7a48130ffb6ea899c5b3bfdb9473f6f30c03e6ee Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 11 May 2026 11:53:01 -0400 Subject: [PATCH] Add social share buttons and per-question OG share stubs --- server.js | 56 ++- src/App.css | 450 +++++++++++++++++----- src/components/QASection.tsx | 721 +++++++++++++++++++++++++++++------ 3 files changed, 1032 insertions(+), 195 deletions(-) diff --git a/server.js b/server.js index 4ae15ef..22a8310 100644 --- a/server.js +++ b/server.js @@ -2531,7 +2531,8 @@ app.get('/api/admin-questions', requireAdminAuth, (_req, res) => { // Get only approved public questions (for homepage) 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 }) }) @@ -2917,6 +2918,59 @@ app.use('/images', express.static(DIST_IMAGES_DIR)) app.use('/images', express.static(PUBLIC_IMAGES_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(` + + + +${ogTitle} — Verse by Verse with Nate + + + + + + + + + + + + + + + + + +`) +}) + app.use(express.static(DIST_DIR)) app.use(async (_req, res) => { diff --git a/src/App.css b/src/App.css index 5280fef..6c27a75 100644 --- a/src/App.css +++ b/src/App.css @@ -3268,21 +3268,73 @@ /* ── Q&A Section ── */ .section-qa { - background: rgba(201, 168, 76, 0.05); - border-top: 1px solid rgba(201, 168, 76, 0.15); + background: + 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 { display: flex; flex-direction: column; - gap: 1rem; - margin-bottom: 2rem; + gap: 0.85rem; + 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 { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; 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 { @@ -3297,11 +3349,11 @@ .qa-topic-btn { background: none; border: 1px solid rgba(201, 168, 76, 0.35); - color: var(--brand-muted); - padding: 0.35rem 0.9rem; - border-radius: 2rem; + color: #cab483; + padding: 0.42rem 0.92rem; + border-radius: 999px; cursor: pointer; - font-size: 0.875rem; + font-size: 0.85rem; font-family: 'Barlow', sans-serif; transition: background 0.18s, border-color 0.18s, color 0.18s; } @@ -3313,16 +3365,22 @@ } .qa-topic-btn--active { - background: rgba(201, 168, 76, 0.18); + background: rgba(201, 168, 76, 0.2); border-color: var(--brand-gold); - color: var(--brand-gold); + color: #f2ddb0; font-weight: 600; } -.qa-search { +.qa-topic-btn--full { + width: 100%; display: flex; - gap: 1rem; - align-items: flex-end; + justify-content: space-between; + align-items: center; + border-radius: 0.6rem; +} + +.qa-search { + display: block; } .qa-search label { @@ -3345,18 +3403,20 @@ } .qa-search input { - padding: 0.75rem 1rem; - background: rgba(30, 30, 30, 0.8); - border: 1px solid rgba(201, 168, 76, 0.3); - color: var(--brand-warm-white); + width: 100%; + padding: 0.78rem 1rem; + background: rgba(22, 21, 17, 0.95); + border: 1px solid rgba(201, 168, 76, 0.33); + color: #f7f0df; font-family: 'Barlow', sans-serif; - border-radius: 0.375rem; - transition: border-color 0.2s ease; + border-radius: 0.55rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; } .qa-search input:focus { outline: none; border-color: var(--brand-gold); + box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18); } .qa-filter-actions { @@ -3364,6 +3424,28 @@ 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 { background: transparent; border: 1px solid rgba(201, 168, 76, 0.38); @@ -3381,14 +3463,72 @@ .qa-cards { display: flex; 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; } -/* ── 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 { border: 1px solid rgba(201, 168, 76, 0.25); - border-radius: 0.5rem; - background: rgba(35, 30, 20, 0.85); + border-radius: 0.68rem; + background: linear-gradient(180deg, rgba(32, 28, 20, 0.96), rgba(20, 19, 15, 0.98)); transition: border-color 0.2s ease; overflow: hidden; } @@ -3397,56 +3537,87 @@ 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%; - cursor: pointer; + display: flex; + align-items: flex-start; + gap: 0.8rem; + padding: 1rem 1rem 0.9rem; } .qa-card-face { - padding: 1.25rem 1.5rem; -} - -.qa-card-front { - display: flex; - align-items: center; - gap: 1rem; - user-select: none; + padding: 0.25rem 1rem 1rem; } .qa-card-back { - border-top: 1px solid rgba(201, 168, 76, 0.2); - background: rgba(20, 28, 20, 0.7); - display: none; -} - -.qa-card-inner.flipped .qa-card-back { - display: block; + border-top: 1px solid rgba(201, 168, 76, 0.16); + background: rgba(17, 24, 17, 0.58); + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.8rem; + align-items: flex-start; + text-align: left; } .qa-face-label { - font-size: 1.3rem; + font-size: 1.15rem; font-weight: 700; color: var(--brand-gold); - flex-shrink: 0; - width: 1.75rem; + flex: 0 0 1.4rem; +} + +.qa-question-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.42rem; } .qa-question-text { margin: 0; - font-size: 1.14rem; - line-height: 1.6; - color: var(--brand-warm-white); + font-size: 1.06rem; + line-height: 1.5; + color: #f6efe0; 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 { margin: 0; - font-size: 1.12rem; - line-height: 1.75; - color: var(--brand-warm-white); + font-size: 1.01rem; + line-height: 1.58; + color: #efe7d4; opacity: 0.92; white-space: pre-wrap; + text-align: left; + justify-self: stretch; } .qa-answer-text a { @@ -3459,12 +3630,76 @@ color: #f0ead8; } -.qa-flip-hint { - font-size: 0.75rem; - color: var(--brand-muted); - margin-left: auto; - flex-shrink: 0; - font-style: italic; +.qa-read-more-btn { + margin-top: 0.55rem; + border: 1px solid rgba(201, 168, 76, 0.35); + background: rgba(201, 168, 76, 0.07); + color: #d8c395; + 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 { @@ -3473,6 +3708,26 @@ 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 { display: flex; align-items: center; @@ -3509,18 +3764,23 @@ 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) { .qa-filters { gap: 0.65rem; - margin-bottom: 1.25rem; + margin-bottom: 1rem; } .qa-topics { - flex-wrap: nowrap; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 0.2rem; - -webkit-overflow-scrolling: touch; + gap: 0.4rem; } .qa-topic-btn { @@ -3535,28 +3795,43 @@ font-size: 0.95rem; } - .qa-card-face { - padding: 1rem 0.9rem; - } - - .qa-card-front { + .qa-meta-row { + flex-direction: column; align-items: flex-start; - gap: 0.65rem; + gap: 0.5rem; } - .qa-face-label { - width: 1.2rem; - font-size: 1.1rem; - line-height: 1; + .qa-layout { + grid-template-columns: 1fr; + } + + .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 { - font-size: 1rem; - line-height: 1.5; + font-size: 0.97rem; + line-height: 1.45; } .qa-answer-text { - font-size: 0.98rem; + font-size: 0.95rem; line-height: 1.6; } @@ -4321,15 +4596,17 @@ /* Q&A related question buttons */ .qa-related-wrap { - margin-top: 1rem; - border-top: 1px solid rgba(201, 168, 76, 0.18); - padding-top: 0.85rem; + margin-top: 0.7rem; + border-top: 1px solid rgba(201, 168, 76, 0.12); + padding-top: 0.6rem; + grid-column: 2; + text-align: left; } .qa-related-label { margin: 0 0 0.5rem; - color: #b9a783; - font-size: 0.88rem; + color: #a9956c; + font-size: 0.76rem; text-transform: uppercase; letter-spacing: 0.05em; } @@ -4338,20 +4615,21 @@ display: flex; flex-wrap: wrap; gap: 0.45rem; + justify-content: flex-start; } .qa-related-btn { - border: 1px solid rgba(201, 168, 76, 0.35); - background: rgba(201, 168, 76, 0.08); - color: #d9be86; + border: 1px solid rgba(201, 168, 76, 0.22); + background: rgba(201, 168, 76, 0.03); + color: #bca57b; border-radius: 999px; - padding: 0.3rem 0.75rem; - font-size: 0.82rem; + padding: 0.22rem 0.62rem; + font-size: 0.74rem; cursor: pointer; } .qa-related-btn:hover { - background: rgba(201, 168, 76, 0.16); + background: rgba(201, 168, 76, 0.09); } @media (max-width: 900px) { diff --git a/src/components/QASection.tsx b/src/components/QASection.tsx index cd6f98e..8572b73 100644 --- a/src/components/QASection.tsx +++ b/src/components/QASection.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react' -import type { KeyboardEvent } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' interface PublicQuestion { id: string @@ -7,24 +6,114 @@ interface PublicQuestion { question: string answer: 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 + +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 = { + faith: ['belief', 'trust'], + trust: ['faith', 'belief'], + grace: ['mercy', 'favor'], + mercy: ['grace', 'compassion'], + hope: ['expectation', 'future'], + love: ['charity', 'care'], + sin: ['wrong', 'evil'], + prayer: ['pray'], + pray: ['prayer'], + bible: ['scripture', 'word'], + scripture: ['bible', 'word'], + church: ['fellowship', 'body'], + leadership: ['elder', 'pastor'], + politics: ['government', 'public'], + family: ['home', 'household'], + translation: ['version'], + salvation: ['saved', 'redeemed'], +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function normalizeToken(token: string) { + return token.toLowerCase().replace(/[^a-z0-9]/g, '').trim() +} + +function tokenize(text: string) { return text .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) - .filter(token => token.length > 3) + .map(normalizeToken) + .filter(token => token.length > 2) } -function renderTextWithLinks(text: string) { +function expandQueryTokens(tokens: string[]) { + const expanded = new Set() + 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 {part} + } + return {part} + }) +} + +function renderTextWithLinks(text: string, highlightQuery = '') { const parts = text.split(/(https?:\/\/[^\s]+)/g) return parts.map((part, index) => { if (!/^https?:\/\//i.test(part)) { - return {part} + return {renderHighlightedText(part, highlightQuery)} } 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() { const [questions, setQuestions] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [selectedTopic, setSelectedTopic] = useState(null) - const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({}) + const [selectedTag, setSelectedTag] = useState(null) + const [focusedQuestionId, setFocusedQuestionId] = useState(null) + const [sortMode, setSortMode] = useState('relevance') + const [compactMode, setCompactMode] = useState(false) const [page, setPage] = useState(0) + const [engagement, setEngagement] = useState({}) + const [expandedCompactAnswers, setExpandedCompactAnswers] = useState>({}) + const [copiedQuestionId, setCopiedQuestionId] = useState(null) const [loading, setLoading] = useState(true) + const resultsTopRef = useRef(null) useEffect(() => { 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 => { - const matchesTopic = !selectedTopic || q.topic === selectedTopic - const matchesSearch = - !searchQuery || - q.question.toLowerCase().includes(searchQuery.toLowerCase()) || - q.answer.toLowerCase().includes(searchQuery.toLowerCase()) - return matchesTopic && matchesSearch - }) + useEffect(() => { + window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement)) + }, [engagement]) - const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE) - const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE) + const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => { + setEngagement(prev => { + const current = prev[id] ?? { shares: 0, related: 0, expands: 0 } + return { + ...prev, + [id]: { + ...current, + [metric]: current[metric] + 1, + }, + } + }) + }, []) - const toggleExpanded = (id: string) => { - setExpanded(state => ({ ...state, [id]: !state[id] })) + 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() + 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() + for (const question of questionSet) { + for (const tag of question._tags) { + counts.set(tag, (counts.get(tag) ?? 0) + 1) + } + } + return Array.from(counts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)) + .slice(0, MAX_TAGS_VISIBLE) + }, [questionSet]) + + const filteredQuestions = useMemo(() => { + const output = questionSet.filter(question => { + const matchesTopic = !selectedTopic || question._topic === selectedTopic + const matchesTag = !selectedTag || question._tags.includes(selectedTag) + const matchesSearch = !normalizedSearch || question._semanticScore > 0 + return matchesTopic && matchesTag && matchesSearch + }) + + const timestamp = (question: PublicQuestion) => { + const value = question.answeredAt ?? question.submittedAt + return value ? new Date(value).getTime() : 0 + } + + output.sort((a, b) => { + if (sortMode === 'newest') return timestamp(b) - timestamp(a) + if (sortMode === 'oldest') return timestamp(a) - timestamp(b) + if (sortMode === 'helpful') { + if (b._helpful !== a._helpful) return b._helpful - a._helpful + return timestamp(b) - timestamp(a) + } + if (b._semanticScore !== a._semanticScore) return b._semanticScore - a._semanticScore + return timestamp(b) - timestamp(a) + }) + + return output + }, [normalizedSearch, questionSet, selectedTag, selectedTopic, sortMode]) + + const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)) + const safePage = Math.min(page, totalPages - 1) + const pageStart = safePage * QA_PAGE_SIZE + const pageEnd = Math.min(pageStart + QA_PAGE_SIZE, filteredQuestions.length) + const pagedQuestions = filteredQuestions.slice(pageStart, pageEnd) + + useEffect(() => { + setPage(0) + }, [normalizedSearch, selectedTag, selectedTopic, sortMode]) + + useEffect(() => { + if (page > totalPages - 1) setPage(totalPages - 1) + }, [page, totalPages]) + + useEffect(() => { + if (!focusedQuestionId) return + const timer = window.setTimeout(() => setFocusedQuestionId(null), 2200) + return () => window.clearTimeout(timer) + }, [focusedQuestionId]) + + useEffect(() => { + if (!focusedQuestionId) return + const index = filteredQuestions.findIndex(item => item.id === focusedQuestionId) + if (index === -1) return + const nextPage = Math.floor(index / QA_PAGE_SIZE) + if (nextPage !== safePage) setPage(nextPage) + }, [filteredQuestions, focusedQuestionId, safePage]) + + const goToPage = (nextPage: number) => { + setPage(nextPage) + window.setTimeout(() => { + resultsTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 40) } const handleSearch = (value: string) => { setSearchQuery(value) - setPage(0) } const handleTopic = (topic: string | null) => { setSelectedTopic(topic) - setPage(0) + } + + const handleTag = (tag: string | null) => { + setSelectedTag(tag) } const clearFilters = () => { setSelectedTopic(null) + setSelectedTag(null) setSearchQuery('') 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}`)) - return questions + return questionSet .filter(candidate => candidate.id !== current.id) .map(candidate => { const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`) const overlap = candidateTokens.filter(token => currentTokens.has(token)).length - const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic) + const sameTopic = current._topic === candidate._topic const score = overlap + (sameTopic ? 5 : 0) return { candidate, score } }) @@ -113,6 +469,9 @@ export default function QASection() { .map(item => item.candidate) } + const topSuggestedTopic = topicCounts[0]?.topic ?? null + const mostHelpfulQuestion = [...questionSet].sort((a, b) => b._helpful - a._helpful)[0] ?? null + return (
@@ -121,13 +480,23 @@ export default function QASection() { {loading ? ( -

Loading questions…

+

Loading questions...

) : questions.length === 0 ? (

No questions have been answered yet. Submit yours below!

) : ( <> +
+ +
+
- {topics.map(topic => ( + {topicCounts.map(({ topic }) => (
+
+ + {tagCounts.map(({ tag }) => ( + + ))} +
- {(searchQuery || selectedTopic) && ( -
- -
- )}
+ {(searchQuery || selectedTopic || selectedTag) && ( +
+ {searchQuery && ( + + )} + {selectedTopic && ( + + )} + {selectedTag && ( + + )} + +
+ )} + {filteredQuestions.length === 0 ? ( -
-

No matching questions found. Submit your question

+
+

No matches for this search yet.

+ {topSuggestedTopic && ( +

+ Try browsing questions. +

+ )} + {mostHelpfulQuestion && ( +

+ Or jump to . +

+ )} +

Submit your question and Nate may add it here.

) : ( <> -
- {pagedQuestions.map(question => { - const relatedQuestions = getRelatedQuestions(question) - return ( -
-
toggleExpanded(question.id)} - role="button" - tabIndex={0} - aria-expanded={!!expanded[question.id]} - aria-label={question.question} - onKeyDown={(e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id) - }} - > -
- Q -

{question.question}

- {expanded[question.id] ? '▲' : '▼'} -
-
- A -
{renderTextWithLinks(question.answer)}
- {relatedQuestions.length > 0 && ( -
-

Related questions

-
- {relatedQuestions.map(related => ( - - ))} -
-
- )} -

- — Answered by Nate -

-
-
-
- )})} +
+

+ {filteredQuestions.length} result{filteredQuestions.length === 1 ? '' : 's'} + {normalizedSearch && for "{normalizedSearch}"} + {filteredQuestions.length > 0 && · Showing {pageStart + 1}-{pageEnd}} +

+
- {totalPages > 1 && ( -
+
+ + +
+ - )} +
)}