Add Nuggets host upload portal with Seafile integration; v2.0.4

- New /host-upload page: per-host login (username + password) with
  session cookie, drag-and-drop file upload to Seafile via server proxy
- Two upload targets: Episode Upload (audio) and Episode Info (docs)
- Server route: /api/nuggets-upload/* for host auth + file proxy,
  /api/admin/nuggets-upload/hosts for admin CRUD (create/disable/reset pw/delete)
- Admin panel: Nuggets → Upload Portal view to manage host accounts
- Seafile config via SEAFILE_URL / SEAFILE_TOKEN / SEAFILE_NUGGETS_REPO_ID env vars
- Directories default to /Episode Upload and /Episode Info (configurable)
- Uses multer for multipart uploads; 500 MB per-file limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-08 13:38:57 -04:00
1 parent 29d2ace1b2
commit afdc00114a
9 files changed
+1074 -3

No files matched your search

+6
View File
@@ -10,6 +10,12 @@ services:
- SITEFORGE_DATA_DIR=/app/data
- ADMIN_PASSWORD=`generate a random password and set it here`
- RESEND_API_KEY=`set your Resend API key here`
# Seafile integration for Nuggets host upload portal
# Get SEAFILE_TOKEN from: Seafile → Profile → Settings → API Token → Generate
# Get SEAFILE_NUGGETS_REPO_ID from the URL when viewing the Nuggets library in Seafile
- SEAFILE_URL=https://seafile.necloud.us
- SEAFILE_TOKEN=your_seafile_api_token_here
- SEAFILE_NUGGETS_REPO_ID=your_repo_id_here
volumes:
- /media/ZimaOS-HD/AppData/siteforge/data:/app/data
restart: unless-stopped
+123 -1
View File
@@ -19,6 +19,7 @@
"express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
"mammoth": "^1.12.0",
"multer": "^2.3.0",
"otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
@@ -3903,6 +3904,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -4163,9 +4170,19 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -4417,6 +4434,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/concat-stream/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -8332,6 +8378,68 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -10096,6 +10204,14 @@
"node": ">= 0.4"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -10605,6 +10721,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -24,6 +24,7 @@
"express-rate-limit": "^8.4.1",
"fuse.js": "^7.4.2",
"mammoth": "^1.12.0",
"multer": "^2.3.0",
"otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4",
+2
View File
@@ -63,6 +63,7 @@ import { register as registerAnalytics } from './server/routes/analytics.js'
import { register as registerDownloads } from './server/routes/downloads.js'
import { register as registerEpisodes } from './server/routes/episodes.js'
import { register as registerNuggets } from './server/routes/nuggets.js'
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
import { register as registerFeed } from './server/routes/feed.js'
import { register as registerQrCodes } from './server/routes/qr-codes.js'
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
@@ -112,6 +113,7 @@ registerAnalytics(app)
registerDownloads(app)
registerEpisodes(app)
registerNuggets(app)
registerNuggetsHostUpload(app)
registerFeed(app)
registerQrCodes(app)
registerBiblePassage(app)
+11
View File
@@ -58,12 +58,23 @@ export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json')
export const EMAIL_SETTINGS_FILE = path.join(DATA_DIR, 'email-settings.json')
export const CALENDAR_EVENTS_FILE = path.join(DATA_DIR, 'calendar-events.json')
export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
// Seafile integration for Nuggets episode uploads
export const SEAFILE_URL = (process.env.SEAFILE_URL ?? '').replace(/\/$/, '')
export const SEAFILE_TOKEN = process.env.SEAFILE_TOKEN ?? ''
export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
export const HOST_SESSION_COOKIE = 'vbn_host_session'
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
export const INDEX_FILE = path.join(DIST_DIR, 'index.html')
export const DIST_IMAGES_DIR = path.join(DIST_DIR, 'images')
+307
View File
@@ -0,0 +1,307 @@
import { randomUUID } from 'node:crypto'
import { readFile, writeFile, unlink } from 'node:fs/promises'
import path from 'node:path'
import multer from 'multer'
import rateLimit from 'express-rate-limit'
import { createHash, timingSafeEqual } from 'node:crypto'
import { parseCookies, cookieSecureFlag } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
import {
NUGGETS_UPLOAD_HOSTS_FILE,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_EPISODE_UPLOAD_DIR,
HOST_SESSION_COOKIE,
HOST_SESSION_TTL_MS,
DATA_DIR,
} from '../config.js'
// ── In-memory host sessions ───────────────────────────────────────────────
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
function createHostSession(hostId, hostName) {
const token = randomUUID()
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
return token
}
function getHostSession(req) {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (!token) return null
const session = hostSessions.get(token)
if (!session || session.expiresAt <= Date.now()) {
hostSessions.delete(token)
return null
}
return session
}
function setHostSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function clearHostSessionCookie(res) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function requireHostAuth(req, res, next) {
if (!getHostSession(req)) {
res.status(401).json({ message: 'Not authenticated.' })
return
}
next()
}
// ── Host credentials data ─────────────────────────────────────────────────
function hashPassword(pw) {
return createHash('sha256').update(String(pw)).digest('hex')
}
function passwordsMatch(plain, hash) {
const a = Buffer.from(hashPassword(plain), 'utf8')
const b = Buffer.from(hash, 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
async function loadUploadHosts() {
try {
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { hosts: [] }
}
}
async function saveUploadHosts(data) {
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
const upload = multer({
dest: UPLOAD_TEMP_DIR,
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
})
// ── Seafile helpers ───────────────────────────────────────────────────────
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
async function getSeafileUploadLink(dir) {
const url = `${SEAFILE_URL}/api/v2.1/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?path=${encodeURIComponent(dir)}`
const res = await fetch(url, {
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
}
// Seafile returns a quoted string: "https://..."
const text = await res.text()
return text.replace(/^"|"$/g, '')
}
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
const formData = new FormData()
formData.append('file', new Blob([fileBuffer]), filename)
formData.append('parent_dir', parentDir)
formData.append('filename', filename)
formData.append('replace', '1')
const res = await fetch(uploadLink, {
method: 'POST',
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
body: formData,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
}
return res.json()
}
// ── Rate limiter ──────────────────────────────────────────────────────────
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
skipSuccessfulRequests: true,
})
// ── Route registration ────────────────────────────────────────────────────
export function register(app) {
// ── Host auth ─────────────────────────────────────────────────────────
app.get('/api/nuggets-upload/me', (req, res) => {
const session = getHostSession(req)
if (!session) {
res.json({ authenticated: false })
return
}
res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() })
})
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!username || !password) {
res.status(400).json({ message: 'Username and password are required.' })
return
}
const data = await loadUploadHosts()
const host = data.hosts.find(h => h.username === username && h.active)
if (!host || !passwordsMatch(password, host.passwordHash)) {
res.status(401).json({ message: 'Invalid username or password.' })
return
}
const token = createHostSession(host.id, host.name)
setHostSessionCookie(res, token)
res.json({ ok: true, hostName: host.name })
})
app.post('/api/nuggets-upload/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (token) hostSessions.delete(token)
clearHostSessionCookie(res)
res.json({ ok: true })
})
// ── File upload ───────────────────────────────────────────────────────
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => {
if (!req.file) {
res.status(400).json({ message: 'No file received.' })
return
}
const targetDir = req.body?.targetDir === 'info' ? SEAFILE_EPISODE_INFO_DIR : SEAFILE_EPISODE_UPLOAD_DIR
const originalName = req.file.originalname || req.file.filename
const tempPath = req.file.path
if (!seafileConfigured()) {
await unlink(tempPath).catch(() => {})
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
return
}
try {
const { readFile: rf } = await import('node:fs/promises')
const fileBuffer = await rf(tempPath)
const uploadLink = await getSeafileUploadLink(targetDir)
await uploadToSeafile(uploadLink, fileBuffer, originalName, targetDir)
await unlink(tempPath).catch(() => {})
const session = getHostSession(req)
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${originalName}" to ${targetDir}`)
res.json({ ok: true, filename: originalName, dir: targetDir })
} catch (err) {
await unlink(tempPath).catch(() => {})
console.error('[nuggets-upload] upload error:', err.message)
res.status(500).json({ message: `Upload failed: ${err.message}` })
}
})
// ── Admin: manage upload host accounts ───────────────────────────────
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
const data = await loadUploadHosts()
// Strip password hashes from the response
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
res.json({ hosts })
})
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
const username = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!name || !username || !password) {
res.status(400).json({ message: 'Name, username, and password are required.' })
return
}
if (password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
const data = await loadUploadHosts()
if (data.hosts.some(h => h.username === username)) {
res.status(409).json({ message: 'Username already taken.' })
return
}
const host = {
id: randomUUID(),
name,
username,
passwordHash: hashPassword(password),
active: true,
createdAt: new Date().toISOString(),
}
data.hosts.push(host)
await saveUploadHosts(data)
const { passwordHash: _ph, ...safeHost } = host
res.json({ ok: true, host: safeHost })
})
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
const patch = {}
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
if (req.body.password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
patch.passwordHash = hashPassword(req.body.password)
}
data.hosts[idx] = { ...data.hosts[idx], ...patch }
await saveUploadHosts(data)
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
res.json({ ok: true, host: safeHost })
})
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
data.hosts.splice(idx, 1)
await saveUploadHosts(data)
res.json({ ok: true })
})
}
+182 -1
View File
@@ -666,7 +666,7 @@ type AdminView =
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
@@ -721,6 +721,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
@@ -1095,6 +1096,183 @@ function QrCodeImage({ url, label }: { url: string; label: string }) {
)
}
// ── Nuggets Upload Portal admin panel ─────────────────────────────────────
interface UploadHost {
id: string
name: string
username: string
active: boolean
createdAt: string
}
function NuggetsUploadPortalAdmin() {
const [hosts, setHosts] = useState<UploadHost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [addName, setAddName] = useState('')
const [addUsername, setAddUsername] = useState('')
const [addPassword, setAddPassword] = useState('')
const [addError, setAddError] = useState('')
const [addSaving, setAddSaving] = useState(false)
const [resetId, setResetId] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const [resetError, setResetError] = useState('')
const [resetSaving, setResetSaving] = useState(false)
function reload() {
setLoading(true)
fetch('/api/admin/nuggets-upload/hosts')
.then(r => r.json())
.then(d => setHosts(d.hosts ?? []))
.catch(() => setError('Failed to load hosts.'))
.finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
async function addHost(e: React.FormEvent) {
e.preventDefault()
setAddError('')
setAddSaving(true)
try {
const r = await fetch('/api/admin/nuggets-upload/hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: addName, username: addUsername, password: addPassword }),
})
const d = await r.json()
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
setAddName(''); setAddUsername(''); setAddPassword('')
reload()
} finally {
setAddSaving(false)
}
}
async function toggleActive(host: UploadHost) {
await fetch(`/api/admin/nuggets-upload/hosts/${host.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !host.active }),
})
reload()
}
async function deleteHost(id: string) {
if (!window.confirm('Remove this host account? This cannot be undone.')) return
await fetch(`/api/admin/nuggets-upload/hosts/${id}`, { method: 'DELETE' })
reload()
}
async function resetPassword(e: React.FormEvent) {
e.preventDefault()
if (!resetId) return
setResetError('')
setResetSaving(true)
try {
const r = await fetch(`/api/admin/nuggets-upload/hosts/${resetId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: resetPw }),
})
const d = await r.json()
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
setResetId(null); setResetPw('')
reload()
} finally {
setResetSaving(false)
}
}
return (
<section className="admin-panel-section" aria-label="Nuggets upload portal">
<div className="admin-panel-head">
<h2>Nuggets — Upload Portal</h2>
<p>
Manage host login accounts for the upload portal at{' '}
<a href="/host-upload" target="_blank" rel="noopener noreferrer">/host-upload</a>.
Each host gets a username and password to upload episode files to Seafile.
</p>
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
Make sure <code>SEAFILE_URL</code>, <code>SEAFILE_TOKEN</code>, and <code>SEAFILE_NUGGETS_REPO_ID</code> are set in your environment.
</p>
</div>
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
{loading && <p className="admin-stats-note">Loading…</p>}
{!loading && hosts.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1.5rem', fontSize: '0.88rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Username</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
<th style={{ padding: '6px 8px' }}></th>
</tr>
</thead>
<tbody>
{hosts.map(h => (
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<td style={{ padding: '7px 8px' }}>{h.name}</td>
<td style={{ padding: '7px 8px', fontFamily: 'monospace' }}>{h.username}</td>
<td style={{ padding: '7px 8px' }}>
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
</td>
<td style={{ padding: '7px 8px', whiteSpace: 'nowrap', textAlign: 'right' }}>
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => toggleActive(h)}>
{h.active ? 'Disable' : 'Enable'}
</button>
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => { setResetId(h.id); setResetPw(''); setResetError('') }}>
Reset PW
</button>
<button className="btn-admin-remove" style={{ fontSize: '0.78rem' }} onClick={() => deleteHost(h.id)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && hosts.length === 0 && <p className="admin-stats-note">No upload hosts yet. Add one below.</p>}
{resetId && (
<form onSubmit={resetPassword} style={{ background: 'var(--color-surface-alt, #f7f5f0)', border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 8, padding: '1rem', marginBottom: '1.5rem' }}>
<p style={{ fontWeight: 600, marginBottom: 8 }}>Reset password for {hosts.find(h => h.id === resetId)?.username}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: '1 1 200px' }}>
<label>New Password (min 8 chars)</label>
<input type="password" value={resetPw} onChange={e => setResetPw(e.target.value)} required minLength={8} />
</div>
<button className="btn-admin-save" type="submit" disabled={resetSaving}>{resetSaving ? 'Saving…' : 'Save'}</button>
<button className="btn-admin-ghost" type="button" onClick={() => setResetId(null)}>Cancel</button>
</div>
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
</form>
)}
<form onSubmit={addHost}>
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="admin-field" style={{ flex: '1 1 160px' }}>
<label>Display Name</label>
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
</div>
<div className="admin-field" style={{ flex: '1 1 140px' }}>
<label>Username</label>
<input type="text" placeholder="janesmith" value={addUsername} onChange={e => setAddUsername(e.target.value.toLowerCase())} required />
</div>
<div className="admin-field" style={{ flex: '1 1 160px' }}>
<label>Password (min 8 chars)</label>
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
</div>
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
</div>
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
</form>
</section>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -4717,6 +4895,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)
})()}
{/* NUGGETS UPLOAD PORTAL */}
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {
+2
View File
@@ -12,6 +12,7 @@ import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer'
import { NuggetsHostPortal } from './components/NuggetsHostPortal'
import { BIBLE_BOOKS } from '../shared/bible-books.js'
import './App.css'
import { sendEvent, useScrollDepthTracking, useTimeOnPage, useUTMCapture, useLinkClickTracking } from './analytics'
@@ -3139,6 +3140,7 @@ export default function App() {
<Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/host-upload" element={<NuggetsHostPortal />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/preview" element={<PreviewPage />} />
<Route
+439
View File
@@ -0,0 +1,439 @@
import { useState, useEffect, useRef } from 'react'
type UploadTarget = 'upload' | 'info'
interface UploadRecord {
filename: string
dir: string
at: string
}
export function NuggetsHostPortal() {
const [checking, setChecking] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
const [hostName, setHostName] = useState('')
const [seafileConfigured, setSeafileConfigured] = useState(true)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loggingIn, setLoggingIn] = useState(false)
const [target, setTarget] = useState<UploadTarget>('upload')
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState('')
const [uploads, setUploads] = useState<UploadRecord[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetch('/api/nuggets-upload/me')
.then(r => r.json())
.then(d => {
setAuthenticated(d.authenticated)
setHostName(d.hostName ?? '')
setSeafileConfigured(d.configured !== false)
})
.finally(() => setChecking(false))
}, [])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoginError('')
setLoggingIn(true)
try {
const r = await fetch('/api/nuggets-upload/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const d = await r.json()
if (!r.ok) {
setLoginError(d.message ?? 'Login failed.')
return
}
setHostName(d.hostName)
setAuthenticated(true)
} finally {
setLoggingIn(false)
}
}
async function handleLogout() {
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
setAuthenticated(false)
setHostName('')
setUsername('')
setPassword('')
setUploads([])
}
async function uploadFile(file: File) {
if (uploading) return
setUploadError('')
setUploading(true)
setProgress(0)
const formData = new FormData()
formData.append('file', file)
formData.append('targetDir', target)
return new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/nuggets-upload/upload')
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100))
})
xhr.addEventListener('load', () => {
setUploading(false)
if (xhr.status === 200) {
try {
const d = JSON.parse(xhr.responseText)
setUploads(prev => [{ filename: d.filename, dir: d.dir, at: new Date().toLocaleTimeString() }, ...prev])
setProgress(100)
} catch {
setUploadError('Upload succeeded but response was unexpected.')
}
} else {
try {
const d = JSON.parse(xhr.responseText)
setUploadError(d.message ?? 'Upload failed.')
} catch {
setUploadError('Upload failed.')
}
}
resolve()
})
xhr.addEventListener('error', () => {
setUploading(false)
setUploadError('Network error during upload.')
resolve()
})
xhr.send(formData)
})
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) uploadFile(file)
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) uploadFile(file)
e.target.value = ''
}
if (checking) {
return (
<div style={styles.page}>
<div style={styles.card}>
<p style={{ color: 'var(--color-muted, #888)', textAlign: 'center' }}>Loading…</p>
</div>
</div>
)
}
if (!authenticated) {
return (
<div style={styles.page}>
<div style={styles.card}>
<div style={styles.logo}>
<span style={styles.logoEmoji}>🎙️</span>
<span style={styles.logoTitle}>Nuggets</span>
</div>
<h1 style={styles.heading}>Host Upload Portal</h1>
<p style={styles.subheading}>Sign in to upload your episode files to Seafile.</p>
<form onSubmit={handleLogin} style={styles.form}>
<div style={styles.field}>
<label style={styles.label} htmlFor="hp-username">Username</label>
<input
id="hp-username"
style={styles.input}
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loggingIn}
required
/>
</div>
<div style={styles.field}>
<label style={styles.label} htmlFor="hp-password">Password</label>
<input
id="hp-password"
style={styles.input}
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loggingIn}
required
/>
</div>
{loginError && <p style={styles.errorMsg}>{loginError}</p>}
<button style={styles.btnPrimary} type="submit" disabled={loggingIn}>
{loggingIn ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
const targetLabel = target === 'upload' ? 'Episode Upload' : 'Episode Info'
const acceptTypes = target === 'upload'
? '.mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov'
: '*'
return (
<div style={styles.page}>
<div style={{ ...styles.card, maxWidth: 640 }}>
<div style={styles.headerRow}>
<div style={styles.logo}>
<span style={styles.logoEmoji}>🎙️</span>
<span style={styles.logoTitle}>Nuggets Upload Portal</span>
</div>
<div style={styles.headerRight}>
<span style={styles.welcomeText}>Hi, {hostName}</span>
<button style={styles.btnGhost} onClick={handleLogout}>Sign out</button>
</div>
</div>
{!seafileConfigured && (
<div style={styles.warningBanner}>
Seafile is not configured yet. Contact the admin to set up the connection.
</div>
)}
<div style={styles.targetRow}>
<button
style={target === 'upload' ? styles.tabActive : styles.tab}
onClick={() => { setTarget('upload'); setUploadError('') }}
>
🎵 Episode Upload
</button>
<button
style={target === 'info' ? styles.tabActive : styles.tab}
onClick={() => { setTarget('info'); setUploadError('') }}
>
📄 Episode Info
</button>
</div>
<p style={styles.targetHint}>
{target === 'upload'
? 'Upload your finished audio file (MP3, WAV, M4A, etc.).'
: 'Upload show notes, outlines, scripts, or any supporting document.'}
</p>
<div
ref={dropRef}
style={{
...styles.dropZone,
...(dragging ? styles.dropZoneActive : {}),
...(uploading ? styles.dropZoneDisabled : {}),
}}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept={acceptTypes}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{uploading ? (
<div style={styles.progressWrap}>
<p style={styles.dropLabel}>Uploading to {targetLabel}…</p>
<div style={styles.progressBar}>
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
</div>
<p style={styles.progressPct}>{progress}%</p>
</div>
) : (
<>
<span style={styles.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
<p style={styles.dropLabel}>{dragging ? 'Drop to upload' : 'Drop a file here, or click to browse'}</p>
<p style={styles.dropSub}>→ {targetLabel}</p>
</>
)}
</div>
{uploadError && <p style={styles.errorMsg}>{uploadError}</p>}
{uploads.length > 0 && (
<div style={styles.uploadList}>
<p style={styles.uploadListTitle}>Uploaded this session</p>
{uploads.map((u, i) => (
<div key={i} style={styles.uploadRow}>
<span style={styles.uploadCheck}>✓</span>
<span style={styles.uploadName}>{u.filename}</span>
<span style={styles.uploadMeta}>{u.dir} · {u.at}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}
// ── Styles ────────────────────────────────────────────────────────────────
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
background: 'var(--color-bg, #f7f5f0)',
},
card: {
background: 'var(--color-surface, #fff)',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 16,
padding: '2.5rem 2rem',
width: '100%',
maxWidth: 420,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
},
logo: {
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 16,
},
logoEmoji: { fontSize: '1.6rem' },
logoTitle: { fontWeight: 700, fontSize: '1.05rem', letterSpacing: '-0.01em' },
heading: { fontSize: '1.45rem', fontWeight: 700, margin: '0 0 6px' },
subheading: { color: 'var(--color-muted, #888)', fontSize: '0.9rem', margin: '0 0 1.5rem' },
form: { display: 'flex', flexDirection: 'column', gap: 16 },
field: { display: 'flex', flexDirection: 'column', gap: 5 },
label: { fontSize: '0.85rem', fontWeight: 600 },
input: {
padding: '0.55rem 0.75rem',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 8,
fontSize: '0.95rem',
background: 'var(--color-input-bg, #faf9f7)',
outline: 'none',
},
errorMsg: { color: '#c0392b', fontSize: '0.85rem', margin: '2px 0' },
btnPrimary: {
background: '#c8a96e',
color: '#fff',
border: 'none',
borderRadius: 8,
padding: '0.65rem 1rem',
fontWeight: 700,
fontSize: '0.95rem',
cursor: 'pointer',
marginTop: 4,
},
headerRow: {
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 20,
gap: 12,
flexWrap: 'wrap',
},
headerRight: { display: 'flex', alignItems: 'center', gap: 10 },
welcomeText: { fontSize: '0.85rem', color: 'var(--color-muted, #888)' },
btnGhost: {
background: 'none',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 6,
padding: '0.3rem 0.6rem',
fontSize: '0.8rem',
cursor: 'pointer',
},
warningBanner: {
background: '#fff8e1',
border: '1px solid #ffe082',
borderRadius: 8,
padding: '0.65rem 0.9rem',
fontSize: '0.85rem',
marginBottom: 16,
color: '#7a5f00',
},
targetRow: { display: 'flex', gap: 8, marginBottom: 10 },
tab: {
flex: 1,
background: 'var(--color-surface-alt, #f7f5f0)',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 8,
padding: '0.5rem',
fontSize: '0.85rem',
cursor: 'pointer',
fontWeight: 500,
},
tabActive: {
flex: 1,
background: '#c8a96e',
border: '1px solid #c8a96e',
borderRadius: 8,
padding: '0.5rem',
fontSize: '0.85rem',
cursor: 'pointer',
fontWeight: 700,
color: '#fff',
},
targetHint: { fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 14px' },
dropZone: {
border: '2px dashed var(--color-border, #e5e0d5)',
borderRadius: 12,
padding: '2.5rem 1.5rem',
textAlign: 'center',
cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s',
userSelect: 'none',
},
dropZoneActive: {
borderColor: '#c8a96e',
background: 'rgba(200,169,110,0.06)',
},
dropZoneDisabled: { cursor: 'default', opacity: 0.7 },
dropIcon: { fontSize: '2rem', display: 'block', marginBottom: 8 },
dropLabel: { fontWeight: 600, margin: '0 0 4px', fontSize: '0.95rem' },
dropSub: { color: 'var(--color-muted, #888)', fontSize: '0.82rem', margin: 0 },
progressWrap: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 },
progressBar: {
width: '100%',
height: 8,
background: '#e5e0d5',
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
background: '#c8a96e',
borderRadius: 4,
transition: 'width 0.2s',
},
progressPct: { fontSize: '0.85rem', color: 'var(--color-muted, #888)', margin: 0 },
uploadList: {
marginTop: 20,
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 10,
padding: '0.75rem 1rem',
},
uploadListTitle: { fontWeight: 600, fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.04em' },
uploadRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '4px 0', flexWrap: 'wrap' },
uploadCheck: { color: '#27ae60', fontWeight: 700 },
uploadName: { fontSize: '0.9rem', fontWeight: 500, wordBreak: 'break-all' },
uploadMeta: { fontSize: '0.78rem', color: 'var(--color-muted, #888)', whiteSpace: 'nowrap' },
}