From b9a9c85e6090e944c3f71bc1a9447dbe87e802de Mon Sep 17 00:00:00 2001 From: Christian Johannesen Date: Sun, 18 Oct 2026 16:10:01 +0200 Subject: [PATCH] feat(admin): complete revision review and rollback workflow --- apps/web/src/features/admin/AdminPage.tsx | 13 ++++++++++++- apps/web/src/lib/api.ts | 1 + apps/web/src/styles.css | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/admin/AdminPage.tsx b/apps/web/src/features/admin/AdminPage.tsx index d08d011..585793d 100644 --- a/apps/web/src/features/admin/AdminPage.tsx +++ b/apps/web/src/features/admin/AdminPage.tsx @@ -26,13 +26,24 @@ function AdminWorkspace({ csrf }: { csrf: string }) { const review = useMutation({ mutationFn: ({ id, action }: { id: string; action: string }) => api.mutate(`/findings/${id}/${action}`, csrf), onSuccess: () => client.invalidateQueries({ queryKey: ['findings'] }) }); return

Kontrolrum

Redaktion

Ingen ændringer bliver publiceret uden et menneskeligt review.

{findings.data?.filter((item) => item.status === 'pending').length ?? 0}Afventer review
ManuelPubliceringspolitik
24 timerScanningsinterval
- KildefundNyt udkast + KildefundNyt udkastRevisioner
{findings.isLoading &&

Henter findings…

}{findings.data?.length === 0 &&

Alt er gennemgået

Der er ingen kildeændringer i kø.

}{findings.data?.map((finding) =>
{finding.status}

{finding.title}

{finding.summary}

Åbn officiel kilde
{finding.status === 'pending' &&
}
)}
+
; } +function RevisionManager({ csrf }: { csrf: string }) { + const [input, setInput] = useState('typescript'); const [slug, setSlug] = useState('typescript'); const client = useQueryClient(); + const revisions = useQuery({ queryKey: ['revisions', slug], queryFn: () => api.revisions(slug), enabled: Boolean(slug) }); + const action = useMutation({ mutationFn: ({ path, body }: { path: string; body?: unknown }) => api.mutate(path, csrf, body), onSuccess: () => { client.invalidateQueries({ queryKey: ['revisions', slug] }); client.invalidateQueries({ queryKey: ['topic', slug] }); } }); + return
{ event.preventDefault(); setSlug(input.trim()); }}>
+ {action.error &&

{action.error.message}

} +
{revisions.isLoading &&

Henter revisioner…

}{revisions.isError &&

Emnet eller dets revisioner kunne ikke hentes.

}{revisions.data?.map((revision) =>
{revision.active ? 'publiceret' : revision.status}

Revision #{revision.number}

{revision.changeNote}

{new Date(revision.createdAt).toLocaleString('da-DK')}
{revision.status === 'draft' && <>}{revision.status === 'approved' && !revision.active && <>}
)}
+
; +} + function DraftEditor({ csrf }: { csrf: string }) { const [message, setMessage] = useState(''); const create = useMutation({ mutationFn: (input: TopicRevisionInput) => api.createDraft(csrf, input), onSuccess: (result) => setMessage(`Udkast #${String(result.number)} blev oprettet. Revision-ID: ${String(result.id)}`) }); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 24aedaa..45d9a14 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -9,6 +9,7 @@ const json = async (pending: Response | Promise): Promise => { export const api = { topics: (params: URLSearchParams) => json>(fetch(`/api/v1/topics?${params}`)), topic: (slug: string) => json(fetch(`/api/v1/topics/${slug}`)), + revisions: (slug: string) => json>(fetch(`/api/v1/topics/${slug}/revisions`)), facets: () => json<{ domains: Array<{ value: string; count: number }>; types: Array<{ value: string; count: number }> }>(fetch('/api/v1/facets')), login: (username: string, password: string) => json<{ csrfToken: string }>(fetch('/api/v1/admin/login', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password }) })), session: () => json<{ csrfToken: string }>(fetch('/api/v1/admin/session', { credentials: 'include' })), diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ce8a787..c25a552 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -6,5 +6,6 @@ .library-layout{display:grid;grid-template-columns:230px 1fr;gap:55px;padding:58px clamp(20px,5vw,72px) 100px;max-width:1500px;margin:auto}.filter-title{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:14px;padding-bottom:18px;border-bottom:1px solid var(--line)}.filter-title span,.filter-title button{display:flex;align-items:center;gap:7px}.filter-title button{border:0;background:none;font-size:11px;color:var(--muted);cursor:pointer}.filters fieldset{border:0;padding:22px 0 3px;margin:0;border-bottom:1px solid var(--line)}.filters legend{text-transform:uppercase;font:500 10px DM Mono;letter-spacing:.1em;margin-bottom:13px}.filters label{display:grid;grid-template-columns:19px 1fr auto;align-items:center;font-size:13px;padding:7px 0;cursor:pointer}.filters input{appearance:none;width:14px;height:14px;border:1px solid #a6aaa7;border-radius:3px;margin:0}.filters input:checked{border:4px solid var(--ink);background:var(--acid)}.filters small{color:#929790}.catalog-header{display:flex;justify-content:space-between;align-items:end;margin-bottom:28px}.kicker{text-transform:uppercase;letter-spacing:.12em;font:500 10px DM Mono;color:var(--coral);margin:0 0 8px}.catalog-header h2{font-size:32px;letter-spacing:-.035em;margin:0}.catalog-header>span{font:11px DM Mono;color:var(--muted)}.topic-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.topic-card{background:var(--paper);border:1px solid var(--line);padding:22px;min-height:250px;text-decoration:none;display:flex;flex-direction:column;border-radius:4px;animation:rise .45s both;animation-delay:var(--delay)}.topic-card:hover{transform:translateY(-4px);box-shadow:0 18px 36px #25312916;border-color:#a8aea9}.card-top{display:flex;justify-content:space-between}.type-badge{font:500 10px DM Mono;text-transform:uppercase;color:var(--green);background:#e4eee7;padding:5px 7px;border-radius:3px}.topic-card h3{font-size:21px;letter-spacing:-.025em;margin:23px 0 9px}.topic-card p{font-size:13px;line-height:1.55;color:var(--muted);margin:0}.tag-row{margin-top:auto;display:flex;gap:5px;padding-top:18px}.tag-row span{font:10px DM Mono;background:#ebeae3;padding:4px 6px}.verified{font:9px DM Mono;color:#9b9d98;text-transform:uppercase;margin-top:12px}.state-card,.page-state{padding:55px;text-align:center;background:var(--paper);border:1px solid var(--line)}.state-card.error,.form-error{color:#a02e20}@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} .topic-page{max-width:1180px;margin:auto;padding:50px 30px 100px}.back-link{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:13px;text-decoration:none}.topic-hero{display:grid;grid-template-columns:1fr 280px;gap:60px;padding:70px 0 50px;border-bottom:1px solid var(--line)}.topic-hero h1{font-size:70px;margin:18px 0;color:var(--ink)}.topic-hero>div>p{font-size:19px;line-height:1.65;color:var(--muted);max-width:730px}.topic-hero dl{margin:0;background:var(--ink);color:white;padding:25px}.topic-hero dl div{padding:12px 0;border-bottom:1px solid #334039}.topic-hero dt{font:9px DM Mono;text-transform:uppercase;color:#8fa097;margin-bottom:6px}.topic-hero dd{margin:0;font-size:13px}.topic-tabs [role=tablist],.admin-tabs>[role=tablist]{display:flex;border-bottom:1px solid var(--line);margin:0 0 40px}.topic-tabs [role=tab],.admin-tabs [role=tab]{border:0;background:none;padding:20px 22px;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent}.topic-tabs [data-state=active],.admin-tabs [data-state=active]{color:var(--ink);border-color:var(--coral)}.topic-tabs [role=tab] span{background:var(--ink);color:white;border-radius:10px;padding:1px 6px;font-size:10px}.prose-grid{display:grid;grid-template-columns:1fr 320px;gap:70px}.prose{max-width:760px}.prose h2,.sources h2{font-size:25px;letter-spacing:-.025em;margin:42px 0 13px}.prose .lead,.prose p{line-height:1.8;color:#4f5a54}.prose ul,.info-card ul{padding-left:20px;line-height:1.7;color:#4f5a54}.prose li{margin:7px 0}.prose pre{overflow:auto;background:var(--ink);color:#d8ff58;padding:22px;border-radius:4px}.info-card{background:#e8e9df;padding:24px;margin-bottom:16px}.info-title{display:flex;align-items:center;gap:9px}.info-title svg{width:18px}.info-title h3{font-size:15px}.info-card li{font-size:13px;margin:8px 0}.source-intro{display:flex;gap:15px;align-items:flex-start;margin-bottom:25px}.source-intro svg{background:var(--acid);padding:9px;width:42px;height:42px}.source-intro h2{margin:0}.source-intro p{color:var(--muted)}.source-card{display:flex;justify-content:space-between;text-decoration:none;padding:24px;border:1px solid var(--line);background:var(--paper);margin-bottom:10px}.source-card span{font:10px DM Mono;text-transform:uppercase;color:var(--coral)}.source-card h3{margin:8px 0}.source-card p{font-size:12px;color:var(--muted)}.source-card small{display:flex;align-items:center;gap:6px;color:var(--green)} .admin-login{min-height:calc(100vh - 145px);display:grid;place-items:center;padding:40px}.login-panel{width:min(450px,100%);background:var(--paper);border:1px solid var(--line);padding:40px}.admin-icon{display:grid;place-items:center;background:var(--ink);color:var(--acid);width:50px;height:50px;margin-bottom:28px}.login-panel h1{font-size:35px;letter-spacing:-.04em;margin:5px 0}.login-panel>p{color:var(--muted);line-height:1.6}.login-panel form,.editor-form{display:grid;gap:16px;margin-top:28px}.login-panel label,.editor-form label{display:grid;gap:7px;font-size:12px;font-weight:600}.login-panel input,.editor-form input,.editor-form textarea,.editor-form select{border:1px solid #bec1bb;background:#fff;padding:12px;outline:0;border-radius:3px}.login-panel input:focus,.editor-form input:focus,.editor-form textarea:focus,.editor-form select:focus{border-color:var(--green);box-shadow:0 0 0 3px #264c3b18}.primary-button,.secondary-button,.finding-actions button{border:0;display:inline-flex;justify-content:center;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;border-radius:3px}.primary-button{background:var(--ink);color:white}.primary-button:hover{background:var(--green)}.admin-page{max-width:1250px;margin:auto;padding:60px 30px 100px}.admin-page>header{display:flex;justify-content:space-between;align-items:end}.admin-page h1{font-size:48px;letter-spacing:-.05em;margin:4px 0}.admin-page header p{color:var(--muted)}.secondary-button{background:#e2e3db}.admin-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:40px 0}.admin-stats>div{display:flex;gap:14px;align-items:center;border:1px solid var(--line);background:var(--paper);padding:20px}.admin-stats svg{color:var(--coral)}.admin-stats span{display:grid;font-size:11px;color:var(--muted)}.admin-stats strong{font-size:19px;color:var(--ink)}.finding{display:flex;justify-content:space-between;gap:30px;background:var(--paper);border:1px solid var(--line);padding:24px;margin-bottom:10px}.finding h3{margin:10px 0 5px}.finding p{font-size:13px;color:var(--muted);max-width:700px}.finding a{font-size:12px;color:var(--green)}.status{font:10px DM Mono;text-transform:uppercase;padding:4px 7px;background:#e6e6df}.status.pending{background:#fff0be}.status.approved{background:#dcebd8}.status.rejected{background:#f5d9d2}.finding-actions{display:flex;align-items:center;gap:8px}.finding-actions button{font-size:12px}.finding-actions svg{width:14px}.finding-actions .approve{background:var(--green);color:white}.empty-state{text-align:center;padding:60px}.empty-state>svg{background:var(--acid);padding:12px;width:52px;height:52px}.editor-form{max-width:920px;background:var(--paper);border:1px solid var(--line);padding:30px}.editor-heading{display:flex;gap:14px}.editor-heading h2{margin:0}.editor-heading p{margin:5px 0;color:var(--muted)}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.form-success{background:#dcebd8;padding:12px;color:#24502f} +.revision-manager>form{display:flex;align-items:end;gap:10px;margin-bottom:24px}.revision-manager>form label{display:grid;gap:6px;font-size:12px;font-weight:600}.revision-manager input{min-width:280px;border:1px solid #bec1bb;background:#fff;padding:11px}.revision-row small{color:var(--muted);font:10px DM Mono} @media(max-width:1000px){.topic-grid{grid-template-columns:repeat(2,1fr)}.prose-grid{grid-template-columns:1fr}.topic-hero{grid-template-columns:1fr}.topic-hero dl{display:grid;grid-template-columns:1fr 1fr}.admin-stats{grid-template-columns:1fr}} @media(max-width:700px){.topbar{height:62px}.topbar nav a{font-size:0}.topbar nav a svg{width:20px;height:20px}.hero{padding-top:60px}.hero:after{opacity:.3}.library-layout{grid-template-columns:1fr;padding-top:30px}.filters{display:none}.topic-grid{grid-template-columns:1fr}.topic-card{min-height:220px}.topic-page{padding:30px 18px 70px}.topic-hero{padding-top:45px}.topic-hero h1{font-size:48px}.topic-tabs [role=tab]{padding:16px 10px;font-size:12px}.admin-page{padding:35px 18px}.admin-page>header,.finding{align-items:flex-start;flex-direction:column}.finding-actions{width:100%}.form-grid{grid-template-columns:1fr}footer{gap:15px;flex-direction:column}}