diff --git a/package.json b/package.json
index 2119e81..02cbb63 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
- "version": "2.0.16",
+ "version": "2.0.17",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/server/config.js b/server/config.js
index 3db51e3..cf4c102 100644
--- a/server/config.js
+++ b/server/config.js
@@ -60,6 +60,9 @@ 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 NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
+export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json')
+export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json')
+export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/'
// Email address for Nuggets upload notifications (defaults to the admin inbox)
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
diff --git a/server/email.js b/server/email.js
index d558953..e9e97e3 100644
--- a/server/email.js
+++ b/server/email.js
@@ -805,3 +805,38 @@ export async function sendNuggetsHostWelcomeEmail({ to, name, username, password
console.error('[nuggets-welcome] send exception:', err)
}
}
+
+export async function sendNuggetsHostOtpEmail({ to, name, code }) {
+ if (!process.env.RESEND_API_KEY || !to) return
+ const resend = new Resend(process.env.RESEND_API_KEY)
+ const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM
+
+ const bodyHtml = `
+
Hi ${escapeHtml(name)} ,
+
+ Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.
+
+
+ ${escapeHtml(code)}
+
+ If you did not request this code, you can safely ignore this email.
+ `
+
+ try {
+ const { error } = await resend.emails.send({
+ from,
+ to: [to],
+ subject: 'Your Nuggets Portal Sign-In Code',
+ text: `Hi ${name},\n\nYour one-time sign-in code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`,
+ html: buildBrandedEmailHtml({
+ title: 'Sign-In Code',
+ eyebrow: 'Nuggets',
+ bodyHtml,
+ footerHtml: 'You received this because a sign-in was attempted on the Nuggets podcast host portal.
',
+ }),
+ })
+ if (error) console.error('[nuggets-otp] send error:', error)
+ } catch (err) {
+ console.error('[nuggets-otp] send exception:', err)
+ }
+}
diff --git a/server/routes/nuggets-host-upload.js b/server/routes/nuggets-host-upload.js
index b4e6b10..169f0c9 100644
--- a/server/routes/nuggets-host-upload.js
+++ b/server/routes/nuggets-host-upload.js
@@ -6,16 +6,23 @@ import rateLimit from 'express-rate-limit'
import { createHash, timingSafeEqual } from 'node:crypto'
import { parseCookies, cookieSecureFlag } from '../helpers.js'
import { requireAdminAuth } from '../auth.js'
-import { sendNuggetsUploadNotification, sendNuggetsHostWelcomeEmail } from '../email.js'
+import {
+ sendNuggetsUploadNotification,
+ sendNuggetsHostWelcomeEmail,
+ sendNuggetsHostOtpEmail,
+} from '../email.js'
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
import {
NUGGETS_UPLOAD_HOSTS_FILE,
+ NUGGETS_UPLOAD_HISTORY_FILE,
+ NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
NUGGETS_NOTIFY_EMAIL,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_EPISODE_UPLOAD_DIR,
+ SEAFILE_NUGGETS_SHARE_URL,
HOST_SESSION_COOKIE,
HOST_SESSION_TTL_MS,
DATA_DIR,
@@ -64,6 +71,15 @@ function requireHostAuth(req, res, next) {
next()
}
+// ── In-memory pending OTP store ───────────────────────────────────────────
+const OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes
+const pendingOtpStore = new Map() // otpToken → { hostId, hostName, code, expiresAt, attempts }
+
+function generateOtpCode() {
+ // 6-digit numeric code
+ return String(Math.floor(100000 + Math.random() * 900000))
+}
+
// ── Host credentials data ─────────────────────────────────────────────────
function hashPassword(pw) {
return createHash('sha256').update(String(pw)).digest('hex')
@@ -89,6 +105,40 @@ async function saveUploadHosts(data) {
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
}
+// ── History helpers ───────────────────────────────────────────────────────
+async function loadHistory() {
+ try {
+ const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8')
+ return JSON.parse(raw)
+ } catch {
+ return { entries: [] }
+ }
+}
+
+async function appendHistory(entry) {
+ const data = await loadHistory()
+ data.entries.unshift({ ...entry, uploadedAt: new Date().toISOString() })
+ // Keep last 1000 entries
+ if (data.entries.length > 1000) data.entries = data.entries.slice(0, 1000)
+ await writeFile(NUGGETS_UPLOAD_HISTORY_FILE, JSON.stringify(data, null, 2), 'utf8')
+}
+
+// ── Announcement helpers ──────────────────────────────────────────────────
+async function loadAnnouncement() {
+ try {
+ const raw = await readFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, 'utf8')
+ return JSON.parse(raw)
+ } catch {
+ return { text: '', updatedAt: null }
+ }
+}
+
+async function saveAnnouncement(text) {
+ const data = { text: String(text ?? '').trim(), updatedAt: new Date().toISOString() }
+ await writeFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, JSON.stringify(data, null, 2), 'utf8')
+ return data
+}
+
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
@@ -135,7 +185,7 @@ async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
await res.text()
}
-// ── Rate limiter ──────────────────────────────────────────────────────────
+// ── Rate limiters ─────────────────────────────────────────────────────────
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
@@ -145,17 +195,32 @@ const loginRateLimiter = rateLimit({
skipSuccessfulRequests: true,
})
+const otpRateLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 10,
+ standardHeaders: true,
+ legacyHeaders: false,
+ message: { message: 'Too many OTP attempts. Wait 15 minutes and try again.' },
+})
+
// ── Route registration ────────────────────────────────────────────────────
export function register(app) {
// ── Host auth ─────────────────────────────────────────────────────────
- app.get('/api/nuggets-upload/me', (req, res) => {
+ app.get('/api/nuggets-upload/me', async (req, res) => {
const session = getHostSession(req)
if (!session) {
res.json({ authenticated: false })
return
}
- res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() })
+ const data = await loadUploadHosts()
+ const host = data.hosts.find(h => h.id === session.hostId)
+ res.json({
+ authenticated: true,
+ hostName: session.hostName,
+ configured: seafileConfigured(),
+ twoFaEnabled: Boolean(host?.twoFa),
+ })
})
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
@@ -174,11 +239,63 @@ export function register(app) {
return
}
+ // Check if 2FA is enabled for this host
+ if (host.twoFa) {
+ const code = generateOtpCode()
+ const otpToken = randomUUID()
+ pendingOtpStore.set(otpToken, {
+ hostId: host.id,
+ hostName: host.name,
+ code,
+ expiresAt: Date.now() + OTP_TTL_MS,
+ attempts: 0,
+ })
+ sendNuggetsHostOtpEmail({ to: host.email, name: host.name, code }).catch(
+ err => console.error('[nuggets-otp] send error:', err),
+ )
+ res.json({ requiresOtp: true, otpToken })
+ return
+ }
+
const token = createHostSession(host.id, host.name)
setHostSessionCookie(res, token)
res.json({ ok: true, hostName: host.name })
})
+ app.post('/api/nuggets-upload/verify-otp', otpRateLimiter, (req, res) => {
+ const otpToken = typeof req.body?.otpToken === 'string' ? req.body.otpToken : ''
+ const code = typeof req.body?.code === 'string' ? req.body.code.trim() : ''
+
+ if (!otpToken || !code) {
+ res.status(400).json({ message: 'OTP token and code are required.' })
+ return
+ }
+
+ const pending = pendingOtpStore.get(otpToken)
+ if (!pending || pending.expiresAt <= Date.now()) {
+ pendingOtpStore.delete(otpToken)
+ res.status(401).json({ message: 'Code expired. Please sign in again.' })
+ return
+ }
+
+ pending.attempts += 1
+ if (pending.attempts > 5) {
+ pendingOtpStore.delete(otpToken)
+ res.status(429).json({ message: 'Too many attempts. Please sign in again.' })
+ return
+ }
+
+ if (pending.code !== code) {
+ res.status(401).json({ message: 'Incorrect code. Try again.' })
+ return
+ }
+
+ pendingOtpStore.delete(otpToken)
+ const sessionToken = createHostSession(pending.hostId, pending.hostName)
+ setHostSessionCookie(res, sessionToken)
+ res.json({ ok: true, hostName: pending.hostName })
+ })
+
app.post('/api/nuggets-upload/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
@@ -187,6 +304,63 @@ export function register(app) {
res.json({ ok: true })
})
+ app.post('/api/nuggets-upload/change-password', requireHostAuth, async (req, res) => {
+ const session = getHostSession(req)
+ const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : ''
+ const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : ''
+
+ if (!currentPassword || !newPassword) {
+ res.status(400).json({ message: 'Current and new password are required.' })
+ return
+ }
+ if (newPassword.length < 8) {
+ res.status(400).json({ message: 'New password must be at least 8 characters.' })
+ return
+ }
+
+ const data = await loadUploadHosts()
+ const idx = data.hosts.findIndex(h => h.id === session.hostId)
+ if (idx === -1) {
+ res.status(404).json({ message: 'Host account not found.' })
+ return
+ }
+
+ if (!passwordsMatch(currentPassword, data.hosts[idx].passwordHash)) {
+ res.status(401).json({ message: 'Current password is incorrect.' })
+ return
+ }
+
+ data.hosts[idx].passwordHash = hashPassword(newPassword)
+ await saveUploadHosts(data)
+ res.json({ ok: true })
+ })
+
+ app.post('/api/nuggets-upload/2fa/enable', requireHostAuth, async (req, res) => {
+ const session = getHostSession(req)
+ const data = await loadUploadHosts()
+ const idx = data.hosts.findIndex(h => h.id === session.hostId)
+ if (idx === -1) {
+ res.status(404).json({ message: 'Host account not found.' })
+ return
+ }
+ data.hosts[idx].twoFa = true
+ await saveUploadHosts(data)
+ res.json({ ok: true })
+ })
+
+ app.post('/api/nuggets-upload/2fa/disable', requireHostAuth, async (req, res) => {
+ const session = getHostSession(req)
+ const data = await loadUploadHosts()
+ const idx = data.hosts.findIndex(h => h.id === session.hostId)
+ if (idx === -1) {
+ res.status(404).json({ message: 'Host account not found.' })
+ return
+ }
+ data.hosts[idx].twoFa = false
+ await saveUploadHosts(data)
+ res.json({ ok: true })
+ })
+
// ── Episode upload (audio file + metadata) ───────────────────────────
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => {
@@ -248,6 +422,17 @@ export function register(app) {
// Mark audio file as seen so the Seafile watcher doesn't double-notify
await markSeafileFileSeen(originalName)
+ // Persist to history
+ await appendHistory({
+ hostId: session?.hostId ?? '',
+ hostName: session?.hostName ?? '',
+ episodeName,
+ passage,
+ description,
+ filename: originalName,
+ infoFile: infoFilename,
+ })
+
// Send admin notification (fire-and-forget)
sendNuggetsUploadNotification({
to: NUGGETS_NOTIFY_EMAIL,
@@ -260,7 +445,13 @@ export function register(app) {
infoFile: infoFilename,
}).catch(err => console.error('[nuggets-upload] notify error:', err))
- res.json({ ok: true, episodeName, filename: originalName, infoFile: infoFilename })
+ res.json({
+ ok: true,
+ episodeName,
+ filename: originalName,
+ infoFile: infoFilename,
+ seafileUrl: SEAFILE_NUGGETS_SHARE_URL,
+ })
} catch (err) {
await unlink(tempPath).catch(() => {})
console.error('[nuggets-upload] upload error:', err.message)
@@ -268,6 +459,22 @@ export function register(app) {
}
})
+ // ── Host: episode history ─────────────────────────────────────────────
+
+ app.get('/api/nuggets-upload/history', requireHostAuth, async (req, res) => {
+ const session = getHostSession(req)
+ const data = await loadHistory()
+ const entries = data.entries.filter(e => e.hostId === session.hostId)
+ res.json({ entries })
+ })
+
+ // ── Public: announcement ──────────────────────────────────────────────
+
+ app.get('/api/nuggets-upload/announcement', async (_req, res) => {
+ const data = await loadAnnouncement()
+ res.json(data)
+ })
+
// ── Admin: manage upload host accounts ───────────────────────────────
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
@@ -303,6 +510,7 @@ export function register(app) {
email,
passwordHash: hashPassword(password),
active: true,
+ twoFa: false,
createdAt: new Date().toISOString(),
}
data.hosts.push(host)
@@ -371,4 +579,28 @@ export function register(app) {
await saveUploadHosts(data)
res.json({ ok: true })
})
+
+ // ── Admin: announcement ───────────────────────────────────────────────
+
+ app.post('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (req, res) => {
+ const text = typeof req.body?.text === 'string' ? req.body.text : ''
+ if (text.length > 500) {
+ res.status(400).json({ message: 'Announcement must be 500 characters or fewer.' })
+ return
+ }
+ const data = await saveAnnouncement(text)
+ res.json({ ok: true, ...data })
+ })
+
+ app.delete('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (_req, res) => {
+ const data = await saveAnnouncement('')
+ res.json({ ok: true, ...data })
+ })
+
+ // ── Admin: upload history ─────────────────────────────────────────────
+
+ app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
+ const data = await loadHistory()
+ res.json({ entries: data.entries })
+ })
}
diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx
index a9704e5..1a95ff0 100644
--- a/src/AdminPage.tsx
+++ b/src/AdminPage.tsx
@@ -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:upload-portal'
+ | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:history'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
@@ -722,6 +722,8 @@ const ADMIN_NAV: NavGroup[] = [
{ 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: 'nuggets:announcement', label: 'Announcement' },
+ { kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
@@ -1279,6 +1281,156 @@ function NuggetsUploadPortalAdmin() {
)
}
+// ── Nuggets Announcement admin ─────────────────────────────────────────────
+function NuggetsAnnouncementAdmin() {
+ const [text, setText] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [msg, setMsg] = useState('')
+
+ useEffect(() => {
+ fetch('/api/admin/nuggets-upload/announcement')
+ .then(r => r.json())
+ .then(d => setText(d.text ?? ''))
+ .finally(() => setLoading(false))
+ }, [])
+
+ async function handleSave(e: React.FormEvent) {
+ e.preventDefault()
+ setSaving(true)
+ setMsg('')
+ try {
+ const r = await fetch('/api/admin/nuggets-upload/announcement', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text }),
+ })
+ const d = await r.json()
+ if (!r.ok) { setMsg(d.message ?? 'Failed.'); return }
+ setMsg(text ? '✓ Announcement saved.' : '✓ Announcement cleared.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ async function handleClear() {
+ setSaving(true)
+ setMsg('')
+ try {
+ await fetch('/api/admin/nuggets-upload/announcement', { method: 'DELETE' })
+ setText('')
+ setMsg('✓ Announcement cleared.')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
Nuggets — Portal Announcement
+
This text appears as a banner at the top of the host upload portal. Leave blank to hide.
+
+ {loading ? Loading…
: (
+
+ )}
+
+ )
+}
+
+// ── Nuggets Upload History admin ───────────────────────────────────────────
+interface HistoryEntryAdmin {
+ hostId: string
+ hostName: string
+ episodeName: string
+ passage: string
+ filename: string
+ uploadedAt: string
+}
+
+function NuggetsHistoryAdmin() {
+ const [entries, setEntries] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [filter, setFilter] = useState('')
+
+ useEffect(() => {
+ fetch('/api/admin/nuggets-upload/history')
+ .then(r => r.json())
+ .then(d => setEntries(d.entries ?? []))
+ .finally(() => setLoading(false))
+ }, [])
+
+ const visible = filter.trim()
+ ? entries.filter(e =>
+ e.hostName.toLowerCase().includes(filter.toLowerCase()) ||
+ e.episodeName.toLowerCase().includes(filter.toLowerCase()) ||
+ e.passage.toLowerCase().includes(filter.toLowerCase()),
+ )
+ : entries
+
+ return (
+
+
+
Nuggets — Upload History
+
All episodes submitted through the host portal, newest first.
+
+ {loading ? Loading…
: (
+ <>
+ {entries.length > 0 && (
+
+ setFilter(e.target.value)} />
+
+ )}
+ {visible.length === 0 ? (
+ {entries.length === 0 ? 'No uploads yet.' : 'No results match that filter.'}
+ ) : (
+
+
+
+ Date
+ Host
+ Episode
+ Passage
+ File
+
+
+
+ {visible.map((e, i) => (
+
+ {new Date(e.uploadedAt).toLocaleDateString()}
+ {e.hostName}
+ {e.episodeName}
+ {e.passage}
+ {e.filename}
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
+
export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState(normalizedContent)
@@ -4904,6 +5056,12 @@ async function handleAssetUpload(event: ChangeEvent) {
{/* NUGGETS UPLOAD PORTAL */}
{adminView === 'nuggets:upload-portal' && }
+ {/* NUGGETS ANNOUNCEMENT */}
+ {adminView === 'nuggets:announcement' && }
+
+ {/* NUGGETS UPLOAD HISTORY */}
+ {adminView === 'nuggets:history' && }
+
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {
diff --git a/src/components/NuggetsHostPortal.tsx b/src/components/NuggetsHostPortal.tsx
index 9497aac..29594b3 100644
--- a/src/components/NuggetsHostPortal.tsx
+++ b/src/components/NuggetsHostPortal.tsx
@@ -1,45 +1,128 @@
import { useState, useEffect, useRef } from 'react'
-interface SubmittedEpisode {
+const DRAFT_KEY = 'nuggets_ep_draft'
+
+interface HistoryEntry {
episodeName: string
+ passage: string
filename: string
- at: string
+ uploadedAt: string
+}
+
+type Screen = 'checking' | 'login' | 'otp' | 'portal' | 'settings'
+
+function formatBytes(b: number) {
+ if (b >= 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`
+ if (b >= 1024) return `${(b / 1024).toFixed(0)} KB`
+ return `${b} B`
+}
+
+function saveDraft(ep: string, passage: string, desc: string) {
+ try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ep, passage, desc })) } catch {}
+}
+
+function loadDraft() {
+ try {
+ const raw = localStorage.getItem(DRAFT_KEY)
+ if (!raw) return null
+ return JSON.parse(raw) as { ep: string; passage: string; desc: string }
+ } catch { return null }
+}
+
+function clearDraft() {
+ try { localStorage.removeItem(DRAFT_KEY) } catch {}
}
export function NuggetsHostPortal() {
- const [checking, setChecking] = useState(true)
- const [authenticated, setAuthenticated] = useState(false)
+ const [screen, setScreen] = useState('checking')
const [hostName, setHostName] = useState('')
const [seafileConfigured, setSeafileConfigured] = useState(true)
+ const [twoFaEnabled, setTwoFaEnabled] = useState(false)
+ const [announcement, setAnnouncement] = useState('')
+ // Login state
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loggingIn, setLoggingIn] = useState(false)
+ // OTP state
+ const [otpToken, setOtpToken] = useState('')
+ const [otpCode, setOtpCode] = useState('')
+ const [otpError, setOtpError] = useState('')
+ const [verifyingOtp, setVerifyingOtp] = useState(false)
+
+ // Upload form state
const [episodeName, setEpisodeName] = useState('')
const [passage, setPassage] = useState('')
const [description, setDescription] = useState('')
const [file, setFile] = useState(null)
+ const [fileWarning, setFileWarning] = useState('')
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState('')
- const [submitted, setSubmitted] = useState([])
+ const [lastSuccess, setLastSuccess] = useState<{ episodeName: string; filename: string; seafileUrl?: string } | null>(null)
+ const [guidelinesOpen, setGuidelinesOpen] = useState(false)
+
+ // History
+ const [history, setHistory] = useState([])
+ const [historyOpen, setHistoryOpen] = useState(false)
+
+ // Settings state
+ const [currentPw, setCurrentPw] = useState('')
+ const [newPw, setNewPw] = useState('')
+ const [confirmPw, setConfirmPw] = useState('')
+ const [pwError, setPwError] = useState('')
+ const [pwSuccess, setPwSuccess] = useState('')
+ const [savingPw, setSavingPw] = useState(false)
+ const [toggling2fa, setToggling2fa] = useState(false)
+ const [twoFaMsg, setTwoFaMsg] = useState('')
const fileInputRef = useRef(null)
+ // Load session on mount
useEffect(() => {
fetch('/api/nuggets-upload/me')
.then(r => r.json())
.then(d => {
- setAuthenticated(d.authenticated)
- setHostName(d.hostName ?? '')
- setSeafileConfigured(d.configured !== false)
+ if (d.authenticated) {
+ setHostName(d.hostName ?? '')
+ setSeafileConfigured(d.configured !== false)
+ setTwoFaEnabled(Boolean(d.twoFaEnabled))
+ setScreen('portal')
+ const draft = loadDraft()
+ if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
+ } else {
+ setScreen('login')
+ }
})
- .finally(() => setChecking(false))
+ .catch(() => setScreen('login'))
}, [])
+ // Load announcement
+ useEffect(() => {
+ fetch('/api/nuggets-upload/announcement')
+ .then(r => r.json())
+ .then(d => { if (d.text) setAnnouncement(d.text) })
+ .catch(() => {})
+ }, [])
+
+ // Load history when history panel is opened
+ useEffect(() => {
+ if (historyOpen && screen === 'portal') {
+ fetch('/api/nuggets-upload/history')
+ .then(r => r.json())
+ .then(d => setHistory(d.entries ?? []))
+ .catch(() => {})
+ }
+ }, [historyOpen, screen])
+
+ // Autosave draft
+ useEffect(() => {
+ if (screen === 'portal') saveDraft(episodeName, passage, description)
+ }, [episodeName, passage, description, screen])
+
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setLoginError('')
@@ -52,51 +135,89 @@ export function NuggetsHostPortal() {
})
const d = await r.json()
if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
+ if (d.requiresOtp) {
+ setOtpToken(d.otpToken)
+ setScreen('otp')
+ return
+ }
setHostName(d.hostName)
- setAuthenticated(true)
+ setTwoFaEnabled(false)
+ setScreen('portal')
+ const draft = loadDraft()
+ if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
} finally {
setLoggingIn(false)
}
}
+ async function handleVerifyOtp(e: React.FormEvent) {
+ e.preventDefault()
+ setOtpError('')
+ setVerifyingOtp(true)
+ try {
+ const r = await fetch('/api/nuggets-upload/verify-otp', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ otpToken, code: otpCode }),
+ })
+ const d = await r.json()
+ if (!r.ok) { setOtpError(d.message ?? 'Verification failed.'); return }
+ setHostName(d.hostName)
+ setTwoFaEnabled(true)
+ setScreen('portal')
+ const draft = loadDraft()
+ if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
+ } finally {
+ setVerifyingOtp(false)
+ }
+ }
+
async function handleLogout() {
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
- setAuthenticated(false)
+ clearDraft()
setHostName('')
setEmail('')
setPassword('')
- setSubmitted([])
- resetForm()
- }
-
- function resetForm() {
setEpisodeName('')
setPassage('')
setDescription('')
setFile(null)
- setUploadError('')
- setProgress(0)
+ setLastSuccess(null)
+ setHistory([])
+ setScreen('login')
+ }
+
+ function pickFile(picked: File | null) {
+ if (!picked) return
+ setFileWarning('')
+ if (picked.size > 500 * 1024 * 1024) {
+ setFileWarning('File exceeds 500 MB limit. Please compress or trim before uploading.')
+ return
+ }
+ if (picked.size < 500 * 1024) {
+ setFileWarning(`File is only ${formatBytes(picked.size)} — unusually small. Check the recording before submitting.`)
+ }
+ setFile(picked)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
- const dropped = e.dataTransfer.files[0]
- if (dropped) setFile(dropped)
+ pickFile(e.dataTransfer.files[0] ?? null)
}
function handleFileChange(e: React.ChangeEvent) {
- const picked = e.target.files?.[0]
- if (picked) setFile(picked)
+ pickFile(e.target.files?.[0] ?? null)
e.target.value = ''
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
- if (!file) { setUploadError('Please select an audio file to upload.'); return }
+ if (!file) { setUploadError('Please select an audio file.'); return }
if (uploading) return
setUploadError('')
+ setLastSuccess(null)
setUploading(true)
setProgress(0)
@@ -109,42 +230,82 @@ export function NuggetsHostPortal() {
await new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/nuggets-upload/upload')
-
xhr.upload.addEventListener('progress', ev => {
if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
})
-
xhr.addEventListener('load', () => {
setUploading(false)
if (xhr.status === 200) {
try {
const d = JSON.parse(xhr.responseText)
- setSubmitted(prev => [{ episodeName: d.episodeName, filename: d.filename, at: new Date().toLocaleTimeString() }, ...prev])
- resetForm()
+ setLastSuccess({ episodeName: d.episodeName, filename: d.filename, seafileUrl: d.seafileUrl })
+ clearDraft()
+ setEpisodeName('')
+ setPassage('')
+ setDescription('')
+ setFile(null)
+ setFileWarning('')
+ setProgress(0)
} catch {
- setUploadError('Upload succeeded but response was unexpected.')
+ setUploadError('Upload succeeded but the response was unexpected.')
}
} else {
- try {
- setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.')
- } catch {
- setUploadError('Upload failed.')
- }
+ try { setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.') }
+ catch { setUploadError('Upload failed.') }
}
resolve()
})
-
xhr.addEventListener('error', () => {
setUploading(false)
setUploadError('Network error during upload.')
resolve()
})
-
xhr.send(formData)
})
}
- if (checking) {
+ async function handleChangePassword(e: React.FormEvent) {
+ e.preventDefault()
+ setPwError('')
+ setPwSuccess('')
+ if (newPw !== confirmPw) { setPwError('New passwords do not match.'); return }
+ if (newPw.length < 8) { setPwError('Password must be at least 8 characters.'); return }
+ setSavingPw(true)
+ try {
+ const r = await fetch('/api/nuggets-upload/change-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ currentPassword: currentPw, newPassword: newPw }),
+ })
+ const d = await r.json()
+ if (!r.ok) { setPwError(d.message ?? 'Failed to change password.'); return }
+ setPwSuccess('Password changed successfully.')
+ setCurrentPw('')
+ setNewPw('')
+ setConfirmPw('')
+ } finally {
+ setSavingPw(false)
+ }
+ }
+
+ async function handle2faToggle() {
+ setToggling2fa(true)
+ setTwoFaMsg('')
+ try {
+ const url = twoFaEnabled ? '/api/nuggets-upload/2fa/disable' : '/api/nuggets-upload/2fa/enable'
+ const r = await fetch(url, { method: 'POST' })
+ const d = await r.json()
+ if (!r.ok) { setTwoFaMsg(d.message ?? 'Failed.'); return }
+ setTwoFaEnabled(!twoFaEnabled)
+ setTwoFaMsg(twoFaEnabled ? '2FA disabled.' : '2FA enabled. A code will be sent to your email each time you sign in.')
+ } finally {
+ setToggling2fa(false)
+ }
+ }
+
+ // ── Screens ────────────────────────────────────────────────────────────────
+
+ if (screen === 'checking') {
return (
@@ -154,7 +315,7 @@ export function NuggetsHostPortal() {
)
}
- if (!authenticated) {
+ if (screen === 'login') {
return (
@@ -185,6 +346,117 @@ export function NuggetsHostPortal() {
)
}
+ if (screen === 'otp') {
+ return (
+
+
+
+ 🎙️
+ Nuggets
+
+
Check Your Email
+
A 6-digit code was sent to your email address. Enter it below to continue.
+
+
+ Sign-In Code
+ setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
+ disabled={verifyingOtp}
+ required
+ />
+
+ {otpError && {otpError}
}
+
+ {verifyingOtp ? 'Verifying…' : 'Verify Code'}
+
+ { setScreen('login'); setOtpCode(''); setOtpError('') }}>
+ ← Back to sign in
+
+
+
+
+ )
+ }
+
+ if (screen === 'settings') {
+ return (
+
+
+ {/* Settings header */}
+
+
+ { setPwError(''); setPwSuccess(''); setTwoFaMsg(''); setScreen('portal') }} title="Back">
+ ←
+
+ Account Settings
+
+
{hostName}
+
+
+ {/* Change Password */}
+
+
+ {/* 2FA */}
+
+
Two-Factor Authentication
+
+ {twoFaEnabled
+ ? 'Email 2FA is currently enabled. You receive a code by email each time you sign in.'
+ : 'Email 2FA is off. Enable it to receive a one-time code by email each time you sign in.'}
+
+ {twoFaMsg &&
{twoFaMsg}
}
+
+ {toggling2fa ? '…' : twoFaEnabled ? 'Disable 2FA' : 'Enable 2FA'}
+
+
+
+ {/* Sign out */}
+
+ Sign out
+
+
+
+ )
+ }
+
+ // ── Portal (main upload screen) ────────────────────────────────────────────
return (
@@ -196,62 +468,87 @@ export function NuggetsHostPortal() {
{hostName}
+ setScreen('settings')}>⚙
Sign out
+ {/* Announcement banner */}
+ {announcement && (
+
+ 📢 {announcement}
+
+ )}
+
{!seafileConfigured && (
Seafile is not configured yet — contact the admin before uploading.
)}
+ {/* Success banner */}
+ {lastSuccess && (
+
+
+ ✓ Episode submitted successfully
+
+
+ {lastSuccess.episodeName} · {lastSuccess.filename}
+
+ {lastSuccess.seafileUrl && (
+
+ View in Seafile →
+
+ )}
+
+ )}
+
+ {/* Recording guidelines (collapsible) */}
+
+
setGuidelinesOpen(o => !o)}
+ >
+ 📋 Recording Guidelines
+ {guidelinesOpen ? '▲' : '▼'}
+
+ {guidelinesOpen && (
+
+
+ Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.
+ File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).
+ Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.
+ Normalize to -1 dBFS before exporting.
+ Match episode name here to the file name exactly so search stays clean.
+
+
+ )}
+
+
+ {/* Upload form */}
- {/* Episode Name */}
Episode Name *
- setEpisodeName(e.target.value)}
- disabled={uploading}
- required
- />
+ value={episodeName} onChange={e => setEpisodeName(e.target.value)} disabled={uploading} required />
- {/* Passage */}
Passage *
- setPassage(e.target.value)}
- disabled={uploading}
- required
- />
+ value={passage} onChange={e => setPassage(e.target.value)} disabled={uploading} required />
- {/* Description */}
Description
- setDescription(e.target.value)}
- disabled={uploading}
- rows={3}
- />
+ value={description} onChange={e => setDescription(e.target.value)} disabled={uploading} rows={3} />
- {/* Audio File */}
Audio File *
!uploading && fileInputRef.current?.click()}
>
-
+
{file ? (
🎵
{file.name}
-
{(file.size / (1024 * 1024)).toFixed(1)} MB — click to change
+
{formatBytes(file.size)} — click to change
) : (
<>
{dragging ? '📂' : '⬆️'}
{dragging ? 'Drop to select' : 'Drop audio file here, or click to browse'}
-
MP3, WAV, M4A, FLAC, etc.
+
MP3, WAV, M4A, FLAC · max 500 MB
>
)}
+ {fileWarning &&
{fileWarning}
}
- {/* Progress */}
{uploading && (
@@ -308,21 +600,31 @@ export function NuggetsHostPortal() {
- {/* Submitted this session */}
- {submitted.length > 0 && (
-
-
Submitted this session
- {submitted.map((ep, i) => (
-
-
✓
-
-
{ep.episodeName}
-
{ep.filename} · {ep.at}
-
-
- ))}
-
- )}
+ {/* Episode History (collapsible) */}
+
+
setHistoryOpen(o => !o)}>
+ 🕐 Your Upload History
+ {historyOpen ? '▲' : '▼'}
+
+ {historyOpen && (
+
+ {history.length === 0 ? (
+
No uploads yet.
+ ) : (
+ history.map((entry, i) => (
+
+
+ {entry.episodeName}
+
+
+ {entry.passage} · {entry.filename} · {new Date(entry.uploadedAt).toLocaleDateString()}
+
+
+ ))
+ )}
+
+ )}
+
)
@@ -360,7 +662,7 @@ const s: Record = {
gap: 12,
flexWrap: 'wrap',
},
- headerRight: { display: 'flex', alignItems: 'center', gap: 10, paddingTop: 2 },
+ headerRight: { display: 'flex', alignItems: 'center', gap: 8, paddingTop: 2 },
welcomeText: { fontSize: '0.85rem', color: 'var(--brand-muted, #b0a48c)', fontWeight: 500 },
btnGhost: {
background: 'none',
@@ -371,14 +673,68 @@ const s: Record = {
cursor: 'pointer',
color: 'var(--brand-muted, #b0a48c)',
},
+ btnIcon: {
+ background: 'none',
+ border: '1px solid var(--brand-border, #2a2518)',
+ borderRadius: 6,
+ padding: '0.3rem 0.55rem',
+ fontSize: '0.95rem',
+ cursor: 'pointer',
+ color: 'var(--brand-muted, #b0a48c)',
+ lineHeight: 1,
+ },
+ btnPrimary: {
+ background: 'var(--brand-gold, #c9a84c)',
+ color: '#0a0a08',
+ border: 'none',
+ borderRadius: 8,
+ padding: '0.7rem 1rem',
+ fontWeight: 700,
+ fontSize: '0.95rem',
+ cursor: 'pointer',
+ width: '100%',
+ },
+ btnDanger: {
+ background: '#8b2020',
+ color: '#f0ead8',
+ border: 'none',
+ borderRadius: 8,
+ padding: '0.7rem 1rem',
+ fontWeight: 700,
+ fontSize: '0.95rem',
+ cursor: 'pointer',
+ width: '100%',
+ },
+ announcement: {
+ background: 'rgba(201,168,76,0.1)',
+ border: '1px solid var(--brand-gold, #c9a84c)',
+ borderRadius: 8,
+ padding: '0.65rem 0.9rem',
+ fontSize: '0.87rem',
+ marginBottom: 16,
+ color: 'var(--brand-warm-white, #f0ead8)',
+ },
warning: {
background: 'rgba(200,169,110,0.1)',
- border: '1px solid var(--brand-gold-dark, #8a6e28)',
+ border: '1px solid #8a6e28',
borderRadius: 8,
padding: '0.6rem 0.85rem',
fontSize: '0.84rem',
marginBottom: 16,
- color: 'var(--brand-gold-light, #e0c070)',
+ color: '#e0c070',
+ },
+ successBanner: {
+ background: 'rgba(50,120,50,0.15)',
+ border: '1px solid #3a6a3a',
+ borderRadius: 10,
+ padding: '0.85rem 1rem',
+ marginBottom: 16,
+ },
+ seafileLink: {
+ color: 'var(--brand-gold, #c9a84c)',
+ fontSize: '0.84rem',
+ textDecoration: 'none',
+ fontWeight: 600,
},
form: { display: 'flex', flexDirection: 'column', gap: 14 },
field: { display: 'flex', flexDirection: 'column', gap: 5 },
@@ -393,17 +749,17 @@ const s: Record = {
color: 'var(--brand-warm-white, #f0ead8)',
outline: 'none',
width: '100%',
- boxSizing: 'border-box',
+ boxSizing: 'border-box' as const,
fontFamily: 'inherit',
},
dropZone: {
border: '2px dashed var(--brand-border, #2a2518)',
borderRadius: 10,
padding: '1.4rem 1rem',
- textAlign: 'center',
+ textAlign: 'center' as const,
cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s',
- userSelect: 'none',
+ userSelect: 'none' as const,
color: 'var(--brand-warm-white, #f0ead8)',
},
dropZoneActive: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.06)' },
@@ -412,42 +768,56 @@ const s: Record = {
dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
dropSub: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.8rem', margin: 0 },
- fileInfo: { display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left' },
+ fileInfo: { display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left' as const },
fileIcon: { fontSize: '1.8rem', flexShrink: 0 },
- fileName: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', wordBreak: 'break-all', color: 'var(--brand-warm-white, #f0ead8)' },
+ fileName: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', wordBreak: 'break-all' as const, color: 'var(--brand-warm-white, #f0ead8)' },
fileSize: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.78rem', margin: 0 },
- progressWrap: { display: 'flex', flexDirection: 'column', gap: 5 },
+ progressWrap: { display: 'flex', flexDirection: 'column' as const, gap: 5 },
progressBar: { height: 7, background: 'var(--brand-black-3, #1a1a15)', borderRadius: 4, overflow: 'hidden' },
progressFill: { height: '100%', background: 'var(--brand-gold, #c9a84c)', borderRadius: 4, transition: 'width 0.2s' },
- progressLabel: { fontSize: '0.82rem', color: 'var(--brand-muted, #b0a48c)', margin: 0, textAlign: 'center' },
+ progressLabel: { fontSize: '0.82rem', color: 'var(--brand-muted, #b0a48c)', margin: 0, textAlign: 'center' as const },
errorMsg: { color: '#e07070', fontSize: '0.84rem', margin: '2px 0' },
- btnPrimary: {
- background: 'var(--brand-gold, #c9a84c)',
- color: '#0a0a08',
- border: 'none',
- borderRadius: 8,
- padding: '0.7rem 1rem',
- fontWeight: 700,
- fontSize: '0.95rem',
- cursor: 'pointer',
- width: '100%',
- },
- submittedList: {
- marginTop: 20,
+ warnMsg: { color: '#e0c070', fontSize: '0.82rem', margin: '2px 0' },
+ successMsg: { color: '#6db86d', fontSize: '0.84rem', margin: '2px 0' },
+ collapsible: {
border: '1px solid var(--brand-border, #2a2518)',
borderRadius: 10,
+ marginBottom: 4,
+ overflow: 'hidden',
+ },
+ collapsibleToggle: {
+ width: '100%',
+ background: 'none',
+ border: 'none',
padding: '0.75rem 1rem',
- },
- submittedTitle: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ cursor: 'pointer',
+ color: 'var(--brand-warm-white, #f0ead8)',
+ fontSize: '0.87rem',
fontWeight: 600,
- fontSize: '0.78rem',
+ fontFamily: 'inherit',
+ textAlign: 'left' as const,
+ },
+ collapsibleBody: {
+ padding: '0.75rem 1rem',
+ fontSize: '0.84rem',
color: 'var(--brand-muted, #b0a48c)',
- margin: '0 0 8px',
- textTransform: 'uppercase',
- letterSpacing: '0.05em',
+ borderTop: '1px solid var(--brand-border, #2a2518)',
+ },
+ code: {
+ fontFamily: 'monospace',
+ background: 'rgba(255,255,255,0.06)',
+ padding: '0 3px',
+ borderRadius: 3,
+ fontSize: '0.82rem',
+ },
+ settingsSection: {},
+ sectionTitle: {
+ fontSize: '0.95rem',
+ fontWeight: 700,
+ margin: '0 0 12px',
+ color: 'var(--brand-warm-white, #f0ead8)',
},
- submittedRow: { display: 'flex', alignItems: 'flex-start', gap: 10, padding: '5px 0' },
- checkMark: { color: 'var(--brand-gold, #c9a84c)', fontWeight: 700, flexShrink: 0 },
- submittedEp: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', color: 'var(--brand-warm-white, #f0ead8)' },
- submittedMeta: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.78rem', margin: 0 },
}