import { useState, useEffect, useRef } from 'react' const DRAFT_KEY = 'nuggets_ep_draft' interface HistoryEntry { episodeName: string passage: string filename: 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 [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 [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 => { 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') } }) .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('') setLoggingIn(true) try { const r = await fetch('/api/nuggets-upload/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }) 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) 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' }) clearDraft() setHostName('') setEmail('') setPassword('') setEpisodeName('') setPassage('') setDescription('') setFile(null) 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) pickFile(e.dataTransfer.files[0] ?? null) } function handleFileChange(e: React.ChangeEvent) { 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.'); return } if (uploading) return setUploadError('') setLastSuccess(null) setUploading(true) setProgress(0) const formData = new FormData() formData.append('file', file) formData.append('episodeName', episodeName) formData.append('passage', passage) formData.append('description', description) 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) setLastSuccess({ episodeName: d.episodeName, filename: d.filename, seafileUrl: d.seafileUrl }) clearDraft() setEpisodeName('') setPassage('') setDescription('') setFile(null) setFileWarning('') setProgress(0) } catch { setUploadError('Upload succeeded but the response was unexpected.') } } else { 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) }) } 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 (

Loading…

) } if (screen === 'login') { return (
🎙️ Nuggets

Host Upload Portal

Sign in with your host credentials to submit an episode.

setEmail(e.target.value)} disabled={loggingIn} required />
setPassword(e.target.value)} disabled={loggingIn} required />
{loginError &&

{loginError}

}
) } if (screen === 'otp') { return (
🎙️ Nuggets

Check Your Email

A 6-digit code was sent to your email address. Enter it below to continue.

setOtpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} disabled={verifyingOtp} required />
{otpError &&

{otpError}

}
) } if (screen === 'settings') { return (
{/* Settings header */}
Account Settings
{hostName}
{/* Change Password */}

Change Password

setCurrentPw(e.target.value)} disabled={savingPw} required />
setNewPw(e.target.value)} disabled={savingPw} required />
setConfirmPw(e.target.value)} disabled={savingPw} required />
{pwError &&

{pwError}

} {pwSuccess &&

{pwSuccess}

}
{/* 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}

}
{/* Sign out */}
) } // ── Portal (main upload screen) ──────────────────────────────────────────── return (
{/* Header */}
🎙️ Nuggets Upload Portal
{hostName}
{/* 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) */}
{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 */}
setEpisodeName(e.target.value)} disabled={uploading} required />
setPassage(e.target.value)} disabled={uploading} required />