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:
1 parent
afdc00114a
commit
6a03813914
3 files changed
+288
-249
No files matched your search
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 },
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user