Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. Adds server/src/modules/registries.js and moves core's own notification streams, announce leg and users-detail routes behind it, so the three seams §1.8 and §1.9 named are exercised on every boot before any module depends on them. Registering is validate-then-commit per registrant: the loader stages what a module claims and the second pass commits it, so a module that throws halfway through register() — or fails checkDeclared after it — leaves nothing behind. That is the registry-side twin of PR 2's second-pass mount rule. Four decisions, all the recommended option: - announce legs became a child table. `announce_job_legs` replaces the towncrier_*/discord_* column groups, so the leg set is data: core registers `discord`, module-uo will register `towncrier`, and a module cannot ALTER a core table to add its own. Backfill is guarded on information_schema (a SELECT of a dropped column is a parse error, not a runtime one) and the columns go with DROP COLUMN IF EXISTS. Verified against the live dev DB: three legacy jobs migrated faithfully, three replays, no duplicates. - `mapEvent` dropped from registerNotificationStreams. §1.8 already inverts the push path so a module owns fromShardEvent and calls core's publish() with a stream id it resolved; a second mapping mechanism was a leftover. The public safety filter, the kinds it reads and the streams it protects now live in one file and move together. - core registers through the same staging area a module uses, via an explicit registries.registerCore() in app.js before modules.load(). - core's six /admin/users/:id/shard/* paths now go through the `admin.users.detail` slot, and getUser moved back to admin.controller.js. Found on the way, and the reason two build tools changed: - scripts/routeManifest.js could not decode a parameterised mount. Its unwinder expected `(?:([^\/]+?))`; express 4.22 emits `(?:\/([^/]+?))` with the separator inside the group. The branch had never run. It threw rather than guessing, which is what it is for. - swagger-autogen cannot follow a route into an extension slot — the slot's router is created by declareSlot() and filled later, so there is no literal mount for a static parse. Regenerating deleted 407 lines and printed `Swagger-autogen: Success`, the spike's exact failure (MODULE_API.md §7.4). swagger/slotSpecs.js generates a fragment per filled slot and re-roots it at the prefix the router actually hangs at in the live app — read from the express stack via routeManifest's own mountPath, so the manifest and the spec cannot disagree. swagger/mergeSpec.js is the merge helper core owes for module fragments anyway (§6.1a), proved here against core's own slot first. 884 tests pass (856 before). routes.manifest.json is unchanged at 229 routes. The OpenAPI spec diff is two lines of intent: the retry endpoint's summary, and its `leg` no longer being a fixed enum. Co-Authored-By: Claude <noreply@anthropic.com>
268 lines
9.2 KiB
JavaScript
268 lines
9.2 KiB
JavaScript
import { lazy, Suspense, useEffect, useState } from 'react'
|
|
import Modal from '../../../components/Modal.jsx'
|
|
import { api } from '../../../api/client.js'
|
|
|
|
const RichTextEditor = lazy(() => import('../../../components/RichTextEditor.jsx'))
|
|
|
|
const CATEGORIES = [
|
|
{ v: 'news', l: 'News' },
|
|
{ v: 'five-on-friday', l: 'Five on Friday' },
|
|
{ v: 'newsletter', l: 'Newsletter' },
|
|
{ v: 'screenshots', l: 'Screenshots' },
|
|
]
|
|
const DB_TO_URL = { news: 'news', five_on_friday: 'five-on-friday', newsletter: 'newsletter', screenshot: 'screenshots' }
|
|
|
|
export default function PostEditor({ post, onClose, onSaved }) {
|
|
const isEdit = Boolean(post)
|
|
const [form, setForm] = useState({
|
|
category: post ? DB_TO_URL[post.category] || 'news' : 'news',
|
|
title: post?.title || '',
|
|
slug: post?.slug || '',
|
|
excerpt: post?.excerpt || '',
|
|
body: post?.body || '',
|
|
image_url: post?.image_url || '',
|
|
published: post ? Boolean(post.published) : false,
|
|
})
|
|
const [busy, setBusy] = useState(false)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }))
|
|
const isScreenshot = form.category === 'screenshots'
|
|
|
|
async function onUpload(e) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setUploading(true)
|
|
setError('')
|
|
try {
|
|
const res = await api.admin.uploadImage(file)
|
|
setForm((f) => ({ ...f, image_url: res.image_url }))
|
|
} catch (err) {
|
|
setError(err.message || 'Upload failed')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (!form.title.trim()) return setError('Title is required.')
|
|
if (isScreenshot && !form.image_url) return setError('Screenshots need an image.')
|
|
setBusy(true)
|
|
setError('')
|
|
const payload = {
|
|
category: form.category,
|
|
title: form.title.trim(),
|
|
slug: form.slug.trim() || null,
|
|
excerpt: form.excerpt.trim() || null,
|
|
body: form.body || null,
|
|
image_url: form.image_url || null,
|
|
published: form.published,
|
|
}
|
|
try {
|
|
if (isEdit) await api.admin.updatePost(post.id, payload)
|
|
else await api.admin.createPost(payload)
|
|
onSaved()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not save the post.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function remove() {
|
|
if (!confirm('Delete this post? This cannot be undone.')) return
|
|
setBusy(true)
|
|
try {
|
|
await api.admin.deletePost(post.id)
|
|
onSaved()
|
|
} catch (err) {
|
|
setError(err.message || 'Could not delete.')
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title={isEdit ? 'Edit post' : 'New post'}
|
|
onClose={onClose}
|
|
width={640}
|
|
footer={
|
|
<>
|
|
{isEdit && (
|
|
<button onClick={remove} disabled={busy} className="sans" style={delStyle}>
|
|
Delete
|
|
</button>
|
|
)}
|
|
<button onClick={onClose} disabled={busy} className="pill">
|
|
Cancel
|
|
</button>
|
|
<button onClick={save} disabled={busy || uploading} className="btn btn-primary btn-sq">
|
|
{busy ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
{error && <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
|
|
|
{isEdit && post.category === 'news' && <AnnouncePanel postId={post.id} />}
|
|
|
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
|
<label style={{ flex: '1 1 200px' }}>
|
|
<span className="field-label">Category</span>
|
|
<select value={form.category} onChange={set('category')} className="select">
|
|
{CATEGORIES.map((c) => (
|
|
<option key={c.v} value={c.v}>
|
|
{c.l}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label style={{ display: 'flex', alignItems: 'flex-end', gap: 8, paddingBottom: 11 }}>
|
|
<input type="checkbox" checked={form.published} onChange={set('published')} />
|
|
<span className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem' }}>Published</span>
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
<span className="field-label">Title</span>
|
|
<input type="text" value={form.title} onChange={set('title')} className="input" />
|
|
</label>
|
|
|
|
<div style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
|
<label style={{ flex: '1 1 200px' }}>
|
|
<span className="field-label">Slug (optional)</span>
|
|
<input type="text" value={form.slug} onChange={set('slug')} className="input" placeholder="auto" />
|
|
</label>
|
|
</div>
|
|
|
|
<label>
|
|
<span className="field-label">Excerpt (optional)</span>
|
|
<input type="text" value={form.excerpt} onChange={set('excerpt')} className="input" />
|
|
</label>
|
|
|
|
<label>
|
|
<span className="field-label">Image{isScreenshot ? ' (required)' : ' (optional)'}</span>
|
|
<input type="file" accept="image/*" onChange={onUpload} className="sans" style={{ color: 'var(--muted)', fontSize: '0.85rem' }} />
|
|
{uploading && <span className="sans dim" style={{ fontSize: '0.8rem' }}> uploading…</span>}
|
|
{form.image_url && (
|
|
<img src={form.image_url} alt="" style={{ display: 'block', marginTop: 10, maxWidth: '100%', borderRadius: 8, border: '1px solid var(--line)' }} />
|
|
)}
|
|
</label>
|
|
|
|
<div>
|
|
<span className="field-label">Body</span>
|
|
<Suspense fallback={<span className="spin" />}>
|
|
<RichTextEditor
|
|
value={form.body}
|
|
onChange={(html) => setForm((f) => ({ ...f, body: html }))}
|
|
variant={isScreenshot ? 'minimal' : 'post'}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const delStyle = {
|
|
border: '1px solid #6e3b38',
|
|
borderRadius: 999,
|
|
padding: '7px 16px',
|
|
background: 'rgba(110,59,56,0.18)',
|
|
color: '#d98b84',
|
|
fontSize: '0.86rem',
|
|
cursor: 'pointer',
|
|
marginRight: 'auto',
|
|
}
|
|
|
|
// ── Announcement status panel ────────────────────────────────────────────────
|
|
// Shows each delivery leg's state for a published news post and offers a per-leg
|
|
// retry (useful after fixing the sidecar / news channel without re-publishing).
|
|
// Only rendered for news posts in edit mode; renders nothing until the post has
|
|
// actually been announced (no job row yet → nothing to show).
|
|
//
|
|
// The legs and their labels come from the JOB, not from a constant here: which
|
|
// legs exist is decided by what the server has registered, so an installed module
|
|
// brings its own leg and this panel renders it with no client change
|
|
// (docs/website/MODULE_SYSTEM.md §1.8).
|
|
const STATUS_STYLE = {
|
|
done: { color: '#7bbf8f', label: 'delivered' },
|
|
pending: { color: '#d9b84a', label: 'pending' },
|
|
failed: { color: '#d98b84', label: 'failed' },
|
|
}
|
|
|
|
function AnnouncePanel({ postId }) {
|
|
const [job, setJob] = useState(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [retrying, setRetrying] = useState('')
|
|
|
|
async function load() {
|
|
try {
|
|
setJob(await api.admin.getAnnounce(postId))
|
|
} catch {
|
|
setJob(null)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [postId])
|
|
|
|
async function retry(leg) {
|
|
setRetrying(leg)
|
|
try {
|
|
setJob(await api.admin.retryAnnounceLeg(postId, leg))
|
|
} catch {
|
|
// leave the current state; the row simply didn't change
|
|
} finally {
|
|
setRetrying('')
|
|
}
|
|
}
|
|
|
|
if (loading || !job) return null
|
|
|
|
return (
|
|
<div style={panelStyle}>
|
|
<span className="field-label" style={{ marginBottom: 2 }}>Announcement</span>
|
|
{(job.legs || []).map(({ leg, label, status, last_error: err }) => {
|
|
const s = STATUS_STYLE[status] || STATUS_STYLE.pending
|
|
return (
|
|
<div key={leg} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span className="sans" style={{ fontSize: '0.85rem', minWidth: 140 }}>{label}</span>
|
|
<span className="sans" style={{ fontSize: '0.8rem', color: s.color, fontWeight: 600 }}>● {s.label}</span>
|
|
{status !== 'done' && (
|
|
<button
|
|
onClick={() => retry(leg)}
|
|
disabled={Boolean(retrying)}
|
|
className="pill"
|
|
style={{ marginLeft: 'auto', fontSize: '0.75rem', padding: '3px 12px' }}
|
|
>
|
|
{retrying === leg ? 'Retrying…' : 'Retry'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{status === 'failed' && err && (
|
|
<span className="sans" style={{ fontSize: '0.75rem', color: '#d98b84', paddingLeft: 148 }}>{err}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const panelStyle = {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 8,
|
|
padding: '12px 14px',
|
|
borderRadius: 8,
|
|
border: '1px solid var(--line)',
|
|
background: 'rgba(255,255,255,0.02)',
|
|
}
|