Add Nuggets host upload portal with Seafile integration; v2.0.4

- New /host-upload page: per-host login (username + password) with
  session cookie, drag-and-drop file upload to Seafile via server proxy
- Two upload targets: Episode Upload (audio) and Episode Info (docs)
- Server route: /api/nuggets-upload/* for host auth + file proxy,
  /api/admin/nuggets-upload/hosts for admin CRUD (create/disable/reset pw/delete)
- Admin panel: Nuggets → Upload Portal view to manage host accounts
- Seafile config via SEAFILE_URL / SEAFILE_TOKEN / SEAFILE_NUGGETS_REPO_ID env vars
- Directories default to /Episode Upload and /Episode Info (configurable)
- Uses multer for multipart uploads; 500 MB per-file limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-08 13:38:57 -04:00
1 parent 29d2ace1b2
commit afdc00114a
9 files changed
+1074 -3

No files matched your search

+439
View File
@@ -0,0 +1,439 @@
import { useState, useEffect, useRef } from 'react'
type UploadTarget = 'upload' | 'info'
interface UploadRecord {
filename: string
dir: string
at: string
}
export function NuggetsHostPortal() {
const [checking, setChecking] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
const [hostName, setHostName] = useState('')
const [seafileConfigured, setSeafileConfigured] = useState(true)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loginError, setLoginError] = useState('')
const [loggingIn, setLoggingIn] = useState(false)
const [target, setTarget] = useState<UploadTarget>('upload')
const [dragging, setDragging] = useState(false)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [uploadError, setUploadError] = useState('')
const [uploads, setUploads] = useState<UploadRecord[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const dropRef = useRef<HTMLDivElement>(null)
useEffect(() => {
fetch('/api/nuggets-upload/me')
.then(r => r.json())
.then(d => {
setAuthenticated(d.authenticated)
setHostName(d.hostName ?? '')
setSeafileConfigured(d.configured !== false)
})
.finally(() => setChecking(false))
}, [])
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({ username, password }),
})
const d = await r.json()
if (!r.ok) {
setLoginError(d.message ?? 'Login failed.')
return
}
setHostName(d.hostName)
setAuthenticated(true)
} finally {
setLoggingIn(false)
}
}
async function handleLogout() {
await fetch('/api/nuggets-upload/logout', { method: 'POST' })
setAuthenticated(false)
setHostName('')
setUsername('')
setPassword('')
setUploads([])
}
async function uploadFile(file: File) {
if (uploading) return
setUploadError('')
setUploading(true)
setProgress(0)
const formData = new FormData()
formData.append('file', file)
formData.append('targetDir', target)
return new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/nuggets-upload/upload')
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100))
})
xhr.addEventListener('load', () => {
setUploading(false)
if (xhr.status === 200) {
try {
const d = JSON.parse(xhr.responseText)
setUploads(prev => [{ filename: d.filename, dir: d.dir, at: new Date().toLocaleTimeString() }, ...prev])
setProgress(100)
} catch {
setUploadError('Upload succeeded but response was unexpected.')
}
} else {
try {
const d = JSON.parse(xhr.responseText)
setUploadError(d.message ?? 'Upload failed.')
} catch {
setUploadError('Upload failed.')
}
}
resolve()
})
xhr.addEventListener('error', () => {
setUploading(false)
setUploadError('Network error during upload.')
resolve()
})
xhr.send(formData)
})
}
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) {
return (
<div style={styles.page}>
<div style={styles.card}>
<p style={{ color: 'var(--color-muted, #888)', textAlign: 'center' }}>Loading…</p>
</div>
</div>
)
}
if (!authenticated) {
return (
<div style={styles.page}>
<div style={styles.card}>
<div style={styles.logo}>
<span style={styles.logoEmoji}>🎙️</span>
<span style={styles.logoTitle}>Nuggets</span>
</div>
<h1 style={styles.heading}>Host Upload Portal</h1>
<p style={styles.subheading}>Sign in to upload your episode files to Seafile.</p>
<form onSubmit={handleLogin} style={styles.form}>
<div style={styles.field}>
<label style={styles.label} htmlFor="hp-username">Username</label>
<input
id="hp-username"
style={styles.input}
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loggingIn}
required
/>
</div>
<div style={styles.field}>
<label style={styles.label} htmlFor="hp-password">Password</label>
<input
id="hp-password"
style={styles.input}
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loggingIn}
required
/>
</div>
{loginError && <p style={styles.errorMsg}>{loginError}</p>}
<button style={styles.btnPrimary} type="submit" disabled={loggingIn}>
{loggingIn ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
const targetLabel = target === 'upload' ? 'Episode Upload' : 'Episode Info'
const acceptTypes = target === 'upload'
? '.mp3,.wav,.m4a,.aac,.flac,.ogg,.mp4,.mov'
: '*'
return (
<div style={styles.page}>
<div style={{ ...styles.card, maxWidth: 640 }}>
<div style={styles.headerRow}>
<div style={styles.logo}>
<span style={styles.logoEmoji}>🎙️</span>
<span style={styles.logoTitle}>Nuggets Upload Portal</span>
</div>
<div style={styles.headerRight}>
<span style={styles.welcomeText}>Hi, {hostName}</span>
<button style={styles.btnGhost} onClick={handleLogout}>Sign out</button>
</div>
</div>
{!seafileConfigured && (
<div style={styles.warningBanner}>
Seafile is not configured yet. Contact the admin to set up the connection.
</div>
)}
<div style={styles.targetRow}>
<button
style={target === 'upload' ? styles.tabActive : styles.tab}
onClick={() => { setTarget('upload'); setUploadError('') }}
>
🎵 Episode Upload
</button>
<button
style={target === 'info' ? styles.tabActive : styles.tab}
onClick={() => { setTarget('info'); setUploadError('') }}
>
📄 Episode Info
</button>
</div>
<p style={styles.targetHint}>
{target === 'upload'
? 'Upload your finished audio file (MP3, WAV, M4A, etc.).'
: 'Upload show notes, outlines, scripts, or any supporting document.'}
</p>
<div
ref={dropRef}
style={{
...styles.dropZone,
...(dragging ? styles.dropZoneActive : {}),
...(uploading ? styles.dropZoneDisabled : {}),
}}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept={acceptTypes}
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{uploading ? (
<div style={styles.progressWrap}>
<p style={styles.dropLabel}>Uploading to {targetLabel}…</p>
<div style={styles.progressBar}>
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
</div>
<p style={styles.progressPct}>{progress}%</p>
</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>}
{uploads.length > 0 && (
<div style={styles.uploadList}>
<p style={styles.uploadListTitle}>Uploaded this session</p>
{uploads.map((u, i) => (
<div key={i} style={styles.uploadRow}>
<span style={styles.uploadCheck}>✓</span>
<span style={styles.uploadName}>{u.filename}</span>
<span style={styles.uploadMeta}>{u.dir} · {u.at}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}
// ── Styles ────────────────────────────────────────────────────────────────
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
background: 'var(--color-bg, #f7f5f0)',
},
card: {
background: 'var(--color-surface, #fff)',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 16,
padding: '2.5rem 2rem',
width: '100%',
maxWidth: 420,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
},
logo: {
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 16,
},
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',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 20,
gap: 12,
flexWrap: 'wrap',
},
headerRight: { display: 'flex', alignItems: 'center', gap: 10 },
welcomeText: { fontSize: '0.85rem', color: 'var(--color-muted, #888)' },
btnGhost: {
background: 'none',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 6,
padding: '0.3rem 0.6rem',
fontSize: '0.8rem',
cursor: 'pointer',
},
warningBanner: {
background: '#fff8e1',
border: '1px solid #ffe082',
borderRadius: 8,
padding: '0.65rem 0.9rem',
fontSize: '0.85rem',
marginBottom: 16,
color: '#7a5f00',
},
targetRow: { display: 'flex', gap: 8, marginBottom: 10 },
tab: {
flex: 1,
background: 'var(--color-surface-alt, #f7f5f0)',
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 8,
padding: '0.5rem',
fontSize: '0.85rem',
cursor: 'pointer',
fontWeight: 500,
},
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: {
border: '2px dashed var(--color-border, #e5e0d5)',
borderRadius: 12,
padding: '2.5rem 1.5rem',
textAlign: 'center',
cursor: 'pointer',
transition: 'border-color 0.15s, background 0.15s',
userSelect: 'none',
},
dropZoneActive: {
borderColor: '#c8a96e',
background: 'rgba(200,169,110,0.06)',
},
dropZoneDisabled: { cursor: 'default', opacity: 0.7 },
dropIcon: { fontSize: '2rem', display: 'block', marginBottom: 8 },
dropLabel: { fontWeight: 600, margin: '0 0 4px', fontSize: '0.95rem' },
dropSub: { color: 'var(--color-muted, #888)', fontSize: '0.82rem', margin: 0 },
progressWrap: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 },
progressBar: {
width: '100%',
height: 8,
background: '#e5e0d5',
borderRadius: 4,
overflow: 'hidden',
},
progressFill: {
height: '100%',
background: '#c8a96e',
borderRadius: 4,
transition: 'width 0.2s',
},
progressPct: { fontSize: '0.85rem', color: 'var(--color-muted, #888)', margin: 0 },
uploadList: {
marginTop: 20,
border: '1px solid var(--color-border, #e5e0d5)',
borderRadius: 10,
padding: '0.75rem 1rem',
},
uploadListTitle: { fontWeight: 600, fontSize: '0.82rem', color: 'var(--color-muted, #888)', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.04em' },
uploadRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '4px 0', flexWrap: 'wrap' },
uploadCheck: { color: '#27ae60', fontWeight: 700 },
uploadName: { fontSize: '0.9rem', fontWeight: 500, wordBreak: 'break-all' },
uploadMeta: { fontSize: '0.78rem', color: 'var(--color-muted, #888)', whiteSpace: 'nowrap' },
}