Add persistent admin analytics, backup restore workflow, and tabbed admin UI
This commit is contained in:
@@ -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/*
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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)
|
||||||
app.listen(PORT, () => {
|
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, () => {
|
||||||
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|||||||
+534
-3
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user