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

+182 -1
View File
@@ -666,7 +666,7 @@ type AdminView =
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
@@ -721,6 +721,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'parent', id: 'nuggets', label: 'Nuggets', children: [
{ kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' },
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
@@ -1095,6 +1096,183 @@ function QrCodeImage({ url, label }: { url: string; label: string }) {
)
}
// ── Nuggets Upload Portal admin panel ─────────────────────────────────────
interface UploadHost {
id: string
name: string
username: string
active: boolean
createdAt: string
}
function NuggetsUploadPortalAdmin() {
const [hosts, setHosts] = useState<UploadHost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [addName, setAddName] = useState('')
const [addUsername, setAddUsername] = useState('')
const [addPassword, setAddPassword] = useState('')
const [addError, setAddError] = useState('')
const [addSaving, setAddSaving] = useState(false)
const [resetId, setResetId] = useState<string | null>(null)
const [resetPw, setResetPw] = useState('')
const [resetError, setResetError] = useState('')
const [resetSaving, setResetSaving] = useState(false)
function reload() {
setLoading(true)
fetch('/api/admin/nuggets-upload/hosts')
.then(r => r.json())
.then(d => setHosts(d.hosts ?? []))
.catch(() => setError('Failed to load hosts.'))
.finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
async function addHost(e: React.FormEvent) {
e.preventDefault()
setAddError('')
setAddSaving(true)
try {
const r = await fetch('/api/admin/nuggets-upload/hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: addName, username: addUsername, password: addPassword }),
})
const d = await r.json()
if (!r.ok) { setAddError(d.message ?? 'Failed.'); return }
setAddName(''); setAddUsername(''); setAddPassword('')
reload()
} finally {
setAddSaving(false)
}
}
async function toggleActive(host: UploadHost) {
await fetch(`/api/admin/nuggets-upload/hosts/${host.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: !host.active }),
})
reload()
}
async function deleteHost(id: string) {
if (!window.confirm('Remove this host account? This cannot be undone.')) return
await fetch(`/api/admin/nuggets-upload/hosts/${id}`, { method: 'DELETE' })
reload()
}
async function resetPassword(e: React.FormEvent) {
e.preventDefault()
if (!resetId) return
setResetError('')
setResetSaving(true)
try {
const r = await fetch(`/api/admin/nuggets-upload/hosts/${resetId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: resetPw }),
})
const d = await r.json()
if (!r.ok) { setResetError(d.message ?? 'Failed.'); return }
setResetId(null); setResetPw('')
reload()
} finally {
setResetSaving(false)
}
}
return (
<section className="admin-panel-section" aria-label="Nuggets upload portal">
<div className="admin-panel-head">
<h2>Nuggets — Upload Portal</h2>
<p>
Manage host login accounts for the upload portal at{' '}
<a href="/host-upload" target="_blank" rel="noopener noreferrer">/host-upload</a>.
Each host gets a username and password to upload episode files to Seafile.
</p>
<p style={{ fontSize: '0.82rem', color: '#888', marginTop: 4 }}>
Make sure <code>SEAFILE_URL</code>, <code>SEAFILE_TOKEN</code>, and <code>SEAFILE_NUGGETS_REPO_ID</code> are set in your environment.
</p>
</div>
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
{loading && <p className="admin-stats-note">Loading…</p>}
{!loading && hosts.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '1.5rem', fontSize: '0.88rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Name</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Username</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Status</th>
<th style={{ padding: '6px 8px' }}></th>
</tr>
</thead>
<tbody>
{hosts.map(h => (
<tr key={h.id} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<td style={{ padding: '7px 8px' }}>{h.name}</td>
<td style={{ padding: '7px 8px', fontFamily: 'monospace' }}>{h.username}</td>
<td style={{ padding: '7px 8px' }}>
<span style={{ color: h.active ? '#27ae60' : '#888', fontWeight: 600 }}>{h.active ? 'Active' : 'Disabled'}</span>
</td>
<td style={{ padding: '7px 8px', whiteSpace: 'nowrap', textAlign: 'right' }}>
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => toggleActive(h)}>
{h.active ? 'Disable' : 'Enable'}
</button>
<button className="btn-admin-ghost" style={{ fontSize: '0.78rem', marginRight: 6 }} onClick={() => { setResetId(h.id); setResetPw(''); setResetError('') }}>
Reset PW
</button>
<button className="btn-admin-remove" style={{ fontSize: '0.78rem' }} onClick={() => deleteHost(h.id)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && hosts.length === 0 && <p className="admin-stats-note">No upload hosts yet. Add one below.</p>}
{resetId && (
<form onSubmit={resetPassword} style={{ background: 'var(--color-surface-alt, #f7f5f0)', border: '1px solid var(--color-border, #e5e0d5)', borderRadius: 8, padding: '1rem', marginBottom: '1.5rem' }}>
<p style={{ fontWeight: 600, marginBottom: 8 }}>Reset password for {hosts.find(h => h.id === resetId)?.username}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div className="admin-field" style={{ flex: '1 1 200px' }}>
<label>New Password (min 8 chars)</label>
<input type="password" value={resetPw} onChange={e => setResetPw(e.target.value)} required minLength={8} />
</div>
<button className="btn-admin-save" type="submit" disabled={resetSaving}>{resetSaving ? 'Saving…' : 'Save'}</button>
<button className="btn-admin-ghost" type="button" onClick={() => setResetId(null)}>Cancel</button>
</div>
{resetError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{resetError}</p>}
</form>
)}
<form onSubmit={addHost}>
<h3 style={{ marginBottom: 10 }}>Add Host Account</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="admin-field" style={{ flex: '1 1 160px' }}>
<label>Display Name</label>
<input type="text" placeholder="Jane Smith" value={addName} onChange={e => setAddName(e.target.value)} required />
</div>
<div className="admin-field" style={{ flex: '1 1 140px' }}>
<label>Username</label>
<input type="text" placeholder="janesmith" value={addUsername} onChange={e => setAddUsername(e.target.value.toLowerCase())} required />
</div>
<div className="admin-field" style={{ flex: '1 1 160px' }}>
<label>Password (min 8 chars)</label>
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)} required minLength={8} />
</div>
<button className="btn-admin-save" type="submit" disabled={addSaving}>{addSaving ? 'Adding…' : '+ Add Host'}</button>
</div>
{addError && <p style={{ color: '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{addError}</p>}
</form>
</section>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -4717,6 +4895,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
)
})()}
{/* NUGGETS UPLOAD PORTAL */}
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {