@rightkit/social-ui 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +40 -2
- package/dist/appName.d.ts +2 -0
- package/dist/appName.js +8 -0
- package/dist/components/ScopeSelect.d.ts +7 -0
- package/dist/components/ScopeSelect.js +6 -0
- package/dist/components/Shared.d.ts +34 -0
- package/dist/components/Shared.js +55 -0
- package/dist/data/derive.d.ts +21 -0
- package/dist/data/derive.js +47 -0
- package/dist/data/draftRules.d.ts +10 -0
- package/dist/data/draftRules.js +20 -0
- package/dist/data/engine/engineDataSource.d.ts +194 -0
- package/dist/data/engine/engineDataSource.js +836 -0
- package/dist/data/engine/errors.d.ts +13 -0
- package/dist/data/engine/errors.js +21 -0
- package/dist/data/engine/mapping.d.ts +172 -0
- package/dist/data/engine/mapping.js +1487 -0
- package/dist/data/engine/protocol.d.ts +910 -0
- package/dist/data/engine/protocol.js +67 -0
- package/dist/data/index.d.ts +18 -0
- package/dist/data/index.js +23 -0
- package/dist/data/mock/devSource.d.ts +7 -0
- package/dist/data/mock/devSource.js +10 -0
- package/dist/data/mock/fixtures-analytics.d.ts +7 -0
- package/dist/data/mock/fixtures-analytics.js +123 -0
- package/dist/data/mock/fixtures-briefs.d.ts +4 -0
- package/dist/data/mock/fixtures-briefs.js +230 -0
- package/dist/data/mock/fixtures-content.d.ts +4 -0
- package/dist/data/mock/fixtures-content.js +89 -0
- package/dist/data/mock/fixtures-digests.d.ts +3 -0
- package/dist/data/mock/fixtures-digests.js +40 -0
- package/dist/data/mock/fixtures-drafts.d.ts +2 -0
- package/dist/data/mock/fixtures-drafts.js +74 -0
- package/dist/data/mock/fixtures-gate.d.ts +3 -0
- package/dist/data/mock/fixtures-gate.js +49 -0
- package/dist/data/mock/fixtures-hooks.d.ts +2 -0
- package/dist/data/mock/fixtures-hooks.js +103 -0
- package/dist/data/mock/fixtures-inbox.d.ts +11 -0
- package/dist/data/mock/fixtures-inbox.js +213 -0
- package/dist/data/mock/fixtures-links.d.ts +5 -0
- package/dist/data/mock/fixtures-links.js +89 -0
- package/dist/data/mock/fixtures-memory.d.ts +3 -0
- package/dist/data/mock/fixtures-memory.js +148 -0
- package/dist/data/mock/fixtures-ops.d.ts +15 -0
- package/dist/data/mock/fixtures-ops.js +403 -0
- package/dist/data/mock/fixtures-optimise.d.ts +18 -0
- package/dist/data/mock/fixtures-optimise.js +221 -0
- package/dist/data/mock/fixtures-pacing.d.ts +5 -0
- package/dist/data/mock/fixtures-pacing.js +285 -0
- package/dist/data/mock/fixtures-publish.d.ts +2 -0
- package/dist/data/mock/fixtures-publish.js +129 -0
- package/dist/data/mock/fixtures-store.d.ts +10 -0
- package/dist/data/mock/fixtures-store.js +13 -0
- package/dist/data/mock/fixtures-variants.d.ts +5 -0
- package/dist/data/mock/fixtures-variants.js +138 -0
- package/dist/data/mock/fixtures.d.ts +6 -0
- package/dist/data/mock/fixtures.js +420 -0
- package/dist/data/mock/mockDataSource.d.ts +98 -0
- package/dist/data/mock/mockDataSource.js +903 -0
- package/dist/data/money.d.ts +14 -0
- package/dist/data/money.js +36 -0
- package/dist/data/platforms.d.ts +12 -0
- package/dist/data/platforms.js +26 -0
- package/dist/data/source.d.ts +196 -0
- package/dist/data/source.js +1 -0
- package/dist/data/time.d.ts +23 -0
- package/dist/data/time.js +86 -0
- package/dist/data/types.d.ts +1092 -0
- package/dist/data/types.js +3 -0
- package/dist/env.d.ts +59 -0
- package/dist/hooks/useAsync.d.ts +19 -0
- package/dist/hooks/useAsync.js +25 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +58 -0
- package/dist/lib/host.d.ts +21 -0
- package/dist/lib/host.js +32 -0
- package/dist/lib/shell.d.ts +156 -0
- package/dist/lib/shell.js +16 -0
- package/dist/screens/AccountsScreen.d.ts +8 -0
- package/dist/screens/AccountsScreen.js +100 -0
- package/dist/screens/AnalyticsScreen.d.ts +7 -0
- package/dist/screens/AnalyticsScreen.js +64 -0
- package/dist/screens/ApprovalsScreen.d.ts +10 -0
- package/dist/screens/ApprovalsScreen.js +144 -0
- package/dist/screens/BriefsScreen.d.ts +7 -0
- package/dist/screens/BriefsScreen.js +83 -0
- package/dist/screens/CalendarScreen.d.ts +10 -0
- package/dist/screens/CalendarScreen.js +143 -0
- package/dist/screens/CampaignsScreen.d.ts +7 -0
- package/dist/screens/CampaignsScreen.js +52 -0
- package/dist/screens/ChannelMemoryScreen.d.ts +7 -0
- package/dist/screens/ChannelMemoryScreen.js +109 -0
- package/dist/screens/ContentScreen.d.ts +7 -0
- package/dist/screens/ContentScreen.js +64 -0
- package/dist/screens/DigestsScreen.d.ts +7 -0
- package/dist/screens/DigestsScreen.js +53 -0
- package/dist/screens/DraftsScreen.d.ts +10 -0
- package/dist/screens/DraftsScreen.js +108 -0
- package/dist/screens/FailuresScreen.d.ts +8 -0
- package/dist/screens/FailuresScreen.js +111 -0
- package/dist/screens/HookTestsScreen.d.ts +7 -0
- package/dist/screens/HookTestsScreen.js +54 -0
- package/dist/screens/InboxScreen.d.ts +12 -0
- package/dist/screens/InboxScreen.js +76 -0
- package/dist/screens/LinksScreen.d.ts +7 -0
- package/dist/screens/LinksScreen.js +52 -0
- package/dist/screens/PacingScreen.d.ts +7 -0
- package/dist/screens/PacingScreen.js +88 -0
- package/dist/screens/PublishJobsScreen.d.ts +7 -0
- package/dist/screens/PublishJobsScreen.js +66 -0
- package/dist/screens/SuggestionsScreen.d.ts +8 -0
- package/dist/screens/SuggestionsScreen.js +94 -0
- package/dist/screens/TextGateScreen.d.ts +7 -0
- package/dist/screens/TextGateScreen.js +111 -0
- package/dist/screens/ThreadsScreen.d.ts +8 -0
- package/dist/screens/ThreadsScreen.js +113 -0
- package/dist/screens/TypedBriefsScreen.d.ts +7 -0
- package/dist/screens/TypedBriefsScreen.js +75 -0
- package/dist/screens/labels.d.ts +121 -0
- package/dist/screens/labels.js +272 -0
- package/package.json +56 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, useState } from "react";
|
|
3
|
+
import { Badge, EmptyState, Field, PageShell, SkeletonRows, Table, TableBody, TableCaption, TableCell, TableHead, TableHeaderCell, TableRow } from "@rightkit/react-shell/react";
|
|
4
|
+
import { ScopeSelect } from "../components/ScopeSelect.js";
|
|
5
|
+
import { LoadError, MockBanner } from "../components/Shared.js";
|
|
6
|
+
import { useAsync } from "../hooks/useAsync.js";
|
|
7
|
+
import { groupDigits, instantLabel, } from "../data/index.js";
|
|
8
|
+
import { PROOF_STATUS_LABEL } from "./labels.js";
|
|
9
|
+
const PROOF_FILTERS = [
|
|
10
|
+
{ value: "all", label: "All statuses" },
|
|
11
|
+
{ value: "collected", label: "Collected" },
|
|
12
|
+
{ value: "approved", label: "Approved" },
|
|
13
|
+
{ value: "withdrawn", label: "Withdrawn" },
|
|
14
|
+
{ value: "superseded", label: "Superseded" },
|
|
15
|
+
];
|
|
16
|
+
/** The document sections the screen shows, in order. Each key is the document's own top-level key. */
|
|
17
|
+
const MEMORY_SECTIONS = [
|
|
18
|
+
{ key: "voice", title: "Voice" },
|
|
19
|
+
{ key: "pillars", title: "Pillars" },
|
|
20
|
+
{ key: "cadence", title: "Cadence" },
|
|
21
|
+
{ key: "bannedMoves", title: "Banned moves" },
|
|
22
|
+
];
|
|
23
|
+
export function ChannelMemoryScreen({ source, rev }) {
|
|
24
|
+
const scopes = useAsync(() => source.listScopes(), [source, rev]);
|
|
25
|
+
const [picked, setPicked] = useState(null);
|
|
26
|
+
const scopeList = scopes.value ?? [];
|
|
27
|
+
const scopeId = scopeList.find((s) => s.id === picked)?.id ?? scopeList[0]?.id ?? null;
|
|
28
|
+
return (_jsxs(PageShell, { title: "Channel memory", subtitle: "Read only. The channel memory document of one channel: its latest revision and revision history, and its proof bank with each proof's status. Nothing here writes a memory revision, imports a proof, changes a proof's status or approves a proof.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, scopes.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the channels from the daemon.", onRetry: scopes.reload })) : scopes.value === null ? (_jsx(SkeletonRows, { label: "Loading channels", rows: 2 })) : scopeList.length === 0 || scopeId === null ? (_jsx(EmptyState, { title: "No channels yet", children: _jsx("p", { children: "Channel memory is kept per channel once one is set up." }) })) : (_jsxs(_Fragment, { children: [_jsx(ScopeSelect, { scopes: scopeList, value: scopeId, onChange: (id) => setPicked(id) }), _jsx(MemoryView, { source: source, rev: rev, scopeId: scopeId }, `memory:${scopeId}`), _jsx(ProofView, { source: source, rev: rev, scopeId: scopeId }, `proofs:${scopeId}`)] }))] }));
|
|
29
|
+
}
|
|
30
|
+
function MemoryView({ source, rev, scopeId }) {
|
|
31
|
+
const latest = useAsync(() => source.channelMemory(scopeId), [source, rev, scopeId]);
|
|
32
|
+
const history = useAsync(() => source.channelMemoryHistory(scopeId), [source, rev, scopeId]);
|
|
33
|
+
if (latest.state.status === "error") {
|
|
34
|
+
return _jsx(LoadError, { message: "Could not read the channel memory from the daemon.", onRetry: latest.reload });
|
|
35
|
+
}
|
|
36
|
+
if (history.state.status === "error") {
|
|
37
|
+
return _jsx(LoadError, { message: "Could not read the revision history from the daemon.", onRetry: history.reload });
|
|
38
|
+
}
|
|
39
|
+
if (latest.state.status === "loading" || history.state.status === "loading") {
|
|
40
|
+
return _jsx(SkeletonRows, { label: "Loading channel memory", rows: 3 });
|
|
41
|
+
}
|
|
42
|
+
// A null latest revision is the not_found state: nothing has been written for this channel yet.
|
|
43
|
+
const detail = latest.state.value;
|
|
44
|
+
const revisions = history.state.value;
|
|
45
|
+
return (_jsxs(_Fragment, { children: [_jsxs("section", { "aria-labelledby": "memory-latest-title", children: [_jsx("h2", { id: "memory-latest-title", className: "op-section-title", children: "Channel memory" }), detail === null ? (_jsx(EmptyState, { title: "No channel memory yet", children: _jsx("p", { children: "No channel memory has been written for this channel yet. This is not an error." }) })) : (_jsx(MemoryDocument, { detail: detail }))] }), _jsxs("section", { "aria-labelledby": "memory-history-title", children: [_jsx("h2", { id: "memory-history-title", className: "op-section-title", children: "Revision history" }), revisions.length === 0 ? (_jsx("p", { className: "op-hint", children: "No revision is stored for this channel." })) : (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "memory-history-title", children: [_jsx(TableCaption, { children: "Revisions of this channel's memory, newest first" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { align: "end", children: "Revision" }), _jsx(TableHeaderCell, { children: "Author" }), _jsx(TableHeaderCell, { children: "Written (UTC)" }), _jsx(TableHeaderCell, { children: "Content hash" })] }) }), _jsx(TableBody, { children: revisions.map((row) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, numeric: true, children: groupDigits(String(row.revision)) }), _jsx(TableCell, { children: row.author }), _jsx(TableCell, { className: "op-mono", children: instantLabel(row.createdAtUtc, "UTC") }), _jsx(TableCell, { className: "op-mono", children: row.contentHash })] }, row.revision))) })] }) }))] })] }));
|
|
46
|
+
}
|
|
47
|
+
/** The latest revision: its metadata, then each section the document holds. A section it lacks is said to be absent. */
|
|
48
|
+
function MemoryDocument({ detail }) {
|
|
49
|
+
return (_jsxs("article", { className: "op-detail", "aria-labelledby": "memory-latest-doc", children: [_jsxs("h3", { id: "memory-latest-doc", className: "op-detail__title", children: ["Revision ", groupDigits(String(detail.revision))] }), _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Revision" }), _jsx("dd", { children: groupDigits(String(detail.revision)) }), detail.supersedes === null ? null : (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Supersedes" }), _jsxs("dd", { children: ["Revision ", groupDigits(String(detail.supersedes))] })] })), _jsx("dt", { children: "Author" }), _jsx("dd", { children: detail.author }), _jsx("dt", { children: "Written (UTC)" }), _jsx("dd", { children: instantLabel(detail.createdAtUtc, "UTC") }), _jsx("dt", { children: "Content hash" }), _jsx("dd", { className: "op-mono", children: detail.contentHash })] }), MEMORY_SECTIONS.map((section) => (_jsx(MemorySection, { title: section.title, value: sectionOf(detail.document, section.key) }, section.key)))] }));
|
|
50
|
+
}
|
|
51
|
+
/** One top-level section of the document, or undefined when this revision does not hold it. */
|
|
52
|
+
function sectionOf(document, key) {
|
|
53
|
+
if (document === null || typeof document !== "object" || Array.isArray(document))
|
|
54
|
+
return undefined;
|
|
55
|
+
return document[key];
|
|
56
|
+
}
|
|
57
|
+
function MemorySection({ title, value }) {
|
|
58
|
+
const id = `memory-section-${title.toLowerCase().replace(/\s+/g, "-")}`;
|
|
59
|
+
return (_jsxs("section", { "aria-labelledby": id, children: [_jsx("h4", { id: id, className: "op-section-title", children: title }), value === undefined ? (_jsxs("p", { className: "op-hint", children: ["This revision has no ", title.toLowerCase(), " section."] })) : (_jsx(MemoryValue, { value: value }))] }));
|
|
60
|
+
}
|
|
61
|
+
/** A stored value as the document holds it: text as written, lists as lists, objects as their own fields. */
|
|
62
|
+
function MemoryValue({ value }) {
|
|
63
|
+
if (value === null)
|
|
64
|
+
return _jsx("span", { children: "Not stated" });
|
|
65
|
+
if (typeof value === "string")
|
|
66
|
+
return _jsx("span", { children: value });
|
|
67
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
68
|
+
return _jsx("span", { children: String(value) });
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return (_jsx("ul", { className: "op-id-list", children: value.map((item, index) => (_jsx("li", { children: _jsx(MemoryValue, { value: item }) }, index))) }));
|
|
71
|
+
}
|
|
72
|
+
return (_jsx("dl", { className: "op-dl", children: Object.entries(value).map(([key, item]) => (_jsxs(Fragment, { children: [_jsx("dt", { children: key }), _jsx("dd", { children: _jsx(MemoryValue, { value: item }) })] }, key))) }));
|
|
73
|
+
}
|
|
74
|
+
function ProofView({ source, rev, scopeId }) {
|
|
75
|
+
const [filter, setFilter] = useState("all");
|
|
76
|
+
const status = filter === "all" ? null : filter;
|
|
77
|
+
const proofs = useAsync(() => source.listProofs(scopeId, status), [source, rev, scopeId, filter]);
|
|
78
|
+
return (_jsxs("section", { "aria-labelledby": "proof-bank-title", children: [_jsx("h2", { id: "proof-bank-title", className: "op-section-title", children: "Proof bank" }), _jsx("p", { className: "op-hint", children: "Only approved proofs can be cited by a script. Approving a proof happens outside this screen. This screen shows each proof's status and changes none of them." }), _jsx(Field, { label: "Proof status", children: ({ id, "aria-describedby": describedBy }) => (_jsx("select", { id: id, "aria-describedby": describedBy, className: "op-select", value: filter, onChange: (e) => {
|
|
79
|
+
const next = PROOF_FILTERS.find((option) => option.value === e.target.value);
|
|
80
|
+
if (next)
|
|
81
|
+
setFilter(next.value);
|
|
82
|
+
}, children: PROOF_FILTERS.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) })) }), proofs.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the proof bank from the daemon.", onRetry: proofs.reload })) : proofs.state.status === "loading" ? (_jsx(SkeletonRows, { label: "Loading the proof bank", rows: 3 })) : (_jsx(ProofTable, { proofs: proofs.state.value, filter: filter }))] }));
|
|
83
|
+
}
|
|
84
|
+
function ProofTable({ proofs, filter }) {
|
|
85
|
+
if (proofs.length === 0) {
|
|
86
|
+
return (_jsx(EmptyState, { title: filter === "all" ? "No proofs yet" : "No proofs with this status", children: _jsx("p", { children: filter === "all"
|
|
87
|
+
? "This channel has no proof in its proof bank yet. This is not an error."
|
|
88
|
+
: "This channel has no proof with this status. This is not an error." }) }));
|
|
89
|
+
}
|
|
90
|
+
return (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "proof-bank-title", children: [_jsxs(TableCaption, { children: ["Proofs in this channel (", groupDigits(String(proofs.length)), ")"] }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Proof" }), _jsx(TableHeaderCell, { children: "Subject" }), _jsx(TableHeaderCell, { align: "end", children: "Value" }), _jsx(TableHeaderCell, { children: "Units" }), _jsx(TableHeaderCell, { children: "Observed period" }), _jsx(TableHeaderCell, { children: "Origin" }), _jsx(TableHeaderCell, { children: "Status" }), _jsx(TableHeaderCell, { children: "Superseding proof or withdrawal note" })] }) }), _jsx(TableBody, { children: proofs.map((proof) => {
|
|
91
|
+
const badge = PROOF_STATUS_LABEL[proof.status];
|
|
92
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: proof.proofId }), _jsx(TableCell, { className: "op-mono", children: proof.subject }), _jsx(TableCell, { numeric: true, className: "op-mono", children: proof.value }), _jsx(TableCell, { children: proof.units }), _jsx(TableCell, { className: "op-mono", children: observedPeriod(proof) }), _jsx(TableCell, { children: proof.origin }), _jsx(TableCell, { children: _jsx(Badge, { tone: badge.tone, children: badge.text }) }), _jsx(TableCell, { children: noteText(proof) })] }, proof.answerId));
|
|
93
|
+
}) })] }) }));
|
|
94
|
+
}
|
|
95
|
+
function observedPeriod(proof) {
|
|
96
|
+
return `${proof.observedFrom} to ${proof.observedTo === "" ? "no end date stated" : proof.observedTo}`;
|
|
97
|
+
}
|
|
98
|
+
/** The superseding proof id or the withdrawal note, in words. Other statuses carry neither. */
|
|
99
|
+
function noteText(proof) {
|
|
100
|
+
if (proof.status === "superseded") {
|
|
101
|
+
return proof.supersededBy === null
|
|
102
|
+
? "Superseded. The engine holds no replacing proof id."
|
|
103
|
+
: `Superseded by ${proof.supersededBy}`;
|
|
104
|
+
}
|
|
105
|
+
if (proof.status === "withdrawn") {
|
|
106
|
+
return proof.withdrawalNote ?? "Withdrawn. The engine holds no withdrawal note.";
|
|
107
|
+
}
|
|
108
|
+
return "Not applicable";
|
|
109
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Badge, EmptyState, Field, PageShell, SkeletonRows, Table, TableBody, TableCaption, TableCell, TableHead, TableHeaderCell, TableRow } from "@rightkit/react-shell/react";
|
|
4
|
+
import { ScopeSelect } from "../components/ScopeSelect.js";
|
|
5
|
+
import { LoadError, MockBanner } from "../components/Shared.js";
|
|
6
|
+
import { useAsync } from "../hooks/useAsync.js";
|
|
7
|
+
import { groupDigits, instantLabel, } from "../data/index.js";
|
|
8
|
+
import { CONTENT_MEDIA_KIND_LABEL, CONTENT_STATE_LABEL, CONTENT_VARIANT_KIND_LABEL, CONTENT_VERDICT_LABEL, } from "./labels.js";
|
|
9
|
+
/** An instant from the engine in UTC, or the words "Never expires" when the record has no expiry. */
|
|
10
|
+
function expiryText(utc) {
|
|
11
|
+
return utc === null ? "Never expires" : instantLabel(utc, "UTC");
|
|
12
|
+
}
|
|
13
|
+
export function ContentScreen({ source, rev }) {
|
|
14
|
+
const scopes = useAsync(() => source.listScopes(), [source, rev]);
|
|
15
|
+
const [picked, setPicked] = useState(null);
|
|
16
|
+
const scopeList = scopes.value ?? [];
|
|
17
|
+
const scopeId = scopeList.find((s) => s.id === picked)?.id ?? scopeList[0]?.id ?? null;
|
|
18
|
+
return (_jsxs(PageShell, { title: "Content library", subtitle: "Read only. The reusable media records of one channel: their state, expiry and cooldown, their caption and call-to-action variants, and the engine's comparison of those variants. Nothing here creates a record, plans an occurrence, withdraws a record or names a winner.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, scopes.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the channels from the daemon.", onRetry: scopes.reload })) : scopes.value === null ? (_jsx(SkeletonRows, { label: "Loading channels", rows: 2 })) : scopeList.length === 0 || scopeId === null ? (_jsx(EmptyState, { title: "No channels yet", children: _jsx("p", { children: "Content records are kept per channel once one is set up." }) })) : (_jsxs(_Fragment, { children: [_jsx(ScopeSelect, { scopes: scopeList, value: scopeId, onChange: (id) => setPicked(id) }), _jsx(ContentView, { source: source, rev: rev, scopeId: scopeId }, scopeId)] }))] }));
|
|
19
|
+
}
|
|
20
|
+
function ContentView({ source, rev, scopeId }) {
|
|
21
|
+
const { state, reload, value } = useAsync(() => source.listContent(scopeId), [source, rev, scopeId]);
|
|
22
|
+
const [picked, setPicked] = useState(null);
|
|
23
|
+
if (state.status === "error") {
|
|
24
|
+
return _jsx(LoadError, { message: "Could not read the content records from the daemon.", onRetry: reload });
|
|
25
|
+
}
|
|
26
|
+
if (value === null)
|
|
27
|
+
return _jsx(SkeletonRows, { label: "Loading content records", rows: 3 });
|
|
28
|
+
if (value.length === 0) {
|
|
29
|
+
return (_jsx(EmptyState, { title: "No content records yet", children: _jsx("p", { children: "This channel has no content record. A record is made on the daemon host, not here. This is not an error." }) }));
|
|
30
|
+
}
|
|
31
|
+
const selected = value.find((r) => r.id === picked) ?? value[0];
|
|
32
|
+
return (_jsxs(_Fragment, { children: [_jsxs("section", { "aria-labelledby": "content-list-title", children: [_jsxs("h2", { id: "content-list-title", className: "op-section-title", children: ["Content records (", value.length, ")"] }), _jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "content-list-title", children: [_jsx(TableCaption, { children: "Content records in this channel" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Title" }), _jsx(TableHeaderCell, { children: "Content id" }), _jsx(TableHeaderCell, { children: "State" }), _jsx(TableHeaderCell, { align: "end", children: "Revision" }), _jsx(TableHeaderCell, { align: "end", children: "Cooldown (days)" }), _jsx(TableHeaderCell, { children: "Expires (UTC)" }), _jsx(TableHeaderCell, { children: "Last changed (UTC)" })] }) }), _jsx(TableBody, { children: value.map((record) => {
|
|
33
|
+
const badge = CONTENT_STATE_LABEL[record.state];
|
|
34
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, children: record.title }), _jsx(TableCell, { className: "op-mono", children: record.id }), _jsx(TableCell, { children: _jsx(Badge, { tone: badge.tone, children: badge.text }) }), _jsx(TableCell, { numeric: true, children: groupDigits(String(record.revision)) }), _jsx(TableCell, { numeric: true, children: groupDigits(String(record.cooldownDays)) }), _jsx(TableCell, { className: "op-mono", children: expiryText(record.expiresAtUtc) }), _jsx(TableCell, { className: "op-mono", children: instantLabel(record.updatedAtUtc, "UTC") })] }, record.id));
|
|
35
|
+
}) })] }) })] }), _jsxs("section", { "aria-labelledby": "content-detail-heading", children: [_jsx("h2", { id: "content-detail-heading", className: "op-section-title", children: "Selected content record" }), _jsx(Field, { label: "Content record", children: ({ id, "aria-describedby": describedBy }) => (_jsx("select", { id: id, "aria-describedby": describedBy, className: "op-select", value: selected.id, onChange: (e) => setPicked(e.target.value), children: value.map((record) => (_jsxs("option", { value: record.id, children: [record.title, " (", record.id, ")"] }, record.id))) })) }), _jsx(ContentRecordDetail, { source: source, rev: rev, scopeId: scopeId, contentId: selected.id }, selected.id)] })] }));
|
|
36
|
+
}
|
|
37
|
+
function ContentRecordDetail({ source, rev, scopeId, contentId, }) {
|
|
38
|
+
const detail = useAsync(() => source.getContent(scopeId, contentId), [source, rev, scopeId, contentId]);
|
|
39
|
+
const summary = useAsync(() => source.contentExperimentSummary(scopeId, contentId), [source, rev, scopeId, contentId]);
|
|
40
|
+
if (detail.state.status === "error") {
|
|
41
|
+
return _jsx(LoadError, { message: "Could not read this record from the daemon.", onRetry: detail.reload });
|
|
42
|
+
}
|
|
43
|
+
if (summary.state.status === "error") {
|
|
44
|
+
return _jsx(LoadError, { message: "Could not read the variant comparison from the daemon.", onRetry: summary.reload });
|
|
45
|
+
}
|
|
46
|
+
if (detail.state.status === "loading" || summary.state.status === "loading") {
|
|
47
|
+
return _jsx(SkeletonRows, { label: "Loading the content record", rows: 3 });
|
|
48
|
+
}
|
|
49
|
+
const loaded = detail.state.value;
|
|
50
|
+
if (loaded === null)
|
|
51
|
+
return _jsx("p", { className: "op-hint", children: "This content record is no longer in this channel." });
|
|
52
|
+
const { record, variants } = loaded;
|
|
53
|
+
const badge = CONTENT_STATE_LABEL[record.state];
|
|
54
|
+
const comparison = summary.state.value;
|
|
55
|
+
return (_jsxs("article", { className: "op-detail", "aria-labelledby": "content-detail-title", children: [_jsx("h3", { id: "content-detail-title", className: "op-detail__title", children: record.title }), _jsx("div", { className: "op-badges", children: _jsx(Badge, { tone: badge.tone, children: badge.text }) }), record.state === "withdrawn" ? (_jsxs("p", { className: "op-hint", children: ["Withdrawn: ", record.withdrawalReason ?? "no reason recorded", ". A withdrawn record is final. It cannot be edited, given variants or planned, and its planned occurrences were cancelled with it."] })) : null, record.state === "expired" ? (_jsx("p", { className: "op-hint", children: "The clock is past this record's expiry, so it cannot be planned. The record itself is not changed." })) : null, _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Content id" }), _jsx("dd", { className: "op-mono", children: record.id }), _jsx("dt", { children: "Evergreen" }), _jsxs("dd", { children: [record.evergreen ? "Yes" : "No", ". Recorded for the operator; nothing acts on it yet."] }), _jsx("dt", { children: "Cooldown" }), _jsxs("dd", { children: [groupDigits(String(record.cooldownDays)), " days between two occurrences on one platform"] }), _jsx("dt", { children: "Expires" }), _jsx("dd", { children: expiryText(record.expiresAtUtc) }), _jsx("dt", { children: "Revision" }), _jsx("dd", { children: groupDigits(String(record.revision)) }), _jsx("dt", { children: "Created" }), _jsx("dd", { children: instantLabel(record.createdAtUtc, "UTC") }), _jsx("dt", { children: "Last changed" }), _jsx("dd", { children: instantLabel(record.updatedAtUtc, "UTC") })] }), _jsx("h4", { className: "op-section-title", children: "Base caption" }), _jsx("div", { className: "op-caption", children: record.baseCaption }), _jsx("h4", { id: "content-media-title", className: "op-section-title", children: "Media" }), _jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "content-media-title", children: [_jsx(TableCaption, { children: "Media this record reuses" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Hash" }), _jsx(TableHeaderCell, { children: "Kind" }), _jsx(TableHeaderCell, { align: "end", children: "Duration (ms)" })] }) }), _jsx(TableBody, { children: record.media.map((item, index) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: item.hash }), _jsx(TableCell, { children: CONTENT_MEDIA_KIND_LABEL[item.kind] }), _jsx(TableCell, { numeric: true, children: item.durationMs === null ? "Not reported" : groupDigits(String(item.durationMs)) })] }, `${item.hash}#${index}`))) })] }) }), _jsx("h4", { id: "content-variants-title", className: "op-section-title", children: "Variants" }), variants.length === 0 ? (_jsx("p", { className: "op-hint", children: "No caption or call-to-action variant is stored for this record." })) : (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "content-variants-title", children: [_jsxs(TableCaption, { children: ["Variants of ", record.title] }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Variant id" }), _jsx(TableHeaderCell, { children: "Kind" }), _jsx(TableHeaderCell, { children: "Text" }), _jsx(TableHeaderCell, { children: "Created (UTC)" })] }) }), _jsx(TableBody, { children: variants.map((variant) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: variant.id }), _jsx(TableCell, { children: CONTENT_VARIANT_KIND_LABEL[variant.kind] }), _jsx(TableCell, { children: variant.text }), _jsx(TableCell, { className: "op-mono", children: instantLabel(variant.createdAtUtc, "UTC") })] }, variant.id))) })] }) })), _jsx("h4", { id: "content-compare-title", className: "op-section-title", children: "Variant comparison" }), comparison === null ? (_jsx("p", { className: "op-hint", children: "The engine holds no variant comparison for this record in this channel." })) : (_jsx(ExperimentView, { summary: comparison }))] }));
|
|
56
|
+
}
|
|
57
|
+
/** The engine's comparison. It shows the verdict and the engine's reason; it never names a winner. */
|
|
58
|
+
function ExperimentView({ summary }) {
|
|
59
|
+
const verdict = CONTENT_VERDICT_LABEL[summary.verdict];
|
|
60
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "op-badges", children: _jsx(Badge, { tone: verdict.tone, children: verdict.text }) }), _jsxs("p", { className: "op-hint", children: ["Reason: ", summary.reason] }), _jsxs("p", { className: "op-hint", children: ["Each variant counts toward the verdict only with at least ", groupDigits(String(summary.minOccurrences)), " ", "published occurrences, and a verdict needs at least two variants of each kind. The counts below are the occurrences the engine holds for each variant. The engine lists no occurrence on its own, so this screen cannot show them one by one. Counts are not engagement."] }), _jsx(VariantCountTable, { id: "content-caption-counts-title", title: "Caption variants", rows: summary.caption }), _jsx(VariantCountTable, { id: "content-cta-counts-title", title: "Call-to-action variants", rows: summary.cta })] }));
|
|
61
|
+
}
|
|
62
|
+
function VariantCountTable({ id, title, rows, }) {
|
|
63
|
+
return (_jsxs(_Fragment, { children: [_jsx("h4", { id: id, className: "op-section-title", children: title }), rows.length === 0 ? (_jsx("p", { className: "op-hint", children: "None is stored, so there is nothing to compare." })) : (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": id, children: [_jsxs(TableCaption, { children: [title, " and their occurrence counts"] }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Variant id" }), _jsx(TableHeaderCell, { children: "Text" }), _jsx(TableHeaderCell, { align: "end", children: "Planned" }), _jsx(TableHeaderCell, { align: "end", children: "Published" }), _jsx(TableHeaderCell, { align: "end", children: "Cancelled" }), _jsx(TableHeaderCell, { children: "Meets minimum" })] }) }), _jsx(TableBody, { children: rows.map((row) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: row.variantId }), _jsx(TableCell, { children: row.text }), _jsx(TableCell, { numeric: true, children: groupDigits(String(row.planned)) }), _jsx(TableCell, { numeric: true, children: groupDigits(String(row.published)) }), _jsx(TableCell, { numeric: true, children: groupDigits(String(row.cancelled)) }), _jsx(TableCell, { children: row.meetsMinimum ? "Yes" : "No" })] }, row.variantId))) })] }) }))] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { EmptyState, Field, PageShell, SkeletonRows, Table, TableBody, TableCaption, TableCell, TableHead, TableHeaderCell, TableRow } from "@rightkit/react-shell/react";
|
|
4
|
+
import { ScopeSelect } from "../components/ScopeSelect.js";
|
|
5
|
+
import { LoadError, MockBanner } from "../components/Shared.js";
|
|
6
|
+
import { useAsync } from "../hooks/useAsync.js";
|
|
7
|
+
import { instantLabel, } from "../data/index.js";
|
|
8
|
+
import { platformName } from "./labels.js";
|
|
9
|
+
export function DigestsScreen({ source, rev }) {
|
|
10
|
+
const scopes = useAsync(() => source.listScopes(), [source, rev]);
|
|
11
|
+
const [picked, setPicked] = useState(null);
|
|
12
|
+
const scopeList = scopes.value ?? [];
|
|
13
|
+
const scopeId = scopeList.find((s) => s.id === picked)?.id ?? scopeList[0]?.id ?? null;
|
|
14
|
+
return (_jsxs(PageShell, { title: "Research digests", subtitle: "Read only. The research digests of one channel: each finding with its source, and which findings became draft calendar entries. Nothing here fetches a source, applies a digest, writes a caption or approves a draft.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, scopes.state.status === "error" ? (_jsx(LoadError, { message: "Could not read the channels from the daemon.", onRetry: scopes.reload })) : scopes.value === null ? (_jsx(SkeletonRows, { label: "Loading channels", rows: 2 })) : scopeList.length === 0 || scopeId === null ? (_jsx(EmptyState, { title: "No channels yet", children: _jsx("p", { children: "Research digests are kept per channel once one is set up." }) })) : (_jsxs(_Fragment, { children: [_jsx(ScopeSelect, { scopes: scopeList, value: scopeId, onChange: (id) => setPicked(id) }), _jsx(DigestView, { source: source, rev: rev, scopeId: scopeId }, scopeId)] }))] }));
|
|
15
|
+
}
|
|
16
|
+
function DigestView({ source, rev, scopeId }) {
|
|
17
|
+
const { state, reload, value } = useAsync(() => source.listDigests(scopeId), [source, rev, scopeId]);
|
|
18
|
+
const [picked, setPicked] = useState(null);
|
|
19
|
+
if (state.status === "error") {
|
|
20
|
+
return _jsx(LoadError, { message: "Could not read the research digests from the daemon.", onRetry: reload });
|
|
21
|
+
}
|
|
22
|
+
if (value === null)
|
|
23
|
+
return _jsx(SkeletonRows, { label: "Loading research digests", rows: 3 });
|
|
24
|
+
if (value.length === 0) {
|
|
25
|
+
return (_jsx(EmptyState, { title: "No research digests yet", children: _jsx("p", { children: "This channel has no digest. A digest is added on the daemon host, not here. This is not an error." }) }));
|
|
26
|
+
}
|
|
27
|
+
const selected = value.find((d) => d.id === picked) ?? value[0];
|
|
28
|
+
return (_jsxs(_Fragment, { children: [_jsxs("section", { "aria-labelledby": "digest-list-title", children: [_jsxs("h2", { id: "digest-list-title", className: "op-section-title", children: ["Research digests (", value.length, ")"] }), _jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "digest-list-title", children: [_jsx(TableCaption, { children: "Research digests in this channel" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Digest" }), _jsx(TableHeaderCell, { children: "Digest date" }), _jsx(TableHeaderCell, { align: "end", children: "Findings" }), _jsx(TableHeaderCell, { children: "Created (UTC)" })] }) }), _jsx(TableBody, { children: value.map((digest) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: digest.id }), _jsx(TableCell, { className: "op-mono", children: digest.digestDate }), _jsx(TableCell, { numeric: true, children: digest.entries.length }), _jsx(TableCell, { className: "op-mono", children: instantLabel(digest.createdAtUtc, "UTC") })] }, digest.id))) })] }) })] }), _jsxs("section", { "aria-labelledby": "digest-detail-heading", children: [_jsx("h2", { id: "digest-detail-heading", className: "op-section-title", children: "Selected digest" }), _jsx(Field, { label: "Digest", children: ({ id, "aria-describedby": describedBy }) => (_jsx("select", { id: id, "aria-describedby": describedBy, className: "op-select", value: selected.id, onChange: (e) => setPicked(e.target.value), children: value.map((digest) => (_jsxs("option", { value: digest.id, children: [digest.digestDate, " (", digest.id, ")"] }, digest.id))) })) }), _jsx(DigestDetail, { source: source, rev: rev, scopeId: scopeId, digestId: selected.id }, selected.id)] })] }));
|
|
29
|
+
}
|
|
30
|
+
function DigestDetail({ source, rev, scopeId, digestId, }) {
|
|
31
|
+
const digest = useAsync(() => source.getDigest(scopeId, digestId), [source, rev, scopeId, digestId]);
|
|
32
|
+
const applied = useAsync(() => source.digestApplications(scopeId, digestId), [source, rev, scopeId, digestId]);
|
|
33
|
+
if (digest.state.status === "error") {
|
|
34
|
+
return _jsx(LoadError, { message: "Could not read this digest from the daemon.", onRetry: digest.reload });
|
|
35
|
+
}
|
|
36
|
+
if (applied.state.status === "error") {
|
|
37
|
+
return _jsx(LoadError, { message: "Could not read which findings were applied from the daemon.", onRetry: applied.reload });
|
|
38
|
+
}
|
|
39
|
+
if (digest.state.status === "loading" || applied.state.status === "loading") {
|
|
40
|
+
return _jsx(SkeletonRows, { label: "Loading the digest", rows: 3 });
|
|
41
|
+
}
|
|
42
|
+
const record = digest.state.value;
|
|
43
|
+
const applications = applied.state.value;
|
|
44
|
+
if (record === null || applications === null) {
|
|
45
|
+
return _jsx("p", { className: "op-hint", children: "This digest is no longer in this channel." });
|
|
46
|
+
}
|
|
47
|
+
// The applied entry for each finding, keyed by the digest entry id.
|
|
48
|
+
const appliedByEntry = new Map(applications.map((a) => [a.digestEntryId, a]));
|
|
49
|
+
return (_jsxs("article", { className: "op-detail", "aria-labelledby": "digest-detail-title", children: [_jsx("h3", { id: "digest-detail-title", className: "op-detail__title", children: record.id }), _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Digest date" }), _jsx("dd", { children: record.digestDate }), _jsx("dt", { children: "Created" }), _jsx("dd", { children: instantLabel(record.createdAtUtc, "UTC") }), _jsx("dt", { children: "Findings" }), _jsx("dd", { children: record.entries.length }), _jsx("dt", { children: "Applied as draft calendar entries" }), _jsxs("dd", { children: [applications.length, " of ", record.entries.length] })] }), _jsx("p", { className: "op-hint", children: "An applied finding became a draft calendar entry. Each draft carries a placeholder caption, so it cannot be approved until a person writes a real caption. This screen does not write that caption, approve the draft or apply the digest." }), _jsx("h4", { id: "digest-entries-title", className: "op-section-title", children: "Findings" }), _jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "digest-entries-title", children: [_jsxs(TableCaption, { children: ["Findings of ", record.id] }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Finding" }), _jsx(TableHeaderCell, { children: "Topic" }), _jsx(TableHeaderCell, { children: "Finding text" }), _jsx(TableHeaderCell, { children: "Source" }), _jsx(TableHeaderCell, { children: "Fetched" }), _jsx(TableHeaderCell, { children: "Suggested platform" }), _jsx(TableHeaderCell, { children: "Suggested format" }), _jsx(TableHeaderCell, { children: "Draft calendar entry" })] }) }), _jsx(TableBody, { children: record.entries.map((entry) => {
|
|
50
|
+
const application = appliedByEntry.get(entry.id);
|
|
51
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: entry.id }), _jsx(TableCell, { children: entry.topic }), _jsx(TableCell, { children: entry.finding }), _jsx(TableCell, { className: "op-mono", children: entry.source }), _jsx(TableCell, { className: "op-mono", children: entry.fetchedOn }), _jsx(TableCell, { children: platformName(entry.platform) }), _jsx(TableCell, { children: entry.format }), _jsx(TableCell, { className: "op-mono", children: application ? application.calendarEntryId : "Not applied yet" })] }, entry.id));
|
|
52
|
+
}) })] }) }), _jsx("h4", { id: "digest-applied-title", className: "op-section-title", children: "Applied findings" }), applications.length === 0 ? (_jsx("p", { className: "op-hint", children: "Nothing from this digest has been applied to the calendar yet." })) : (_jsx("div", { className: "op-table-scroll", children: _jsxs(Table, { "aria-labelledby": "digest-applied-title", children: [_jsx(TableCaption, { children: "Draft calendar entries made from this digest" }), _jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableHeaderCell, { children: "Finding" }), _jsx(TableHeaderCell, { children: "Brief" }), _jsx(TableHeaderCell, { children: "Draft calendar entry" }), _jsx(TableHeaderCell, { children: "Applied (UTC)" })] }) }), _jsx(TableBody, { children: applications.map((application) => (_jsxs(TableRow, { children: [_jsx(TableCell, { rowHeader: true, className: "op-mono", children: application.digestEntryId }), _jsx(TableCell, { className: "op-mono", children: application.briefId }), _jsx(TableCell, { className: "op-mono", children: application.calendarEntryId }), _jsx(TableCell, { className: "op-mono", children: instantLabel(application.appliedAtUtc, "UTC") })] }, application.digestEntryId))) })] }) }))] }));
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type DataSource } from "../data/index.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
source: DataSource;
|
|
4
|
+
rev: number;
|
|
5
|
+
onChanged: () => void;
|
|
6
|
+
/** Disclosure phrases the caller supplies for approval. The engine has no built-in list. */
|
|
7
|
+
disclosurePhrases: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function DraftsScreen({ source, rev, onChanged, disclosurePhrases }: Props): import("react").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Badge, Button, EmptyState, Field, Input, Notice, PageShell, SkeletonRows } from "@rightkit/react-shell/react";
|
|
4
|
+
import { LoadError, MockBanner } from "../components/Shared.js";
|
|
5
|
+
import { useAsync } from "../hooks/useAsync.js";
|
|
6
|
+
import { shell } from "../lib/shell.js";
|
|
7
|
+
import { disclosureFound } from "../data/index.js";
|
|
8
|
+
import { platformName } from "./labels.js";
|
|
9
|
+
import { hostAppName } from "../appName.js";
|
|
10
|
+
const SECTIONS = [
|
|
11
|
+
{ state: "drafted", title: "Drafted, waiting for your approval", empty: "No drafts are waiting for approval." },
|
|
12
|
+
{ state: "approved", title: "Approved, to post by hand", empty: "No approved drafts are waiting for you to post." },
|
|
13
|
+
{ state: "posted_by_operator", title: "Posted by you", empty: "No drafts are recorded as posted." },
|
|
14
|
+
{ state: "skipped", title: "Skipped", empty: "No drafts are skipped." },
|
|
15
|
+
{ state: "stale", title: "Stale", empty: "No drafts are stale." },
|
|
16
|
+
];
|
|
17
|
+
const KIND_LABEL = { post: "Post", comment: "Comment", reply: "Reply" };
|
|
18
|
+
const STATE_BADGE = {
|
|
19
|
+
drafted: { text: "Drafted", tone: "neutral" },
|
|
20
|
+
approved: { text: "Approved, post by hand", tone: "accent" },
|
|
21
|
+
posted_by_operator: { text: "Posted by you", tone: "ok" },
|
|
22
|
+
skipped: { text: "Skipped", tone: "neutral" },
|
|
23
|
+
stale: { text: "Stale", tone: "warn" },
|
|
24
|
+
};
|
|
25
|
+
/** Identifiers in the engine contain slashes; DOM ids here use only letters, digits, "_" and "-". */
|
|
26
|
+
function domId(raw) {
|
|
27
|
+
return raw.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
28
|
+
}
|
|
29
|
+
/** What the disclosure rule says, in words. The engine's gate at approval is the authority. */
|
|
30
|
+
function disclosureText(draft, phrases) {
|
|
31
|
+
if (!draft.disclosureRequired)
|
|
32
|
+
return "Not required for this draft.";
|
|
33
|
+
if (phrases.every((p) => p.trim() === "")) {
|
|
34
|
+
return "Required. No disclosure phrases are set in this app, so approval will be refused until they are.";
|
|
35
|
+
}
|
|
36
|
+
return disclosureFound(draft.body, phrases)
|
|
37
|
+
? "Required. A disclosure phrase is in the first sentence. The engine checks again when you approve."
|
|
38
|
+
: "Required. No disclosure phrase is in the first sentence yet, so approval will be refused.";
|
|
39
|
+
}
|
|
40
|
+
export function DraftsScreen({ source, rev, onChanged, disclosurePhrases }) {
|
|
41
|
+
const { state, reload, value } = useAsync(() => source.listDrafts(), [source, rev]);
|
|
42
|
+
return (_jsxs(PageShell, { title: "Drafts", subtitle: "Text you post by hand on each platform. Approve it, post it yourself, then record the link.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, _jsxs("p", { className: "op-draft__rule", children: [_jsxs("strong", { children: [hostAppName(), " never posts these."] }), " You post by hand and record the link."] }), state.status === "error" ? (_jsx(LoadError, { message: "Could not read drafts from the daemon.", onRetry: reload })) : value === null ? (_jsx(SkeletonRows, { label: "Loading drafts", rows: 3 })) : value.length === 0 ? (_jsx(EmptyState, { title: "No drafts yet", children: _jsx("p", { children: "Drafts appear here once one is created." }) })) : (SECTIONS.map((section) => {
|
|
43
|
+
const items = value.filter((draft) => draft.state === section.state);
|
|
44
|
+
return (_jsxs("section", { className: "op-drafts-section", "aria-labelledby": `drafts-${section.state}`, children: [_jsxs("h2", { id: `drafts-${section.state}`, className: "op-section-title", children: [section.title, " (", items.length, ")"] }), items.length === 0 ? (_jsx("p", { className: "op-hint", children: section.empty })) : (_jsx("div", { className: "op-drafts", children: items.map((draft) => (_jsx(DraftCard, { draft: draft, disclosurePhrases: disclosurePhrases, source: source, onChanged: onChanged }, draft.id))) }))] }, section.state));
|
|
45
|
+
}))] }));
|
|
46
|
+
}
|
|
47
|
+
function DraftCard({ draft, disclosurePhrases, source, onChanged, }) {
|
|
48
|
+
const [busy, setBusy] = useState(false);
|
|
49
|
+
const [notice, setNotice] = useState(null);
|
|
50
|
+
const [linkOpen, setLinkOpen] = useState(false);
|
|
51
|
+
const [postedUrl, setPostedUrl] = useState("");
|
|
52
|
+
const [skipReason, setSkipReason] = useState("");
|
|
53
|
+
const id = domId(draft.id);
|
|
54
|
+
const platform = platformName(draft.platform);
|
|
55
|
+
const badge = STATE_BADGE[draft.state];
|
|
56
|
+
const canSkip = draft.state === "drafted" || draft.state === "approved";
|
|
57
|
+
async function copyBody() {
|
|
58
|
+
try {
|
|
59
|
+
await shell.clipboard.write({ text: draft.body });
|
|
60
|
+
setNotice({ tone: "ok", text: "Copied. Paste it on the platform, post it there, then record the link here." });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
setNotice({ tone: "bad", text: "Copying is not available here. Select the text above and copy it by hand." });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function approve() {
|
|
67
|
+
setBusy(true);
|
|
68
|
+
const result = await source.approveDraft(draft.id, draft.revision, disclosurePhrases);
|
|
69
|
+
setBusy(false);
|
|
70
|
+
if (result.ok) {
|
|
71
|
+
setNotice({ tone: "ok", text: "Approved. Post it by hand, then record the link." });
|
|
72
|
+
onChanged();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
setNotice({ tone: "bad", text: result.error.message });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function recordPost() {
|
|
79
|
+
const url = postedUrl.trim();
|
|
80
|
+
if (!/^https:\/\//i.test(url)) {
|
|
81
|
+
setNotice({ tone: "bad", text: "The link must start with https://. Nothing was recorded." });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setBusy(true);
|
|
85
|
+
const result = await source.markDraftPosted(draft.id, draft.revision, url);
|
|
86
|
+
setBusy(false);
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
setNotice({ tone: "ok", text: "Recorded. This draft is now marked as posted by you." });
|
|
89
|
+
onChanged();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
setNotice({ tone: "bad", text: result.error.message });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function skip() {
|
|
96
|
+
setBusy(true);
|
|
97
|
+
const result = await source.skipDraft(draft.id, draft.revision, skipReason);
|
|
98
|
+
setBusy(false);
|
|
99
|
+
if (result.ok) {
|
|
100
|
+
setNotice({ tone: "ok", text: "Skipped." });
|
|
101
|
+
onChanged();
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
setNotice({ tone: "bad", text: result.error.message });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return (_jsxs("article", { className: "op-card op-draft", "aria-labelledby": `draft-${id}`, children: [_jsxs("h3", { id: `draft-${id}`, className: "op-draft__title", children: [platform, ": ", KIND_LABEL[draft.kind], draft.title ? `, ${draft.title}` : ""] }), _jsx("div", { className: "op-badges", children: _jsx(Badge, { tone: badge.tone, children: badge.text }) }), _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "Account" }), _jsx("dd", { children: draft.channelLabel }), _jsx("dt", { children: "Target" }), _jsx("dd", { children: draft.target ?? "None: a new post" }), _jsx("dt", { children: "Disclosure" }), _jsx("dd", { children: disclosureText(draft, disclosurePhrases) }), _jsx("dt", { children: "Length" }), _jsxs("dd", { children: [draft.bodyChars, " characters"] }), draft.sourceNote ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Why this draft exists" }), _jsx("dd", { children: draft.sourceNote })] })) : null, draft.postedUrl ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Link you recorded" }), _jsxs("dd", { children: [draft.postedUrl, draft.postedAtUtc ? ` (recorded ${draft.postedAtUtc})` : ""] })] })) : null, draft.skipReason ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Skip reason" }), _jsx("dd", { children: draft.skipReason })] })) : null] }), _jsxs("section", { "aria-labelledby": `draft-text-${id}`, children: [_jsx("h4", { id: `draft-text-${id}`, className: "op-section-title", children: "Text" }), _jsx("div", { className: "op-caption", children: draft.body })] }), _jsxs("div", { className: "op-decide__buttons", children: [_jsx(Button, { variant: "secondary", size: "sm", onClick: copyBody, children: "Copy text" }), draft.state === "drafted" ? (_jsx(Button, { variant: "primary", disabled: busy, onClick: approve, children: "Approve" })) : null, draft.state === "approved" ? (_jsx(Button, { variant: "primary", disabled: busy, "aria-expanded": linkOpen, onClick: () => setLinkOpen((open) => !open), children: "I posted this" })) : null] }), draft.state === "approved" && linkOpen ? (_jsxs("div", { className: "op-draft__form", children: [_jsx(Field, { label: "Link to your post", note: "Paste the full https address of the post you made by hand.", children: ({ id: fieldId, "aria-describedby": describedBy }) => (_jsx(Input, { id: fieldId, "aria-describedby": describedBy, type: "url", inputMode: "url", placeholder: "https://", value: postedUrl, onChange: (e) => setPostedUrl(e.target.value) })) }), _jsx("div", { className: "op-decide__buttons", children: _jsx(Button, { variant: "primary", disabled: busy || postedUrl.trim() === "", onClick: recordPost, children: "Record posted link" }) })] })) : null, canSkip ? (_jsxs("div", { className: "op-draft__form", children: [_jsx(Field, { label: "Reason to skip (required)", children: ({ id: fieldId, "aria-describedby": describedBy }) => (_jsx(Input, { id: fieldId, "aria-describedby": describedBy, value: skipReason, onChange: (e) => setSkipReason(e.target.value) })) }), _jsx("div", { className: "op-decide__buttons", children: _jsx(Button, { variant: "secondary", disabled: busy || skipReason.trim() === "", onClick: skip, children: "Skip draft" }) })] })) : null, notice ? (_jsx(Notice, { tone: notice.tone, className: "op-notice", children: notice.text })) : null] }));
|
|
108
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Button, EmptyState, Notice, PageShell, SkeletonRows } from "@rightkit/react-shell/react";
|
|
4
|
+
import { AccountChip, LoadError, MockBanner, PermalinkCopy } from "../components/Shared.js";
|
|
5
|
+
import { useAsync } from "../hooks/useAsync.js";
|
|
6
|
+
import { OUTCOME_UNKNOWN_EXPLANATION } from "./labels.js";
|
|
7
|
+
import { accountOrdinals, instantLabel, } from "../data/index.js";
|
|
8
|
+
const KIND_ORDER = { outcome_unknown: 0, dead_letter: 1, alert: 2 };
|
|
9
|
+
const TITLE = {
|
|
10
|
+
outcome_unknown: "Outcome unknown",
|
|
11
|
+
dead_letter: "Stopped after repeated failures",
|
|
12
|
+
alert: "Alert",
|
|
13
|
+
};
|
|
14
|
+
const ALERT_TITLE = {
|
|
15
|
+
disk_full: "Alert: server disk is nearly full",
|
|
16
|
+
credentials_expired: "Alert: account credentials expired",
|
|
17
|
+
quota_cooldown: "Alert: quota cooldown",
|
|
18
|
+
};
|
|
19
|
+
const RESOLUTION_LABEL = {
|
|
20
|
+
published_confirmed: "Confirmed published",
|
|
21
|
+
cancelled_not_published: "Cancelled, not published",
|
|
22
|
+
accepted_as_lost: "Accepted as lost",
|
|
23
|
+
acknowledged: "Acknowledged",
|
|
24
|
+
};
|
|
25
|
+
function reconcileText(result, nowLabel) {
|
|
26
|
+
return result.outcome === "found_on_platform"
|
|
27
|
+
? `Found on the platform. Checked ${nowLabel(result.checkedAtUtc)}.`
|
|
28
|
+
: `Not found on the platform. Checked ${nowLabel(result.checkedAtUtc)}. Safe to retry: the post did not go out.`;
|
|
29
|
+
}
|
|
30
|
+
export function FailuresScreen({ source, rev, onChanged }) {
|
|
31
|
+
// A resolved card leaves the open list and unmounts, so its own notice would never be read.
|
|
32
|
+
// The result is announced from here, in a region that stays mounted.
|
|
33
|
+
const [resolvedNotice, setResolvedNotice] = useState(null);
|
|
34
|
+
const listRef = useRef(null);
|
|
35
|
+
const headingRef = useRef(null);
|
|
36
|
+
// Set by a resolve, read once the reloaded list is on screen: the next failure row, or the heading when none remain.
|
|
37
|
+
const pendingFocus = useRef(null);
|
|
38
|
+
const { state, reload, value } = useAsync(async () => {
|
|
39
|
+
const [accounts, failures, digest] = await Promise.all([
|
|
40
|
+
source.listAccounts(),
|
|
41
|
+
source.listFailures(),
|
|
42
|
+
source.listDigest(),
|
|
43
|
+
]);
|
|
44
|
+
return { accounts, failures, digest };
|
|
45
|
+
}, [source, rev]);
|
|
46
|
+
const accounts = value?.accounts ?? [];
|
|
47
|
+
const ordinals = useMemo(() => accountOrdinals(accounts), [accounts]);
|
|
48
|
+
const open = (value?.failures ?? [])
|
|
49
|
+
.filter((f) => f.status === "open")
|
|
50
|
+
.sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || b.atUtc.localeCompare(a.atUtc));
|
|
51
|
+
const resolved = (value?.failures ?? []).filter((f) => f.status === "resolved");
|
|
52
|
+
function handleResolved(id, text) {
|
|
53
|
+
const index = open.findIndex((f) => f.id === id);
|
|
54
|
+
const remaining = open.filter((f) => f.id !== id);
|
|
55
|
+
const next = remaining[index] ?? remaining[index - 1];
|
|
56
|
+
pendingFocus.current = { rowId: next ? next.id : null };
|
|
57
|
+
setResolvedNotice(text);
|
|
58
|
+
}
|
|
59
|
+
// The resolved card has unmounted and took focus with it. Once the reloaded list renders, move focus to the target.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const pending = pendingFocus.current;
|
|
62
|
+
if (pending === null)
|
|
63
|
+
return;
|
|
64
|
+
const rows = listRef.current ? Array.from(listRef.current.querySelectorAll("[data-failure-id]")) : [];
|
|
65
|
+
const row = pending.rowId === null ? undefined : rows.find((el) => el.dataset.failureId === pending.rowId);
|
|
66
|
+
const target = row ?? headingRef.current;
|
|
67
|
+
if (target === null)
|
|
68
|
+
return;
|
|
69
|
+
pendingFocus.current = null;
|
|
70
|
+
target.focus();
|
|
71
|
+
}, [value]);
|
|
72
|
+
return (_jsxs(PageShell, { title: "Failures", subtitle: "Everything that stopped or may have published wrongly, with the one next action for each.", children: [source.kind === "mock" ? _jsx(MockBanner, {}) : null, state.status === "error" ? (_jsx(LoadError, { message: "Could not read failures and the digest from the daemon.", onRetry: reload })) : value === null ? (_jsx(SkeletonRows, { label: "Loading failures and the digest", rows: 4 })) : (_jsxs(_Fragment, { children: [_jsx("div", { "aria-live": "polite", children: resolvedNotice ? _jsx(Notice, { tone: "ok", className: "op-notice", children: resolvedNotice }) : null }), _jsxs("section", { className: "op-digest", "aria-labelledby": "digest-title", children: [_jsx("h2", { id: "digest-title", className: "op-section-title", children: "Last 24 hours" }), _jsx("ul", { className: "op-digest__list", children: value.digest.sentences.map((sentence) => (_jsx("li", { children: sentence }, sentence))) })] }), _jsxs("section", { ref: listRef, "aria-labelledby": "open-title", children: [_jsxs("h2", { id: "open-title", ref: headingRef, tabIndex: -1, className: "op-section-title", children: ["Needs action (", open.length, ")"] }), open.length === 0 ? (_jsx(EmptyState, { title: "No open failures", children: _jsx("p", { children: "Nothing has stopped or may have published wrongly." }) })) : (_jsx("div", { className: "op-failures", children: open.map((item) => (_jsx(FailureCard, { item: item, account: accounts.find((a) => a.id === item.accountId) ?? null, ordinal: item.accountId ? ordinals.get(item.accountId) : undefined, source: source, onChanged: onChanged, onResolved: handleResolved }, item.id))) }))] }), resolved.length > 0 ? (_jsxs("details", { className: "op-resolved", children: [_jsxs("summary", { children: ["Resolved (", resolved.length, ")"] }), _jsx("ul", { children: resolved.map((item) => (_jsxs("li", { children: [item.resolution ? RESOLUTION_LABEL[item.resolution] : "Resolved", ": ", item.summary, item.resolutionNote ? ` Note: ${item.resolutionNote}` : ""] }, item.id))) })] })) : null] }))] }));
|
|
73
|
+
}
|
|
74
|
+
function FailureCard({ item, account, ordinal, source, onChanged, onResolved, }) {
|
|
75
|
+
const [note, setNote] = useState("");
|
|
76
|
+
const [busy, setBusy] = useState(false);
|
|
77
|
+
const [notice, setNotice] = useState(null);
|
|
78
|
+
const [reconcile, setReconcile] = useState(item.reconcile);
|
|
79
|
+
const tz = account?.timeZone ?? "UTC";
|
|
80
|
+
const when = (iso) => instantLabel(iso, tz);
|
|
81
|
+
const title = item.kind === "alert" && item.alertKind ? ALERT_TITLE[item.alertKind] : TITLE[item.kind];
|
|
82
|
+
async function check() {
|
|
83
|
+
if (!item.jobId)
|
|
84
|
+
return;
|
|
85
|
+
setBusy(true);
|
|
86
|
+
const result = await source.reconcile(item.jobId);
|
|
87
|
+
setBusy(false);
|
|
88
|
+
if (result.ok) {
|
|
89
|
+
setReconcile(result.value);
|
|
90
|
+
setNotice({ tone: "ok", text: "The platform check finished. Choose the resolution that matches it." });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
setNotice({ tone: "bad", text: result.error.message });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async function resolve(resolution) {
|
|
97
|
+
setBusy(true);
|
|
98
|
+
const result = await source.resolveFailure(item.id, resolution, note);
|
|
99
|
+
setBusy(false);
|
|
100
|
+
if (result.ok) {
|
|
101
|
+
onResolved(item.id, `Recorded: ${RESOLUTION_LABEL[resolution]}.`);
|
|
102
|
+
onChanged();
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
setNotice({ tone: "bad", text: result.error.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const noteMissing = note.trim() === "";
|
|
109
|
+
const reconciled = reconcile !== null;
|
|
110
|
+
return (_jsxs("article", { className: "op-card op-failure", "aria-labelledby": `f-${item.id}`, "data-failure-id": item.id, tabIndex: -1, children: [_jsx("h3", { id: `f-${item.id}`, className: "op-failure__title", children: title }), account ? _jsx(AccountChip, { account: account, ordinal: ordinal, detail: true }) : null, _jsxs("dl", { className: "op-dl", children: [_jsx("dt", { children: "What happened" }), _jsx("dd", { children: item.summary }), _jsx("dt", { children: "When" }), _jsx("dd", { children: when(item.atUtc) }), item.attempts !== null ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Attempts" }), _jsx("dd", { children: item.attempts })] })) : null, _jsx("dt", { children: "Next action" }), _jsx("dd", { children: _jsx("strong", { children: item.nextAction }) })] }), item.kind === "outcome_unknown" ? (_jsxs("div", { className: "op-failure__check", children: [_jsx("p", { children: _jsx("strong", { children: OUTCOME_UNKNOWN_EXPLANATION }) }), reconcile === null ? (_jsx("p", { children: "Not checked yet. Checking reads the platform. It does not publish or retry." })) : (_jsx("p", { children: reconcileText(reconcile, when) })), reconcile !== null && reconcile.outcome === "found_on_platform" ? (_jsxs(_Fragment, { children: [_jsxs("p", { children: ["Platform id ", _jsx("code", { children: reconcile.remoteId })] }), _jsx(PermalinkCopy, { url: reconcile.permalink })] })) : null, !reconciled ? (_jsx(Button, { variant: "secondary", size: "sm", disabled: busy, onClick: check, children: "Check the platform" })) : null] })) : null, _jsx("div", { "aria-live": "polite", children: notice ? (_jsx(Notice, { tone: notice.tone, className: "op-notice", children: notice.text })) : null }), item.kind === "alert" ? (_jsxs("div", { className: "op-decide__buttons", children: [_jsx(Button, { variant: "secondary", disabled: busy, onClick: () => resolve("acknowledged"), children: "Acknowledge alert" }), item.alertKind === "credentials_expired" ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", disabled: true, children: "Reconnect account" }), _jsx("p", { className: "op-hint", children: "Reconnect opens the provider sign-in. It is not available in mock data." })] })) : null] })) : (_jsxs("div", { className: "op-failure__resolve", children: [_jsx("label", { className: "op-field-label", htmlFor: `note-${item.id}`, children: "Note (required to accept as lost)" }), _jsx("textarea", { id: `note-${item.id}`, className: "op-textarea", rows: 2, value: note, onChange: (e) => setNote(e.target.value) }), _jsxs("div", { className: "op-decide__buttons", children: [item.kind === "outcome_unknown" ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", disabled: busy || reconcile?.outcome !== "found_on_platform", onClick: () => resolve("published_confirmed"), children: "Mark published (platform confirmed)" }), _jsx(Button, { variant: "secondary", disabled: busy || reconcile?.outcome !== "not_found", onClick: () => resolve("cancelled_not_published"), children: "Mark cancelled, not published" })] })) : (_jsx(Button, { variant: "secondary", disabled: busy, onClick: () => resolve("cancelled_not_published"), children: "Mark cancelled, not published" })), _jsx(Button, { variant: "danger", disabled: busy || noteMissing, onClick: () => resolve("accepted_as_lost"), children: "Accept as lost" })] }), item.kind === "outcome_unknown" && !reconciled ? (_jsx("p", { className: "op-hint", children: "Publishing resolutions unlock after the platform check." })) : null] }))] }));
|
|
111
|
+
}
|