Redesign Nuggets upload portal: single episode submission form; v2.0.5

- Replace two-tab file picker with a combined form: episode name, passage,
  description, and audio file in one submit
- Server now generates an Episode Info text file (name, host, passage,
  description, timestamp) and uploads it to /Episode Info/ automatically,
  while the audio goes to /Episode Upload/
- Drop zone changes color when a valid file is selected (green border)
- Success shows submitted episode name and filename in the session log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-08 13:46:20 -04:00
1 parent afdc00114a
commit 6a03813914
3 files changed
+288 -249

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.0.4", "version": "2.0.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+36 -8
View File
@@ -184,7 +184,7 @@ export function register(app) {
res.json({ ok: true }) res.json({ ok: true })
}) })
// ── File upload ─────────────────────────────────────────────────────── // ── 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) => {
if (!req.file) { if (!req.file) {
@@ -192,10 +192,19 @@ export function register(app) {
return return
} }
const targetDir = req.body?.targetDir === 'info' ? SEAFILE_EPISODE_INFO_DIR : SEAFILE_EPISODE_UPLOAD_DIR const session = getHostSession(req)
const episodeName = typeof req.body?.episodeName === 'string' ? req.body.episodeName.trim() : ''
const passage = typeof req.body?.passage === 'string' ? req.body.passage.trim() : ''
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''
const originalName = req.file.originalname || req.file.filename const originalName = req.file.originalname || req.file.filename
const tempPath = req.file.path const tempPath = req.file.path
if (!episodeName || !passage) {
await unlink(tempPath).catch(() => {})
res.status(400).json({ message: 'Episode name and passage are required.' })
return
}
if (!seafileConfigured()) { if (!seafileConfigured()) {
await unlink(tempPath).catch(() => {}) await unlink(tempPath).catch(() => {})
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' }) res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
@@ -206,14 +215,33 @@ export function register(app) {
const { readFile: rf } = await import('node:fs/promises') const { readFile: rf } = await import('node:fs/promises')
const fileBuffer = await rf(tempPath) const fileBuffer = await rf(tempPath)
const uploadLink = await getSeafileUploadLink(targetDir) // Upload audio to /Episode Upload/
await uploadToSeafile(uploadLink, fileBuffer, originalName, targetDir) const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
await uploadToSeafile(audioLink, fileBuffer, originalName, SEAFILE_EPISODE_UPLOAD_DIR)
await unlink(tempPath).catch(() => {}) await unlink(tempPath).catch(() => {})
const session = getHostSession(req) // Build and upload info file to /Episode Info/
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${originalName}" to ${targetDir}`) const now = new Date()
res.json({ ok: true, filename: originalName, dir: targetDir }) const dateStr = now.toISOString().slice(0, 10)
const infoContent = [
`Episode: ${episodeName}`,
`Host: ${session?.hostName ?? 'Unknown'}`,
`Passage: ${passage}`,
`Audio File: ${originalName}`,
`Submitted: ${now.toUTCString()}`,
'',
'Description:',
description || '(none)',
].join('\n')
const safeEpName = episodeName.replace(/[^a-z0-9 _-]/gi, '').trim().replace(/\s+/g, '-').slice(0, 60)
const infoFilename = `${dateStr}-${safeEpName}-info.txt`
const infoBuffer = Buffer.from(infoContent, 'utf8')
const infoLink = await getSeafileUploadLink(SEAFILE_EPISODE_INFO_DIR)
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
res.json({ ok: true, episodeName, filename: originalName, infoFile: infoFilename })
} 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)
+251 -240
View File
@@ -1,10 +1,8 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
type UploadTarget = 'upload' | 'info' interface SubmittedEpisode {
episodeName: string
interface UploadRecord {
filename: string filename: string
dir: string
at: string at: string
} }
@@ -19,14 +17,17 @@ export function NuggetsHostPortal() {
const [loginError, setLoginError] = useState('') const [loginError, setLoginError] = useState('')
const [loggingIn, setLoggingIn] = useState(false) const [loggingIn, setLoggingIn] = useState(false)
const [target, setTarget] = useState<UploadTarget>('upload') const [episodeName, setEpisodeName] = useState('')
const [passage, setPassage] = useState('')
const [description, setDescription] = useState('')
const [file, setFile] = useState<File | null>(null)
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 [uploads, setUploads] = useState<UploadRecord[]>([]) const [submitted, setSubmitted] = useState<SubmittedEpisode[]>([])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
fetch('/api/nuggets-upload/me') fetch('/api/nuggets-upload/me')
@@ -50,10 +51,7 @@ export function NuggetsHostPortal() {
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}) })
const d = await r.json() const d = await r.json()
if (!r.ok) { if (!r.ok) { setLoginError(d.message ?? 'Login failed.'); return }
setLoginError(d.message ?? 'Login failed.')
return
}
setHostName(d.hostName) setHostName(d.hostName)
setAuthenticated(true) setAuthenticated(true)
} finally { } finally {
@@ -67,25 +65,53 @@ export function NuggetsHostPortal() {
setHostName('') setHostName('')
setUsername('') setUsername('')
setPassword('') setPassword('')
setUploads([]) setSubmitted([])
resetForm()
} }
async function uploadFile(file: File) { function resetForm() {
setEpisodeName('')
setPassage('')
setDescription('')
setFile(null)
setUploadError('')
setProgress(0)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
const dropped = e.dataTransfer.files[0]
if (dropped) setFile(dropped)
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = e.target.files?.[0]
if (picked) setFile(picked)
e.target.value = ''
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!file) { setUploadError('Please select an audio file to upload.'); return }
if (uploading) return if (uploading) return
setUploadError('') setUploadError('')
setUploading(true) setUploading(true)
setProgress(0) setProgress(0)
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
formData.append('targetDir', target) formData.append('episodeName', episodeName)
formData.append('passage', passage)
formData.append('description', description)
return 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', e => { xhr.upload.addEventListener('progress', ev => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)) if (ev.lengthComputable) setProgress(Math.round((ev.loaded / ev.total) * 100))
}) })
xhr.addEventListener('load', () => { xhr.addEventListener('load', () => {
@@ -93,15 +119,14 @@ export function NuggetsHostPortal() {
if (xhr.status === 200) { if (xhr.status === 200) {
try { try {
const d = JSON.parse(xhr.responseText) const d = JSON.parse(xhr.responseText)
setUploads(prev => [{ filename: d.filename, dir: d.dir, at: new Date().toLocaleTimeString() }, ...prev]) setSubmitted(prev => [{ episodeName: d.episodeName, filename: d.filename, at: new Date().toLocaleTimeString() }, ...prev])
setProgress(100) resetForm()
} catch { } catch {
setUploadError('Upload succeeded but response was unexpected.') setUploadError('Upload succeeded but response was unexpected.')
} }
} else { } else {
try { try {
const d = JSON.parse(xhr.responseText) setUploadError(JSON.parse(xhr.responseText).message ?? 'Upload failed.')
setUploadError(d.message ?? 'Upload failed.')
} catch { } catch {
setUploadError('Upload failed.') setUploadError('Upload failed.')
} }
@@ -119,24 +144,11 @@ export function NuggetsHostPortal() {
}) })
} }
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (file) uploadFile(file)
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (file) uploadFile(file)
e.target.value = ''
}
if (checking) { if (checking) {
return ( return (
<div style={styles.page}> <div style={s.page}>
<div style={styles.card}> <div style={s.card}>
<p style={{ color: 'var(--color-muted, #888)', textAlign: 'center' }}>Loading…</p> <p style={{ color: '#888', textAlign: 'center', margin: 0 }}>Loading…</p>
</div> </div>
</div> </div>
) )
@@ -144,44 +156,27 @@ export function NuggetsHostPortal() {
if (!authenticated) { if (!authenticated) {
return ( return (
<div style={styles.page}> <div style={s.page}>
<div style={styles.card}> <div style={s.card}>
<div style={styles.logo}> <div style={s.logoRow}>
<span style={styles.logoEmoji}>🎙️</span> <span style={s.logoEmoji}>🎙️</span>
<span style={styles.logoTitle}>Nuggets</span> <span style={s.logoTitle}>Nuggets</span>
</div> </div>
<h1 style={styles.heading}>Host Upload Portal</h1> <h1 style={s.heading}>Host Upload Portal</h1>
<p style={styles.subheading}>Sign in to upload your episode files to Seafile.</p> <p style={s.subText}>Sign in with your host credentials to submit an episode.</p>
<form onSubmit={handleLogin} style={s.form}>
<form onSubmit={handleLogin} style={styles.form}> <div style={s.field}>
<div style={styles.field}> <label style={s.label} htmlFor="hp-u">Username</label>
<label style={styles.label} htmlFor="hp-username">Username</label> <input id="hp-u" style={s.input} type="text" autoComplete="username"
<input value={username} onChange={e => setUsername(e.target.value)} disabled={loggingIn} required />
id="hp-username"
style={styles.input}
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loggingIn}
required
/>
</div> </div>
<div style={styles.field}> <div style={s.field}>
<label style={styles.label} htmlFor="hp-password">Password</label> <label style={s.label} htmlFor="hp-p">Password</label>
<input <input id="hp-p" style={s.input} type="password" autoComplete="current-password"
id="hp-password" value={password} onChange={e => setPassword(e.target.value)} disabled={loggingIn} required />
style={styles.input}
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loggingIn}
required
/>
</div> </div>
{loginError && <p style={styles.errorMsg}>{loginError}</p>} {loginError && <p style={s.errorMsg}>{loginError}</p>}
<button style={styles.btnPrimary} type="submit" disabled={loggingIn}> <button style={s.btnPrimary} type="submit" disabled={loggingIn}>
{loggingIn ? 'Signing in…' : 'Sign In'} {loggingIn ? 'Signing in…' : 'Sign In'}
</button> </button>
</form> </form>
@@ -190,98 +185,140 @@ export function NuggetsHostPortal() {
) )
} }
const targetLabel = target === 'upload' ? 'Episode Upload' : 'Episode Info'
const acceptTypes = target === 'upload'
? '.mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov'
: '*'
return ( return (
<div style={styles.page}> <div style={s.page}>
<div style={{ ...styles.card, maxWidth: 640 }}> <div style={{ ...s.card, maxWidth: 560 }}>
<div style={styles.headerRow}> {/* Header */}
<div style={styles.logo}> <div style={s.header}>
<span style={styles.logoEmoji}>🎙️</span> <div style={s.logoRow}>
<span style={styles.logoTitle}>Nuggets Upload Portal</span> <span style={s.logoEmoji}>🎙️</span>
<span style={s.logoTitle}>Nuggets Upload Portal</span>
</div> </div>
<div style={styles.headerRight}> <div style={s.headerRight}>
<span style={styles.welcomeText}>Hi, {hostName}</span> <span style={s.welcomeText}>{hostName}</span>
<button style={styles.btnGhost} onClick={handleLogout}>Sign out</button> <button style={s.btnGhost} onClick={handleLogout}>Sign out</button>
</div> </div>
</div> </div>
{!seafileConfigured && ( {!seafileConfigured && (
<div style={styles.warningBanner}> <div style={s.warning}>
Seafile is not configured yet. Contact the admin to set up the connection. Seafile is not configured yet — contact the admin before uploading.
</div> </div>
)} )}
<div style={styles.targetRow}> <form onSubmit={handleSubmit} style={s.form}>
<button {/* Episode Name */}
style={target === 'upload' ? styles.tabActive : styles.tab} <div style={s.field}>
onClick={() => { setTarget('upload'); setUploadError('') }} <label style={s.label} htmlFor="ep-name">Episode Name <span style={s.req}>*</span></label>
> <input
🎵 Episode Upload id="ep-name"
</button> style={s.input}
<button type="text"
style={target === 'info' ? styles.tabActive : styles.tab} placeholder="e.g. The Grace of God in Romans 5"
onClick={() => { setTarget('info'); setUploadError('') }} value={episodeName}
> onChange={e => setEpisodeName(e.target.value)}
📄 Episode Info disabled={uploading}
</button> required
</div> />
</div>
<p style={styles.targetHint}> {/* Passage */}
{target === 'upload' <div style={s.field}>
? 'Upload your finished audio file (MP3, WAV, M4A, etc.).' <label style={s.label} htmlFor="ep-passage">Passage <span style={s.req}>*</span></label>
: 'Upload show notes, outlines, scripts, or any supporting document.'} <input
</p> 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 {/* Description */}
ref={dropRef} <div style={s.field}>
style={{ <label style={s.label} htmlFor="ep-desc">Description</label>
...styles.dropZone, <textarea
...(dragging ? styles.dropZoneActive : {}), id="ep-desc"
...(uploading ? styles.dropZoneDisabled : {}), style={{ ...s.input, resize: 'vertical', minHeight: 80 }}
}} placeholder="A short summary of what this episode covers…"
onDragOver={e => { e.preventDefault(); setDragging(true) }} value={description}
onDragLeave={() => setDragging(false)} onChange={e => setDescription(e.target.value)}
onDrop={handleDrop} disabled={uploading}
onClick={() => !uploading && fileInputRef.current?.click()} rows={3}
> />
<input </div>
ref={fileInputRef}
type="file" {/* Audio File */}
accept={acceptTypes} <div style={s.field}>
style={{ display: 'none' }} <label style={s.label}>Audio File <span style={s.req}>*</span></label>
onChange={handleFileChange} <div
/> style={{
{uploading ? ( ...s.dropZone,
<div style={styles.progressWrap}> ...(dragging ? s.dropZoneActive : {}),
<p style={styles.dropLabel}>Uploading to {targetLabel}…</p> ...(uploading ? s.dropZoneDisabled : {}),
<div style={styles.progressBar}> ...(file ? s.dropZoneHasFile : {}),
<div style={{ ...styles.progressFill, width: `${progress}%` }} /> }}
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}>{(file.size / (1024 * 1024)).toFixed(1)} MB — 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, etc.</p>
</>
)}
</div>
</div>
{/* Progress */}
{uploading && (
<div style={s.progressWrap}>
<div style={s.progressBar}>
<div style={{ ...s.progressFill, width: `${progress}%` }} />
</div> </div>
<p style={styles.progressPct}>{progress}%</p> <p style={s.progressLabel}>Uploading… {progress}%</p>
</div> </div>
) : (
<>
<span style={styles.dropIcon}>{dragging ? '📂' : '⬆️'}</span>
<p style={styles.dropLabel}>{dragging ? 'Drop to upload' : 'Drop a file here, or click to browse'}</p>
<p style={styles.dropSub}>→ {targetLabel}</p>
</>
)} )}
</div>
{uploadError && <p style={styles.errorMsg}>{uploadError}</p>} {uploadError && <p style={s.errorMsg}>{uploadError}</p>}
{uploads.length > 0 && ( <button style={{ ...s.btnPrimary, marginTop: 4 }} type="submit" disabled={uploading || !seafileConfigured}>
<div style={styles.uploadList}> {uploading ? 'Uploading…' : 'Submit Episode'}
<p style={styles.uploadListTitle}>Uploaded this session</p> </button>
{uploads.map((u, i) => ( </form>
<div key={i} style={styles.uploadRow}>
<span style={styles.uploadCheck}>✓</span> {/* Submitted this session */}
<span style={styles.uploadName}>{u.filename}</span> {submitted.length > 0 && (
<span style={styles.uploadMeta}>{u.dir} · {u.at}</span> <div style={s.submittedList}>
<p style={s.submittedTitle}>Submitted this session</p>
{submitted.map((ep, i) => (
<div key={i} style={s.submittedRow}>
<span style={s.checkMark}>✓</span>
<div>
<p style={s.submittedEp}>{ep.episodeName}</p>
<p style={s.submittedMeta}>{ep.filename} · {ep.at}</p>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -292,7 +329,7 @@ export function NuggetsHostPortal() {
} }
// ── Styles ──────────────────────────────────────────────────────────────── // ── Styles ────────────────────────────────────────────────────────────────
const styles: Record<string, React.CSSProperties> = { const s: Record<string, React.CSSProperties> = {
page: { page: {
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
@@ -305,45 +342,17 @@ const styles: Record<string, React.CSSProperties> = {
background: 'var(--color-surface, #fff)', background: 'var(--color-surface, #fff)',
border: '1px solid var(--color-border, #e5e0d5)', border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 16, borderRadius: 16,
padding: '2.5rem 2rem', padding: '2.25rem 2rem',
width: '100%', width: '100%',
maxWidth: 420, maxWidth: 420,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)', boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
}, },
logo: { logoRow: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 },
display: 'flex', logoEmoji: { fontSize: '1.5rem' },
alignItems: 'center', logoTitle: { fontWeight: 700, fontSize: '1rem' },
gap: 8, heading: { fontSize: '1.4rem', fontWeight: 700, margin: '0 0 6px' },
marginBottom: 16, subText: { color: '#888', fontSize: '0.88rem', margin: '0 0 1.4rem' },
}, header: {
logoEmoji: { fontSize: '1.6rem' },
logoTitle: { fontWeight: 700, fontSize: '1.05rem', letterSpacing: '-0.01em' },
heading: { fontSize: '1.45rem', fontWeight: 700, margin: '0 0 6px' },
subheading: { color: 'var(--color-muted, #888)', fontSize: '0.9rem', margin: '0 0 1.5rem' },
form: { display: 'flex', flexDirection: 'column', gap: 16 },
field: { display: 'flex', flexDirection: 'column', gap: 5 },
label: { fontSize: '0.85rem', fontWeight: 600 },
input: {
padding: '0.55rem 0.75rem',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 8,
fontSize: '0.95rem',
background: 'var(--color-input-bg, #faf9f7)',
outline: 'none',
},
errorMsg: { color: '#c0392b', fontSize: '0.85rem', margin: '2px 0' },
btnPrimary: {
background: '#c8a96e',
color: '#fff',
border: 'none',
borderRadius: 8,
padding: '0.65rem 1rem',
fontWeight: 700,
fontSize: '0.95rem',
cursor: 'pointer',
marginTop: 4,
},
headerRow: {
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -351,89 +360,91 @@ const styles: Record<string, React.CSSProperties> = {
gap: 12, gap: 12,
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
headerRight: { display: 'flex', alignItems: 'center', gap: 10 }, headerRight: { display: 'flex', alignItems: 'center', gap: 10, paddingTop: 2 },
welcomeText: { fontSize: '0.85rem', color: 'var(--color-muted, #888)' }, welcomeText: { fontSize: '0.85rem', color: '#888', fontWeight: 500 },
btnGhost: { btnGhost: {
background: 'none', background: 'none',
border: '1px solid var(--color-border, #e5e0d5)', border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 6, borderRadius: 6,
padding: '0.3rem 0.6rem', padding: '0.3rem 0.65rem',
fontSize: '0.8rem', fontSize: '0.8rem',
cursor: 'pointer', cursor: 'pointer',
}, },
warningBanner: { warning: {
background: '#fff8e1', background: '#fff8e1',
border: '1px solid #ffe082', border: '1px solid #ffe082',
borderRadius: 8, borderRadius: 8,
padding: '0.65rem 0.9rem', padding: '0.6rem 0.85rem',
fontSize: '0.85rem', fontSize: '0.84rem',
marginBottom: 16, marginBottom: 16,
color: '#7a5f00', color: '#7a5f00',
}, },
targetRow: { display: 'flex', gap: 8, marginBottom: 10 }, form: { display: 'flex', flexDirection: 'column', gap: 14 },
tab: { field: { display: 'flex', flexDirection: 'column', gap: 5 },
flex: 1, label: { fontSize: '0.85rem', fontWeight: 600 },
background: 'var(--color-surface-alt, #f7f5f0)', req: { color: '#c0392b' },
input: {
padding: '0.55rem 0.75rem',
border: '1px solid var(--color-border, #e5e0d5)', border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 8, borderRadius: 8,
padding: '0.5rem', fontSize: '0.93rem',
fontSize: '0.85rem', background: 'var(--color-input-bg, #faf9f7)',
cursor: 'pointer', outline: 'none',
fontWeight: 500, width: '100%',
boxSizing: 'border-box',
fontFamily: 'inherit',
}, },
tabActive: {
flex: 1,
background: '#c8a96e',
border: '1px solid #c8a96e',
borderRadius: 8,
padding: '0.5rem',
fontSize: '0.85rem',
cursor: 'pointer',
fontWeight: 700,
color: '#fff',
},
targetHint: { fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 14px' },
dropZone: { dropZone: {
border: '2px dashed var(--color-border, #e5e0d5)', border: '2px dashed var(--color-border, #ddd8cc)',
borderRadius: 12, borderRadius: 10,
padding: '2.5rem 1.5rem', padding: '1.4rem 1rem',
textAlign: 'center', textAlign: 'center',
cursor: 'pointer', cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s', transition: 'border-color 0.15s, background 0.15s',
userSelect: 'none', userSelect: 'none',
}, },
dropZoneActive: { dropZoneActive: { borderColor: '#c8a96e', background: 'rgba(200,169,110,0.06)' },
borderColor: '#c8a96e', dropZoneDisabled: { cursor: 'default', opacity: 0.65 },
background: 'rgba(200,169,110,0.06)', dropZoneHasFile: { borderColor: '#27ae60', background: 'rgba(39,174,96,0.04)' },
}, dropIcon: { fontSize: '1.8rem', display: 'block', marginBottom: 6 },
dropZoneDisabled: { cursor: 'default', opacity: 0.7 }, dropLabel: { fontWeight: 600, margin: '0 0 3px', fontSize: '0.9rem' },
dropIcon: { fontSize: '2rem', display: 'block', marginBottom: 8 }, dropSub: { color: '#888', fontSize: '0.8rem', margin: 0 },
dropLabel: { fontWeight: 600, margin: '0 0 4px', fontSize: '0.95rem' }, fileInfo: { display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left' },
dropSub: { color: 'var(--color-muted, #888)', fontSize: '0.82rem', margin: 0 }, fileIcon: { fontSize: '1.8rem', flexShrink: 0 },
progressWrap: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }, fileName: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px', wordBreak: 'break-all' },
progressBar: { fileSize: { color: '#888', fontSize: '0.78rem', margin: 0 },
width: '100%', progressWrap: { display: 'flex', flexDirection: 'column', gap: 5 },
height: 8, progressBar: { height: 7, background: '#e5e0d5', borderRadius: 4, overflow: 'hidden' },
background: '#e5e0d5', progressFill: { height: '100%', background: '#c8a96e', borderRadius: 4, transition: 'width 0.2s' },
borderRadius: 4, progressLabel: { fontSize: '0.82rem', color: '#888', margin: 0, textAlign: 'center' },
overflow: 'hidden', errorMsg: { color: '#c0392b', fontSize: '0.84rem', margin: '2px 0' },
}, btnPrimary: {
progressFill: {
height: '100%',
background: '#c8a96e', background: '#c8a96e',
borderRadius: 4, color: '#fff',
transition: 'width 0.2s', border: 'none',
borderRadius: 8,
padding: '0.7rem 1rem',
fontWeight: 700,
fontSize: '0.95rem',
cursor: 'pointer',
width: '100%',
}, },
progressPct: { fontSize: '0.85rem', color: 'var(--color-muted, #888)', margin: 0 }, submittedList: {
uploadList: {
marginTop: 20, marginTop: 20,
border: '1px solid var(--color-border, #e5e0d5)', border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 10, borderRadius: 10,
padding: '0.75rem 1rem', padding: '0.75rem 1rem',
}, },
uploadListTitle: { fontWeight: 600, fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.04em' }, submittedTitle: {
uploadRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '4px 0', flexWrap: 'wrap' }, fontWeight: 600,
uploadCheck: { color: '#27ae60', fontWeight: 700 }, fontSize: '0.78rem',
uploadName: { fontSize: '0.9rem', fontWeight: 500, wordBreak: 'break-all' }, color: '#888',
uploadMeta: { fontSize: '0.78rem', color: 'var(--color-muted, #888)', whiteSpace: 'nowrap' }, margin: '0 0 8px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
},
submittedRow: { display: 'flex', alignItems: 'flex-start', gap: 10, padding: '5px 0' },
checkMark: { color: '#27ae60', fontWeight: 700, flexShrink: 0 },
submittedEp: { fontWeight: 600, fontSize: '0.88rem', margin: '0 0 2px' },
submittedMeta: { color: '#888', fontSize: '0.78rem', margin: 0 },
} }