Add full portal expansion: 2FA, change password, history, announcements, recording guidelines, Seafile link, draft saving, file validation; v2.0.17
Publish Container / docker (push) Successful in 14m36s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-09 08:08:42 -04:00
1 parent afff859d17
commit ef1d9fd8fc
6 files changed
+930 -132

No files matched your search

+159 -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:upload-portal'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:history'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
@@ -722,6 +722,8 @@ const ADMIN_NAV: NavGroup[] = [
{ 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: 'nuggets:announcement', label: 'Announcement' },
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
{ kind: 'leaf', view: 'downloads', label: 'Resources' },
@@ -1279,6 +1281,156 @@ function NuggetsUploadPortalAdmin() {
)
}
// ── Nuggets Announcement admin ─────────────────────────────────────────────
function NuggetsAnnouncementAdmin() {
const [text, setText] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets-upload/announcement')
.then(r => r.json())
.then(d => setText(d.text ?? ''))
.finally(() => setLoading(false))
}, [])
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setMsg('')
try {
const r = await fetch('/api/admin/nuggets-upload/announcement', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
const d = await r.json()
if (!r.ok) { setMsg(d.message ?? 'Failed.'); return }
setMsg(text ? '✓ Announcement saved.' : '✓ Announcement cleared.')
} finally {
setSaving(false)
}
}
async function handleClear() {
setSaving(true)
setMsg('')
try {
await fetch('/api/admin/nuggets-upload/announcement', { method: 'DELETE' })
setText('')
setMsg('✓ Announcement cleared.')
} finally {
setSaving(false)
}
}
return (
<section className="admin-panel-section" aria-label="Nuggets announcement">
<div className="admin-panel-head">
<h2>Nuggets — Portal Announcement</h2>
<p>This text appears as a banner at the top of the host upload portal. Leave blank to hide.</p>
</div>
{loading ? <p className="admin-stats-note">Loading…</p> : (
<form onSubmit={handleSave}>
<div className="admin-field" style={{ maxWidth: 560 }}>
<label htmlFor="ann-text">Announcement (max 500 chars)</label>
<textarea
id="ann-text"
rows={3}
maxLength={500}
placeholder="e.g. Please use the updated naming convention for episode files this week."
value={text}
onChange={e => setText(e.target.value)}
/>
<span style={{ fontSize: '0.78rem', color: '#999' }}>{text.length}/500</span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<button className="btn-admin-save" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
{text && <button className="btn-admin-ghost" type="button" onClick={handleClear} disabled={saving}>Clear</button>}
</div>
{msg && <p style={{ color: msg.startsWith('✓') ? '#27ae60' : '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{msg}</p>}
</form>
)}
</section>
)
}
// ── Nuggets Upload History admin ───────────────────────────────────────────
interface HistoryEntryAdmin {
hostId: string
hostName: string
episodeName: string
passage: string
filename: string
uploadedAt: string
}
function NuggetsHistoryAdmin() {
const [entries, setEntries] = useState<HistoryEntryAdmin[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets-upload/history')
.then(r => r.json())
.then(d => setEntries(d.entries ?? []))
.finally(() => setLoading(false))
}, [])
const visible = filter.trim()
? entries.filter(e =>
e.hostName.toLowerCase().includes(filter.toLowerCase()) ||
e.episodeName.toLowerCase().includes(filter.toLowerCase()) ||
e.passage.toLowerCase().includes(filter.toLowerCase()),
)
: entries
return (
<section className="admin-panel-section" aria-label="Nuggets upload history">
<div className="admin-panel-head">
<h2>Nuggets — Upload History</h2>
<p>All episodes submitted through the host portal, newest first.</p>
</div>
{loading ? <p className="admin-stats-note">Loading…</p> : (
<>
{entries.length > 0 && (
<div className="admin-field" style={{ maxWidth: 320, marginBottom: 12 }}>
<input type="text" placeholder="Filter by host, episode, or passage…" value={filter} onChange={e => setFilter(e.target.value)} />
</div>
)}
{visible.length === 0 ? (
<p className="admin-stats-note">{entries.length === 0 ? 'No uploads yet.' : 'No results match that filter.'}</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.86rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--color-border, #e5e0d5)' }}>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Date</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Host</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Episode</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>Passage</th>
<th style={{ textAlign: 'left', padding: '6px 8px' }}>File</th>
</tr>
</thead>
<tbody>
{visible.map((e, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<td style={{ padding: '6px 8px', whiteSpace: 'nowrap' }}>{new Date(e.uploadedAt).toLocaleDateString()}</td>
<td style={{ padding: '6px 8px' }}>{e.hostName}</td>
<td style={{ padding: '6px 8px' }}>{e.episodeName}</td>
<td style={{ padding: '6px 8px' }}>{e.passage}</td>
<td style={{ padding: '6px 8px', fontFamily: 'monospace', fontSize: '0.78rem', wordBreak: 'break-all' }}>{e.filename}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</section>
)
}
export default function AdminPage({ content, onSave, onLogout }: Props) {
const normalizedContent = normalizeSiteContentForAdmin(content)
const [form, setForm] = useState<SiteContent>(normalizedContent)
@@ -4904,6 +5056,12 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
{/* NUGGETS UPLOAD PORTAL */}
{adminView === 'nuggets:upload-portal' && <NuggetsUploadPortalAdmin />}
{/* NUGGETS ANNOUNCEMENT */}
{adminView === 'nuggets:announcement' && <NuggetsAnnouncementAdmin />}
{/* NUGGETS UPLOAD HISTORY */}
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
const filtered = studyComments.filter(c => {