Add full portal expansion: 2FA, change password, history, announcements, recording guidelines, Seafile link, draft saving, file validation; v2.0.17
Publish Container / docker (push) Successful in 14m36s
Publish Container / docker (push) Successful in 14m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
afff859d17
commit
ef1d9fd8fc
6 files changed
+925
-127
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.16",
|
"version": "2.0.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 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_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_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)
|
// 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'
|
export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us'
|
||||||
|
|||||||
@@ -805,3 +805,38 @@ export async function sendNuggetsHostWelcomeEmail({ to, name, username, password
|
|||||||
console.error('[nuggets-welcome] send exception:', err)
|
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 = `
|
||||||
|
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
|
||||||
|
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
|
||||||
|
Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.
|
||||||
|
</p>
|
||||||
|
<div style="margin:0 0 24px;padding:20px 24px;background:#1a1a15;border:1px solid #2a2518;border-radius:4px;text-align:center;">
|
||||||
|
<span style="font-family:monospace;font-size:36px;font-weight:700;letter-spacing:8px;color:#c9a84c;">${escapeHtml(code)}</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you did not request this code, you can safely ignore this email.</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
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: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because a sign-in was attempted on the Nuggets podcast host portal.</p>',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (error) console.error('[nuggets-otp] send error:', error)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nuggets-otp] send exception:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,16 +6,23 @@ import rateLimit from 'express-rate-limit'
|
|||||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||||
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
import { parseCookies, cookieSecureFlag } from '../helpers.js'
|
||||||
import { requireAdminAuth } from '../auth.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 { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
|
||||||
import {
|
import {
|
||||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||||
|
NUGGETS_UPLOAD_HISTORY_FILE,
|
||||||
|
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
|
||||||
NUGGETS_NOTIFY_EMAIL,
|
NUGGETS_NOTIFY_EMAIL,
|
||||||
SEAFILE_URL,
|
SEAFILE_URL,
|
||||||
SEAFILE_TOKEN,
|
SEAFILE_TOKEN,
|
||||||
SEAFILE_NUGGETS_REPO_ID,
|
SEAFILE_NUGGETS_REPO_ID,
|
||||||
SEAFILE_EPISODE_INFO_DIR,
|
SEAFILE_EPISODE_INFO_DIR,
|
||||||
SEAFILE_EPISODE_UPLOAD_DIR,
|
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||||
|
SEAFILE_NUGGETS_SHARE_URL,
|
||||||
HOST_SESSION_COOKIE,
|
HOST_SESSION_COOKIE,
|
||||||
HOST_SESSION_TTL_MS,
|
HOST_SESSION_TTL_MS,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
@@ -64,6 +71,15 @@ function requireHostAuth(req, res, next) {
|
|||||||
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 ─────────────────────────────────────────────────
|
// ── Host credentials data ─────────────────────────────────────────────────
|
||||||
function hashPassword(pw) {
|
function hashPassword(pw) {
|
||||||
return createHash('sha256').update(String(pw)).digest('hex')
|
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')
|
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) ──────────────────────────────────────
|
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
||||||
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
||||||
|
|
||||||
@@ -135,7 +185,7 @@ async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
|
|||||||
await res.text()
|
await res.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rate limiter ──────────────────────────────────────────────────────────
|
// ── Rate limiters ─────────────────────────────────────────────────────────
|
||||||
const loginRateLimiter = rateLimit({
|
const loginRateLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 10,
|
||||||
@@ -145,17 +195,32 @@ const loginRateLimiter = rateLimit({
|
|||||||
skipSuccessfulRequests: true,
|
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 ────────────────────────────────────────────────────
|
// ── Route registration ────────────────────────────────────────────────────
|
||||||
export function register(app) {
|
export function register(app) {
|
||||||
// ── Host auth ─────────────────────────────────────────────────────────
|
// ── Host auth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/nuggets-upload/me', (req, res) => {
|
app.get('/api/nuggets-upload/me', async (req, res) => {
|
||||||
const session = getHostSession(req)
|
const session = getHostSession(req)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
res.json({ authenticated: false })
|
res.json({ authenticated: false })
|
||||||
return
|
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) => {
|
app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => {
|
||||||
@@ -174,11 +239,63 @@ export function register(app) {
|
|||||||
return
|
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)
|
const token = createHostSession(host.id, host.name)
|
||||||
setHostSessionCookie(res, token)
|
setHostSessionCookie(res, token)
|
||||||
res.json({ ok: true, hostName: host.name })
|
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) => {
|
app.post('/api/nuggets-upload/logout', (req, res) => {
|
||||||
const cookies = parseCookies(req.headers.cookie)
|
const cookies = parseCookies(req.headers.cookie)
|
||||||
const token = cookies[HOST_SESSION_COOKIE]
|
const token = cookies[HOST_SESSION_COOKIE]
|
||||||
@@ -187,6 +304,63 @@ export function register(app) {
|
|||||||
res.json({ ok: true })
|
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) ───────────────────────────
|
// ── Episode upload (audio file + metadata) ───────────────────────────
|
||||||
|
|
||||||
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => {
|
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
|
// Mark audio file as seen so the Seafile watcher doesn't double-notify
|
||||||
await markSeafileFileSeen(originalName)
|
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)
|
// Send admin notification (fire-and-forget)
|
||||||
sendNuggetsUploadNotification({
|
sendNuggetsUploadNotification({
|
||||||
to: NUGGETS_NOTIFY_EMAIL,
|
to: NUGGETS_NOTIFY_EMAIL,
|
||||||
@@ -260,7 +445,13 @@ export function register(app) {
|
|||||||
infoFile: infoFilename,
|
infoFile: infoFilename,
|
||||||
}).catch(err => console.error('[nuggets-upload] notify error:', err))
|
}).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) {
|
} catch (err) {
|
||||||
await unlink(tempPath).catch(() => {})
|
await unlink(tempPath).catch(() => {})
|
||||||
console.error('[nuggets-upload] upload error:', err.message)
|
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 ───────────────────────────────
|
// ── Admin: manage upload host accounts ───────────────────────────────
|
||||||
|
|
||||||
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
||||||
@@ -303,6 +510,7 @@ export function register(app) {
|
|||||||
email,
|
email,
|
||||||
passwordHash: hashPassword(password),
|
passwordHash: hashPassword(password),
|
||||||
active: true,
|
active: true,
|
||||||
|
twoFa: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
data.hosts.push(host)
|
data.hosts.push(host)
|
||||||
@@ -371,4 +579,28 @@ export function register(app) {
|
|||||||
await saveUploadHosts(data)
|
await saveUploadHosts(data)
|
||||||
res.json({ ok: true })
|
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 })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
+159
-1
@@ -666,7 +666,7 @@ type AdminView =
|
|||||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||||
| 'contact:profile' | 'contact:copy'
|
| 'contact:profile' | 'contact:copy'
|
||||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
| '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'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||||
@@ -722,6 +722,8 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
|
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
|
||||||
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
|
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
|
||||||
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
|
{ 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: 'colossians-study', label: 'Studies' },
|
||||||
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
|
{ 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 (
|
||||||
|
<section className="admin-panel-section" aria-label="Nuggets announcement">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Nuggets — Portal Announcement</h2>
|
||||||
|
<p>This text appears as a banner at the top of the host upload portal. Leave blank to hide.</p>
|
||||||
|
</div>
|
||||||
|
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="admin-field" style={{ maxWidth: 560 }}>
|
||||||
|
<label htmlFor="ann-text">Announcement (max 500 chars)</label>
|
||||||
|
<textarea
|
||||||
|
id="ann-text"
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="e.g. Please use the updated naming convention for episode files this week."
|
||||||
|
value={text}
|
||||||
|
onChange={e => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: '#999' }}>{text.length}/500</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||||
|
<button className="btn-admin-save" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
{text && <button className="btn-admin-ghost" type="button" onClick={handleClear} disabled={saving}>Clear</button>}
|
||||||
|
</div>
|
||||||
|
{msg && <p style={{ color: msg.startsWith('✓') ? '#27ae60' : '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{msg}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nuggets Upload History admin ───────────────────────────────────────────
|
||||||
|
interface HistoryEntryAdmin {
|
||||||
|
hostId: string
|
||||||
|
hostName: string
|
||||||
|
episodeName: string
|
||||||
|
passage: string
|
||||||
|
filename: string
|
||||||
|
uploadedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function NuggetsHistoryAdmin() {
|
||||||
|
const [entries, setEntries] = useState<HistoryEntryAdmin[]>([])
|
||||||
|
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 (
|
||||||
|
<section className="admin-panel-section" aria-label="Nuggets upload history">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Nuggets — Upload History</h2>
|
||||||
|
<p>All episodes submitted through the host portal, newest first.</p>
|
||||||
|
</div>
|
||||||
|
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||||
|
<>
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<div className="admin-field" style={{ maxWidth: 320, marginBottom: 12 }}>
|
||||||
|
<input type="text" placeholder="Filter by host, episode, or passage…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<p className="admin-stats-note">{entries.length === 0 ? 'No uploads yet.' : 'No results match that filter.'}</p>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Date</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Host</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Episode</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Passage</th>
|
||||||
|
<th style={{ textAlign: 'left', padding: '6px 8px' }}>File</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visible.map((e, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<td style={{ padding: '6px 8px', whiteSpace: 'nowrap' }}>{new Date(e.uploadedAt).toLocaleDateString()}</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>{e.hostName}</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>{e.episodeName}</td>
|
||||||
|
<td style={{ padding: '6px 8px' }}>{e.passage}</td>
|
||||||
|
<td style={{ padding: '6px 8px', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>{e.filename}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||||
@@ -4904,6 +5056,12 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
{/* NUGGETS UPLOAD PORTAL */}
|
{/* NUGGETS UPLOAD PORTAL */}
|
||||||
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
|
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
|
||||||
|
|
||||||
|
{/* NUGGETS ANNOUNCEMENT */}
|
||||||
|
{adminView === 'nuggets:announcement' && <NuggetsAnnouncementAdmin />}
|
||||||
|
|
||||||
|
{/* NUGGETS UPLOAD HISTORY */}
|
||||||
|
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
||||||
|
|
||||||
{/* STUDY COMMENTS */}
|
{/* STUDY COMMENTS */}
|
||||||
{adminView === 'study-comments' && (() => {
|
{adminView === 'study-comments' && (() => {
|
||||||
const filtered = studyComments.filter(c => {
|
const filtered = studyComments.filter(c => {
|
||||||
|
|||||||
@@ -1,45 +1,128 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
interface SubmittedEpisode {
|
const DRAFT_KEY = 'nuggets_ep_draft'
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
episodeName: string
|
episodeName: string
|
||||||
|
passage: string
|
||||||
filename: 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() {
|
export function NuggetsHostPortal() {
|
||||||
const [checking, setChecking] = useState(true)
|
const [screen, setScreen] = useState<Screen>('checking')
|
||||||
const [authenticated, setAuthenticated] = useState(false)
|
|
||||||
const [hostName, setHostName] = useState('')
|
const [hostName, setHostName] = useState('')
|
||||||
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
||||||
|
const [twoFaEnabled, setTwoFaEnabled] = useState(false)
|
||||||
|
const [announcement, setAnnouncement] = useState('')
|
||||||
|
|
||||||
|
// Login state
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [loginError, setLoginError] = useState('')
|
const [loginError, setLoginError] = useState('')
|
||||||
const [loggingIn, setLoggingIn] = useState(false)
|
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 [episodeName, setEpisodeName] = useState('')
|
||||||
const [passage, setPassage] = useState('')
|
const [passage, setPassage] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | null>(null)
|
||||||
|
const [fileWarning, setFileWarning] = useState('')
|
||||||
const [dragging, setDragging] = useState(false)
|
const [dragging, setDragging] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
const [uploadError, setUploadError] = useState('')
|
const [uploadError, setUploadError] = useState('')
|
||||||
const [submitted, setSubmitted] = useState<SubmittedEpisode[]>([])
|
const [lastSuccess, setLastSuccess] = useState<{ episodeName: string; filename: string; seafileUrl?: string } | null>(null)
|
||||||
|
const [guidelinesOpen, setGuidelinesOpen] = useState(false)
|
||||||
|
|
||||||
|
// History
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>([])
|
||||||
|
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<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Load session on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/nuggets-upload/me')
|
fetch('/api/nuggets-upload/me')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setAuthenticated(d.authenticated)
|
if (d.authenticated) {
|
||||||
setHostName(d.hostName ?? '')
|
setHostName(d.hostName ?? '')
|
||||||
setSeafileConfigured(d.configured !== false)
|
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) {
|
async function handleLogin(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoginError('')
|
setLoginError('')
|
||||||
@@ -52,51 +135,89 @@ export function NuggetsHostPortal() {
|
|||||||
})
|
})
|
||||||
const d = await r.json()
|
const d = await r.json()
|
||||||
if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
|
if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
|
||||||
|
if (d.requiresOtp) {
|
||||||
|
setOtpToken(d.otpToken)
|
||||||
|
setScreen('otp')
|
||||||
|
return
|
||||||
|
}
|
||||||
setHostName(d.hostName)
|
setHostName(d.hostName)
|
||||||
setAuthenticated(true)
|
setTwoFaEnabled(false)
|
||||||
|
setScreen('portal')
|
||||||
|
const draft = loadDraft()
|
||||||
|
if (draft) { setEpisodeName(draft.ep); setPassage(draft.passage); setDescription(draft.desc) }
|
||||||
} finally {
|
} finally {
|
||||||
setLoggingIn(false)
|
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() {
|
async function handleLogout() {
|
||||||
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
|
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
|
||||||
setAuthenticated(false)
|
clearDraft()
|
||||||
setHostName('')
|
setHostName('')
|
||||||
setEmail('')
|
setEmail('')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setSubmitted([])
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
setEpisodeName('')
|
setEpisodeName('')
|
||||||
setPassage('')
|
setPassage('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setFile(null)
|
setFile(null)
|
||||||
setUploadError('')
|
setLastSuccess(null)
|
||||||
setProgress(0)
|
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) {
|
function handleDrop(e: React.DragEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragging(false)
|
setDragging(false)
|
||||||
const dropped = e.dataTransfer.files[0]
|
pickFile(e.dataTransfer.files[0] ?? null)
|
||||||
if (dropped) setFile(dropped)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const picked = e.target.files?.[0]
|
pickFile(e.target.files?.[0] ?? null)
|
||||||
if (picked) setFile(picked)
|
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
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
|
if (uploading) return
|
||||||
|
|
||||||
setUploadError('')
|
setUploadError('')
|
||||||
|
setLastSuccess(null)
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
|
|
||||||
@@ -109,42 +230,82 @@ export function NuggetsHostPortal() {
|
|||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
xhr.open('POST', '/api/nuggets-upload/upload')
|
xhr.open('POST', '/api/nuggets-upload/upload')
|
||||||
|
|
||||||
xhr.upload.addEventListener('progress', ev => {
|
xhr.upload.addEventListener('progress', ev => {
|
||||||
if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
|
if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
|
||||||
})
|
})
|
||||||
|
|
||||||
xhr.addEventListener('load', () => {
|
xhr.addEventListener('load', () => {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
try {
|
try {
|
||||||
const d = JSON.parse(xhr.responseText)
|
const d = JSON.parse(xhr.responseText)
|
||||||
setSubmitted(prev => [{ episodeName: d.episodeName, filename: d.filename, at: new Date().toLocaleTimeString() }, ...prev])
|
setLastSuccess({ episodeName: d.episodeName, filename: d.filename, seafileUrl: d.seafileUrl })
|
||||||
resetForm()
|
clearDraft()
|
||||||
|
setEpisodeName('')
|
||||||
|
setPassage('')
|
||||||
|
setDescription('')
|
||||||
|
setFile(null)
|
||||||
|
setFileWarning('')
|
||||||
|
setProgress(0)
|
||||||
} catch {
|
} catch {
|
||||||
setUploadError('Upload succeeded but response was unexpected.')
|
setUploadError('Upload succeeded but the response was unexpected.')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try { setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.') }
|
||||||
setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.')
|
catch { setUploadError('Upload failed.') }
|
||||||
} catch {
|
|
||||||
setUploadError('Upload failed.')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
setUploadError('Network error during upload.')
|
setUploadError('Network error during upload.')
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
xhr.send(formData)
|
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 (
|
return (
|
||||||
<div style={s.page}>
|
<div style={s.page}>
|
||||||
<div style={s.card}>
|
<div style={s.card}>
|
||||||
@@ -154,7 +315,7 @@ export function NuggetsHostPortal() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated) {
|
if (screen === 'login') {
|
||||||
return (
|
return (
|
||||||
<div style={s.page}>
|
<div style={s.page}>
|
||||||
<div style={s.card}>
|
<div style={s.card}>
|
||||||
@@ -185,6 +346,117 @@ export function NuggetsHostPortal() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (screen === 'otp') {
|
||||||
|
return (
|
||||||
|
<div style={s.page}>
|
||||||
|
<div style={s.card}>
|
||||||
|
<div style={s.logoRow}>
|
||||||
|
<span style={s.logoEmoji}>🎙️</span>
|
||||||
|
<span style={s.logoTitle}>Nuggets</span>
|
||||||
|
</div>
|
||||||
|
<h1 style={s.heading}>Check Your Email</h1>
|
||||||
|
<p style={s.subText}>A 6-digit code was sent to your email address. Enter it below to continue.</p>
|
||||||
|
<form onSubmit={handleVerifyOtp} style={s.form}>
|
||||||
|
<div style={s.field}>
|
||||||
|
<label style={s.label} htmlFor="otp-code">Sign-In Code</label>
|
||||||
|
<input
|
||||||
|
id="otp-code"
|
||||||
|
style={{ ...s.input, textAlign: 'center', letterSpacing: '0.3em', fontSize: '1.3rem', fontWeight: 700 }}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="000000"
|
||||||
|
value={otpCode}
|
||||||
|
onChange={e => setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
disabled={verifyingOtp}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{otpError && <p style={s.errorMsg}>{otpError}</p>}
|
||||||
|
<button style={s.btnPrimary} type="submit" disabled={verifyingOtp || otpCode.length !== 6}>
|
||||||
|
{verifyingOtp ? 'Verifying…' : 'Verify Code'}
|
||||||
|
</button>
|
||||||
|
<button style={s.btnGhost} type="button" onClick={() => { setScreen('login'); setOtpCode(''); setOtpError('') }}>
|
||||||
|
← Back to sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screen === 'settings') {
|
||||||
|
return (
|
||||||
|
<div style={s.page}>
|
||||||
|
<div style={{ ...s.card, maxWidth: 460 }}>
|
||||||
|
{/* Settings header */}
|
||||||
|
<div style={s.header}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<button style={s.btnIcon} onClick={() => { setPwError(''); setPwSuccess(''); setTwoFaMsg(''); setScreen('portal') }} title="Back">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--brand-warm-white, #f0ead8)' }}>Account Settings</span>
|
||||||
|
</div>
|
||||||
|
<span style={s.welcomeText}>{hostName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Password */}
|
||||||
|
<div style={s.settingsSection}>
|
||||||
|
<h2 style={s.sectionTitle}>Change Password</h2>
|
||||||
|
<form onSubmit={handleChangePassword} style={s.form}>
|
||||||
|
<div style={s.field}>
|
||||||
|
<label style={s.label} htmlFor="cur-pw">Current Password</label>
|
||||||
|
<input id="cur-pw" style={s.input} type="password" autoComplete="current-password"
|
||||||
|
value={currentPw} onChange={e => setCurrentPw(e.target.value)} disabled={savingPw} required />
|
||||||
|
</div>
|
||||||
|
<div style={s.field}>
|
||||||
|
<label style={s.label} htmlFor="new-pw">New Password</label>
|
||||||
|
<input id="new-pw" style={s.input} type="password" autoComplete="new-password"
|
||||||
|
value={newPw} onChange={e => setNewPw(e.target.value)} disabled={savingPw} required />
|
||||||
|
</div>
|
||||||
|
<div style={s.field}>
|
||||||
|
<label style={s.label} htmlFor="conf-pw">Confirm New Password</label>
|
||||||
|
<input id="conf-pw" style={s.input} type="password" autoComplete="new-password"
|
||||||
|
value={confirmPw} onChange={e => setConfirmPw(e.target.value)} disabled={savingPw} required />
|
||||||
|
</div>
|
||||||
|
{pwError && <p style={s.errorMsg}>{pwError}</p>}
|
||||||
|
{pwSuccess && <p style={s.successMsg}>{pwSuccess}</p>}
|
||||||
|
<button style={s.btnPrimary} type="submit" disabled={savingPw}>
|
||||||
|
{savingPw ? 'Saving…' : 'Update Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2FA */}
|
||||||
|
<div style={{ ...s.settingsSection, marginTop: 24 }}>
|
||||||
|
<h2 style={s.sectionTitle}>Two-Factor Authentication</h2>
|
||||||
|
<p style={{ ...s.subText, margin: '0 0 12px' }}>
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
{twoFaMsg && <p style={twoFaEnabled ? s.successMsg : s.errorMsg}>{twoFaMsg}</p>}
|
||||||
|
<button
|
||||||
|
style={twoFaEnabled ? s.btnDanger : s.btnPrimary}
|
||||||
|
type="button"
|
||||||
|
onClick={handle2faToggle}
|
||||||
|
disabled={toggling2fa}
|
||||||
|
>
|
||||||
|
{toggling2fa ? '…' : twoFaEnabled ? 'Disable 2FA' : 'Enable 2FA'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<div style={{ marginTop: 28, paddingTop: 20, borderTop: '1px solid var(--brand-border, #2a2518)' }}>
|
||||||
|
<button style={s.btnGhost} type="button" onClick={handleLogout}>Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Portal (main upload screen) ────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div style={s.page}>
|
<div style={s.page}>
|
||||||
<div style={{ ...s.card, maxWidth: 560 }}>
|
<div style={{ ...s.card, maxWidth: 560 }}>
|
||||||
@@ -196,62 +468,87 @@ export function NuggetsHostPortal() {
|
|||||||
</div>
|
</div>
|
||||||
<div style={s.headerRight}>
|
<div style={s.headerRight}>
|
||||||
<span style={s.welcomeText}>{hostName}</span>
|
<span style={s.welcomeText}>{hostName}</span>
|
||||||
|
<button style={s.btnIcon} title="Settings" onClick={() => setScreen('settings')}>⚙</button>
|
||||||
<button style={s.btnGhost} onClick={handleLogout}>Sign out</button>
|
<button style={s.btnGhost} onClick={handleLogout}>Sign out</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Announcement banner */}
|
||||||
|
{announcement && (
|
||||||
|
<div style={s.announcement}>
|
||||||
|
<span style={{ marginRight: 6 }}>📢</span>{announcement}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!seafileConfigured && (
|
{!seafileConfigured && (
|
||||||
<div style={s.warning}>
|
<div style={s.warning}>
|
||||||
Seafile is not configured yet — contact the admin before uploading.
|
Seafile is not configured yet — contact the admin before uploading.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Success banner */}
|
||||||
|
{lastSuccess && (
|
||||||
|
<div style={s.successBanner}>
|
||||||
|
<p style={{ margin: '0 0 4px', fontWeight: 700, color: 'var(--brand-gold, #c9a84c)' }}>
|
||||||
|
✓ Episode submitted successfully
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '0.87rem', color: '#c8c0ac' }}>
|
||||||
|
{lastSuccess.episodeName} · {lastSuccess.filename}
|
||||||
|
</p>
|
||||||
|
{lastSuccess.seafileUrl && (
|
||||||
|
<a href={lastSuccess.seafileUrl} target="_blank" rel="noopener noreferrer" style={s.seafileLink}>
|
||||||
|
View in Seafile →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recording guidelines (collapsible) */}
|
||||||
|
<div style={s.collapsible}>
|
||||||
|
<button
|
||||||
|
style={s.collapsibleToggle}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGuidelinesOpen(o => !o)}
|
||||||
|
>
|
||||||
|
<span>📋 Recording Guidelines</span>
|
||||||
|
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{guidelinesOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{guidelinesOpen && (
|
||||||
|
<div style={s.collapsibleBody}>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
||||||
|
<li>Export as <strong>MP3</strong> or <strong>WAV</strong> at 44.1 kHz, 128 kbps minimum.</li>
|
||||||
|
<li>File name: <code style={s.code}>YYYY-MM-DD-EpisodeName.mp3</code> (e.g. <code style={s.code}>2026-09-09-Romans-5.mp3</code>).</li>
|
||||||
|
<li>Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.</li>
|
||||||
|
<li>Normalize to <strong>-1 dBFS</strong> before exporting.</li>
|
||||||
|
<li>Match episode name here to the file name exactly so search stays clean.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload form */}
|
||||||
<form onSubmit={handleSubmit} style={s.form}>
|
<form onSubmit={handleSubmit} style={s.form}>
|
||||||
{/* Episode Name */}
|
|
||||||
<div style={s.field}>
|
<div style={s.field}>
|
||||||
<label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
|
<label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
|
||||||
<input
|
<input id="ep-name" style={s.input} type="text"
|
||||||
id="ep-name"
|
|
||||||
style={s.input}
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. The Grace of God in Romans 5"
|
placeholder="e.g. The Grace of God in Romans 5"
|
||||||
value={episodeName}
|
value={episodeName} onChange={e => setEpisodeName(e.target.value)} disabled={uploading} required />
|
||||||
onChange={e => setEpisodeName(e.target.value)}
|
|
||||||
disabled={uploading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Passage */}
|
|
||||||
<div style={s.field}>
|
<div style={s.field}>
|
||||||
<label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
|
<label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
|
||||||
<input
|
<input id="ep-passage" style={s.input} type="text"
|
||||||
id="ep-passage"
|
|
||||||
style={s.input}
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Romans 5:1-11"
|
placeholder="e.g. Romans 5:1-11"
|
||||||
value={passage}
|
value={passage} onChange={e => setPassage(e.target.value)} disabled={uploading} required />
|
||||||
onChange={e => setPassage(e.target.value)}
|
|
||||||
disabled={uploading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div style={s.field}>
|
<div style={s.field}>
|
||||||
<label style={s.label} htmlFor="ep-desc">Description</label>
|
<label style={s.label} htmlFor="ep-desc">Description</label>
|
||||||
<textarea
|
<textarea id="ep-desc" style={{ ...s.input, resize: 'vertical', minHeight: 80 } as React.CSSProperties}
|
||||||
id="ep-desc"
|
|
||||||
style={{ ...s.input, resize: 'vertical', minHeight: 80 }}
|
|
||||||
placeholder="A short summary of what this episode covers…"
|
placeholder="A short summary of what this episode covers…"
|
||||||
value={description}
|
value={description} onChange={e => setDescription(e.target.value)} disabled={uploading} rows={3} />
|
||||||
onChange={e => setDescription(e.target.value)}
|
|
||||||
disabled={uploading}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audio File */}
|
|
||||||
<div style={s.field}>
|
<div style={s.field}>
|
||||||
<label style={s.label}>Audio File <span style={s.req}>*</span></label>
|
<label style={s.label}>Audio File <span style={s.req}>*</span></label>
|
||||||
<div
|
<div
|
||||||
@@ -266,32 +563,27 @@ export function NuggetsHostPortal() {
|
|||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => !uploading && fileInputRef.current?.click()}
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<input
|
<input ref={fileInputRef} type="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov"
|
||||||
ref={fileInputRef}
|
style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
type="file"
|
|
||||||
accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
{file ? (
|
{file ? (
|
||||||
<div style={s.fileInfo}>
|
<div style={s.fileInfo}>
|
||||||
<span style={s.fileIcon}>🎵</span>
|
<span style={s.fileIcon}>🎵</span>
|
||||||
<div>
|
<div>
|
||||||
<p style={s.fileName}>{file.name}</p>
|
<p style={s.fileName}>{file.name}</p>
|
||||||
<p style={s.fileSize}>{(file.size / (1024 * 1024)).toFixed(1)} MB — click to change</p>
|
<p style={s.fileSize}>{formatBytes(file.size)} — click to change</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span style={s.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
|
<span style={s.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
|
||||||
<p style={s.dropLabel}>{dragging ? 'Drop to select' : 'Drop audio file here, or click to browse'}</p>
|
<p style={s.dropLabel}>{dragging ? 'Drop to select' : 'Drop audio file here, or click to browse'}</p>
|
||||||
<p style={s.dropSub}>MP3, WAV, M4A, FLAC, etc.</p>
|
<p style={s.dropSub}>MP3, WAV, M4A, FLAC · max 500 MB</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{fileWarning && <p style={s.warnMsg}>{fileWarning}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div style={s.progressWrap}>
|
<div style={s.progressWrap}>
|
||||||
<div style={s.progressBar}>
|
<div style={s.progressBar}>
|
||||||
@@ -308,23 +600,33 @@ export function NuggetsHostPortal() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Submitted this session */}
|
{/* Episode History (collapsible) */}
|
||||||
{submitted.length > 0 && (
|
<div style={{ ...s.collapsible, marginTop: 12 }}>
|
||||||
<div style={s.submittedList}>
|
<button style={s.collapsibleToggle} type="button" onClick={() => setHistoryOpen(o => !o)}>
|
||||||
<p style={s.submittedTitle}>Submitted this session</p>
|
<span>🕐 Your Upload History</span>
|
||||||
{submitted.map((ep, i) => (
|
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{historyOpen ? '▲' : '▼'}</span>
|
||||||
<div key={i} style={s.submittedRow}>
|
</button>
|
||||||
<span style={s.checkMark}>✓</span>
|
{historyOpen && (
|
||||||
<div>
|
<div style={s.collapsibleBody}>
|
||||||
<p style={s.submittedEp}>{ep.episodeName}</p>
|
{history.length === 0 ? (
|
||||||
<p style={s.submittedMeta}>{ep.filename} · {ep.at}</p>
|
<p style={{ margin: 0, color: 'var(--brand-muted, #b0a48c)', fontSize: '0.85rem' }}>No uploads yet.</p>
|
||||||
|
) : (
|
||||||
|
history.map((entry, i) => (
|
||||||
|
<div key={i} style={{ padding: '6px 0', borderBottom: i < history.length - 1 ? '1px solid var(--brand-border, #2a2518)' : undefined }}>
|
||||||
|
<p style={{ margin: '0 0 2px', fontWeight: 600, fontSize: '0.87rem', color: 'var(--brand-warm-white, #f0ead8)' }}>
|
||||||
|
{entry.episodeName}
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: '0.78rem', color: 'var(--brand-muted, #b0a48c)' }}>
|
||||||
|
{entry.passage} · {entry.filename} · {new Date(entry.uploadedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +662,7 @@ const s: Record<string, React.CSSProperties> = {
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
flexWrap: 'wrap',
|
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 },
|
welcomeText: { fontSize: '0.85rem', color: 'var(--brand-muted, #b0a48c)', fontWeight: 500 },
|
||||||
btnGhost: {
|
btnGhost: {
|
||||||
background: 'none',
|
background: 'none',
|
||||||
@@ -371,14 +673,68 @@ const s: Record<string, React.CSSProperties> = {
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: 'var(--brand-muted, #b0a48c)',
|
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: {
|
warning: {
|
||||||
background: 'rgba(200,169,110,0.1)',
|
background: 'rgba(200,169,110,0.1)',
|
||||||
border: '1px solid var(--brand-gold-dark, #8a6e28)',
|
border: '1px solid #8a6e28',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: '0.6rem 0.85rem',
|
padding: '0.6rem 0.85rem',
|
||||||
fontSize: '0.84rem',
|
fontSize: '0.84rem',
|
||||||
marginBottom: 16,
|
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 },
|
form: { display: 'flex', flexDirection: 'column', gap: 14 },
|
||||||
field: { display: 'flex', flexDirection: 'column', gap: 5 },
|
field: { display: 'flex', flexDirection: 'column', gap: 5 },
|
||||||
@@ -393,17 +749,17 @@ const s: Record<string, React.CSSProperties> = {
|
|||||||
color: 'var(--brand-warm-white, #f0ead8)',
|
color: 'var(--brand-warm-white, #f0ead8)',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box' as const,
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
},
|
},
|
||||||
dropZone: {
|
dropZone: {
|
||||||
border: '2px dashed var(--brand-border, #2a2518)',
|
border: '2px dashed var(--brand-border, #2a2518)',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
padding: '1.4rem 1rem',
|
padding: '1.4rem 1rem',
|
||||||
textAlign: 'center',
|
textAlign: 'center' as const,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'border-color 0.15s, background 0.15s',
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
userSelect: 'none',
|
userSelect: 'none' as const,
|
||||||
color: 'var(--brand-warm-white, #f0ead8)',
|
color: 'var(--brand-warm-white, #f0ead8)',
|
||||||
},
|
},
|
||||||
dropZoneActive: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.06)' },
|
dropZoneActive: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.06)' },
|
||||||
@@ -412,42 +768,56 @@ const s: Record<string, React.CSSProperties> = {
|
|||||||
dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
|
dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
|
||||||
dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
|
dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
|
||||||
dropSub: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.8rem', margin: 0 },
|
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 },
|
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 },
|
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' },
|
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' },
|
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' },
|
errorMsg: { color: '#e07070', fontSize: '0.84rem', margin: '2px 0' },
|
||||||
btnPrimary: {
|
warnMsg: { color: '#e0c070', fontSize: '0.82rem', margin: '2px 0' },
|
||||||
background: 'var(--brand-gold, #c9a84c)',
|
successMsg: { color: '#6db86d', fontSize: '0.84rem', margin: '2px 0' },
|
||||||
color: '#0a0a08',
|
collapsible: {
|
||||||
border: 'none',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '0.7rem 1rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
fontSize: '0.95rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
submittedList: {
|
|
||||||
marginTop: 20,
|
|
||||||
border: '1px solid var(--brand-border, #2a2518)',
|
border: '1px solid var(--brand-border, #2a2518)',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
marginBottom: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
collapsibleToggle: {
|
||||||
|
width: '100%',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
padding: '0.75rem 1rem',
|
padding: '0.75rem 1rem',
|
||||||
},
|
display: 'flex',
|
||||||
submittedTitle: {
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--brand-warm-white, #f0ead8)',
|
||||||
|
fontSize: '0.87rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.78rem',
|
fontFamily: 'inherit',
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
},
|
||||||
|
collapsibleBody: {
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
fontSize: '0.84rem',
|
||||||
color: 'var(--brand-muted, #b0a48c)',
|
color: 'var(--brand-muted, #b0a48c)',
|
||||||
margin: '0 0 8px',
|
borderTop: '1px solid var(--brand-border, #2a2518)',
|
||||||
textTransform: 'uppercase',
|
},
|
||||||
letterSpacing: '0.05em',
|
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 },
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user