@anthusai/papyrus 2.0.0-next.1 → 2.0.0-next.11
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/amplify/data/resource.js +7 -0
- package/amplify/data/resource.ts +8 -0
- package/amplify/data/schema.js +10 -0
- package/amplify/data/schema.ts +11 -0
- package/amplify/functions/assignment-action/data-access.js +88 -0
- package/amplify/functions/assignment-action/data-access.ts +87 -0
- package/amplify/functions/content-actions/handler.py +7 -0
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/functions/editorial-checks/resource.js +43 -0
- package/amplify/functions/editorial-checks/resource.ts +60 -0
- package/amplify/functions/editorial-checks/schema.js +92 -0
- package/amplify/functions/editorial-checks/schema.ts +94 -0
- package/amplify/functions/editorial-checks/wire.js +49 -0
- package/amplify/functions/editorial-checks/wire.ts +57 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/site-backend.js +9 -0
- package/amplify/site-backend.ts +15 -0
- package/components/editorial-checks-admin-panel.tsx +347 -0
- package/components/editorial-finding-card.tsx +162 -0
- package/components/editorial-findings-panel.tsx +91 -0
- package/components/editorial-preview-popover.tsx +103 -0
- package/components/markus-body-editor.tsx +213 -0
- package/components/markus-ir-preview.tsx +147 -6
- package/components/newsroom-article-editor.tsx +136 -11
- package/components/newsroom-board-view.tsx +6 -7
- package/components/newsroom-ops-shell.tsx +7 -3
- package/components/newsroom-organizer.css +57 -61
- package/components/newsroom-theme-fonts/broadsheet-green.css +10 -0
- package/components/newsroom-theme-fonts/broadsheet-green.tsx +6 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.css +9 -0
- package/components/newsroom-theme-fonts/newspaper-gothic.tsx +6 -0
- package/components/newsroom-theme-fonts/night-desk.css +10 -0
- package/components/newsroom-theme-fonts/night-desk.tsx +6 -0
- package/components/newsroom-theme-fonts/refined-newsprint.css +11 -0
- package/components/newsroom-theme-fonts/refined-newsprint.tsx +6 -0
- package/components/newsroom-theme.css +39 -0
- package/components/newsroom-theme.tsx +33 -0
- package/components/reference-cyclotron-priming.tsx +12 -13
- package/components/reference-cyclotron-status.tsx +4 -4
- package/components/reference-cyclotron-suggestion.tsx +6 -6
- package/components/reference-live-agreement.tsx +10 -10
- package/components/reference-priming.tsx +1 -1
- package/components/reference-relevance-review.tsx +1 -1
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-editorial-checks.ts +133 -0
- package/lib/cyclotron-suggestions.ts +9 -9
- package/lib/editorial-checks-backend.ts +16 -0
- package/lib/editorial-checks-client.ts +194 -0
- package/lib/editorial-checks-demo.ts +147 -0
- package/lib/editorial-checks-settings.ts +27 -0
- package/lib/editorial-checks.ts +171 -0
- package/lib/editorial-preview.ts +228 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/relevance-status.ts +4 -4
- package/lib/site-brand.ts +17 -0
- package/package.json +34 -2
- package/routes.manifest.json +1 -1
- package/components/newsroom-fonts/playfair-display-700.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-900.ttf +0 -0
- package/components/newsroom-fonts/playfair-display-OFL.txt +0 -93
- package/components/newsroom-fonts/plus-jakarta-sans-400.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-600.ttf +0 -0
- package/components/newsroom-fonts/plus-jakarta-sans-OFL.txt +0 -93
- package/components/newsroom-newsprint-board.css +0 -7
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
1
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
|
2
|
+
import { CITATION_TOKEN, planPreview, selectionToSource, splitByMarks, type BlockAnnotation, type PreviewMark, type PreviewPlan } from "../lib/editorial-preview";
|
|
3
|
+
import type { EditorialCheckResult, EditorialFinding, Span } from "../lib/editorial-checks";
|
|
2
4
|
import {
|
|
3
5
|
parseMarkusDocument,
|
|
4
6
|
type MarkusBlock,
|
|
@@ -17,7 +19,20 @@ type PreviewSidecar = {
|
|
|
17
19
|
bibliography: string[];
|
|
18
20
|
};
|
|
19
21
|
|
|
22
|
+
/** Editorial findings drawn on the preview (see lib/editorial-preview.ts). */
|
|
23
|
+
export type PreviewEditorial = {
|
|
24
|
+
/** A check result computed for the same body the preview shows, and the findings to draw from it. */
|
|
25
|
+
result: EditorialCheckResult;
|
|
26
|
+
findings: EditorialFinding[];
|
|
27
|
+
activeFindingIds?: readonly string[];
|
|
28
|
+
/** A marked passage was clicked. */
|
|
29
|
+
onOpenMark: (findingIds: string[], anchor: HTMLElement) => void;
|
|
30
|
+
/** Text was selected inside one prose block: the source span of the sentences it touches. */
|
|
31
|
+
onSelectSource?: (source: Span, anchor: DOMRect) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
20
34
|
type PreviewContext = {
|
|
35
|
+
editorial?: PreviewEditorial & { plan: PreviewPlan; blocks: Map<string, BlockAnnotation> };
|
|
21
36
|
sidecar: PreviewSidecar;
|
|
22
37
|
citationNumbers: Map<string, number>;
|
|
23
38
|
imageUrls: Record<string, string>;
|
|
@@ -91,7 +106,7 @@ function renderText(text: string, context: PreviewContext): ReactNode {
|
|
|
91
106
|
if (!keys) continue;
|
|
92
107
|
if (index > cursor) parts.push(text.slice(cursor, index));
|
|
93
108
|
parts.push(
|
|
94
|
-
<sup className="text-primary" key={`${index}-${token}`}>
|
|
109
|
+
<sup className="text-primary" data-editorial-skip key={`${index}-${token}`}>
|
|
95
110
|
{keys.map((key) => `[${context.citationNumbers.get(key)}]`).join("")}
|
|
96
111
|
</sup>,
|
|
97
112
|
);
|
|
@@ -127,7 +142,7 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
|
|
|
127
142
|
}
|
|
128
143
|
case "image":
|
|
129
144
|
return (
|
|
130
|
-
<span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
|
|
145
|
+
<span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" data-editorial-skip key={index}>
|
|
131
146
|
{`Image: ${node.src ?? ""} (${node.alt})`}
|
|
132
147
|
</span>
|
|
133
148
|
);
|
|
@@ -141,6 +156,92 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
|
|
|
141
156
|
});
|
|
142
157
|
}
|
|
143
158
|
|
|
159
|
+
const MARK_CLASSES: Record<PreviewMark["severity"], string> = {
|
|
160
|
+
error: "decoration-destructive",
|
|
161
|
+
warn: "decoration-amber-500",
|
|
162
|
+
info: "decoration-sky-500",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function markElement(text: string, mark: PreviewMark, key: string, context: PreviewContext): ReactNode {
|
|
166
|
+
const active = mark.findingIds.some((id) => context.editorial?.activeFindingIds?.includes(id));
|
|
167
|
+
return (
|
|
168
|
+
<mark
|
|
169
|
+
className={`cursor-pointer bg-transparent text-inherit underline underline-offset-4 ${mark.status === "review" ? "decoration-dotted" : "decoration-wavy"} ${MARK_CLASSES[mark.severity]} ${active ? "rounded-sm bg-ring/25" : ""}`}
|
|
170
|
+
data-editorial-mark={mark.findingIds.join(" ")}
|
|
171
|
+
key={key}
|
|
172
|
+
onClick={(event) => {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
context.editorial?.onOpenMark(mark.findingIds, event.currentTarget);
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
{text}
|
|
178
|
+
</mark>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Like renderInlines, but splits rendered text at editorial marks (offsets as in inlineLeaves). */
|
|
183
|
+
function renderMarkedInlines(inlines: MarkusInline[], context: PreviewContext, marks: PreviewMark[], cursor: { at: number }): ReactNode[] {
|
|
184
|
+
const marked = (text: string, keyPrefix: string): ReactNode[] => {
|
|
185
|
+
const start = cursor.at;
|
|
186
|
+
cursor.at += text.length;
|
|
187
|
+
return splitByMarks(start, start + text.length, marks).map((piece, pieceIndex) => {
|
|
188
|
+
const slice = text.slice(piece.start - start, piece.end - start);
|
|
189
|
+
return piece.mark ? markElement(slice, piece.mark, `${keyPrefix}-${pieceIndex}`, context) : slice;
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
return inlines.map((node, index) => {
|
|
193
|
+
switch (node.type) {
|
|
194
|
+
case "text": {
|
|
195
|
+
const parts: ReactNode[] = [];
|
|
196
|
+
let last = 0;
|
|
197
|
+
for (const match of node.text.matchAll(CITATION_TOKEN)) {
|
|
198
|
+
const at = match.index ?? 0;
|
|
199
|
+
if (at > last) parts.push(...marked(node.text.slice(last, at), `${index}-${last}`));
|
|
200
|
+
parts.push(<span key={`${index}-t${at}`}>{renderText(match[0], context)}</span>);
|
|
201
|
+
last = at + match[0].length;
|
|
202
|
+
}
|
|
203
|
+
if (last < node.text.length) parts.push(...marked(node.text.slice(last), `${index}-${last}`));
|
|
204
|
+
return <span key={index}>{parts}</span>;
|
|
205
|
+
}
|
|
206
|
+
case "emphasis":
|
|
207
|
+
return <em key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</em>;
|
|
208
|
+
case "strong":
|
|
209
|
+
return <strong key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</strong>;
|
|
210
|
+
case "strikethrough":
|
|
211
|
+
return <s key={index}>{renderMarkedInlines(node.children, context, marks, cursor)}</s>;
|
|
212
|
+
case "code_span":
|
|
213
|
+
return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{marked(node.code, `${index}`)}</code>;
|
|
214
|
+
case "link": {
|
|
215
|
+
const children = renderMarkedInlines(node.children, context, marks, cursor);
|
|
216
|
+
const custom = node.href ? context.renderLink?.(node.href, children) : null;
|
|
217
|
+
if (custom) return <span key={index}>{custom}</span>;
|
|
218
|
+
return node.href ? (
|
|
219
|
+
<a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">{children}</a>
|
|
220
|
+
) : (
|
|
221
|
+
<span key={index}>{children}</span>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
case "soft_break":
|
|
225
|
+
cursor.at += 1;
|
|
226
|
+
return <span key={index}>{" "}</span>;
|
|
227
|
+
case "hard_break":
|
|
228
|
+
cursor.at += 1;
|
|
229
|
+
return <br key={index} />;
|
|
230
|
+
default:
|
|
231
|
+
return renderInlines([node], context)[0];
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Render a prose block's inlines, with editorial marks when the block has an annotation. */
|
|
237
|
+
function proseInlines(block: { inline: MarkusInline[] }, context: PreviewContext): { children: ReactNode[]; blockKey?: string } {
|
|
238
|
+
const annotation = context.editorial?.plan.get(block);
|
|
239
|
+
if (!context.editorial || !annotation) return { children: renderInlines(block.inline, context) };
|
|
240
|
+
const blockKey = String(context.editorial.blocks.size);
|
|
241
|
+
context.editorial.blocks.set(blockKey, annotation);
|
|
242
|
+
return { children: renderMarkedInlines(block.inline, context, annotation.marks, { at: 0 }), blockKey };
|
|
243
|
+
}
|
|
244
|
+
|
|
144
245
|
function soleToken(inlines: MarkusInline[]): string | null {
|
|
145
246
|
if (inlines.length !== 1 || inlines[0].type !== "text") return null;
|
|
146
247
|
const text = inlines[0].text.trim();
|
|
@@ -170,12 +271,14 @@ function renderBlock(block: MarkusBlock, index: number, context: PreviewContext)
|
|
|
170
271
|
</ol>
|
|
171
272
|
);
|
|
172
273
|
}
|
|
173
|
-
|
|
274
|
+
const prose = proseInlines(block, context);
|
|
275
|
+
return <p className="my-3 leading-relaxed" data-editorial-block={prose.blockKey} key={index}>{prose.children}</p>;
|
|
174
276
|
}
|
|
175
277
|
case "heading": {
|
|
176
278
|
const level = Math.min(Math.max(block.level, 1), 6);
|
|
177
279
|
const Tag = `h${level}` as "h1";
|
|
178
|
-
|
|
280
|
+
const prose = proseInlines(block, context);
|
|
281
|
+
return <Tag className={HEADING_CLASSES[level - 1]} data-editorial-block={prose.blockKey} key={index}>{prose.children}</Tag>;
|
|
179
282
|
}
|
|
180
283
|
case "list": {
|
|
181
284
|
const items = block.items.map((item, itemIndex) => (
|
|
@@ -310,12 +413,45 @@ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[]
|
|
|
310
413
|
);
|
|
311
414
|
}
|
|
312
415
|
|
|
416
|
+
/** Characters of rendered text from the start of `block` to (node, offset), skipping citation and image furniture. */
|
|
417
|
+
function renderedOffset(block: HTMLElement, node: Node, offset: number): number {
|
|
418
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
419
|
+
let total = 0;
|
|
420
|
+
for (let current = walker.nextNode(); current; current = walker.nextNode()) {
|
|
421
|
+
const skipped = current.parentElement?.closest("[data-editorial-skip]");
|
|
422
|
+
if (current === node) return total + (skipped ? 0 : offset);
|
|
423
|
+
if (!skipped) total += current.textContent?.length ?? 0;
|
|
424
|
+
}
|
|
425
|
+
return total;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function previewSelectionHandler(context: PreviewContext) {
|
|
429
|
+
return (event: ReactMouseEvent<HTMLDivElement>) => {
|
|
430
|
+
const editorial = context.editorial;
|
|
431
|
+
if (!editorial?.onSelectSource) return;
|
|
432
|
+
const selection = window.getSelection();
|
|
433
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return;
|
|
434
|
+
const range = selection.getRangeAt(0);
|
|
435
|
+
const startBlock = (range.startContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
|
|
436
|
+
const endBlock = (range.endContainer.parentElement ?? null)?.closest<HTMLElement>("[data-editorial-block]");
|
|
437
|
+
if (!startBlock || startBlock !== endBlock || !event.currentTarget.contains(startBlock)) return;
|
|
438
|
+
const annotation = editorial.blocks.get(startBlock.dataset.editorialBlock ?? "");
|
|
439
|
+
const source = selectionToSource(annotation, {
|
|
440
|
+
start: renderedOffset(startBlock, range.startContainer, range.startOffset),
|
|
441
|
+
end: renderedOffset(startBlock, range.endContainer, range.endOffset),
|
|
442
|
+
});
|
|
443
|
+
if (source) editorial.onSelectSource(source, range.getBoundingClientRect());
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
313
447
|
export function MarkusIrPreview({
|
|
314
448
|
bodyIr,
|
|
315
449
|
imageUrls = {},
|
|
316
450
|
dynamicDirectives = {},
|
|
317
451
|
renderLink,
|
|
452
|
+
editorial,
|
|
318
453
|
}: {
|
|
454
|
+
editorial?: PreviewEditorial;
|
|
319
455
|
bodyIr: unknown;
|
|
320
456
|
imageUrls?: Record<string, string>;
|
|
321
457
|
/** Live components for leaf directives whose content the host app computes, keyed by directive name. */
|
|
@@ -333,8 +469,13 @@ export function MarkusIrPreview({
|
|
|
333
469
|
imageUrls,
|
|
334
470
|
dynamicDirectives,
|
|
335
471
|
renderLink,
|
|
472
|
+
editorial: editorial ? { ...editorial, plan: planPreview(document, editorial.result, editorial.findings), blocks: new Map() } : undefined,
|
|
336
473
|
};
|
|
337
|
-
return
|
|
474
|
+
return (
|
|
475
|
+
<div data-markus-preview onMouseUp={editorial?.onSelectSource ? previewSelectionHandler(context) : undefined}>
|
|
476
|
+
{renderBlocks(document.children, context)}
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
338
479
|
} catch (error) {
|
|
339
480
|
return (
|
|
340
481
|
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" data-markus-preview-error role="alert">
|
|
@@ -48,7 +48,15 @@ import {
|
|
|
48
48
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
49
49
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
50
50
|
import { cn } from "../lib/utils";
|
|
51
|
+
import { applyReplacement, inlineFindings, visibleFindings } from "../lib/editorial-checks";
|
|
52
|
+
import { graphqlEditorialBackend } from "../lib/editorial-checks-client";
|
|
53
|
+
import { demoEditorialBackend } from "../lib/editorial-checks-demo";
|
|
54
|
+
import type { FindingActions } from "./editorial-finding-card";
|
|
55
|
+
import { EditorialFindingsPanel } from "./editorial-findings-panel";
|
|
56
|
+
import { EditorialPreviewPopover, type PreviewPopoverTarget } from "./editorial-preview-popover";
|
|
57
|
+
import { MarkusBodyEditor, type MarkusBodyEditorHandle } from "./markus-body-editor";
|
|
51
58
|
import { MarkusIrPreview } from "./markus-ir-preview";
|
|
59
|
+
import { useEditorialChecks } from "./use-editorial-checks";
|
|
52
60
|
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
53
61
|
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
54
62
|
import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
|
|
@@ -118,6 +126,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
118
126
|
const [notice, setNotice] = useState<Notice | null>(null);
|
|
119
127
|
const [conflictOpen, setConflictOpen] = useState(false);
|
|
120
128
|
const [unpublishOpen, setUnpublishOpen] = useState(false);
|
|
129
|
+
const [publishWarningOpen, setPublishWarningOpen] = useState(false);
|
|
130
|
+
const [previewPopover, setPreviewPopover] = useState<PreviewPopoverTarget | null>(null);
|
|
131
|
+
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
121
132
|
const [mobileTab, setMobileTab] = useState<MobileTab>("preview");
|
|
122
133
|
const [focusedReferenceId, setFocusedReferenceId] = useState<string | null>(null);
|
|
123
134
|
const [lineageId, setLineageId] = useState<string | null>(null);
|
|
@@ -133,7 +144,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
133
144
|
const [imageError, setImageError] = useState<string | null>(null);
|
|
134
145
|
|
|
135
146
|
const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
|
|
136
|
-
const bodyRef = useRef<
|
|
147
|
+
const bodyRef = useRef<MarkusBodyEditorHandle | null>(null);
|
|
137
148
|
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
|
138
149
|
const mediaBackend = useMemo(() => resolveMediaBackend(previewMode), [previewMode]);
|
|
139
150
|
const validationSequence = useRef(0);
|
|
@@ -205,6 +216,21 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
205
216
|
|
|
206
217
|
const frontMatterYaml = editor?.frontMatterYaml ?? null;
|
|
207
218
|
const bodyMarkus = editor?.bodyMarkus ?? null;
|
|
219
|
+
const editorialBackend = useMemo(
|
|
220
|
+
() => (demo ? demoEditorialBackend : !previewMode ? graphqlEditorialBackend : null),
|
|
221
|
+
[demo, previewMode],
|
|
222
|
+
);
|
|
223
|
+
const editorial = useEditorialChecks({
|
|
224
|
+
backend: editorialBackend,
|
|
225
|
+
body: bodyMarkus,
|
|
226
|
+
section: editor?.section ?? "",
|
|
227
|
+
itemId: editor?.id ?? null,
|
|
228
|
+
});
|
|
229
|
+
const editorialOpen = useMemo(
|
|
230
|
+
() => (editorial.result ? visibleFindings(editorial.result.findings, editorial.dismissed) : []),
|
|
231
|
+
[editorial.result, editorial.dismissed],
|
|
232
|
+
);
|
|
233
|
+
const editorialMarks = useMemo(() => inlineFindings(editorialOpen), [editorialOpen]);
|
|
208
234
|
|
|
209
235
|
useEffect(() => {
|
|
210
236
|
if (frontMatterYaml === null || bodyMarkus === null) return;
|
|
@@ -381,7 +407,45 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
381
407
|
}
|
|
382
408
|
}, [aliases, backend, demo, editor]);
|
|
383
409
|
|
|
410
|
+
const requestPublish = () => {
|
|
411
|
+
if (editorial.openWarnings.length > 0) {
|
|
412
|
+
setPublishWarningOpen(true);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
void publish();
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const applyEditorialRewrite = (
|
|
419
|
+
option: { span: { start: number; end: number }; replacement: string; expected: string },
|
|
420
|
+
finding: Parameters<typeof editorial.fixed>[0] | null,
|
|
421
|
+
) => {
|
|
422
|
+
if (!editor) return;
|
|
423
|
+
const next = applyReplacement(editor.bodyMarkus, option.span, option.expected, option.replacement);
|
|
424
|
+
if (next === null) {
|
|
425
|
+
setNotice({ tone: "info", text: "That text changed since the check. Check again, then apply the rewrite." });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
patchEditor({ bodyMarkus: next });
|
|
429
|
+
if (finding) editorial.fixed(finding);
|
|
430
|
+
setPreviewPopover(null);
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const editorialActions: FindingActions = {
|
|
434
|
+
onSelect: (finding) => {
|
|
435
|
+
editorial.setActiveFindingId(finding.id);
|
|
436
|
+
if (editorial.inlineResult && finding.excerpt) bodyRef.current?.reveal(finding.span.start, finding.span.end);
|
|
437
|
+
},
|
|
438
|
+
onDismiss: (finding, reason) => {
|
|
439
|
+
editorial.dismiss(finding, reason);
|
|
440
|
+
setPreviewPopover(null);
|
|
441
|
+
},
|
|
442
|
+
onAccept: editorial.accept,
|
|
443
|
+
onSuggest: editorial.suggest,
|
|
444
|
+
onApply: applyEditorialRewrite,
|
|
445
|
+
};
|
|
446
|
+
|
|
384
447
|
const publish = async () => {
|
|
448
|
+
setPublishWarningOpen(false);
|
|
385
449
|
if (!editor) return;
|
|
386
450
|
let id = editor.id;
|
|
387
451
|
if (editor.dirty || id === null) {
|
|
@@ -551,7 +615,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
551
615
|
<Button
|
|
552
616
|
data-newsroom-article-publish
|
|
553
617
|
disabled={publishBlocked}
|
|
554
|
-
onClick={
|
|
618
|
+
onClick={requestPublish}
|
|
555
619
|
size="sm"
|
|
556
620
|
type="button"
|
|
557
621
|
>
|
|
@@ -714,18 +778,29 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
714
778
|
{imageError}
|
|
715
779
|
</p>
|
|
716
780
|
) : null}
|
|
717
|
-
<
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
781
|
+
<MarkusBodyEditor
|
|
782
|
+
activeFindingId={editorial.activeFindingId}
|
|
783
|
+
findings={editorialMarks}
|
|
784
|
+
onChange={(value) => patchEditor({ bodyMarkus: value })}
|
|
785
|
+
onFindingClick={editorial.setActiveFindingId}
|
|
786
|
+
onSelectionChange={editorial.setSelection}
|
|
722
787
|
ref={bodyRef}
|
|
723
|
-
rows={28}
|
|
724
|
-
spellCheck={false}
|
|
725
788
|
value={editor.bodyMarkus}
|
|
726
789
|
/>
|
|
727
790
|
</div>
|
|
728
791
|
|
|
792
|
+
{editorial.enabled ? (
|
|
793
|
+
<EditorialFindingsPanel
|
|
794
|
+
{...editorialActions}
|
|
795
|
+
activeFindingId={editorial.activeFindingId}
|
|
796
|
+
dismissed={editorial.dismissed}
|
|
797
|
+
onCheck={() => void editorial.check()}
|
|
798
|
+
result={editorial.result}
|
|
799
|
+
selection={editorial.selection}
|
|
800
|
+
status={editorial.status}
|
|
801
|
+
/>
|
|
802
|
+
) : null}
|
|
803
|
+
|
|
729
804
|
{validation.status === "unavailable" ? (
|
|
730
805
|
<NewsroomOpsStatusBanner tone="error">{validation.message}</NewsroomOpsStatusBanner>
|
|
731
806
|
) : null}
|
|
@@ -763,7 +838,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
763
838
|
className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden")}
|
|
764
839
|
data-newsroom-article-preview-column
|
|
765
840
|
>
|
|
766
|
-
<div className="rounded-xl border border-border bg-card p-4" data-newsroom-article-preview>
|
|
841
|
+
<div className="relative rounded-xl border border-border bg-card p-4" data-newsroom-article-preview ref={previewRef}>
|
|
767
842
|
{(readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) || readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)) ? (
|
|
768
843
|
<div className="newsroom-article-summary" data-newsroom-article-summary>
|
|
769
844
|
{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) ? <p className="newsroom-article-subtitle">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)}</p> : null}
|
|
@@ -774,12 +849,40 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
774
849
|
<p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
|
|
775
850
|
) : null}
|
|
776
851
|
{bodyIr ? (
|
|
777
|
-
<MarkusIrPreview
|
|
852
|
+
<MarkusIrPreview
|
|
853
|
+
bodyIr={bodyIr}
|
|
854
|
+
editorial={
|
|
855
|
+
editorial.inlineResult
|
|
856
|
+
? {
|
|
857
|
+
result: editorial.inlineResult,
|
|
858
|
+
findings: editorialOpen,
|
|
859
|
+
activeFindingIds: previewPopover?.kind === "findings" ? previewPopover.findingIds : editorial.activeFindingId ? [editorial.activeFindingId] : [],
|
|
860
|
+
onOpenMark: (findingIds, anchor) => {
|
|
861
|
+
editorial.setActiveFindingId(findingIds[0] ?? null);
|
|
862
|
+
setPreviewPopover({ kind: "findings", findingIds, anchor: anchor.getBoundingClientRect() });
|
|
863
|
+
},
|
|
864
|
+
onSelectSource: (source, anchor) => setPreviewPopover({ kind: "selection", source, anchor }),
|
|
865
|
+
}
|
|
866
|
+
: undefined
|
|
867
|
+
}
|
|
868
|
+
imageUrls={imageUrls}
|
|
869
|
+
/>
|
|
778
870
|
) : (
|
|
779
871
|
<p className="text-sm text-muted-foreground">
|
|
780
872
|
{invalid ? "Fix the errors to see a preview." : "The preview appears once the markup is valid."}
|
|
781
873
|
</p>
|
|
782
874
|
)}
|
|
875
|
+
{previewPopover && editorial.enabled ? (
|
|
876
|
+
<EditorialPreviewPopover
|
|
877
|
+
actions={editorialActions}
|
|
878
|
+
body={editor.bodyMarkus}
|
|
879
|
+
container={previewRef.current}
|
|
880
|
+
findings={editorialOpen}
|
|
881
|
+
key={previewPopover.kind === "findings" ? previewPopover.findingIds.join() : `${previewPopover.source.start}-${previewPopover.source.end}`}
|
|
882
|
+
onClose={() => setPreviewPopover(null)}
|
|
883
|
+
target={previewPopover}
|
|
884
|
+
/>
|
|
885
|
+
) : null}
|
|
783
886
|
</div>
|
|
784
887
|
</div>
|
|
785
888
|
</div>
|
|
@@ -801,6 +904,28 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
801
904
|
</DialogContent>
|
|
802
905
|
</Dialog>
|
|
803
906
|
|
|
907
|
+
<Dialog onOpenChange={setPublishWarningOpen} open={publishWarningOpen}>
|
|
908
|
+
<DialogContent>
|
|
909
|
+
<DialogHeader>
|
|
910
|
+
<DialogTitle>Publish with open editorial findings?</DialogTitle>
|
|
911
|
+
<DialogDescription>
|
|
912
|
+
{editorial.openWarnings.length === 1
|
|
913
|
+
? "One editorial finding is still open."
|
|
914
|
+
: `${editorial.openWarnings.length} editorial findings are still open.`}{" "}
|
|
915
|
+
Findings are advice: you can publish anyway.
|
|
916
|
+
</DialogDescription>
|
|
917
|
+
</DialogHeader>
|
|
918
|
+
<ul className="max-h-48 list-disc space-y-1 overflow-auto pl-5 text-sm" data-newsroom-article-publish-warnings>
|
|
919
|
+
{editorial.openWarnings.slice(0, 8).map((finding) => (
|
|
920
|
+
<li key={finding.id}>{finding.message}{finding.excerpt ? `: “${finding.excerpt}”` : ""}</li>
|
|
921
|
+
))}
|
|
922
|
+
</ul>
|
|
923
|
+
<DialogFooter>
|
|
924
|
+
<Button onClick={() => setPublishWarningOpen(false)} type="button" variant="outline">Keep editing</Button>
|
|
925
|
+
<Button data-newsroom-article-publish-anyway onClick={() => void publish()} type="button">Publish anyway</Button>
|
|
926
|
+
</DialogFooter>
|
|
927
|
+
</DialogContent>
|
|
928
|
+
</Dialog>
|
|
804
929
|
<Dialog onOpenChange={setUnpublishOpen} open={unpublishOpen}>
|
|
805
930
|
<DialogContent>
|
|
806
931
|
<DialogHeader>
|
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
6
|
-
import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
|
|
7
|
-
import "
|
|
8
|
-
import "@kanbus/ui/styles/newsprint.css";
|
|
9
|
-
import "./newsroom-newsprint-board.css";
|
|
6
|
+
import { IssueCollection, IssueDetailPanel, IssueIdFormatProvider, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
|
|
7
|
+
import "./newsroom-theme";
|
|
10
8
|
import { PlusIcon } from "lucide-react";
|
|
11
9
|
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
12
10
|
import {
|
|
@@ -18,6 +16,7 @@ import {
|
|
|
18
16
|
deskBoardConfig,
|
|
19
17
|
deskContainsAssignment,
|
|
20
18
|
deskContainsContentItem,
|
|
19
|
+
formatNativeIssueId,
|
|
21
20
|
projectAssignmentBoard,
|
|
22
21
|
projectContentItemBoard,
|
|
23
22
|
type NativeBoardIssue,
|
|
@@ -35,7 +34,6 @@ import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
|
35
34
|
import { NewsroomArticleEditor } from "./newsroom-article-editor";
|
|
36
35
|
import { NewsroomAssignmentDetail } from "./newsroom-assignment-detail";
|
|
37
36
|
import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
|
|
38
|
-
import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
39
37
|
import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reader-settings";
|
|
40
38
|
|
|
41
39
|
/** A board with a story or assignment open lives at `?open=<id>`, distinct from the standalone page for that record. */
|
|
@@ -61,7 +59,6 @@ export type NewsroomBoardSubject = "contentItem" | "assignment" | "desk";
|
|
|
61
59
|
export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardSubject; deskId?: string }) {
|
|
62
60
|
const session = useOptionalNewsDeskClient();
|
|
63
61
|
const allowed = canReadNewsroomBoard(session?.shell.phase);
|
|
64
|
-
const theme = useResolvedPapyrusTheme();
|
|
65
62
|
const [lowMotion, setLowMotion] = useState(false);
|
|
66
63
|
const [assignmentBoardIssues, setAssignmentBoardIssues] = useState<NativeBoardIssue[]>([]);
|
|
67
64
|
const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
|
|
@@ -172,7 +169,8 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
172
169
|
</Link>
|
|
173
170
|
)}
|
|
174
171
|
</>}>
|
|
175
|
-
<
|
|
172
|
+
<IssueIdFormatProvider formatIssueId={formatNativeIssueId}>
|
|
173
|
+
<div className="organizer-native-board">
|
|
176
174
|
{error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
|
|
177
175
|
{subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
|
|
178
176
|
<div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
|
|
@@ -196,5 +194,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
196
194
|
</IssueDetailPanel>}
|
|
197
195
|
</div>
|
|
198
196
|
</div>
|
|
197
|
+
</IssueIdFormatProvider>
|
|
199
198
|
</NewsroomOpsShell>;
|
|
200
199
|
}
|
|
@@ -19,6 +19,7 @@ import { ProspectusAccountChooser, useProspectusAccountSummary } from "./prospec
|
|
|
19
19
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger } from "./ui/sheet";
|
|
20
20
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
|
|
21
21
|
import "./newsroom-organizer.css";
|
|
22
|
+
import { NewsroomThemeFonts, useNewsroomThemeAttributes } from "./newsroom-theme";
|
|
22
23
|
|
|
23
24
|
export type NewsroomNavCount = {
|
|
24
25
|
count: number | null;
|
|
@@ -85,11 +86,12 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
|
|
|
85
86
|
media.addEventListener("change", close);
|
|
86
87
|
return () => media.removeEventListener("change", close);
|
|
87
88
|
}, []);
|
|
89
|
+
const themeAttributes = useNewsroomThemeAttributes();
|
|
88
90
|
return <DropdownMenu open={open} onOpenChange={setOpen}>
|
|
89
91
|
<DropdownMenuTrigger id={`organizer-account-trigger-${placement}`} className={`organizer-account organizer-account-${placement}`} aria-label="Account menu" title="Account menu">
|
|
90
92
|
<UserRound size={18} aria-hidden="true" /><span><strong>{auth.label}</strong><small>{accountSummary ?? (auth.status === "signedIn" ? "Account & preferences" : auth.status === "signedOut" ? "Not signed in" : "Checking session")}</small></span>
|
|
91
93
|
</DropdownMenuTrigger>
|
|
92
|
-
<DropdownMenuContent className="organizer-account-popup" side={placement === "sidebar" ? "top" : "bottom"} align="end">
|
|
94
|
+
<DropdownMenuContent className="organizer-account-popup" {...themeAttributes} side={placement === "sidebar" ? "top" : "bottom"} align="end">
|
|
93
95
|
<DropdownMenuGroup>
|
|
94
96
|
<DropdownMenuLabel>{auth.label}</DropdownMenuLabel><DropdownMenuSeparator />
|
|
95
97
|
<ProspectusAccountChooser />
|
|
@@ -104,6 +106,7 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
|
|
|
104
106
|
|
|
105
107
|
export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabel, children, demo = false, headerActions, pageTitle, showNavigation = true }: NewsroomOpsShellProps) {
|
|
106
108
|
const pathname = usePathname();
|
|
109
|
+
const themeAttributes = useNewsroomThemeAttributes();
|
|
107
110
|
const session = useOptionalNewsDeskClient();
|
|
108
111
|
const admin = demo || session?.shell.dashboard?.canManageUsers === true;
|
|
109
112
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
@@ -145,7 +148,8 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
|
|
|
145
148
|
aria-label={sidebarToggleLabel} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
|
|
146
149
|
{collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}
|
|
147
150
|
</button>;
|
|
148
|
-
return <div className="newsroom-organizer" data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
|
|
151
|
+
return <div className="newsroom-organizer" {...themeAttributes} data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
|
|
152
|
+
<NewsroomThemeFonts />
|
|
149
153
|
{showNavigation && <aside className="organizer-sidebar" hidden={collapsed} id="organizer-sidebar">
|
|
150
154
|
<div className="organizer-sidebar-head">
|
|
151
155
|
<Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{SITE_BRAND.components?.NewsroomLogo ? <SITE_BRAND.components.NewsroomLogo className="organizer-brand-logo" /> : appTitle}<small>The newsroom</small></Link>
|
|
@@ -167,7 +171,7 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
|
|
|
167
171
|
href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}>
|
|
168
172
|
<Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
|
|
169
173
|
<li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
|
|
170
|
-
<SheetContent side="bottom" className="organizer-menu"><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
|
|
174
|
+
<SheetContent side="bottom" className="organizer-menu" {...themeAttributes}><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
|
|
171
175
|
<Navigation activePath={activePath} demo={demo} admin={admin} surface="menu" onNavigate={() => setMenuOpen(false)} />
|
|
172
176
|
</SheetContent>
|
|
173
177
|
</Sheet></li>
|