Add Nuggets Episode Workflow to admin (listen, cold open, record, edit, Spotify); v2.13.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff119e7f0
commit
ffb3551b7e
5 files changed
+320
-4
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.12.0",
|
"version": "2.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"version": "2.12.0",
|
"version": "2.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1135.0",
|
"@aws-sdk/client-s3": "^3.1135.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.12.0",
|
"version": "2.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { register as registerDownloads } from './server/routes/downloads.js'
|
|||||||
import { register as registerEpisodes } from './server/routes/episodes.js'
|
import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||||
import { register as registerNuggets } from './server/routes/nuggets.js'
|
import { register as registerNuggets } from './server/routes/nuggets.js'
|
||||||
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
import { register as registerNuggetsHostUpload } from './server/routes/nuggets-host-upload.js'
|
||||||
|
import { register as registerNuggetsWorkflow } from './server/routes/nuggets-workflow.js'
|
||||||
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
import { startNuggetsSeafileWatcher } from './server/nuggets-seafile-watcher.js'
|
||||||
import { register as registerFeed } from './server/routes/feed.js'
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
@@ -126,6 +127,7 @@ registerDownloads(app)
|
|||||||
registerEpisodes(app)
|
registerEpisodes(app)
|
||||||
registerNuggets(app)
|
registerNuggets(app)
|
||||||
registerNuggetsHostUpload(app)
|
registerNuggetsHostUpload(app)
|
||||||
|
registerNuggetsWorkflow(app)
|
||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
registerBiblePassage(app)
|
registerBiblePassage(app)
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { Readable } from 'node:stream'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { writeFile } from '../atomic-write.js'
|
||||||
|
import { requireAdminAuth } from '../auth.js'
|
||||||
|
import { loadHistory } from './nuggets-host-upload.js'
|
||||||
|
import {
|
||||||
|
DATA_DIR,
|
||||||
|
SEAFILE_URL,
|
||||||
|
SEAFILE_TOKEN,
|
||||||
|
SEAFILE_NUGGETS_REPO_ID,
|
||||||
|
SEAFILE_EPISODE_UPLOAD_DIR,
|
||||||
|
} from '../config.js'
|
||||||
|
|
||||||
|
const WORKFLOW_FILE = path.join(DATA_DIR, 'nuggets-workflow.json')
|
||||||
|
|
||||||
|
export const WORKFLOW_STEPS = ['listen', 'coldOpen', 'recordColdOpen', 'edit', 'spotify']
|
||||||
|
|
||||||
|
async function loadWorkflow() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(WORKFLOW_FILE, 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return { items: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryId(entry) {
|
||||||
|
return createHash('sha1').update(`${entry.uploadedAt}|${entry.filename}`).digest('hex').slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState() {
|
||||||
|
return {
|
||||||
|
steps: Object.fromEntries(WORKFLOW_STEPS.map(s => [s, null])), // step → ISO completion time
|
||||||
|
coldOpenText: '',
|
||||||
|
notes: '',
|
||||||
|
spotifyUrl: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seafileConfigured() {
|
||||||
|
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/admin/nuggets-workflow', requireAdminAuth, async (_req, res) => {
|
||||||
|
const [history, wf] = await Promise.all([loadHistory(), loadWorkflow()])
|
||||||
|
const items = history.entries.map(entry => {
|
||||||
|
const id = entryId(entry)
|
||||||
|
return { id, entry, state: { ...emptyState(), ...(wf.items[id] ?? {}) } }
|
||||||
|
})
|
||||||
|
res.json({ items })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/admin/nuggets-workflow/:id', requireAdminAuth, async (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const history = await loadHistory()
|
||||||
|
if (!history.entries.some(e => entryId(e) === id)) {
|
||||||
|
res.status(404).json({ message: 'Upload not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wf = await loadWorkflow()
|
||||||
|
const prev = { ...emptyState(), ...(wf.items[id] ?? {}) }
|
||||||
|
const body = req.body ?? {}
|
||||||
|
const next = { ...prev, steps: { ...prev.steps } }
|
||||||
|
|
||||||
|
if (body.steps && typeof body.steps === 'object') {
|
||||||
|
for (const s of WORKFLOW_STEPS) {
|
||||||
|
if (typeof body.steps[s] === 'boolean') {
|
||||||
|
// Keep the original completion time if the step was already done
|
||||||
|
next.steps[s] = body.steps[s] ? (prev.steps[s] ?? new Date().toISOString()) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof body.coldOpenText === 'string') next.coldOpenText = body.coldOpenText.slice(0, 5000)
|
||||||
|
if (typeof body.notes === 'string') next.notes = body.notes.slice(0, 5000)
|
||||||
|
if (typeof body.spotifyUrl === 'string') next.spotifyUrl = body.spotifyUrl.trim().slice(0, 500)
|
||||||
|
|
||||||
|
wf.items[id] = next
|
||||||
|
await writeFile(WORKFLOW_FILE, JSON.stringify(wf, null, 2), 'utf8')
|
||||||
|
res.json({ ok: true, state: next })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stream the uploaded audio from Seafile so it can be auditioned in the admin.
|
||||||
|
app.get('/api/admin/nuggets-workflow/:id/audio', requireAdminAuth, async (req, res) => {
|
||||||
|
if (!seafileConfigured()) {
|
||||||
|
res.status(503).json({ message: 'Seafile is not configured.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const history = await loadHistory()
|
||||||
|
const entry = history.entries.find(e => entryId(e) === req.params.id)
|
||||||
|
if (!entry) {
|
||||||
|
res.status(404).json({ message: 'Upload not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const filePath = `${SEAFILE_EPISODE_UPLOAD_DIR.replace(/\/$/, '')}/${entry.filename}`
|
||||||
|
const linkRes = await fetch(
|
||||||
|
`${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
|
||||||
|
{ headers: { Authorization: `Token ${SEAFILE_TOKEN}` }, signal: AbortSignal.timeout(10_000) },
|
||||||
|
)
|
||||||
|
if (!linkRes.ok) {
|
||||||
|
res.status(404).json({ message: 'Audio file not found in Seafile.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const downloadUrl = (await linkRes.text()).replace(/^"|"$/g, '')
|
||||||
|
|
||||||
|
const headers = {}
|
||||||
|
if (req.headers.range) headers.Range = req.headers.range
|
||||||
|
const upstream = await fetch(downloadUrl, { headers })
|
||||||
|
if (!upstream.ok && upstream.status !== 206) {
|
||||||
|
res.status(502).json({ message: 'Could not fetch audio from Seafile.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(upstream.status)
|
||||||
|
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) {
|
||||||
|
const v = upstream.headers.get(h)
|
||||||
|
if (v) res.setHeader(h, v)
|
||||||
|
}
|
||||||
|
if (!upstream.headers.get('content-type')) res.setHeader('content-type', 'audio/mpeg')
|
||||||
|
const stream = Readable.fromWeb(upstream.body)
|
||||||
|
req.on('close', () => stream.destroy())
|
||||||
|
stream.on('error', () => res.end())
|
||||||
|
stream.pipe(res)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[nuggets-workflow] audio error:', err.message)
|
||||||
|
if (!res.headersSent) res.status(500).json({ message: 'Audio stream failed.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+185
-1
@@ -731,7 +731,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:guidelines' | 'nuggets:history'
|
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
|
||||||
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
|
||||||
| 'inbox'
|
| 'inbox'
|
||||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||||
@@ -790,6 +790,7 @@ const ADMIN_NAV: NavGroup[] = [
|
|||||||
{ 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:guidelines', label: 'Recording Guidelines' },
|
||||||
|
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
|
||||||
{ 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' },
|
||||||
@@ -1615,6 +1616,188 @@ function NuggetsHistoryAdmin() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
|
||||||
|
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
|
||||||
|
|
||||||
|
interface WorkflowState {
|
||||||
|
steps: Record<WorkflowStepKey, string | null>
|
||||||
|
coldOpenText: string
|
||||||
|
notes: string
|
||||||
|
spotifyUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowItem {
|
||||||
|
id: string
|
||||||
|
entry: { hostName: string; episodeName: string; passage: string; description?: string; filename: string; uploadedAt: string }
|
||||||
|
state: WorkflowState
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORKFLOW_STEP_DEFS: { key: WorkflowStepKey; label: string; hint: string }[] = [
|
||||||
|
{ key: 'listen', label: 'Listen to the uploaded file', hint: 'Audition the host’s recording and jot down notes.' },
|
||||||
|
{ key: 'coldOpen', label: 'Get a cold open', hint: 'Pick or write the hook that opens the episode.' },
|
||||||
|
{ key: 'recordColdOpen', label: 'Record the cold open', hint: 'Record the cold open take.' },
|
||||||
|
{ key: 'edit', label: 'Edit the episode', hint: 'Cold open + episode: trim, clean up, mix, master.' },
|
||||||
|
{ key: 'spotify', label: 'Upload to Spotify', hint: 'Publish with show notes and paste the episode link below.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function NuggetsWorkflowAdmin() {
|
||||||
|
const [items, setItems] = useState<WorkflowItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedId, setSelectedId] = useState('')
|
||||||
|
const [showDone, setShowDone] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/nuggets-workflow')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setItems(d.items ?? []))
|
||||||
|
.catch(() => setError('Could not load uploads.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isDone = (i: WorkflowItem) => WORKFLOW_STEP_DEFS.every(s => i.state.steps[s.key])
|
||||||
|
const visible = showDone ? items : items.filter(i => !isDone(i))
|
||||||
|
const selected = items.find(i => i.id === selectedId) ?? null
|
||||||
|
|
||||||
|
const save = async (id: string, patch: Partial<Omit<WorkflowState, 'steps'>> & { steps?: Partial<Record<WorkflowStepKey, boolean>> }) => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/nuggets-workflow/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
const d = await r.json()
|
||||||
|
if (!r.ok) throw new Error(d.message ?? 'Save failed.')
|
||||||
|
setItems(prev => prev.map(i => (i.id === id ? { ...i, state: d.state } : i)))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Save failed.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchLocal = (id: string, patch: Partial<WorkflowState>) =>
|
||||||
|
setItems(prev => prev.map(i => (i.id === id ? { ...i, state: { ...i.state, ...patch } } : i)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="admin-panel-section" aria-label="Nuggets episode workflow">
|
||||||
|
<div className="admin-panel-head">
|
||||||
|
<h2>Nuggets — Episode Workflow</h2>
|
||||||
|
<p>Walk each portal upload through: listen → cold open → record cold open → edit → Spotify.</p>
|
||||||
|
</div>
|
||||||
|
{error && <p style={{ color: '#c0392b' }}>{error}</p>}
|
||||||
|
{loading ? <p className="admin-stats-note">Loading…</p> : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', gap: 20, alignItems: 'start' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '0.82rem', display: 'block', marginBottom: 8 }}>
|
||||||
|
<input type="checkbox" checked={showDone} onChange={e => setShowDone(e.target.checked)} /> Show completed
|
||||||
|
</label>
|
||||||
|
{visible.length === 0 && <p className="admin-stats-note">{items.length === 0 ? 'No uploads yet.' : 'All caught up.'}</p>}
|
||||||
|
{visible.map(i => {
|
||||||
|
const done = WORKFLOW_STEP_DEFS.filter(s => i.state.steps[s.key]).length
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(i.id)}
|
||||||
|
style={{
|
||||||
|
display: 'block', width: '100%', textAlign: 'left', padding: '8px 10px', marginBottom: 6,
|
||||||
|
border: `1px solid ${i.id === selectedId ? '#8a6d2f' : 'var(--color-border, #e5e0d5)'}`,
|
||||||
|
borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{i.entry.episodeName}</strong>
|
||||||
|
<div style={{ fontSize: '0.78rem', opacity: 0.75 }}>
|
||||||
|
{i.entry.passage} · {i.entry.hostName} · {new Date(i.entry.uploadedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.78rem', marginTop: 2 }}>{done}/{WORKFLOW_STEP_DEFS.length} steps</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{!selected ? <p className="admin-stats-note">Select an upload to work on.</p> : (
|
||||||
|
<>
|
||||||
|
<h3 style={{ marginTop: 0 }}>{selected.entry.episodeName}</h3>
|
||||||
|
<p style={{ fontSize: '0.86rem', opacity: 0.8 }}>
|
||||||
|
{selected.entry.passage} · uploaded by {selected.entry.hostName} · <code>{selected.entry.filename}</code>
|
||||||
|
</p>
|
||||||
|
{selected.entry.description && <p style={{ fontSize: '0.86rem' }}>{selected.entry.description}</p>}
|
||||||
|
|
||||||
|
<ol style={{ paddingLeft: 0, listStyle: 'none', margin: 0 }}>
|
||||||
|
{WORKFLOW_STEP_DEFS.map((s, idx) => {
|
||||||
|
const completedAt = selected.state.steps[s.key]
|
||||||
|
return (
|
||||||
|
<li key={s.key} style={{ padding: '12px 0', borderTop: '1px solid var(--color-border, #e5e0d5)' }}>
|
||||||
|
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(completedAt)}
|
||||||
|
onChange={e => save(selected.id, { steps: { [s.key]: e.target.checked } })}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{idx + 1}. {s.label}</strong>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.8rem', opacity: 0.7 }}>
|
||||||
|
{s.hint}{completedAt ? ` · done ${new Date(completedAt).toLocaleString()}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{s.key === 'listen' && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<audio controls preload="none" style={{ width: '100%' }} src={`/api/admin/nuggets-workflow/${selected.id}/audio`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{s.key === 'coldOpen' && (
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
placeholder="Cold open script / hook…"
|
||||||
|
value={selected.state.coldOpenText}
|
||||||
|
onChange={e => patchLocal(selected.id, { coldOpenText: e.target.value })}
|
||||||
|
onBlur={e => save(selected.id, { coldOpenText: e.target.value })}
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{s.key === 'spotify' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Spotify episode URL (optional)"
|
||||||
|
value={selected.state.spotifyUrl}
|
||||||
|
onChange={e => patchLocal(selected.id, { spotifyUrl: e.target.value })}
|
||||||
|
onBlur={e => save(selected.id, { spotifyUrl: e.target.value })}
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="admin-field" style={{ marginTop: 12 }}>
|
||||||
|
<label htmlFor="wf-notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="wf-notes"
|
||||||
|
rows={3}
|
||||||
|
value={selected.state.notes}
|
||||||
|
onChange={e => patchLocal(selected.id, { notes: e.target.value })}
|
||||||
|
onBlur={e => save(selected.id, { notes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{saving && <p className="admin-stats-note">Saving…</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||||
const normalizedContent = normalizeSiteContentForAdmin(content)
|
const normalizedContent = normalizeSiteContentForAdmin(content)
|
||||||
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
const [form, setForm] = useState<SiteContent>(normalizedContent)
|
||||||
@@ -6102,6 +6285,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
|
|||||||
{adminView === 'nuggets:guidelines' && <NuggetsGuidelinesAdmin />}
|
{adminView === 'nuggets:guidelines' && <NuggetsGuidelinesAdmin />}
|
||||||
|
|
||||||
{/* NUGGETS UPLOAD HISTORY */}
|
{/* NUGGETS UPLOAD HISTORY */}
|
||||||
|
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
|
||||||
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
|
||||||
|
|
||||||
{/* STUDY COMMENTS */}
|
{/* STUDY COMMENTS */}
|
||||||
|
|||||||
Reference in new issue
Block a user