Publish Container / docker (push) Successful in 14m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
824 lines
31 KiB
TypeScript
824 lines
31 KiB
TypeScript
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<Screen>('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<File | null>(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<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)
|
||
|
||
// 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<HTMLInputElement>) {
|
||
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<void>(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 (
|
||
<div style={s.page}>
|
||
<div style={s.card}>
|
||
<p style={{ color: '#888', textAlign: 'center', margin: 0 }}>Loading…</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (screen === 'login') {
|
||
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}>Host Upload Portal</h1>
|
||
<p style={s.subText}>Sign in with your host credentials to submit an episode.</p>
|
||
<form onSubmit={handleLogin} style={s.form}>
|
||
<div style={s.field}>
|
||
<label style={s.label} htmlFor="hp-u">Email</label>
|
||
<input id="hp-u" style={s.input} type="email" autoComplete="email"
|
||
value={email} onChange={e => setEmail(e.target.value)} disabled={loggingIn} required />
|
||
</div>
|
||
<div style={s.field}>
|
||
<label style={s.label} htmlFor="hp-p">Password</label>
|
||
<input id="hp-p" style={s.input} type="password" autoComplete="current-password"
|
||
value={password} onChange={e => setPassword(e.target.value)} disabled={loggingIn} required />
|
||
</div>
|
||
{loginError && <p style={s.errorMsg}>{loginError}</p>}
|
||
<button style={s.btnPrimary} type="submit" disabled={loggingIn}>
|
||
{loggingIn ? 'Signing in…' : 'Sign In'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div style={s.page}>
|
||
<div style={{ ...s.card, maxWidth: 560 }}>
|
||
{/* Header */}
|
||
<div style={s.header}>
|
||
<div style={s.logoRow}>
|
||
<span style={s.logoEmoji}>🎙️</span>
|
||
<span style={s.logoTitle}>Nuggets Upload Portal</span>
|
||
</div>
|
||
<div style={s.headerRight}>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Announcement banner */}
|
||
{announcement && (
|
||
<div style={s.announcement}>
|
||
<span style={{ marginRight: 6 }}>📢</span>{announcement}
|
||
</div>
|
||
)}
|
||
|
||
{!seafileConfigured && (
|
||
<div style={s.warning}>
|
||
Seafile is not configured yet — contact the admin before uploading.
|
||
</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}>
|
||
<div style={s.field}>
|
||
<label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
|
||
<input id="ep-name" style={s.input} type="text"
|
||
placeholder="e.g. The Grace of God in Romans 5"
|
||
value={episodeName} onChange={e => setEpisodeName(e.target.value)} disabled={uploading} required />
|
||
</div>
|
||
|
||
<div style={s.field}>
|
||
<label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
|
||
<input id="ep-passage" style={s.input} type="text"
|
||
placeholder="e.g. Romans 5:1-11"
|
||
value={passage} onChange={e => setPassage(e.target.value)} disabled={uploading} required />
|
||
</div>
|
||
|
||
<div style={s.field}>
|
||
<label style={s.label} htmlFor="ep-desc">Description</label>
|
||
<textarea id="ep-desc" style={{ ...s.input, resize: 'vertical', minHeight: 80 } as React.CSSProperties}
|
||
placeholder="A short summary of what this episode covers…"
|
||
value={description} onChange={e => setDescription(e.target.value)} disabled={uploading} rows={3} />
|
||
</div>
|
||
|
||
<div style={s.field}>
|
||
<label style={s.label}>Audio File <span style={s.req}>*</span></label>
|
||
<div
|
||
style={{
|
||
...s.dropZone,
|
||
...(dragging ? s.dropZoneActive : {}),
|
||
...(uploading ? s.dropZoneDisabled : {}),
|
||
...(file ? s.dropZoneHasFile : {}),
|
||
}}
|
||
onDragOver={e => { e.preventDefault(); if (!uploading) setDragging(true) }}
|
||
onDragLeave={() => setDragging(false)}
|
||
onDrop={handleDrop}
|
||
onClick={() => !uploading && fileInputRef.current?.click()}
|
||
>
|
||
<input ref={fileInputRef} type="file" accept=".mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov"
|
||
style={{ display: 'none' }} onChange={handleFileChange} />
|
||
{file ? (
|
||
<div style={s.fileInfo}>
|
||
<span style={s.fileIcon}>🎵</span>
|
||
<div>
|
||
<p style={s.fileName}>{file.name}</p>
|
||
<p style={s.fileSize}>{formatBytes(file.size)} — click to change</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<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.dropSub}>MP3, WAV, M4A, FLAC · max 500 MB</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
{fileWarning && <p style={s.warnMsg}>{fileWarning}</p>}
|
||
</div>
|
||
|
||
{uploading && (
|
||
<div style={s.progressWrap}>
|
||
<div style={s.progressBar}>
|
||
<div style={{ ...s.progressFill, width: `${progress}%` }} />
|
||
</div>
|
||
<p style={s.progressLabel}>Uploading… {progress}%</p>
|
||
</div>
|
||
)}
|
||
|
||
{uploadError && <p style={s.errorMsg}>{uploadError}</p>}
|
||
|
||
<button style={{ ...s.btnPrimary, marginTop: 4 }} type="submit" disabled={uploading || !seafileConfigured}>
|
||
{uploading ? 'Uploading…' : 'Submit Episode'}
|
||
</button>
|
||
</form>
|
||
|
||
{/* Episode History (collapsible) */}
|
||
<div style={{ ...s.collapsible, marginTop: 12 }}>
|
||
<button style={s.collapsibleToggle} type="button" onClick={() => setHistoryOpen(o => !o)}>
|
||
<span>🕐 Your Upload History</span>
|
||
<span style={{ fontSize: '0.9rem', opacity: 0.7 }}>{historyOpen ? '▲' : '▼'}</span>
|
||
</button>
|
||
{historyOpen && (
|
||
<div style={s.collapsibleBody}>
|
||
{history.length === 0 ? (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
// ── Styles ────────────────────────────────────────────────────────────────
|
||
const s: Record<string, React.CSSProperties> = {
|
||
page: {
|
||
minHeight: '100vh',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '2rem 1rem',
|
||
background: 'var(--brand-black, #0a0a08)',
|
||
},
|
||
card: {
|
||
background: 'var(--brand-black-2, #0f0f0c)',
|
||
border: '1px solid var(--brand-border, #2a2518)',
|
||
borderRadius: 16,
|
||
padding: '2.25rem 2rem',
|
||
width: '100%',
|
||
maxWidth: 420,
|
||
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
|
||
},
|
||
logoRow: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 },
|
||
logoEmoji: { fontSize: '1.5rem' },
|
||
logoTitle: { fontWeight: 700, fontSize: '1rem', color: 'var(--brand-gold, #c9a84c)' },
|
||
heading: { fontSize: '1.4rem', fontWeight: 700, margin: '0 0 6px', color: 'var(--brand-warm-white, #f0ead8)' },
|
||
subText: { color: 'var(--brand-muted, #b0a48c)', fontSize: '0.88rem', margin: '0 0 1.4rem' },
|
||
header: {
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 20,
|
||
gap: 12,
|
||
flexWrap: 'wrap',
|
||
},
|
||
headerRight: { display: 'flex', alignItems: 'center', gap: 8, paddingTop: 2 },
|
||
welcomeText: { fontSize: '0.85rem', color: 'var(--brand-muted, #b0a48c)', fontWeight: 500 },
|
||
btnGhost: {
|
||
background: 'none',
|
||
border: '1px solid var(--brand-border, #2a2518)',
|
||
borderRadius: 6,
|
||
padding: '0.3rem 0.65rem',
|
||
fontSize: '0.8rem',
|
||
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 #8a6e28',
|
||
borderRadius: 8,
|
||
padding: '0.6rem 0.85rem',
|
||
fontSize: '0.84rem',
|
||
marginBottom: 16,
|
||
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 },
|
||
label: { fontSize: '0.85rem', fontWeight: 600, color: 'var(--brand-warm-white, #f0ead8)' },
|
||
req: { color: '#e07070' },
|
||
input: {
|
||
padding: '0.55rem 0.75rem',
|
||
border: '1px solid var(--brand-border, #2a2518)',
|
||
borderRadius: 8,
|
||
fontSize: '0.93rem',
|
||
background: 'var(--brand-black-3, #1a1a15)',
|
||
color: 'var(--brand-warm-white, #f0ead8)',
|
||
outline: 'none',
|
||
width: '100%',
|
||
boxSizing: 'border-box' as const,
|
||
fontFamily: 'inherit',
|
||
},
|
||
dropZone: {
|
||
border: '2px dashed var(--brand-border, #2a2518)',
|
||
borderRadius: 10,
|
||
padding: '1.4rem 1rem',
|
||
textAlign: 'center' as const,
|
||
cursor: 'pointer',
|
||
transition: 'border-color 0.15s, background 0.15s',
|
||
userSelect: 'none' as const,
|
||
color: 'var(--brand-warm-white, #f0ead8)',
|
||
},
|
||
dropZoneActive: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.06)' },
|
||
dropZoneDisabled: { cursor: 'default', opacity: 0.5 },
|
||
dropZoneHasFile: { borderColor: 'var(--brand-gold, #c9a84c)', background: 'rgba(201,168,76,0.04)' },
|
||
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' as const },
|
||
fileIcon: { fontSize: '1.8rem', flexShrink: 0 },
|
||
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' 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' as const },
|
||
errorMsg: { color: '#e07070', fontSize: '0.84rem', margin: '2px 0' },
|
||
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',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
cursor: 'pointer',
|
||
color: 'var(--brand-warm-white, #f0ead8)',
|
||
fontSize: '0.87rem',
|
||
fontWeight: 600,
|
||
fontFamily: 'inherit',
|
||
textAlign: 'left' as const,
|
||
},
|
||
collapsibleBody: {
|
||
padding: '0.75rem 1rem',
|
||
fontSize: '0.84rem',
|
||
color: 'var(--brand-muted, #b0a48c)',
|
||
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)',
|
||
},
|
||
}
|