Make recording guidelines editable from admin panel; v2.0.18
Publish Container / docker (push) Successful in 14m23s
Publish Container / docker (push) Successful in 14m23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ef1d9fd8fc
commit
86de45dbc3
5 files changed
+157
-8
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.17",
|
"version": "2.0.18",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hos
|
|||||||
export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
|
export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json')
|
||||||
export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json')
|
export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json')
|
||||||
export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json')
|
export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json')
|
||||||
|
export const NUGGETS_PORTAL_GUIDELINES_FILE = path.join(DATA_DIR, 'nuggets-portal-guidelines.json')
|
||||||
export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/'
|
export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/'
|
||||||
|
|
||||||
// Email address for Nuggets upload notifications (defaults to the admin inbox)
|
// Email address for Nuggets upload notifications (defaults to the admin inbox)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
NUGGETS_UPLOAD_HOSTS_FILE,
|
NUGGETS_UPLOAD_HOSTS_FILE,
|
||||||
NUGGETS_UPLOAD_HISTORY_FILE,
|
NUGGETS_UPLOAD_HISTORY_FILE,
|
||||||
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
|
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
|
||||||
|
NUGGETS_PORTAL_GUIDELINES_FILE,
|
||||||
NUGGETS_NOTIFY_EMAIL,
|
NUGGETS_NOTIFY_EMAIL,
|
||||||
SEAFILE_URL,
|
SEAFILE_URL,
|
||||||
SEAFILE_TOKEN,
|
SEAFILE_TOKEN,
|
||||||
@@ -139,6 +140,30 @@ async function saveAnnouncement(text) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Guidelines helpers ────────────────────────────────────────────────────
|
||||||
|
const DEFAULT_GUIDELINES = [
|
||||||
|
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
|
||||||
|
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
|
||||||
|
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
|
||||||
|
'Normalize to -1 dBFS before exporting.',
|
||||||
|
'Match the episode name here to the file name exactly so search stays clean.',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function loadGuidelines() {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(NUGGETS_PORTAL_GUIDELINES_FILE, 'utf8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return { lines: DEFAULT_GUIDELINES, updatedAt: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGuidelines(lines) {
|
||||||
|
const data = { lines, updatedAt: new Date().toISOString() }
|
||||||
|
await writeFile(NUGGETS_PORTAL_GUIDELINES_FILE, JSON.stringify(data, null, 2), 'utf8')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
|
||||||
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
|
||||||
|
|
||||||
@@ -475,6 +500,13 @@ export function register(app) {
|
|||||||
res.json(data)
|
res.json(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Public: recording guidelines ─────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/nuggets-upload/guidelines', async (_req, res) => {
|
||||||
|
const data = await loadGuidelines()
|
||||||
|
res.json(data)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Admin: manage upload host accounts ───────────────────────────────
|
// ── Admin: manage upload host accounts ───────────────────────────────
|
||||||
|
|
||||||
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
|
||||||
@@ -597,6 +629,24 @@ export function register(app) {
|
|||||||
res.json({ ok: true, ...data })
|
res.json({ ok: true, ...data })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Admin: recording guidelines ───────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (req, res) => {
|
||||||
|
const raw = typeof req.body?.lines === 'string' ? req.body.lines : ''
|
||||||
|
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
|
||||||
|
if (lines.length > 20) {
|
||||||
|
res.status(400).json({ message: 'Maximum 20 guideline lines.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await saveGuidelines(lines)
|
||||||
|
res.json({ ok: true, ...data })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (_req, res) => {
|
||||||
|
const data = await saveGuidelines(DEFAULT_GUIDELINES)
|
||||||
|
res.json({ ok: true, ...data })
|
||||||
|
})
|
||||||
|
|
||||||
// ── Admin: upload history ─────────────────────────────────────────────
|
// ── Admin: upload history ─────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
|
app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => {
|
||||||
|
|||||||
+92
-1
@@ -666,7 +666,7 @@ type AdminView =
|
|||||||
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
|
||||||
| 'contact:profile' | 'contact:copy'
|
| 'contact:profile' | 'contact:copy'
|
||||||
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss'
|
||||||
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:history'
|
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
| 'subscribers' | 'study-users' | 'email-templates' | 'broadcast'
|
||||||
@@ -723,6 +723,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
|
{ kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' },
|
||||||
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
|
{ kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' },
|
||||||
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
|
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
|
||||||
|
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
|
||||||
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
|
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
|
||||||
]},
|
]},
|
||||||
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
|
||||||
@@ -1356,6 +1357,93 @@ function NuggetsAnnouncementAdmin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nuggets Guidelines admin ───────────────────────────────────────────────
|
||||||
|
const DEFAULT_GUIDELINE_LINES = [
|
||||||
|
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
|
||||||
|
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
|
||||||
|
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
|
||||||
|
'Normalize to -1 dBFS before exporting.',
|
||||||
|
'Match the episode name here to the file name exactly so search stays clean.',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
function NuggetsGuidelinesAdmin() {
|
||||||
|
const [lines, setLines] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/nuggets-upload/guidelines')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setLines(Array.isArray(d.lines) ? d.lines.join('\n') : DEFAULT_GUIDELINE_LINES))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/admin/nuggets-upload/guidelines', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ lines }),
|
||||||
|
})
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) { setMsg(d.message ?? 'Failed.'); return }
|
||||||
|
setMsg('✓ Guidelines saved.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
setSaving(true)
|
||||||
|
setMsg('')
|
||||||
|
try {
|
||||||
|
await fetch('/api/admin/nuggets-upload/guidelines', { method: 'DELETE' })
|
||||||
|
setLines(DEFAULT_GUIDELINE_LINES)
|
||||||
|
setMsg('✓ Reset to defaults.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Nuggets recording guidelines">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Nuggets — Recording Guidelines</h2>
|
||||||
|
<p>
|
||||||
|
One guideline per line. These appear in the collapsible "Recording Guidelines" panel on the host upload portal.
|
||||||
|
Max 20 lines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="admin-field" style={{ maxWidth: 620 }}>
|
||||||
|
<label htmlFor="gl-lines">Guidelines (one per line)</label>
|
||||||
|
<textarea
|
||||||
|
id="gl-lines"
|
||||||
|
rows={10}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: '0.87rem' }}
|
||||||
|
value={lines}
|
||||||
|
onChange={e => setLines(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.78rem', color: '#999' }}>
|
||||||
|
{lines.split('\n').filter(l => l.trim()).length} / 20 lines
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||||
|
<button className="btn-admin-save" type="submit" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
|
||||||
|
<button className="btn-admin-ghost" type="button" onClick={handleReset} disabled={saving}>Reset to defaults</button>
|
||||||
|
</div>
|
||||||
|
{msg && <p style={{ color: msg.startsWith('✓') ? '#27ae60' : '#c0392b', marginTop: 6, fontSize: '0.85rem' }}>{msg}</p>}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Nuggets Upload History admin ───────────────────────────────────────────
|
// ── Nuggets Upload History admin ───────────────────────────────────────────
|
||||||
interface HistoryEntryAdmin {
|
interface HistoryEntryAdmin {
|
||||||
hostId: string
|
hostId: string
|
||||||
@@ -5059,6 +5147,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
|||||||
{/* NUGGETS ANNOUNCEMENT */}
|
{/* NUGGETS ANNOUNCEMENT */}
|
||||||
{adminView === 'nuggets:announcement' && <NuggetsAnnouncementAdmin />}
|
{adminView === 'nuggets:announcement' && <NuggetsAnnouncementAdmin />}
|
||||||
|
|
||||||
|
{/* NUGGETS RECORDING GUIDELINES */}
|
||||||
|
{adminView === 'nuggets:guidelines' && <NuggetsGuidelinesAdmin />}
|
||||||
|
|
||||||
{/* NUGGETS UPLOAD HISTORY */}
|
{/* NUGGETS UPLOAD HISTORY */}
|
||||||
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ export function NuggetsHostPortal() {
|
|||||||
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
const [seafileConfigured, setSeafileConfigured] = useState(true)
|
||||||
const [twoFaEnabled, setTwoFaEnabled] = useState(false)
|
const [twoFaEnabled, setTwoFaEnabled] = useState(false)
|
||||||
const [announcement, setAnnouncement] = useState('')
|
const [announcement, setAnnouncement] = useState('')
|
||||||
|
const [guidelines, setGuidelines] = useState<string[]>([
|
||||||
|
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
|
||||||
|
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
|
||||||
|
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
|
||||||
|
'Normalize to -1 dBFS before exporting.',
|
||||||
|
'Match the episode name here to the file name exactly so search stays clean.',
|
||||||
|
])
|
||||||
|
|
||||||
// Login state
|
// Login state
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -100,12 +107,16 @@ export function NuggetsHostPortal() {
|
|||||||
.catch(() => setScreen('login'))
|
.catch(() => setScreen('login'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load announcement
|
// Load announcement and guidelines
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/nuggets-upload/announcement')
|
fetch('/api/nuggets-upload/announcement')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => { if (d.text) setAnnouncement(d.text) })
|
.then(d => { if (d.text) setAnnouncement(d.text) })
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
fetch('/api/nuggets-upload/guidelines')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (Array.isArray(d.lines) && d.lines.length) setGuidelines(d.lines) })
|
||||||
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load history when history panel is opened
|
// Load history when history panel is opened
|
||||||
@@ -516,11 +527,7 @@ export function NuggetsHostPortal() {
|
|||||||
{guidelinesOpen && (
|
{guidelinesOpen && (
|
||||||
<div style={s.collapsibleBody}>
|
<div style={s.collapsibleBody}>
|
||||||
<ul style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
<ul style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
||||||
<li>Export as <strong>MP3</strong> or <strong>WAV</strong> at 44.1 kHz, 128 kbps minimum.</li>
|
{guidelines.map((line, i) => <li key={i}>{line}</li>)}
|
||||||
<li>File name: <code style={s.code}>YYYY-MM-DD-EpisodeName.mp3</code> (e.g. <code style={s.code}>2026-09-09-Romans-5.mp3</code>).</li>
|
|
||||||
<li>Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.</li>
|
|
||||||
<li>Normalize to <strong>-1 dBFS</strong> before exporting.</li>
|
|
||||||
<li>Match episode name here to the file name exactly so search stays clean.</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user