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
Publish Container / docker (push) Successful in 14m36s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
afff859d17
commit
ef1d9fd8fc
6 files changed
+930
-132
No files matched your search
+159
-1
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user