Files
Siteforge/src/components/NuggetsHostPortal.tsx
T

824 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)',
},
}