Add a newsletter archive so past issues can be shared and found; v2.3.0
Publish Container / build (push) Successful in 20m51s
Publish Container / build (push) Successful in 20m51s
Web-hosted copies of episode newsletters, published from Resend broadcasts tagged with the "New Episode" topic. Gives Facebook (and anyone else) a proper link to share instead of raw HTML email, and makes issues findable via site search and Google. - Admin "Newsletter Archive" tab lists sent Resend broadcasts, flags ones tagged "New Episode", and publishes them with one click (pulls the HTML straight from Resend's API) - Public /newsletter archive index and /newsletter/:slug issue pages, server-rendered with per-issue Open Graph tags for social previews - Unsubscribe merge-tag stripped from the web copy; archive + subscribe links appended - Issues added to sitemap.xml and the on-site search index Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
60b23b0fba
commit
fe4c09f8a3
11 files changed
+516
-4
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.2.4",
|
"version": "2.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
loadWebhooksFromDisk,
|
loadWebhooksFromDisk,
|
||||||
loadStudySessionsFromDisk,
|
loadStudySessionsFromDisk,
|
||||||
queueStudySessionsWrite,
|
queueStudySessionsWrite,
|
||||||
|
loadNewsletterIssuesFromDisk,
|
||||||
createBackupSnapshot,
|
createBackupSnapshot,
|
||||||
refreshContentCaches,
|
refreshContentCaches,
|
||||||
queueHitStatsWrite,
|
queueHitStatsWrite,
|
||||||
@@ -70,6 +71,7 @@ import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
|||||||
import { register as registerFeed } from './server/routes/feed.js'
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||||
|
import { register as registerNewsletter } from './server/routes/newsletter.js'
|
||||||
import { register as registerPublic } from './server/routes/public.js'
|
import { register as registerPublic } from './server/routes/public.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
@@ -120,6 +122,7 @@ registerNuggetsHostUpload(app)
|
|||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
registerBiblePassage(app)
|
registerBiblePassage(app)
|
||||||
|
registerNewsletter(app)
|
||||||
|
|
||||||
// Hit-counting middleware (must come before public routes)
|
// Hit-counting middleware (must come before public routes)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
@@ -168,6 +171,7 @@ Promise.all([
|
|||||||
loadAuditLogFromDisk(),
|
loadAuditLogFromDisk(),
|
||||||
loadWebhooksFromDisk(),
|
loadWebhooksFromDisk(),
|
||||||
loadStudySessionsFromDisk(),
|
loadStudySessionsFromDisk(),
|
||||||
|
loadNewsletterIssuesFromDisk(),
|
||||||
refreshContentCaches(),
|
refreshContentCaches(),
|
||||||
])
|
])
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
|
|||||||
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
|
||||||
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
|
||||||
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
|
||||||
|
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
|
||||||
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
|
||||||
|
|
||||||
// Seafile integration for Nuggets episode uploads
|
// Seafile integration for Nuggets episode uploads
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
AUDIT_LOG_FILE,
|
AUDIT_LOG_FILE,
|
||||||
WEBHOOKS_FILE,
|
WEBHOOKS_FILE,
|
||||||
STUDY_SESSIONS_FILE,
|
STUDY_SESSIONS_FILE,
|
||||||
|
NEWSLETTER_ISSUES_FILE,
|
||||||
EMPTY_HIT_STATS,
|
EMPTY_HIT_STATS,
|
||||||
EMPTY_VISITOR_STATS,
|
EMPTY_VISITOR_STATS,
|
||||||
DEFAULT_REPLY_TEMPLATES,
|
DEFAULT_REPLY_TEMPLATES,
|
||||||
@@ -1673,3 +1674,27 @@ export function loadQrCodesFromDisk() {
|
|||||||
state.qrScans = []
|
state.qrScans = []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Newsletter Issues ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function queueNewsletterIssuesWrite() {
|
||||||
|
state.newsletterIssuesWritePromise = state.newsletterIssuesWritePromise
|
||||||
|
.then(async () => {
|
||||||
|
await mkdir(DATA_DIR, { recursive: true })
|
||||||
|
await writeFile(NEWSLETTER_ISSUES_FILE, JSON.stringify({ issues: state.newsletterIssues }, null, 2), 'utf8')
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('[newsletter-issues] failed to write:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadNewsletterIssuesFromDisk() {
|
||||||
|
return readFile(NEWSLETTER_ISSUES_FILE, 'utf8')
|
||||||
|
.then(raw => {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
state.newsletterIssues = Array.isArray(parsed?.issues) ? parsed.issues : []
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
state.newsletterIssues = []
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { Resend } from 'resend'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { state } from '../state.js'
|
||||||
|
import { queueNewsletterIssuesWrite } from '../data.js'
|
||||||
|
import { escapeHtml } from '../helpers.js'
|
||||||
|
|
||||||
|
const BASE = 'https://versebyversewithnate.us'
|
||||||
|
const NEW_EPISODE_TOPIC_NAME = 'new episode'
|
||||||
|
|
||||||
|
function truncate(str, max) {
|
||||||
|
const clean = String(str || '').trim()
|
||||||
|
if (clean.length <= max) return clean
|
||||||
|
return `${clean.slice(0, max - 1).trimEnd()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripSubjectPrefix(subject) {
|
||||||
|
return String(subject || '').replace(/^New Episode:?\s*-?\s*/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str) {
|
||||||
|
return String(str || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueSlug(base) {
|
||||||
|
const root = slugify(base) || 'issue'
|
||||||
|
let candidate = root
|
||||||
|
let n = 2
|
||||||
|
while (state.newsletterIssues.some(i => i.slug === candidate)) {
|
||||||
|
candidate = `${root}-${n}`
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDescription(text, previewText) {
|
||||||
|
if (previewText && previewText.trim()) return truncate(previewText, 200)
|
||||||
|
const paragraphs = String(text || '')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map(p => p.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const candidate = paragraphs.find(p => p.length > 60 && !/^[◆✦✍✎▶]/.test(p) && p !== p.toUpperCase())
|
||||||
|
return truncate(candidate || paragraphs[paragraphs.length - 1] || '', 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the raw {{{RESEND_UNSUBSCRIBE_URL}}} placeholder link — it only resolves inside an actual sent email.
|
||||||
|
function stripUnsubscribeLink(html) {
|
||||||
|
return String(html || '').replace(
|
||||||
|
/<p[^>]*>\s*<a[^>]*href="\{\{\{RESEND_UNSUBSCRIBE_URL\}\}\}"[\s\S]*?<\/a>\s*<\/p>/i,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIssuePageHtml(issue) {
|
||||||
|
const canonical = `${BASE}/newsletter/${encodeURIComponent(issue.slug)}`
|
||||||
|
const ogImage = `${BASE}/images/banner.png`
|
||||||
|
const title = escapeHtml(issue.title)
|
||||||
|
const description = escapeHtml(issue.description)
|
||||||
|
|
||||||
|
const metaBlock = `
|
||||||
|
<title>${title} — Verse by Verse with Nate</title>
|
||||||
|
<meta name="description" content="${description}"/>
|
||||||
|
<meta property="og:type" content="article"/>
|
||||||
|
<meta property="og:site_name" content="Verse by Verse with Nate"/>
|
||||||
|
<meta property="og:url" content="${escapeHtml(canonical)}"/>
|
||||||
|
<meta property="og:title" content="${title}"/>
|
||||||
|
<meta property="og:description" content="${description}"/>
|
||||||
|
<meta property="og:image" content="${escapeHtml(ogImage)}"/>
|
||||||
|
<meta property="og:image:alt" content="${title}"/>
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
<meta name="twitter:title" content="${title}"/>
|
||||||
|
<meta name="twitter:description" content="${description}"/>
|
||||||
|
<meta name="twitter:image" content="${escapeHtml(ogImage)}"/>
|
||||||
|
<link rel="canonical" href="${escapeHtml(canonical)}"/>
|
||||||
|
</head>`
|
||||||
|
|
||||||
|
const ctaBlock = `
|
||||||
|
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
|
||||||
|
<tbody><tr><td align="center" style="padding:32px 20px 48px;background-color:#080807">
|
||||||
|
<table width="580" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:580px;width:100%">
|
||||||
|
<tbody><tr><td align="center" style="padding:0">
|
||||||
|
<p style="margin:0 0 16px;font-family:Georgia,serif;font-size:13px;color:#6b6044">
|
||||||
|
<a href="${BASE}/newsletter" style="color:#6b6044;text-decoration:none">← Newsletter Archive</a>
|
||||||
|
</p>
|
||||||
|
<a href="${BASE}/subscribe" style="display:inline-block;padding:12px 32px;font-family:Georgia,serif;font-size:12px;letter-spacing:0.16em;text-transform:uppercase;color:#B8972A;border:1px solid #B8972A;text-decoration:none">Subscribe by Email</a>
|
||||||
|
</td></tr></tbody></table>
|
||||||
|
</td></tr></tbody></table>
|
||||||
|
</body>`
|
||||||
|
|
||||||
|
let html = stripUnsubscribeLink(issue.html)
|
||||||
|
html = html.replace(/<title>[\s\S]*?<\/title>/i, '')
|
||||||
|
html = /<\/head>/i.test(html) ? html.replace(/<\/head>/i, metaBlock) : html.replace(/<head[^>]*>/i, m => `${m}${metaBlock.replace('</head>', '')}`)
|
||||||
|
html = /<\/body>/i.test(html) ? html.replace(/<\/body>/i, ctaBlock) : `${html}${ctaBlock}`
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
// ── Admin: browse Resend broadcasts, flagged by New Episode topic ────────
|
||||||
|
|
||||||
|
app.get('/api/admin/newsletter/resend-broadcasts', requireAdminAuth, async (_req, res) => {
|
||||||
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
|
||||||
|
resend.topics.list(),
|
||||||
|
resend.broadcasts.list(),
|
||||||
|
])
|
||||||
|
if (topicsError) throw new Error(topicsError.message)
|
||||||
|
if (listError) throw new Error(listError.message)
|
||||||
|
|
||||||
|
const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME)
|
||||||
|
const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent')
|
||||||
|
|
||||||
|
const details = await Promise.all(sentBroadcasts.map(b => resend.broadcasts.get(b.id)))
|
||||||
|
|
||||||
|
const broadcasts = details.map(({ data }, i) => {
|
||||||
|
const summary = sentBroadcasts[i]
|
||||||
|
if (!data) return null
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
subject: data.subject,
|
||||||
|
sentAt: data.sent_at ?? summary.sent_at,
|
||||||
|
topicId: data.topic_id ?? null,
|
||||||
|
matchesNewEpisodeTopic: !!newEpisodeTopic && data.topic_id === newEpisodeTopic.id,
|
||||||
|
alreadyPublished: state.newsletterIssues.some(issue => issue.resendBroadcastId === data.id),
|
||||||
|
}
|
||||||
|
}).filter(Boolean).sort((a, b) => new Date(b.sentAt ?? 0).getTime() - new Date(a.sentAt ?? 0).getTime())
|
||||||
|
|
||||||
|
res.json({ broadcasts, newEpisodeTopicFound: !!newEpisodeTopic })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[newsletter] failed to list Resend broadcasts:', err)
|
||||||
|
res.status(502).json({ message: 'Could not reach Resend.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Admin: manage published issues ────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/admin/newsletter/issues', requireAdminAuth, (_req, res) => {
|
||||||
|
const issues = [...state.newsletterIssues]
|
||||||
|
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
|
||||||
|
.map(({ html, ...rest }) => rest)
|
||||||
|
res.json({ issues })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/admin/newsletter/issues', requireAdminAuth, async (req, res) => {
|
||||||
|
const { broadcastId, title: titleOverride, slug: slugOverride, public: isPublic } = req.body ?? {}
|
||||||
|
if (!broadcastId || typeof broadcastId !== 'string') {
|
||||||
|
res.status(400).json({ message: 'broadcastId is required.' }); return
|
||||||
|
}
|
||||||
|
if (state.newsletterIssues.some(i => i.resendBroadcastId === broadcastId)) {
|
||||||
|
res.status(409).json({ message: 'This broadcast has already been published.' }); return
|
||||||
|
}
|
||||||
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||||
|
const { data, error } = await resend.broadcasts.get(broadcastId)
|
||||||
|
if (error || !data) throw new Error(error?.message ?? 'Broadcast not found.')
|
||||||
|
if (!data.html) { res.status(400).json({ message: 'This broadcast has no HTML content.' }); return }
|
||||||
|
|
||||||
|
const title = typeof titleOverride === 'string' && titleOverride.trim()
|
||||||
|
? titleOverride.trim().slice(0, 200)
|
||||||
|
: stripSubjectPrefix(data.subject) || data.name || 'Newsletter Issue'
|
||||||
|
const slug = typeof slugOverride === 'string' && slugOverride.trim()
|
||||||
|
? uniqueSlug(slugOverride)
|
||||||
|
: uniqueSlug(title)
|
||||||
|
|
||||||
|
const issue = {
|
||||||
|
id: randomUUID(),
|
||||||
|
resendBroadcastId: data.id,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
subject: data.subject ?? '',
|
||||||
|
description: extractDescription(data.text, data.preview_text),
|
||||||
|
html: data.html,
|
||||||
|
sentAt: data.sent_at ?? null,
|
||||||
|
publishedAt: new Date().toISOString(),
|
||||||
|
public: isPublic !== false,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.newsletterIssues.push(issue)
|
||||||
|
queueNewsletterIssuesWrite()
|
||||||
|
const { html, ...rest } = issue
|
||||||
|
res.json({ ok: true, issue: rest })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[newsletter] failed to publish issue:', err)
|
||||||
|
res.status(502).json({ message: 'Could not fetch broadcast content from Resend.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const issue = state.newsletterIssues.find(i => i.id === req.params.id)
|
||||||
|
if (!issue) { res.status(404).json({ message: 'Issue not found.' }); return }
|
||||||
|
|
||||||
|
if (typeof req.body?.title === 'string' && req.body.title.trim()) issue.title = req.body.title.trim().slice(0, 200)
|
||||||
|
if (typeof req.body?.slug === 'string' && req.body.slug.trim() && req.body.slug.trim() !== issue.slug) {
|
||||||
|
const candidate = slugify(req.body.slug)
|
||||||
|
if (!candidate) { res.status(400).json({ message: 'Invalid slug.' }); return }
|
||||||
|
if (state.newsletterIssues.some(i => i.id !== issue.id && i.slug === candidate)) {
|
||||||
|
res.status(409).json({ message: 'That slug is already in use.' }); return
|
||||||
|
}
|
||||||
|
issue.slug = candidate
|
||||||
|
}
|
||||||
|
if (typeof req.body?.public === 'boolean') issue.public = req.body.public
|
||||||
|
|
||||||
|
queueNewsletterIssuesWrite()
|
||||||
|
const { html, ...rest } = issue
|
||||||
|
res.json({ ok: true, issue: rest })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin/newsletter/issues/:id', requireAdminAuth, (req, res) => {
|
||||||
|
const idx = state.newsletterIssues.findIndex(i => i.id === req.params.id)
|
||||||
|
if (idx === -1) { res.status(404).json({ message: 'Issue not found.' }); return }
|
||||||
|
state.newsletterIssues.splice(idx, 1)
|
||||||
|
queueNewsletterIssuesWrite()
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Public ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/newsletter/issues', (_req, res) => {
|
||||||
|
const issues = state.newsletterIssues
|
||||||
|
.filter(i => i.public !== false)
|
||||||
|
.sort((a, b) => new Date(b.sentAt ?? b.publishedAt).getTime() - new Date(a.sentAt ?? a.publishedAt).getTime())
|
||||||
|
.map(({ html, ...rest }) => rest)
|
||||||
|
res.json({ issues })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/newsletter/:slug', (req, res, next) => {
|
||||||
|
const issue = state.newsletterIssues.find(i => i.slug === req.params.slug && i.public !== false)
|
||||||
|
if (!issue) { next(); return }
|
||||||
|
res.type('html').send(buildIssuePageHtml(issue))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -106,6 +106,11 @@ export function register(app) {
|
|||||||
for (const ep of highlights) {
|
for (const ep of highlights) {
|
||||||
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
if (ep.id) urls.push(`${root}/episodes/${encodeURIComponent(ep.id)}`)
|
||||||
}
|
}
|
||||||
|
// Newsletter archive + published issues
|
||||||
|
urls.push(`${root}/newsletter`)
|
||||||
|
for (const issue of state.newsletterIssues) {
|
||||||
|
if (issue.slug && issue.public !== false) urls.push(`${root}/newsletter/${encodeURIComponent(issue.slug)}`)
|
||||||
|
}
|
||||||
|
|
||||||
const xml = [
|
const xml = [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ export const state = {
|
|||||||
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
emailSettings: { signature: 'Grace and peace,\nVerse by Verse with Nate' },
|
||||||
emailSettingsWritePromise: Promise.resolve(),
|
emailSettingsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
|
||||||
|
newsletterIssues: [],
|
||||||
|
newsletterIssuesWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
calendarEvents: [],
|
calendarEvents: [],
|
||||||
calendarEventsWritePromise: Promise.resolve(),
|
calendarEventsWritePromise: Promise.resolve(),
|
||||||
|
|
||||||
|
|||||||
+139
-1
@@ -688,7 +688,7 @@ type AdminView =
|
|||||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'inbox'
|
| 'inbox'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast' | 'newsletter-archive'
|
||||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'webhooks'
|
||||||
|
|
||||||
interface AdminSectionLink {
|
interface AdminSectionLink {
|
||||||
@@ -761,6 +761,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
{ kind: 'leaf', view: 'study-comments', label: 'Study Comments' },
|
||||||
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
{ kind: 'leaf', view: 'subscribers', label: 'Subscribers' },
|
||||||
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
|
{ kind: 'leaf', view: 'broadcast', label: 'Broadcast Email' },
|
||||||
|
{ kind: 'leaf', view: 'newsletter-archive', label: 'Newsletter Archive' },
|
||||||
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
|
{ kind: 'leaf', view: 'study-users', label: 'Study Users' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1660,6 +1661,17 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
const [broadcastMsg, setBroadcastMsg] = useState('')
|
const [broadcastMsg, setBroadcastMsg] = useState('')
|
||||||
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
const [broadcastConfirm, setBroadcastConfirm] = useState(false)
|
||||||
|
|
||||||
|
// Newsletter archive state
|
||||||
|
interface ResendBroadcastRow { id: string; name: string; subject: string; sentAt: string | null; topicId: string | null; matchesNewEpisodeTopic: boolean; alreadyPublished: boolean }
|
||||||
|
interface NewsletterIssueAdmin { id: string; resendBroadcastId: string; slug: string; title: string; subject: string; description: string; sentAt: string | null; publishedAt: string; public: boolean }
|
||||||
|
const [nlBroadcasts, setNlBroadcasts] = useState<ResendBroadcastRow[]>([])
|
||||||
|
const [nlIssues, setNlIssues] = useState<NewsletterIssueAdmin[]>([])
|
||||||
|
const [nlLoaded, setNlLoaded] = useState(false)
|
||||||
|
const [nlLoading, setNlLoading] = useState(false)
|
||||||
|
const [nlTopicFound, setNlTopicFound] = useState(true)
|
||||||
|
const [nlMsg, setNlMsg] = useState('')
|
||||||
|
const [nlPublishingId, setNlPublishingId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
||||||
const [manualQuestion, setManualQuestion] = useState({
|
const [manualQuestion, setManualQuestion] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -2923,6 +2935,65 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
finally { setQrLoading(false) }
|
finally { setQrLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadNewsletterAdmin() {
|
||||||
|
setNlLoading(true)
|
||||||
|
setNlMsg('')
|
||||||
|
try {
|
||||||
|
const [broadcastsRes, issuesRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/newsletter/resend-broadcasts'),
|
||||||
|
fetch('/api/admin/newsletter/issues'),
|
||||||
|
])
|
||||||
|
const broadcastsData = await broadcastsRes.json()
|
||||||
|
const issuesData = await issuesRes.json()
|
||||||
|
if (!broadcastsRes.ok) { setNlMsg(broadcastsData.message || 'Failed to load Resend broadcasts.') }
|
||||||
|
setNlBroadcasts(Array.isArray(broadcastsData.broadcasts) ? broadcastsData.broadcasts : [])
|
||||||
|
setNlTopicFound(broadcastsData.newEpisodeTopicFound !== false)
|
||||||
|
setNlIssues(Array.isArray(issuesData.issues) ? issuesData.issues : [])
|
||||||
|
setNlLoaded(true)
|
||||||
|
} catch { setNlMsg('Network error loading newsletter data.') }
|
||||||
|
finally { setNlLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishNewsletterIssue(broadcast: ResendBroadcastRow) {
|
||||||
|
setNlPublishingId(broadcast.id)
|
||||||
|
setNlMsg('')
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/newsletter/issues', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ broadcastId: broadcast.id }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) { setNlMsg(data.message || 'Failed to publish issue.'); return }
|
||||||
|
setNlIssues(prev => [data.issue, ...prev])
|
||||||
|
setNlBroadcasts(prev => prev.map(b => b.id === broadcast.id ? { ...b, alreadyPublished: true } : b))
|
||||||
|
} catch { setNlMsg('Network error. Try again.') }
|
||||||
|
finally { setNlPublishingId(null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleNewsletterIssuePublic(issue: NewsletterIssueAdmin) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/newsletter/issues/${issue.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ public: !issue.public }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (res.ok) setNlIssues(prev => prev.map(i => i.id === issue.id ? data.issue : i))
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNewsletterIssue(issue: NewsletterIssueAdmin) {
|
||||||
|
if (!window.confirm(`Remove "${issue.title}" from the newsletter archive? This does not affect the sent email.`)) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/newsletter/issues/${issue.id}`, { method: 'DELETE' })
|
||||||
|
if (res.ok) {
|
||||||
|
setNlIssues(prev => prev.filter(i => i.id !== issue.id))
|
||||||
|
setNlBroadcasts(prev => prev.map(b => b.id === issue.resendBroadcastId ? { ...b, alreadyPublished: false } : b))
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateQrCode() {
|
async function handleCreateQrCode() {
|
||||||
setQrMsg('')
|
setQrMsg('')
|
||||||
try {
|
try {
|
||||||
@@ -6321,6 +6392,73 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* ── NEWSLETTER ARCHIVE ── */}
|
||||||
|
{adminView === 'newsletter-archive' && (() => {
|
||||||
|
if (!nlLoaded && !nlLoading) loadNewsletterAdmin()
|
||||||
|
const unpublished = nlBroadcasts.filter(b => !b.alreadyPublished && b.matchesNewEpisodeTopic)
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Newsletter Archive</h2>
|
||||||
|
<p>Publish a web copy of each episode newsletter so it can be shared (e.g. on Facebook) and found via search. Only Resend broadcasts tagged with the <strong>New Episode</strong> topic show up here.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!nlTopicFound && (
|
||||||
|
<p className="admin-save-error" style={{ marginBottom: '1rem' }}>
|
||||||
|
No "New Episode" topic found in Resend. Broadcasts won't be flagged for publishing until that topic exists and is applied when sending.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{nlMsg && <p className="admin-save-error" style={{ marginBottom: '1rem' }}>{nlMsg}</p>}
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 0 }}>Published Issues ({nlIssues.length})</h3>
|
||||||
|
{nlLoading && nlIssues.length === 0 ? <p className="admin-stats-note">Loading…</p> : null}
|
||||||
|
{!nlLoading && nlIssues.length === 0 && <p className="admin-stats-note">No issues published yet.</p>}
|
||||||
|
{nlIssues.map(issue => (
|
||||||
|
<div key={issue.id} className="admin-list-row" style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.6rem 0', borderBottom: '1px solid var(--admin-border)' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||||
|
<strong>{issue.title}</strong>
|
||||||
|
<div style={{ fontSize: '0.8rem', opacity: 0.65 }}>
|
||||||
|
/newsletter/{issue.slug} · {issue.sentAt ? new Date(issue.sentAt).toLocaleDateString() : 'no send date'} · {issue.public ? 'Public' : 'Unlisted'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href={`/newsletter/${issue.slug}`} target="_blank" rel="noreferrer" className="btn-admin-reset">View</a>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={() => toggleNewsletterIssuePublic(issue)}>
|
||||||
|
{issue.public ? 'Unlist' : 'Make Public'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-admin-reset" onClick={() => deleteNewsletterIssue(issue)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<h3>Ready to Publish ({unpublished.length})</h3>
|
||||||
|
<p className="admin-stats-note">Sent broadcasts tagged "New Episode" that aren't on the site yet.</p>
|
||||||
|
{nlLoading && unpublished.length === 0 ? <p className="admin-stats-note">Loading from Resend…</p> : null}
|
||||||
|
{!nlLoading && unpublished.length === 0 && <p className="admin-stats-note">Nothing new to publish.</p>}
|
||||||
|
{unpublished.map(broadcast => (
|
||||||
|
<div key={broadcast.id} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap', padding: '0.6rem 0', borderBottom: '1px solid var(--admin-border)' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||||
|
<strong>{broadcast.subject || broadcast.name}</strong>
|
||||||
|
<div style={{ fontSize: '0.8rem', opacity: 0.65 }}>
|
||||||
|
{broadcast.sentAt ? new Date(broadcast.sentAt).toLocaleDateString() : 'no send date'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-admin-apply"
|
||||||
|
disabled={nlPublishingId === broadcast.id}
|
||||||
|
onClick={() => publishNewsletterIssue(broadcast)}
|
||||||
|
>
|
||||||
|
{nlPublishingId === broadcast.id ? 'Publishing…' : 'Publish'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button type="button" className="btn-admin-reset" style={{ marginTop: '1rem' }} onClick={loadNewsletterAdmin} disabled={nlLoading}>
|
||||||
|
{nlLoading ? 'Refreshing…' : 'Refresh from Resend'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* ── WEBHOOKS ── */}
|
{/* ── WEBHOOKS ── */}
|
||||||
{adminView === 'webhooks' && (() => {
|
{adminView === 'webhooks' && (() => {
|
||||||
if (!webhooksLoaded) loadWebhooksAdmin()
|
if (!webhooksLoaded) loadWebhooksAdmin()
|
||||||
|
|||||||
+68
@@ -3022,6 +3022,73 @@ function QuestionsPage({ content }: { content: SiteContent }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NewsletterIssueSummary {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
subject: string
|
||||||
|
description: string
|
||||||
|
sentAt: string | null
|
||||||
|
publishedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewsletterArchivePage({ content }: { content: SiteContent }) {
|
||||||
|
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||||
|
usePageMeta(
|
||||||
|
`Newsletter | ${siteTitle}`,
|
||||||
|
'Read past newsletter issues from Verse by Verse with Nate — episode notes, study excerpts, and Greek word studies.',
|
||||||
|
)
|
||||||
|
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/newsletter/issues')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (Array.isArray(d.issues)) setIssues(d.issues) })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="site">
|
||||||
|
<SiteHeader content={content} />
|
||||||
|
<SiteSearchBar content={content} />
|
||||||
|
<PageBanner eyebrow="Newsletter" title="Newsletter Archive" desc="Past issues of the Verse by Verse with Nate newsletter — episode notes, study excerpts, and Greek word studies." />
|
||||||
|
<section className="section-resources" aria-label="Newsletter archive">
|
||||||
|
<div className="section-inner">
|
||||||
|
{loading ? (
|
||||||
|
<p className="episode-seo-intro">Loading issues…</p>
|
||||||
|
) : issues.length === 0 ? (
|
||||||
|
<p className="episode-seo-intro">No newsletter issues published yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="resources-list">
|
||||||
|
{issues.map(issue => (
|
||||||
|
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
|
||||||
|
<div className="resource-download-header">
|
||||||
|
<div className="resource-download-meta">
|
||||||
|
<span className="resource-link-label">{issue.title}</span>
|
||||||
|
{issue.description && <p className="resource-link-description">{issue.description}</p>}
|
||||||
|
{issue.sentAt && (
|
||||||
|
<span className="resource-link-tags">
|
||||||
|
{new Date(issue.sentAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="resource-link-action">Read issue →</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<CustomBlocksSection content={content} page="newsletter" />
|
||||||
|
<SiteFooter content={content} />
|
||||||
|
<AnalyticsConsentBanner content={content} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PreviewPage() {
|
function PreviewPage() {
|
||||||
const [content, setContent] = useState<SiteContent | null>(null)
|
const [content, setContent] = useState<SiteContent | null>(null)
|
||||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
@@ -3239,6 +3306,7 @@ export default function App() {
|
|||||||
<Route path="/about" element={<AboutPage content={content} />} />
|
<Route path="/about" element={<AboutPage content={content} />} />
|
||||||
<Route path="/contact" element={<ContactPage content={content} />} />
|
<Route path="/contact" element={<ContactPage content={content} />} />
|
||||||
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
<Route path="/questions" element={<QuestionsPage content={content} />} />
|
||||||
|
<Route path="/newsletter" element={<NewsletterArchivePage content={content} />} />
|
||||||
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
|
<Route path="/scripture" element={<ScriptureIndexPage content={content} />} />
|
||||||
<Route path="/thanks" element={<ThankYouPage />} />
|
<Route path="/thanks" element={<ThankYouPage />} />
|
||||||
<Route path="/subscribe" element={<SubscribePage />} />
|
<Route path="/subscribe" element={<SubscribePage />} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const TYPE_LABEL: Record<SearchResultType, string> = {
|
|||||||
question: 'Q&A',
|
question: 'Q&A',
|
||||||
resource: 'Download',
|
resource: 'Download',
|
||||||
series: 'Series',
|
series: 'Series',
|
||||||
|
newsletter: 'Newsletter',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_ICON: Record<SearchResultType, string> = {
|
const TYPE_ICON: Record<SearchResultType, string> = {
|
||||||
@@ -15,6 +16,7 @@ const TYPE_ICON: Record<SearchResultType, string> = {
|
|||||||
question: '❓',
|
question: '❓',
|
||||||
resource: '📄',
|
resource: '📄',
|
||||||
series: '📚',
|
series: '📚',
|
||||||
|
newsletter: '✉️',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -57,6 +59,9 @@ export function GlobalSearch({ query, setQuery, results }: Props) {
|
|||||||
setQuery('')
|
setQuery('')
|
||||||
if (result.href.startsWith('http')) {
|
if (result.href.startsWith('http')) {
|
||||||
window.open(result.href, '_blank', 'noreferrer')
|
window.open(result.href, '_blank', 'noreferrer')
|
||||||
|
} else if (result.type === 'newsletter') {
|
||||||
|
// Newsletter issues are server-rendered outside the SPA (for social-share OG tags), so use a full navigation.
|
||||||
|
window.location.href = result.href
|
||||||
} else {
|
} else {
|
||||||
navigate(result.href)
|
navigate(result.href)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState, useEffect, useRef } from 'react'
|
|||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import type { SiteContent } from '../content'
|
import type { SiteContent } from '../content'
|
||||||
|
|
||||||
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series'
|
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series' | 'newsletter'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,6 +37,7 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||||
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
|
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
|
||||||
const [nuggetsItems, setNuggetsItems] = useState<SearchResult[]>([])
|
const [nuggetsItems, setNuggetsItems] = useState<SearchResult[]>([])
|
||||||
|
const [newsletterItems, setNewsletterItems] = useState<SearchResult[]>([])
|
||||||
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
|
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
|
||||||
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
@@ -95,6 +96,24 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch published newsletter issues once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/newsletter/issues')
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject())
|
||||||
|
.then((data: { issues?: Array<{ id: string; slug: string; title: string; description?: string; sentAt?: string | null }> }) => {
|
||||||
|
const items: SearchResult[] = (data.issues ?? []).map(issue => ({
|
||||||
|
id: `newsletter-${issue.id}`,
|
||||||
|
type: 'newsletter' as const,
|
||||||
|
title: issue.title,
|
||||||
|
subtitle: 'Newsletter',
|
||||||
|
description: issue.description,
|
||||||
|
href: `/newsletter/${issue.slug}`,
|
||||||
|
}))
|
||||||
|
setNewsletterItems(items)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Season number → book/study title, so script results can show "Romans" instead of "Season 3"
|
// Season number → book/study title, so script results can show "Romans" instead of "Season 3"
|
||||||
const seasonTitleMap = useMemo(() => {
|
const seasonTitleMap = useMemo(() => {
|
||||||
const map = new Map<string, string>()
|
const map = new Map<string, string>()
|
||||||
@@ -164,7 +183,7 @@ export function useGlobalSearch(content: SiteContent) {
|
|||||||
return results
|
return results
|
||||||
}, [content, questions])
|
}, [content, questions])
|
||||||
|
|
||||||
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...staticItems], [episodeItems, nuggetsItems, staticItems])
|
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...newsletterItems, ...staticItems], [episodeItems, nuggetsItems, newsletterItems, staticItems])
|
||||||
|
|
||||||
const fuse = useMemo(() => new Fuse(allFuseItems, {
|
const fuse = useMemo(() => new Fuse(allFuseItems, {
|
||||||
keys: [
|
keys: [
|
||||||
|
|||||||
Reference in new issue
Block a user