Add persistent admin analytics, backup restore workflow, and tabbed admin UI

This commit is contained in:
nmemmert
2026-04-12 13:12:03 -04:00
parent bac22950ba
commit 738cc2a412
6 changed files with 1707 additions and 109 deletions
+1
View File
@@ -17,6 +17,7 @@ dist-ssr
# the image default and is preserved here for the Docker build context only. # the image default and is preserved here for the Docker build context only.
# Uncomment the line below if you do NOT want to track live data in git. # Uncomment the line below if you do NOT want to track live data in git.
# data/admin-content.json # data/admin-content.json
data/backups/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+9 -1
View File
@@ -55,8 +55,11 @@ The app will be available at `http://localhost:4173`.
Persistent admin saves: Persistent admin saves:
- Admin updates are written to `data/admin-content.json`. - Admin updates are written to `data/admin-content.json`.
- Built-in page hit stats are written to `data/hit-stats.json`.
- Detailed visitor analytics are written to `data/visitor-stats.json`.
- Backup snapshots are written to `data/backups/`.
- `docker-compose.yml` mounts `./data` into the container at `/app/data`. - `docker-compose.yml` mounts `./data` into the container at `/app/data`.
- This keeps your edits after container restarts/rebuilds. - This keeps all admin-managed data after container restarts/rebuilds/updates.
## Where to edit content ## Where to edit content
@@ -80,6 +83,11 @@ Persistent admin saves:
- Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available. - Enter a URL and click **Scan URL metadata** to auto-fill title, summary, domain, category, and icon when available.
- Click **Save project** to apply updates instantly. - Click **Save project** to apply updates instantly.
- Saved edits are written to `data/admin-content.json` through the API server. - Saved edits are written to `data/admin-content.json` through the API server.
- Built-in stats in `/admin` include page hits plus visitor details (IP, country/state/county/city, returning visitors, and recent visitor log).
- Analytics cookies are consent-based. Visitors can accept or decline tracking from the site banner.
- Admin now includes maintenance actions: **Export JSON**, **Backup Now**, **Prune Old Data**, and **Clear Analytics**.
- Admin also supports restoring from a backup snapshot from `/admin`.
- The server creates startup + daily backup snapshots and retains recent backups automatically.
- Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset. - Use the **Theme** dropdown to switch between Sandstone, Ocean, Midnight, Forest, and Sunset.
- Use **Remove project** to delete the selected project from your local Admin data. - Use **Remove project** to delete the selected project from your local Admin data.
- Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`. - Use **Reset project** or **Reset all** to restore defaults from `src/data/projects.ts`.
+711 -1
View File
@@ -1,5 +1,6 @@
import express from 'express' import express from 'express'
import { mkdir, readFile, writeFile } from 'node:fs/promises' import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { Resend } from 'resend' import { Resend } from 'resend'
@@ -25,11 +26,589 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const DATA_DIR = path.join(__dirname, 'data') const DATA_DIR = path.join(__dirname, 'data')
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json') const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
const HIT_STATS_FILE = path.join(DATA_DIR, 'hit-stats.json')
const VISITOR_STATS_FILE = path.join(DATA_DIR, 'visitor-stats.json')
const BACKUP_DIR = path.join(DATA_DIR, 'backups')
const DIST_DIR = path.join(__dirname, 'dist') const DIST_DIR = path.join(__dirname, 'dist')
const INDEX_FILE = path.join(DIST_DIR, 'index.html') const INDEX_FILE = path.join(DIST_DIR, 'index.html')
const EMPTY_HIT_STATS = {
totalHits: 0,
firstHitAt: null,
lastHitAt: null,
byPath: {},
byDay: {},
}
let hitStats = { ...EMPTY_HIT_STATS }
let hitStatsWritePromise = Promise.resolve()
const VISITOR_COOKIE = 'vbn_vid'
const CONSENT_COOKIE = 'vbn_analytics_consent'
const MAX_RECENT_VISITS = 1000
const VISITOR_RETENTION_DAYS_DEFAULT = 180
const BACKUP_RETENTION_DAYS = 30
const BACKUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const EMPTY_VISITOR_STATS = {
totalVisits: 0,
uniqueVisitors: 0,
returningVisits: 0,
firstVisitAt: null,
lastVisitAt: null,
visitors: {},
recentVisits: [],
geoCacheByIp: {},
}
let visitorStats = { ...EMPTY_VISITOR_STATS }
let visitorStatsWritePromise = Promise.resolve()
let lastVisitorStatsWrite = { ok: true, at: null, error: null }
let lastHitStatsWrite = { ok: true, at: null, error: null }
let lastBackupStatus = { ok: true, at: null, error: null, file: null }
function normalizeIp(rawIp) {
if (!rawIp) return 'unknown'
let ip = String(rawIp).trim()
if (ip.includes(',')) {
ip = ip.split(',')[0].trim()
}
if (ip.startsWith('::ffff:')) {
ip = ip.slice(7)
}
if (ip === '::1') {
ip = '127.0.0.1'
}
return ip || 'unknown'
}
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for']
if (forwarded) {
return normalizeIp(forwarded)
}
return normalizeIp(req.ip)
}
function parseCookies(cookieHeader) {
if (!cookieHeader) return {}
return cookieHeader
.split(';')
.map(v => v.trim())
.filter(Boolean)
.reduce((acc, part) => {
const idx = part.indexOf('=')
if (idx === -1) return acc
const key = part.slice(0, idx).trim()
const value = part.slice(idx + 1).trim()
try {
acc[key] = decodeURIComponent(value)
} catch {
acc[key] = value
}
return acc
}, {})
}
function hasVisitorConsent(req) {
const cookies = parseCookies(req.headers.cookie)
return cookies[CONSENT_COOKIE] === 'yes'
}
function setConsentCookie(res, consent) {
const value = consent ? 'yes' : 'no'
res.append('Set-Cookie', `${CONSENT_COOKIE}=${value}; Max-Age=31536000; Path=/; SameSite=Lax`)
}
function isPrivateOrLocalIp(ip) {
return (
ip === '127.0.0.1'
|| ip === 'localhost'
|| ip.startsWith('10.')
|| ip.startsWith('192.168.')
|| /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip)
|| ip.startsWith('fc')
|| ip.startsWith('fd')
|| ip.startsWith('fe80:')
|| ip === 'unknown'
)
}
function queueVisitorStatsWrite() {
visitorStatsWritePromise = visitorStatsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
VISITOR_STATS_FILE,
JSON.stringify({
...visitorStats,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
lastVisitorStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
})
.catch(err => {
console.error('[visitor-stats] failed to write visitor stats:', err)
lastVisitorStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
})
}
function sanitizeUserAgent(userAgent) {
if (!userAgent || typeof userAgent !== 'string') return 'unknown'
return userAgent.trim().slice(0, 300) || 'unknown'
}
async function resolveGeo(ip) {
if (!ip || isPrivateOrLocalIp(ip)) {
return {
country: 'Local/Unknown',
state: 'Local/Unknown',
county: 'Local/Unknown',
city: 'Local/Unknown',
}
}
const cached = visitorStats.geoCacheByIp[ip]
if (cached) {
return cached
}
const providers = [
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(
`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,regionName,city,district`,
{ signal: controller.signal },
)
if (!response.ok) return null
const data = await response.json()
if (data?.status !== 'success') return null
return {
country: data?.country || 'Unknown',
state: data?.regionName || 'Unknown',
county: data?.district || 'Unknown',
city: data?.city || 'Unknown',
}
} finally {
clearTimeout(timeout)
}
},
async () => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { signal: controller.signal })
if (!response.ok) return null
const data = await response.json()
if (!data?.success) return null
return {
country: data?.country || 'Unknown',
state: data?.region || 'Unknown',
county: data?.region || 'Unknown',
city: data?.city || 'Unknown',
}
} finally {
clearTimeout(timeout)
}
},
]
for (const provider of providers) {
try {
const geo = await provider()
if (geo) {
visitorStats.geoCacheByIp[ip] = geo
queueVisitorStatsWrite()
return geo
}
} catch {
// Try next provider.
}
}
const fallback = {
country: 'Unknown',
state: 'Unknown',
county: 'Unknown',
city: 'Unknown',
}
visitorStats.geoCacheByIp[ip] = fallback
queueVisitorStatsWrite()
return fallback
}
async function recordVisitor(req, res) {
const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE]
if (!visitorId) {
visitorId = randomUUID()
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`)
}
const nowIso = new Date().toISOString()
const pathKey = normalizeHitPath(req.path)
const ip = getClientIp(req)
const ua = sanitizeUserAgent(req.get('user-agent'))
const existingVisitor = visitorStats.visitors[visitorId]
const isReturning = Boolean(existingVisitor)
const geo = await resolveGeo(ip)
if (!existingVisitor) {
visitorStats.uniqueVisitors += 1
} else {
visitorStats.returningVisits += 1
}
const ipHash = createHash('sha256').update(ip).digest('hex')
const nextVisitCount = (existingVisitor?.visitCount ?? 0) + 1
const userAgents = Array.from(new Set([...(existingVisitor?.userAgents ?? []), ua])).slice(0, 5)
visitorStats.visitors[visitorId] = {
visitorId,
ip,
ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
lastSeenAt: nowIso,
visitCount: nextVisitCount,
lastPath: pathKey,
returningVisitor: isReturning,
location: geo,
userAgents,
}
visitorStats.totalVisits += 1
visitorStats.firstVisitAt = visitorStats.firstVisitAt ?? nowIso
visitorStats.lastVisitAt = nowIso
visitorStats.recentVisits.unshift({
at: nowIso,
visitorId,
ip,
path: pathKey,
country: geo.country,
state: geo.state,
county: geo.county,
city: geo.city,
returningVisitor: isReturning,
visitCount: nextVisitCount,
})
visitorStats.recentVisits = visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
queueVisitorStatsWrite()
}
function loadVisitorStatsFromDisk() {
return readFile(VISITOR_STATS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
visitorStats = {
totalVisits: Number(parsed?.totalVisits) || 0,
uniqueVisitors: Number(parsed?.uniqueVisitors) || 0,
returningVisits: Number(parsed?.returningVisits) || 0,
firstVisitAt: typeof parsed?.firstVisitAt === 'string' ? parsed.firstVisitAt : null,
lastVisitAt: typeof parsed?.lastVisitAt === 'string' ? parsed.lastVisitAt : null,
visitors: parsed?.visitors && typeof parsed.visitors === 'object' ? parsed.visitors : {},
recentVisits: Array.isArray(parsed?.recentVisits) ? parsed.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: parsed?.geoCacheByIp && typeof parsed.geoCacheByIp === 'object' ? parsed.geoCacheByIp : {},
}
})
.catch(() => {
visitorStats = { ...EMPTY_VISITOR_STATS }
})
}
function buildTopLocations(list, key) {
const counts = {}
for (const row of list) {
const val = row?.[key] || 'Unknown'
counts[val] = (counts[val] ?? 0) + 1
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([name, hits]) => ({ name, hits }))
}
function pruneStatsByDays(daysRaw) {
const days = Number(daysRaw)
const retentionDays = Number.isFinite(days) && days > 0 ? Math.floor(days) : VISITOR_RETENTION_DAYS_DEFAULT
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000
const keepRecent = visitorStats.recentVisits.filter(v => {
const ts = new Date(v.at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
const allowedVisitorIds = new Set(keepRecent.map(v => v.visitorId))
const nextVisitors = {}
for (const [id, data] of Object.entries(visitorStats.visitors)) {
const lastSeen = new Date(data.lastSeenAt ?? 0).getTime()
if (allowedVisitorIds.has(id) || (Number.isFinite(lastSeen) && lastSeen >= cutoff)) {
nextVisitors[id] = data
}
}
const nextByDay = {}
for (const [day, count] of Object.entries(hitStats.byDay)) {
const ts = new Date(`${day}T00:00:00.000Z`).getTime()
if (Number.isFinite(ts) && ts >= cutoff) {
nextByDay[day] = count
}
}
visitorStats.recentVisits = keepRecent
visitorStats.visitors = nextVisitors
visitorStats.uniqueVisitors = Object.keys(nextVisitors).length
visitorStats.totalVisits = keepRecent.length
visitorStats.returningVisits = keepRecent.filter(v => v.returningVisitor).length
visitorStats.firstVisitAt = keepRecent.length > 0 ? keepRecent[keepRecent.length - 1].at : null
visitorStats.lastVisitAt = keepRecent.length > 0 ? keepRecent[0].at : null
hitStats.byDay = nextByDay
queueHitStatsWrite()
queueVisitorStatsWrite()
return {
retentionDays,
remainingVisits: visitorStats.totalVisits,
remainingVisitors: visitorStats.uniqueVisitors,
}
}
async function createBackupSnapshot(reason = 'scheduled') {
try {
await mkdir(BACKUP_DIR, { recursive: true })
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupPath = path.join(BACKUP_DIR, `snapshot-${stamp}-${reason}.json`)
const payload = {
createdAt: new Date().toISOString(),
reason,
adminContent: null,
hitStats,
visitorStats,
}
try {
const contentRaw = await readFile(DATA_FILE, 'utf8')
payload.adminContent = JSON.parse(contentRaw)
} catch {
payload.adminContent = null
}
await writeFile(backupPath, JSON.stringify(payload, null, 2), 'utf8')
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort()
const maxFiles = BACKUP_RETENTION_DAYS
if (files.length > maxFiles) {
const toDelete = files.slice(0, files.length - maxFiles)
await Promise.all(toDelete.map(name => unlink(path.join(BACKUP_DIR, name)).catch(() => {})))
}
lastBackupStatus = { ok: true, at: new Date().toISOString(), error: null, file: path.basename(backupPath) }
} catch (err) {
lastBackupStatus = { ok: false, at: new Date().toISOString(), error: String(err), file: null }
console.error('[backup] failed to create snapshot:', err)
}
}
async function listBackupFiles() {
await mkdir(BACKUP_DIR, { recursive: true })
const files = (await readdir(BACKUP_DIR)).filter(name => name.endsWith('.json')).sort().reverse()
return files
}
async function readBackupPreview(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
}
const fullPath = path.join(BACKUP_DIR, filename)
const [fileInfo, raw] = await Promise.all([
stat(fullPath),
readFile(fullPath, 'utf8'),
])
const parsed = JSON.parse(raw)
return {
filename,
sizeBytes: fileInfo.size,
createdAt: typeof parsed?.createdAt === 'string' ? parsed.createdAt : null,
reason: typeof parsed?.reason === 'string' ? parsed.reason : 'unknown',
adminUpdatedAt: typeof parsed?.adminContent?.updatedAt === 'string' ? parsed.adminContent.updatedAt : null,
totalHits: Number(parsed?.hitStats?.totalHits) || 0,
totalVisits: Number(parsed?.visitorStats?.totalVisits) || 0,
}
}
async function listBackupPreviews() {
const files = await listBackupFiles()
const previews = await Promise.all(files.map(async filename => {
try {
return await readBackupPreview(filename)
} catch {
return {
filename,
sizeBytes: 0,
createdAt: null,
reason: 'unknown',
adminUpdatedAt: null,
totalHits: 0,
totalVisits: 0,
}
}
}))
return previews
}
function sanitizeLoadedHitStats(value) {
return {
totalHits: Number(value?.totalHits) || 0,
firstHitAt: typeof value?.firstHitAt === 'string' ? value.firstHitAt : null,
lastHitAt: typeof value?.lastHitAt === 'string' ? value.lastHitAt : null,
byPath: value?.byPath && typeof value.byPath === 'object' ? value.byPath : {},
byDay: value?.byDay && typeof value.byDay === 'object' ? value.byDay : {},
}
}
function sanitizeLoadedVisitorStats(value) {
return {
totalVisits: Number(value?.totalVisits) || 0,
uniqueVisitors: Number(value?.uniqueVisitors) || 0,
returningVisits: Number(value?.returningVisits) || 0,
firstVisitAt: typeof value?.firstVisitAt === 'string' ? value.firstVisitAt : null,
lastVisitAt: typeof value?.lastVisitAt === 'string' ? value.lastVisitAt : null,
visitors: value?.visitors && typeof value.visitors === 'object' ? value.visitors : {},
recentVisits: Array.isArray(value?.recentVisits) ? value.recentVisits.slice(0, MAX_RECENT_VISITS) : [],
geoCacheByIp: value?.geoCacheByIp && typeof value.geoCacheByIp === 'object' ? value.geoCacheByIp : {},
}
}
async function restoreFromBackup(filename) {
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..') || !filename.endsWith('.json')) {
throw new Error('Invalid backup filename')
}
const fullPath = path.join(BACKUP_DIR, filename)
const raw = await readFile(fullPath, 'utf8')
const parsed = JSON.parse(raw)
await createBackupSnapshot('pre-restore')
if (parsed?.adminContent && typeof parsed.adminContent === 'object') {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(DATA_FILE, JSON.stringify(parsed.adminContent, null, 2), 'utf8')
}
hitStats = sanitizeLoadedHitStats(parsed?.hitStats)
visitorStats = sanitizeLoadedVisitorStats(parsed?.visitorStats)
queueHitStatsWrite()
queueVisitorStatsWrite()
await Promise.all([hitStatsWritePromise, visitorStatsWritePromise])
await createBackupSnapshot('post-restore')
}
function normalizeHitPath(pathname) {
if (!pathname || pathname === '') return '/'
if (pathname.length > 1 && pathname.endsWith('/')) {
return pathname.slice(0, -1)
}
return pathname
}
function shouldCountHit(req) {
if (req.method !== 'GET') return false
if (req.path.startsWith('/api/')) return false
if (req.path === '/favicon.ico') return false
// Ignore direct asset requests and only count document-like requests.
const hasFileExt = path.extname(req.path) !== ''
if (hasFileExt) return false
const accept = req.get('accept') ?? ''
return accept.includes('text/html') || accept === '*/*' || accept === ''
}
function queueHitStatsWrite() {
hitStatsWritePromise = hitStatsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(
HIT_STATS_FILE,
JSON.stringify({
...hitStats,
updatedAt: new Date().toISOString(),
}, null, 2),
'utf8',
)
lastHitStatsWrite = { ok: true, at: new Date().toISOString(), error: null }
})
.catch(err => {
console.error('[stats] failed to write hit stats:', err)
lastHitStatsWrite = { ok: false, at: new Date().toISOString(), error: String(err) }
})
}
function recordHit(pathname) {
const nowIso = new Date().toISOString()
const dayKey = nowIso.slice(0, 10)
const safePath = normalizeHitPath(pathname)
hitStats.totalHits += 1
hitStats.lastHitAt = nowIso
hitStats.firstHitAt = hitStats.firstHitAt ?? nowIso
hitStats.byPath[safePath] = (hitStats.byPath[safePath] ?? 0) + 1
hitStats.byDay[dayKey] = (hitStats.byDay[dayKey] ?? 0) + 1
queueHitStatsWrite()
}
function buildLastNDaysStats(days) {
const out = []
const today = new Date()
for (let i = days - 1; i >= 0; i -= 1) {
const d = new Date(today)
d.setDate(today.getDate() - i)
const dayKey = d.toISOString().slice(0, 10)
out.push({ day: dayKey, hits: hitStats.byDay[dayKey] ?? 0 })
}
return out
}
function loadHitStatsFromDisk() {
return readFile(HIT_STATS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
hitStats = {
totalHits: Number(parsed?.totalHits) || 0,
firstHitAt: typeof parsed?.firstHitAt === 'string' ? parsed.firstHitAt : null,
lastHitAt: typeof parsed?.lastHitAt === 'string' ? parsed.lastHitAt : null,
byPath: parsed?.byPath && typeof parsed.byPath === 'object' ? parsed.byPath : {},
byDay: parsed?.byDay && typeof parsed.byDay === 'object' ? parsed.byDay : {},
}
})
.catch(() => {
hitStats = { ...EMPTY_HIT_STATS }
})
}
const app = express() const app = express()
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
app.set('trust proxy', true)
app.get('/api/admin-content', async (_req, res) => { app.get('/api/admin-content', async (_req, res) => {
try { try {
@@ -63,6 +642,126 @@ app.put('/api/admin-content', async (req, res) => {
} }
}) })
app.post('/api/analytics-consent', (req, res) => {
const consent = req.body?.consent === true
setConsentCookie(res, consent)
res.json({ ok: true, consent })
})
app.get('/api/admin-stats', (_req, res) => {
const topPaths = Object.entries(hitStats.byPath)
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([pathKey, hits]) => ({ path: pathKey, hits }))
const recentVisitorRows = visitorStats.recentVisits.slice(0, 100)
res.json({
totalHits: hitStats.totalHits,
firstHitAt: hitStats.firstHitAt,
lastHitAt: hitStats.lastHitAt,
topPaths,
last7Days: buildLastNDaysStats(7),
last30DaysTotal: buildLastNDaysStats(30).reduce((sum, item) => sum + item.hits, 0),
visitors: {
totalVisits: visitorStats.totalVisits,
uniqueVisitors: visitorStats.uniqueVisitors,
returningVisits: visitorStats.returningVisits,
firstVisitAt: visitorStats.firstVisitAt,
lastVisitAt: visitorStats.lastVisitAt,
topCountries: buildTopLocations(recentVisitorRows, 'country'),
topStates: buildTopLocations(recentVisitorRows, 'state'),
topCounties: buildTopLocations(recentVisitorRows, 'county'),
topCities: buildTopLocations(recentVisitorRows, 'city'),
recentVisits: recentVisitorRows,
},
writeStatus: {
hitStats: lastHitStatsWrite,
visitorStats: lastVisitorStatsWrite,
backups: lastBackupStatus,
},
})
})
app.get('/api/admin-stats/export', async (_req, res) => {
let adminContent = null
try {
const raw = await readFile(DATA_FILE, 'utf8')
adminContent = JSON.parse(raw)
} catch {
adminContent = null
}
res.json({
exportedAt: new Date().toISOString(),
adminContent,
hitStats,
visitorStats,
})
})
app.post('/api/admin-stats/clear', (_req, res) => {
hitStats = { ...EMPTY_HIT_STATS }
visitorStats = { ...EMPTY_VISITOR_STATS }
queueHitStatsWrite()
queueVisitorStatsWrite()
createBackupSnapshot('post-clear').catch(() => {})
res.json({ ok: true })
})
app.post('/api/admin-stats/prune', (req, res) => {
const result = pruneStatsByDays(req.body?.days)
createBackupSnapshot('post-prune').catch(() => {})
res.json({ ok: true, ...result })
})
app.post('/api/admin-stats/backup', async (_req, res) => {
await createBackupSnapshot('manual')
res.json({ ok: true, backup: lastBackupStatus })
})
app.get('/api/admin-stats/backups', async (_req, res) => {
try {
const backups = await listBackupPreviews()
res.json({ backups })
} catch {
res.status(500).json({ message: 'Could not list backups.' })
}
})
app.post('/api/admin-stats/backup-preview', async (req, res) => {
try {
const { filename } = req.body ?? {}
const preview = await readBackupPreview(filename)
res.json({ preview })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Could not load backup preview.' })
}
})
app.post('/api/admin-stats/restore', async (req, res) => {
try {
const { filename } = req.body ?? {}
await restoreFromBackup(filename)
const backups = await listBackupPreviews()
res.json({ ok: true, restored: filename, backups })
} catch (err) {
res.status(400).json({ message: err instanceof Error ? err.message : 'Restore failed.' })
}
})
app.use((req, res, next) => {
if (shouldCountHit(req)) {
recordHit(req.path)
if (hasVisitorConsent(req)) {
recordVisitor(req, res).catch(err => {
console.error('[visitor-stats] failed to record visitor:', err)
})
}
}
next()
})
// Rate-limit contact submissions: max 5 per IP per 10 minutes // Rate-limit contact submissions: max 5 per IP per 10 minutes
const contactHits = new Map() const contactHits = new Map()
function contactRateLimit(req, res, next) { function contactRateLimit(req, res, next) {
@@ -217,6 +916,17 @@ app.use(async (_req, res) => {
}) })
const PORT = Number(process.env.PORT ?? 4173) const PORT = Number(process.env.PORT ?? 4173)
Promise.all([loadHitStatsFromDisk(), loadVisitorStatsFromDisk()])
.catch(err => {
console.error('[stats] failed to load persisted stats:', err)
})
.finally(() => {
createBackupSnapshot('startup').catch(() => {})
setInterval(() => {
createBackupSnapshot('scheduled').catch(() => {})
}, BACKUP_INTERVAL_MS)
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Portfolio app listening on http://localhost:${PORT}`) console.log(`Portfolio app listening on http://localhost:${PORT}`)
}) })
})
+534 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock } from './App' import type { SiteContent, CustomLink, CustomBlock } from './App'
import { DEFAULTS } from './App' import { DEFAULTS } from './App'
@@ -8,6 +8,53 @@ interface Props {
onSave: (c: SiteContent) => void onSave: (c: SiteContent) => void
} }
interface BackupPreview {
filename: string
sizeBytes: number
createdAt: string | null
reason: string
adminUpdatedAt: string | null
totalHits: number
totalVisits: number
}
interface AdminStats {
totalHits: number
firstHitAt: string | null
lastHitAt: string | null
topPaths: Array<{ path: string; hits: number }>
last7Days: Array<{ day: string; hits: number }>
last30DaysTotal: number
visitors: {
totalVisits: number
uniqueVisitors: number
returningVisits: number
firstVisitAt: string | null
lastVisitAt: string | null
topCountries: Array<{ name: string; hits: number }>
topStates: Array<{ name: string; hits: number }>
topCounties: Array<{ name: string; hits: number }>
topCities: Array<{ name: string; hits: number }>
recentVisits: Array<{
at: string
visitorId: string
ip: string
path: string
country: string
state: string
county: string
city: string
returningVisitor: boolean
visitCount: number
}>
}
writeStatus: {
hitStats: { ok: boolean; at: string | null; error: string | null }
visitorStats: { ok: boolean; at: string | null; error: string | null }
backups: { ok: boolean; at: string | null; error: string | null; file: string | null }
}
}
type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'> type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks'>
const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [ const FIELDS: { key: StringField; label: string; multiline?: boolean }[] = [
@@ -31,6 +78,194 @@ export default function AdminPage({ content, onSave }: Props) {
const [form, setForm] = useState<SiteContent>(content) const [form, setForm] = useState<SiteContent>(content)
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [adminTab, setAdminTab] = useState<'content' | 'stats'>('content')
const [contentTab, setContentTab] = useState<'main' | 'custom'>('main')
const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
const [selectedBackup, setSelectedBackup] = useState('')
const [selectedBackupPreview, setSelectedBackupPreview] = useState<BackupPreview | null>(null)
useEffect(() => {
fetch('/api/admin-stats')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load stats'))))
.then(data => {
setStats(data as AdminStats)
setStatsStatus('ready')
})
.catch(() => {
setStatsStatus('error')
})
fetch('/api/admin-stats/backups')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load backups'))))
.then(data => {
const files = Array.isArray((data as { backups?: unknown }).backups) ? (data as { backups: BackupPreview[] }).backups : []
setBackupFiles(files)
if (files.length > 0) {
setSelectedBackup(files[0].filename)
}
})
.catch(() => {})
}, [])
useEffect(() => {
if (!selectedBackup) {
setSelectedBackupPreview(null)
return
}
fetch('/api/admin-stats/backup-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: selectedBackup }),
})
.then(r => (r.ok ? r.json() : Promise.reject(new Error('Preview failed'))))
.then(data => {
const preview = (data as { preview?: BackupPreview }).preview ?? null
setSelectedBackupPreview(preview)
})
.catch(() => {
setSelectedBackupPreview(null)
})
}, [selectedBackup])
async function reloadStats() {
const r = await fetch('/api/admin-stats')
if (!r.ok) throw new Error('Could not refresh stats')
const data = await r.json()
setStats(data as AdminStats)
setStatsStatus('ready')
}
async function reloadBackups() {
const r = await fetch('/api/admin-stats/backups')
if (!r.ok) throw new Error('Could not refresh backups')
const data = await r.json() as { backups?: BackupPreview[] }
const files = Array.isArray(data.backups) ? data.backups : []
setBackupFiles(files)
const names = files.map(f => f.filename)
if (files.length > 0 && !names.includes(selectedBackup)) {
setSelectedBackup(files[0].filename)
}
}
async function reloadContentFromServer() {
const r = await fetch('/api/admin-content')
if (!r.ok) return
const data = await r.json() as { siteContent?: Partial<SiteContent> }
if (data?.siteContent && typeof data.siteContent === 'object') {
const next = { ...DEFAULTS, ...data.siteContent }
setForm(next)
onSave(next)
}
}
function maskIp(ip: string) {
if (!ip || ip === 'unknown') return 'unknown'
if (ip.includes('.')) {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x`
}
if (ip.includes(':')) {
const parts = ip.split(':')
return `${parts.slice(0, 3).join(':')}:x:x`
}
return ip
}
async function handleExport() {
try {
const r = await fetch('/api/admin-stats/export')
if (!r.ok) throw new Error('Export failed')
const data = await r.json()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `siteforge-admin-export-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
setMaintenanceMsg('Export downloaded.')
} catch {
setMaintenanceMsg('Export failed.')
}
}
async function handlePrune() {
const input = prompt('Keep how many days of analytics data?', '180')
if (input === null) return
const days = Number(input)
if (!Number.isFinite(days) || days <= 0) {
setMaintenanceMsg('Invalid retention days.')
return
}
try {
const r = await fetch('/api/admin-stats/prune', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days }),
})
if (!r.ok) throw new Error('Prune failed')
await reloadStats()
setMaintenanceMsg(`Pruned analytics to ${Math.floor(days)} days.`)
} catch {
setMaintenanceMsg('Prune failed.')
}
}
async function handleClear() {
if (!confirm('Clear ALL analytics data now? This cannot be undone.')) return
try {
const r = await fetch('/api/admin-stats/clear', { method: 'POST' })
if (!r.ok) throw new Error('Clear failed')
await reloadStats()
setMaintenanceMsg('All analytics data cleared.')
} catch {
setMaintenanceMsg('Clear failed.')
}
}
async function handleBackupNow() {
try {
const r = await fetch('/api/admin-stats/backup', { method: 'POST' })
if (!r.ok) throw new Error('Backup failed')
await reloadStats()
await reloadBackups()
setMaintenanceMsg('Backup snapshot created.')
} catch {
setMaintenanceMsg('Backup failed.')
}
}
async function handleRestoreBackup() {
if (!selectedBackup) {
setMaintenanceMsg('Select a backup first.')
return
}
if (!confirm(`Restore backup ${selectedBackup}? This will overwrite current admin data and analytics.`)) return
try {
const r = await fetch('/api/admin-stats/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: selectedBackup }),
})
if (!r.ok) throw new Error('Restore failed')
await reloadContentFromServer()
await reloadStats()
await reloadBackups()
setMaintenanceMsg(`Restored from ${selectedBackup}. Content fields were refreshed from backup.`)
} catch {
setMaintenanceMsg('Restore failed.')
}
}
function formatDate(value: string | null) {
if (!value) return 'Not available yet'
const d = new Date(value)
return Number.isNaN(d.getTime()) ? 'Not available yet' : d.toLocaleString()
}
function handleChange(key: StringField, value: string) { function handleChange(key: StringField, value: string) {
setForm(f => ({ ...f, [key]: value })) setForm(f => ({ ...f, [key]: value }))
@@ -117,10 +352,301 @@ export default function AdminPage({ content, onSave }: Props) {
</div> </div>
<div className="admin-form-wrap"> <div className="admin-form-wrap">
<div className="admin-top-tabs" role="tablist" aria-label="Admin sections">
<button
type="button"
role="tab"
aria-selected={adminTab === 'content'}
className={`admin-tab ${adminTab === 'content' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('content')}
>
Content
</button>
<button
type="button"
role="tab"
aria-selected={adminTab === 'stats'}
className={`admin-tab ${adminTab === 'stats' ? 'admin-tab--active' : ''}`}
onClick={() => setAdminTab('stats')}
>
Site Stats
</button>
</div>
{adminTab === 'stats' && (
<section className="admin-stats" aria-label="Site hit statistics">
<div className="admin-stats-head">
<h2>Site Hit Stats</h2>
<p>Built-in page traffic and visitor intelligence from this server.</p>
</div>
{statsStatus === 'loading' && <p className="admin-stats-note">Loading stats...</p>}
{statsStatus === 'error' && <p className="admin-stats-note admin-stats-note--err">Could not load stats right now.</p>}
{statsStatus === 'ready' && stats && (
<>
<div className="admin-stats-grid">
<article>
<h3>Total Hits</h3>
<p>{stats.totalHits.toLocaleString()}</p>
</article>
<article>
<h3>Last 30 Days</h3>
<p>{stats.last30DaysTotal.toLocaleString()}</p>
</article>
<article>
<h3>First Hit</h3>
<p>{formatDate(stats.firstHitAt)}</p>
</article>
<article>
<h3>Latest Hit</h3>
<p>{formatDate(stats.lastHitAt)}</p>
</article>
</div>
<div className="admin-stats-lists">
<div>
<h3>Top Paths</h3>
{stats.topPaths.length === 0 ? (
<p className="admin-stats-note">No hits tracked yet.</p>
) : (
<ul>
{stats.topPaths.map(item => (
<li key={item.path}>
<span>{item.path}</span>
<strong>{item.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
<div>
<h3>Daily Hits (7 Days)</h3>
<ul>
{stats.last7Days.map(item => (
<li key={item.day}>
<span>{item.day}</span>
<strong>{item.hits.toLocaleString()}</strong>
</li>
))}
</ul>
</div>
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Visitor Details</h2>
<p>IP, geography, and returning visitor behavior.</p>
</div>
<p className="admin-privacy-note">
Privacy: visitor analytics only run after cookie consent. IPs below are masked.
</p>
<div className="admin-stats-grid">
<article>
<h3>Total Visits</h3>
<p>{stats.visitors.totalVisits.toLocaleString()}</p>
</article>
<article>
<h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
</article>
<article>
<h3>Returning Visits</h3>
<p>{stats.visitors.returningVisits.toLocaleString()}</p>
</article>
<article>
<h3>Returning Rate</h3>
<p>
{stats.visitors.totalVisits > 0
? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%`
: '0%'}
</p>
</article>
</div>
<div className="admin-stats-lists">
<div>
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.map(item => (
<li key={item.name}>
<span>{item.name}</span>
<strong>{item.hits.toLocaleString()}</strong>
</li>
))}
</ul>
</div>
<div>
<h3>Top States</h3>
<ul>
{stats.visitors.topStates.map(item => (
<li key={item.name}>
<span>{item.name}</span>
<strong>{item.hits.toLocaleString()}</strong>
</li>
))}
</ul>
</div>
<div>
<h3>Top Counties</h3>
<ul>
{stats.visitors.topCounties.map(item => (
<li key={item.name}>
<span>{item.name}</span>
<strong>{item.hits.toLocaleString()}</strong>
</li>
))}
</ul>
</div>
<div>
<h3>Top Cities</h3>
<ul>
{stats.visitors.topCities.map(item => (
<li key={item.name}>
<span>{item.name}</span>
<strong>{item.hits.toLocaleString()}</strong>
</li>
))}
</ul>
</div>
</div>
<div className="admin-visits-table-wrap">
<h3>Recent Visitor Log</h3>
{stats.visitors.recentVisits.length === 0 ? (
<p className="admin-stats-note">No visitor records yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>Country</th>
<th>State</th>
<th>County</th>
<th>City</th>
<th>Path</th>
<th>Returning</th>
<th>Visit #</th>
</tr>
</thead>
<tbody>
{stats.visitors.recentVisits.map(row => (
<tr key={`${row.visitorId}-${row.at}`}>
<td>{formatDate(row.at)}</td>
<td>{maskIp(row.ip)}</td>
<td>{row.country}</td>
<td>{row.state}</td>
<td>{row.county}</td>
<td>{row.city}</td>
<td>{row.path}</td>
<td>{row.returningVisitor ? 'Yes' : 'No'}</td>
<td>{row.visitCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p>
</div>
<div className="admin-stats-grid">
<article>
<h3>Hit Stats Write</h3>
<p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p>
<p>{formatDate(stats.writeStatus.hitStats.at)}</p>
</article>
<article>
<h3>Visitor Stats Write</h3>
<p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p>
<p>{formatDate(stats.writeStatus.visitorStats.at)}</p>
</article>
<article>
<h3>Backup Status</h3>
<p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p>
<p>{formatDate(stats.writeStatus.backups.at)}</p>
</article>
<article>
<h3>Latest Backup File</h3>
<p>{stats.writeStatus.backups.file ?? 'Not available yet'}</p>
</article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={handleExport}>Export JSON</button>
<button type="button" className="btn-admin-reset" onClick={handleBackupNow}>Backup Now</button>
<button type="button" className="btn-admin-reset" onClick={handlePrune}>Prune Old Data</button>
<button type="button" className="btn-admin-remove" onClick={handleClear}>Clear Analytics</button>
</div>
<div className="admin-restore-row">
<label htmlFor="restore-backup">Restore Backup</label>
<select
id="restore-backup"
value={selectedBackup}
onChange={e => setSelectedBackup(e.target.value)}
disabled={backupFiles.length === 0}
>
{backupFiles.length === 0 && <option value="">No backups found</option>}
{backupFiles.map(file => (
<option key={file.filename} value={file.filename}>{file.filename}</option>
))}
</select>
<button type="button" className="btn-admin-reset" onClick={handleRestoreBackup} disabled={!selectedBackup}>
Restore Selected Backup
</button>
</div>
{selectedBackupPreview && (
<div className="admin-restore-preview">
<h3>Restore Preview</h3>
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
<p><strong>Reason:</strong> {selectedBackupPreview.reason}</p>
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
<p><strong>Content Updated At:</strong> {formatDate(selectedBackupPreview.adminUpdatedAt)}</p>
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
</div>
)}
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
</>
)}
</section>
)}
{adminTab === 'content' && (
<form <form
className="admin-form" className="admin-form"
onSubmit={e => { e.preventDefault(); handleSave() }} onSubmit={e => { e.preventDefault(); handleSave() }}
> >
<div className="admin-tabs" role="tablist" aria-label="Content editor tabs">
<button
type="button"
role="tab"
aria-selected={contentTab === 'main'}
className={`admin-tab ${contentTab === 'main' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('main')}
>
Main Content
</button>
<button
type="button"
role="tab"
aria-selected={contentTab === 'custom'}
className={`admin-tab ${contentTab === 'custom' ? 'admin-tab--active' : ''}`}
onClick={() => setContentTab('custom')}
>
Custom Content
</button>
</div>
{contentTab === 'main' && (
<>
{FIELDS.map(({ key, label, multiline }) => ( {FIELDS.map(({ key, label, multiline }) => (
<div className="admin-field" key={key}> <div className="admin-field" key={key}>
<label htmlFor={`field-${key}`}>{label}</label> <label htmlFor={`field-${key}`}>{label}</label>
@@ -141,8 +667,11 @@ export default function AdminPage({ content, onSave }: Props) {
)} )}
</div> </div>
))} ))}
</>
)}
{/* ── Custom Links ── */} {contentTab === 'custom' && (
<>
<div className="admin-section-header"> <div className="admin-section-header">
<h3>Custom Links</h3> <h3>Custom Links</h3>
<p>Add links to show in the platform buttons row, footer, or a dedicated "More Resources" section.</p> <p>Add links to show in the platform buttons row, footer, or a dedicated "More Resources" section.</p>
@@ -192,7 +721,6 @@ export default function AdminPage({ content, onSave }: Props) {
+ Add Link + Add Link
</button> </button>
{/* ── Custom Blocks ── */}
<div className="admin-section-header"> <div className="admin-section-header">
<h3>Custom Content Blocks</h3> <h3>Custom Content Blocks</h3>
<p>Add extra text sections. They appear below the share/QR section on the site.</p> <p>Add extra text sections. They appear below the share/QR section on the site.</p>
@@ -229,6 +757,8 @@ export default function AdminPage({ content, onSave }: Props) {
<button type="button" className="btn-admin-add" onClick={addBlock}> <button type="button" className="btn-admin-add" onClick={addBlock}>
+ Add Content Block + Add Content Block
</button> </button>
</>
)}
<div className="admin-actions"> <div className="admin-actions">
<button <button
@@ -254,6 +784,7 @@ export default function AdminPage({ content, onSave }: Props) {
<p className="admin-status admin-status--err"> {errorMsg}</p> <p className="admin-status admin-status--err"> {errorMsg}</p>
)} )}
</form> </form>
)}
</div> </div>
</div> </div>
) )
+292
View File
@@ -960,12 +960,299 @@
padding: 3rem 2rem 5rem; padding: 3rem 2rem 5rem;
} }
.admin-top-tabs {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
margin-bottom: 1.1rem;
}
.admin-stats {
background: #0d0d0d;
border: 1px solid rgba(200, 134, 10, 0.22);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 2rem;
}
.admin-stats-head h2 {
font-family: 'Playfair Display', Georgia, serif;
font-size: 1.4rem;
margin: 0;
}
.admin-stats-head p {
font-family: 'Barlow Condensed', sans-serif;
color: #a89060;
margin: 0.4rem 0 0;
}
.admin-stats-head--visitors {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(200, 134, 10, 0.2);
}
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1rem;
}
.admin-stats-grid article {
background: #111;
border: 1px solid rgba(200, 134, 10, 0.16);
border-radius: 8px;
padding: 0.85rem;
}
.admin-stats-grid h3 {
margin: 0;
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #c8860a;
}
.admin-stats-grid p {
margin: 0.45rem 0 0;
font-family: 'Barlow Condensed', sans-serif;
color: #f0e6d0;
font-size: 1rem;
}
.admin-stats-lists {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.admin-stats-lists h3 {
margin: 0 0 0.45rem;
font-family: 'Barlow Condensed', sans-serif;
color: #c8860a;
font-size: 0.86rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.admin-stats-lists ul {
margin: 0;
padding: 0;
list-style: none;
}
.admin-stats-lists li {
display: flex;
justify-content: space-between;
gap: 0.6rem;
border-bottom: 1px solid rgba(200, 134, 10, 0.12);
padding: 0.45rem 0;
font-family: 'Barlow Condensed', sans-serif;
color: #f0e6d0;
}
.admin-stats-lists strong {
color: #c8860a;
}
.admin-stats-note {
font-family: 'Barlow Condensed', sans-serif;
color: #a89060;
margin: 1rem 0 0;
}
.admin-stats-note--err {
color: #e05c5c;
}
.admin-privacy-note {
margin: 0.8rem 0 0;
font-family: 'Barlow Condensed', sans-serif;
color: #a89060;
font-size: 0.95rem;
}
.admin-visits-table-wrap {
margin-top: 1.25rem;
}
.admin-visits-table-wrap h3 {
margin: 0 0 0.5rem;
font-family: 'Barlow Condensed', sans-serif;
color: #c8860a;
font-size: 0.9rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.admin-visits-table-scroll {
overflow-x: auto;
}
.admin-visits-table {
width: 100%;
border-collapse: collapse;
min-width: 920px;
}
.admin-visits-table th,
.admin-visits-table td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(200, 134, 10, 0.16);
font-family: 'Barlow Condensed', sans-serif;
color: #f0e6d0;
font-size: 0.9rem;
}
.admin-visits-table th {
color: #c8860a;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.74rem;
position: sticky;
top: 0;
background: #111;
}
.admin-actions--maintenance {
margin-top: 0.9rem;
}
.admin-restore-row {
margin-top: 0.9rem;
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.admin-restore-row label {
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.8rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #c8860a;
}
.admin-restore-row select {
min-width: 260px;
background: #111;
border: 1px solid rgba(200, 134, 10, 0.22);
border-radius: 6px;
color: #f0e6d0;
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.95rem;
padding: 0.55rem 0.8rem;
}
.admin-restore-preview {
margin-top: 0.9rem;
background: #101010;
border: 1px solid rgba(200, 134, 10, 0.2);
border-radius: 8px;
padding: 0.85rem;
}
.admin-restore-preview h3 {
margin: 0 0 0.45rem;
color: #c8860a;
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.85rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.admin-restore-preview p {
margin: 0.2rem 0;
color: #f0e6d0;
font-family: 'Barlow Condensed', sans-serif;
}
.footer-privacy {
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.8rem;
color: #a89060;
margin-top: 0.75rem;
max-width: 760px;
margin-left: auto;
margin-right: auto;
}
.consent-banner {
position: fixed;
left: 1rem;
right: 1rem;
bottom: 1rem;
z-index: 220;
background: rgba(10, 10, 10, 0.96);
border: 1px solid rgba(200, 134, 10, 0.45);
border-radius: 12px;
padding: 0.9rem 1rem;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.consent-banner p {
margin: 0;
color: #f0e6d0;
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.92rem;
}
.consent-actions {
margin-top: 0.75rem;
display: flex;
gap: 0.6rem;
}
.consent-actions .btn-primary,
.consent-actions .btn-secondary {
font-size: 0.85rem;
padding: 0.55rem 1rem;
}
.admin-form { .admin-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
} }
.admin-tabs {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
}
.admin-tab {
background: transparent;
color: #a89060;
border: 1px solid rgba(168, 144, 96, 0.35);
border-radius: 999px;
font-family: 'Barlow Condensed', sans-serif;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.5rem 1rem;
cursor: pointer;
transition: border-color 200ms, color 200ms, background 200ms;
}
.admin-tab:hover {
color: #f0e6d0;
border-color: rgba(240, 230, 208, 0.45);
}
.admin-tab--active {
color: #0a0a0a;
background: #c8860a;
border-color: #c8860a;
}
.admin-field { .admin-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1253,6 +1540,11 @@
.header-ornament { .header-ornament {
display: none; display: none;
} }
.admin-stats-grid,
.admin-stats-lists {
grid-template-columns: 1fr;
}
} }
@media (max-width: 540px) { @media (max-width: 540px) {
+56
View File
@@ -13,6 +13,7 @@ const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558'
const AMAZON_MUSIC_URL = const AMAZON_MUSIC_URL =
'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate' 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate' const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function FacebookIcon() { function FacebookIcon() {
return ( return (
@@ -176,6 +177,56 @@ function ContactForm() {
) )
} }
function AnalyticsConsentBanner() {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
if (saved === 'accepted' || saved === 'declined') return saved
return 'unknown'
})
async function sendChoice(consent: boolean) {
await fetch('/api/analytics-consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent }),
})
}
async function accept() {
setChoice('accepted')
localStorage.setItem(CONSENT_KEY, 'accepted')
try {
await sendChoice(true)
} catch {
// Keep local preference even if network fails.
}
}
async function decline() {
setChoice('declined')
localStorage.setItem(CONSENT_KEY, 'declined')
try {
await sendChoice(false)
} catch {
// Keep local preference even if network fails.
}
}
if (choice !== 'unknown') return null
return (
<div className="consent-banner" role="region" aria-label="Analytics consent">
<p>
We use optional analytics cookies to measure visits and location trends for site improvement.
</p>
<div className="consent-actions">
<button type="button" className="btn-primary" onClick={accept}>Accept</button>
<button type="button" className="btn-secondary" onClick={decline}>Decline</button>
</div>
</div>
)
}
function LandingPage({ content }: { content: SiteContent }) { function LandingPage({ content }: { content: SiteContent }) {
return ( return (
<div className="site"> <div className="site">
@@ -468,7 +519,12 @@ function LandingPage({ content }: { content: SiteContent }) {
])} ])}
</nav> </nav>
<p className="footer-copy">© 2026 Nate Emmert · Made with faith.</p> <p className="footer-copy">© 2026 Nate Emmert · Made with faith.</p>
<p className="footer-privacy">
Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.
</p>
</footer> </footer>
<AnalyticsConsentBanner />
</div> </div>
) )
} }