@anthusai/papyrus 1.10.0 → 1.11.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.
Files changed (61) hide show
  1. package/amplify/data/resource.js +3 -3
  2. package/amplify/data/resource.ts +3 -3
  3. package/amplify/functions/category-action/handler.ts +18 -18
  4. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  5. package/amplify/functions/content-actions/handler.py +23 -0
  6. package/amplify/functions/content-actions/requirements.txt +1 -1
  7. package/amplify/functions/content-actions/resource.js +1 -1
  8. package/amplify/functions/content-actions/resource.ts +1 -1
  9. package/amplify/functions/renditions/requirements.txt +1 -1
  10. package/amplify/functions/shared/slack-events.ts +2 -2
  11. package/amplify/publication/renditions-function.js +1 -1
  12. package/amplify/publication/renditions-function.ts +1 -1
  13. package/app/api/local-preview/derive/route.ts +28 -0
  14. package/app/api/local-preview/media/route.ts +25 -0
  15. package/app/globals.css +28 -28
  16. package/app/layout.tsx +8 -5
  17. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  18. package/app/newsroom/[section]/page.tsx +0 -1
  19. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  20. package/app/newsroom/insights/page.tsx +4 -12
  21. package/components/board-card-excerpts.tsx +45 -0
  22. package/components/markus-ir-preview.tsx +47 -2
  23. package/components/news-desk-page.tsx +0 -1
  24. package/components/news-desk-taxonomy-client.ts +25 -220
  25. package/components/newsroom-article-editor.tsx +135 -39
  26. package/components/newsroom-assignment-detail.tsx +35 -1
  27. package/components/newsroom-board-view.tsx +29 -7
  28. package/components/newsroom-newsprint-board.css +9 -2
  29. package/components/newsroom-ops-overview.tsx +27 -5
  30. package/components/newsroom-organizer-shell.tsx +25 -7
  31. package/components/newsroom-organizer.css +196 -4
  32. package/components/priming-proof-page.tsx +17 -8
  33. package/components/prospectus-account-chooser.tsx +103 -0
  34. package/components/story-reporting-trail-client.ts +91 -0
  35. package/components/story-reporting-trail.tsx +115 -0
  36. package/components/topic-steering-workspace.tsx +154 -671
  37. package/components/ui/sheet.tsx +1 -0
  38. package/components/wiki-concepts-view.tsx +153 -0
  39. package/components/wiki-index-client.ts +51 -0
  40. package/lib/content-actions-client.ts +3 -0
  41. package/lib/content-actions-demo.ts +55 -3
  42. package/lib/content-item-lifecycle.ts +4 -2
  43. package/lib/front-matter-fields.ts +42 -0
  44. package/lib/local-preview-seed.ts +33 -0
  45. package/lib/newsroom-articles.ts +2 -0
  46. package/lib/newsroom-board.ts +4 -0
  47. package/lib/newsroom-forum-routes.ts +2 -43
  48. package/lib/newsroom-index-filters.ts +3 -40
  49. package/lib/newsroom-media-backend.ts +5 -0
  50. package/lib/newsroom-nav.ts +1 -3
  51. package/lib/newsroom-organizer-nav.ts +5 -12
  52. package/lib/newsroom-section-routing.ts +1 -1
  53. package/lib/newsroom-sections.ts +12 -0
  54. package/lib/papyrus-web-locations.ts +3 -24
  55. package/lib/priming-proof-report.ts +1 -0
  56. package/lib/semantic-graph.ts +5 -5
  57. package/lib/site-brand.ts +9 -0
  58. package/lib/story-reporting-trail.ts +106 -0
  59. package/lib/wiki.ts +56 -0
  60. package/package.json +12 -1
  61. package/routes.manifest.json +23 -7
@@ -14,10 +14,15 @@ import {
14
14
  DialogTitle,
15
15
  } from "./ui/dialog";
16
16
  import { Input } from "./ui/input";
17
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
17
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
18
+ import { newsroomDeskOptions } from "../lib/newsroom-sections";
19
+ import { StoryReportingTrailView } from "./story-reporting-trail";
20
+ import { loadStoryReportingTrail } from "./story-reporting-trail-client";
21
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "./ui/select";
18
22
  import { Spinner } from "./ui/spinner";
19
23
  import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
20
24
  import { Textarea } from "./ui/textarea";
25
+ import { FRONT_MATTER_BLURB_KEY, FRONT_MATTER_SUBTITLE_KEY, FRONT_MATTER_TITLE_KEY, readFrontMatterText, writeFrontMatterText } from "../lib/front-matter-fields";
21
26
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
22
27
  import { ContentActionFailure, type ContentActionError } from "../lib/content-actions-client";
23
28
  import { newsroomHref } from "../lib/newsroom-base-path";
@@ -59,7 +64,7 @@ type ValidationState =
59
64
  | { status: "unavailable"; message: string };
60
65
 
61
66
  type Notice = { tone: "ok" | "error" | "info"; text: string };
62
- type MobileTab = "edit" | "preview";
67
+ type MobileTab = "edit" | "preview" | "trail" | "references";
63
68
 
64
69
  function lineStartIndex(text: string, line: number): number {
65
70
  let index = 0;
@@ -91,10 +96,15 @@ function failureMessage(error: unknown): string {
91
96
  return error instanceof Error ? error.message : "The request failed.";
92
97
  }
93
98
 
99
+ const NO_DESK_VALUE = "__no-desk__";
100
+
94
101
  export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { articleId: string; demo: boolean; embedded?: boolean }) {
95
102
  const session = useOptionalNewsDeskClient();
96
- const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
97
- const backend: ArticlesBackend = useMemo(() => resolveArticlesBackend(demo), [demo]);
103
+ const blocked = !(demo || isLocalPreviewEnabled()) && articlesAccessBlocked(session?.shell.phase);
104
+ const previewMode = demo || isLocalPreviewEnabled();
105
+ const backend: ArticlesBackend = useMemo(() => resolveArticlesBackend(previewMode), [previewMode]);
106
+ const [activeArticleId, setActiveArticleId] = useState(articleId);
107
+ useEffect(() => { setActiveArticleId(articleId); }, [articleId]);
98
108
  const isNew = articleId === "new";
99
109
 
100
110
  const [editor, setEditor] = useState<NewsroomArticleEditorState | null>(isNew ? emptyArticle() : null);
@@ -108,8 +118,15 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
108
118
  const [notice, setNotice] = useState<Notice | null>(null);
109
119
  const [conflictOpen, setConflictOpen] = useState(false);
110
120
  const [unpublishOpen, setUnpublishOpen] = useState(false);
111
- const [mobileTab, setMobileTab] = useState<MobileTab>("edit");
121
+ const [mobileTab, setMobileTab] = useState<MobileTab>("preview");
122
+ const [focusedReferenceId, setFocusedReferenceId] = useState<string | null>(null);
123
+ const [lineageId, setLineageId] = useState<string | null>(null);
124
+ const [latestVersion, setLatestVersion] = useState<{ id: string; number: number } | null>(null);
125
+ const latestVersionNumber = latestVersion?.number ?? null;
126
+ const [changeReason, setChangeReason] = useState("");
127
+ const citedKeys = useMemo(() => Object.values(((bodyIr as { papyrus?: { citations?: Record<string, string[]> } } | null)?.papyrus?.citations) ?? {}).flat(), [bodyIr]);
112
128
  const [sections, setSections] = useState<string[]>([]);
129
+ const deskOptions = useMemo(() => newsroomDeskOptions(session?.shell.dashboard?.newsroomSections ?? []), [session?.shell.dashboard?.newsroomSections]);
113
130
  const [reloadToken, setReloadToken] = useState(0);
114
131
  const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
115
132
  const [imageUploading, setImageUploading] = useState(false);
@@ -118,17 +135,18 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
118
135
  const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
119
136
  const bodyRef = useRef<HTMLTextAreaElement | null>(null);
120
137
  const imageInputRef = useRef<HTMLInputElement | null>(null);
121
- const mediaBackend = useMemo(() => resolveMediaBackend(demo), [demo]);
138
+ const mediaBackend = useMemo(() => resolveMediaBackend(previewMode), [previewMode]);
122
139
  const validationSequence = useRef(0);
123
140
 
124
141
  useEffect(() => {
125
142
  if (blocked || isNew) return;
126
143
  let cancelled = false;
127
144
  backend
128
- .loadArticle(articleId)
145
+ .loadArticle(activeArticleId)
129
146
  .then((article) => {
130
147
  if (cancelled) return;
131
148
  setEditor(stateFromArticle(article));
149
+ setLineageId(article.lineageId ?? null);
132
150
  setAliases(article.aliases);
133
151
  setEverPublished(article.everPublished);
134
152
  setSlugEdited(true);
@@ -140,7 +158,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
140
158
  return () => {
141
159
  cancelled = true;
142
160
  };
143
- }, [articleId, backend, blocked, isNew, reloadToken]);
161
+ }, [activeArticleId, backend, blocked, isNew, reloadToken]);
144
162
 
145
163
  useEffect(() => {
146
164
  if (blocked) return;
@@ -234,8 +252,12 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
234
252
  patchEditor(patch);
235
253
  };
236
254
 
255
+ const changeFrontMatterField = (key: string, value: string) => {
256
+ if (editor) changeFrontMatter(writeFrontMatterText(editor.frontMatterYaml, key, value));
257
+ };
258
+
237
259
  const goToLine = (location: ErrorLocation) => {
238
- setMobileTab("edit");
260
+ setMobileTab("preview");
239
261
  window.setTimeout(() => {
240
262
  const target = location.field === "frontMatterYaml" ? frontMatterRef.current : bodyRef.current;
241
263
  if (!target) return;
@@ -274,6 +296,41 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
274
296
  }
275
297
  };
276
298
 
299
+ useEffect(() => {
300
+ if (!lineageId || isNew) { setLatestVersion(null); return; }
301
+ let cancelled = false;
302
+ loadStoryReportingTrail(lineageId, activeArticleId)
303
+ .then(trail => { if (!cancelled) { const newest = trail.versions[0]; setLatestVersion(newest ? { id: newest.id, number: newest.versionNumber } : null); } })
304
+ .catch(() => { if (!cancelled) setLatestVersion(null); });
305
+ return () => { cancelled = true; };
306
+ }, [activeArticleId, isNew, lineageId, reloadToken]);
307
+
308
+ const saveNewVersion = useCallback(async () => {
309
+ if (!editor || editor.id === null) return;
310
+ setBusy("save");
311
+ try {
312
+ const result = await backend.actions.saveItemDraft({
313
+ id: editor.id,
314
+ type: editor.type,
315
+ slug: editor.slug,
316
+ section: editor.section.trim() ? editor.section.trim() : null,
317
+ frontMatterYaml: editor.frontMatterYaml,
318
+ bodyMarkus: editor.bodyMarkus,
319
+ aliases,
320
+ expectedContentHash: editor.contentHash,
321
+ newVersion: true,
322
+ changeReason: changeReason.trim() || null,
323
+ });
324
+ setChangeReason("");
325
+ setActiveArticleId(result.item.id);
326
+ setNotice({ tone: "ok", text: `Saved as version ${result.item.versionNumber ?? ""}.` });
327
+ } catch (error) {
328
+ setNotice({ tone: "error", text: failureMessage(error) });
329
+ } finally {
330
+ setBusy(null);
331
+ }
332
+ }, [aliases, backend, changeReason, editor]);
333
+
277
334
  const saveDraft = useCallback(async (): Promise<string | null> => {
278
335
  if (!editor) return null;
279
336
  setBusy("save");
@@ -400,8 +457,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
400
457
 
401
458
  const slugLocked = everPublished;
402
459
  const invalid = validation.status === "invalid" || validation.status === "unavailable";
403
- const publishBlocked = invalid || validation.status === "checking" || busy !== null || !editor.slug.trim();
404
- const saveBlocked = invalid || busy !== null || !editor.slug.trim() || (!editor.dirty && editor.id !== null);
460
+ const isOlderVersion = latestVersionNumber !== null && editor.versionNumber !== null && editor.versionNumber < latestVersionNumber;
461
+ const publishBlocked = isOlderVersion || invalid || validation.status === "checking" || busy !== null || !editor.slug.trim();
462
+ const saveBlocked = isOlderVersion || invalid || busy !== null || !editor.slug.trim() || (!editor.dirty && editor.id !== null);
405
463
  const stagingUrl = stagingPreviewUrl(
406
464
  process.env.NEXT_PUBLIC_PAPYRUS_STAGING_URL,
407
465
  editor.type,
@@ -480,6 +538,16 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
480
538
  {busy === "save" ? <Spinner /> : null}
481
539
  Save draft
482
540
  </Button>
541
+ <Button
542
+ data-newsroom-article-save-version
543
+ disabled={isOlderVersion || invalid || busy !== null || editor.id === null || !editor.dirty}
544
+ onClick={() => void saveNewVersion()}
545
+ size="sm"
546
+ type="button"
547
+ variant="outline"
548
+ >
549
+ Save as new version
550
+ </Button>
483
551
  <Button
484
552
  data-newsroom-article-publish
485
553
  disabled={publishBlocked}
@@ -493,7 +561,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
493
561
  {editor.status === "published" ? (
494
562
  <Button
495
563
  data-newsroom-article-unpublish
496
- disabled={busy !== null}
564
+ disabled={isOlderVersion || busy !== null}
497
565
  onClick={() => setUnpublishOpen(true)}
498
566
  size="sm"
499
567
  type="button"
@@ -511,16 +579,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
511
579
  </div>
512
580
  ) : null}
513
581
 
514
- <Tabs className="lg:hidden" defaultValue="edit" onValueChange={(value) => setMobileTab(value as MobileTab)} value={mobileTab}>
515
- <TabsList>
516
- <TabsTrigger className="min-w-[6rem]" value="edit">Edit</TabsTrigger>
517
- <TabsTrigger className="min-w-[6rem]" value="preview">Preview</TabsTrigger>
518
- </TabsList>
519
- </Tabs>
520
-
521
- <div className="grid gap-6 lg:grid-cols-2">
582
+ <div className="grid gap-6">
522
583
  <div
523
- className={cn("min-w-0 space-y-4", mobileTab === "edit" ? "block" : "hidden", "lg:block")}
584
+ className="min-w-0 space-y-4"
524
585
  data-newsroom-article-edit-column
525
586
  >
526
587
  <div className="grid gap-3 sm:grid-cols-3">
@@ -543,18 +604,29 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
543
604
  </Select>
544
605
  </label>
545
606
  <label className="space-y-1 text-sm font-medium">
546
- <span>Section</span>
547
- <Input
548
- data-newsroom-article-section
549
- list="newsroom-article-sections"
550
- onChange={(event) => patchEditor({ section: event.target.value })}
551
- value={editor.section}
552
- />
553
- <datalist id="newsroom-article-sections">
554
- {sections.map((section) => (
555
- <option key={section} value={section} />
556
- ))}
557
- </datalist>
607
+ <span>Desk</span>
608
+ <Select
609
+ onValueChange={(value) => {
610
+ if (typeof value === "string") patchEditor({ section: value === NO_DESK_VALUE ? "" : value });
611
+ }}
612
+ value={deskOptions.some(option => option.value === editor.section) ? editor.section : NO_DESK_VALUE}
613
+ >
614
+ <SelectTrigger aria-label="Desk" className="w-full" data-newsroom-article-section>
615
+ <SelectValue>{deskOptions.find(option => option.value === editor.section)?.label ?? "No desk"}</SelectValue>
616
+ </SelectTrigger>
617
+ <SelectContent>
618
+ <SelectItem value={NO_DESK_VALUE}>No desk</SelectItem>
619
+ {(["core", "rotating"] as const).map((kind) => deskOptions.some(option => option.kind === kind) ? (
620
+ <SelectGroup key={kind}>
621
+ <SelectLabel>{kind === "core" ? "Core desks" : "Rotating desks"}</SelectLabel>
622
+ {deskOptions.filter(option => option.kind === kind).map((option) => (
623
+ <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
624
+ ))}
625
+ </SelectGroup>
626
+ ) : null)}
627
+ </SelectContent>
628
+ </Select>
629
+ {editor.section.trim() && !deskOptions.some(option => option.value === editor.section) ? <small className="newsroom-desk-legacy-value">Old section value: {editor.section.trim()}</small> : null}
558
630
  </label>
559
631
  <label className="space-y-1 text-sm font-medium">
560
632
  <span>Slug</span>
@@ -571,8 +643,27 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
571
643
  </label>
572
644
  </div>
573
645
 
646
+ {isOlderVersion ? <p className="newsroom-article-older-version" data-newsroom-article-older-version role="status">
647
+ This is version {editor.versionNumber} of {latestVersionNumber}, shown read-only. <button className="newsroom-article-link-button" onClick={() => setActiveArticleId(latestVersion?.id ?? activeArticleId)} type="button">Open the latest version</button>
648
+ </p> : null}
649
+ <div aria-label="Story view" className="newsroom-article-view-toggle" role="group">
650
+ <button aria-pressed={mobileTab === "preview"} className="organizer-header-button" data-newsroom-article-view="preview" onClick={() => setMobileTab("preview")} type="button">Preview</button>
651
+ <button aria-pressed={mobileTab === "edit"} className="organizer-header-button" data-newsroom-article-view="edit" onClick={() => setMobileTab("edit")} type="button">Edit</button>
652
+ <button aria-pressed={mobileTab === "trail"} className="organizer-header-button" data-newsroom-article-view="trail" onClick={() => setMobileTab("trail")} type="button">Messages</button>
653
+ <button aria-pressed={mobileTab === "references"} className="organizer-header-button" data-newsroom-article-view="references" onClick={() => { setFocusedReferenceId(null); setMobileTab("references"); }} type="button">References</button>
654
+ </div>
655
+ {(mobileTab === "trail" || mobileTab === "references") && editor.id ? <StoryReportingTrailView focusedReferenceId={focusedReferenceId} onShowReference={(referenceId) => { setFocusedReferenceId(referenceId); setMobileTab("references"); }} view={mobileTab === "references" ? "references" : "messages"} citedKeys={citedKeys} demo={previewMode} itemId={editor.id} lineageId={lineageId} onOpenVersion={(versionId) => { setActiveArticleId(versionId); setMobileTab("preview"); }} /> : null}
656
+ <div className={cn("space-y-4", mobileTab === "edit" ? "block" : "hidden")} data-newsroom-article-raw-code>
657
+ <div className="newsroom-article-front-matter-fields" data-newsroom-article-fields>
658
+ <label><span>Title</span><Input data-newsroom-article-field="title" onChange={(event) => changeFrontMatterField(FRONT_MATTER_TITLE_KEY, event.target.value)} value={readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_TITLE_KEY)} /></label>
659
+ <label><span>Subtitle</span><Input data-newsroom-article-field="subtitle" onChange={(event) => changeFrontMatterField(FRONT_MATTER_SUBTITLE_KEY, event.target.value)} placeholder="A line under the headline" value={readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)} /></label>
660
+ <label><span>Blurb</span><Textarea data-newsroom-article-field="blurb" onChange={(event) => changeFrontMatterField(FRONT_MATTER_BLURB_KEY, event.target.value)} placeholder="Two or three lines that describe the story on the board and in listings" rows={3} value={readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)} /></label>
661
+ </div>
662
+ <Input aria-label="What changed in this version" className="max-w-xl" onChange={(event) => setChangeReason(event.target.value)} placeholder="What changed? Recorded when you save as a new version." value={changeReason} />
663
+ <details className="newsroom-article-front-matter" data-newsroom-article-front-matter-details>
664
+ <summary>Front matter (YAML)</summary>
574
665
  <label className="block space-y-1 text-sm font-medium">
575
- <span>Front matter (YAML)</span>
666
+ <span className="sr-only">Front matter (YAML)</span>
576
667
  <Textarea
577
668
  className={TEXTAREA_CLASS}
578
669
  data-newsroom-article-front-matter
@@ -583,10 +674,11 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
583
674
  value={editor.frontMatterYaml}
584
675
  />
585
676
  </label>
677
+ </details>
586
678
 
587
679
  <div className="space-y-1 text-sm font-medium">
588
680
  <div className="flex flex-wrap items-center justify-between gap-2">
589
- <label htmlFor="newsroom-article-body">Body (Markus)</label>
681
+ <label className="sr-only" htmlFor="newsroom-article-body">Story body</label>
590
682
  <Button
591
683
  data-newsroom-article-insert-image
592
684
  disabled={editor.id === null || imageUploading || busy !== null}
@@ -664,16 +756,20 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
664
756
  })}
665
757
  </ul>
666
758
  ) : null}
759
+ </div>
667
760
  </div>
668
761
 
669
762
  <div
670
- className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden", "lg:block")}
763
+ className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden")}
671
764
  data-newsroom-article-preview-column
672
765
  >
673
766
  <div className="rounded-xl border border-border bg-card p-4" data-newsroom-article-preview>
674
- <p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
675
- Preview
676
- </p>
767
+ {(readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) || readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)) ? (
768
+ <div className="newsroom-article-summary" data-newsroom-article-summary>
769
+ {readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) ? <p className="newsroom-article-subtitle">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)}</p> : null}
770
+ {readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY) ? <p className="newsroom-article-blurb">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)}</p> : null}
771
+ </div>
772
+ ) : null}
677
773
  {invalid && bodyIr ? (
678
774
  <p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
679
775
  ) : null}
@@ -1,7 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import Link from "next/link";
4
+ import { useState } from "react";
4
5
  import type { AssignmentRecord } from "../lib/category-repository";
6
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "./ui/select";
7
+
8
+ export type AssignmentDeskOption = { value: string; label: string; kind: "core" | "rotating" };
9
+ const NO_DESK_VALUE = "__no-desk__";
5
10
 
6
11
  const assignmentDateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
7
12
 
@@ -17,7 +22,22 @@ type DetailField = { label: string; value: string | null | undefined };
17
22
  * The whole assignment, laid out by container width: one column in a narrow Board panel, labelled
18
23
  * columns when the container is wide, such as the dedicated page on a large monitor.
19
24
  */
20
- export function NewsroomAssignmentDetail({ assignment, pageHref, showTitle = true }: { assignment: AssignmentRecord; pageHref?: string; showTitle?: boolean }) {
25
+ export function NewsroomAssignmentDetail({ assignment, deskOptions = [], onDeskChange, pageHref, showTitle = true }: {
26
+ assignment: AssignmentRecord;
27
+ deskOptions?: AssignmentDeskOption[];
28
+ onDeskChange?: (deskId: string | null) => Promise<void>;
29
+ pageHref?: string;
30
+ showTitle?: boolean;
31
+ }) {
32
+ const [deskError, setDeskError] = useState<string | null>(null);
33
+ const [savingDesk, setSavingDesk] = useState(false);
34
+ const currentDesk = assignment.sectionId || assignment.sectionKey || "";
35
+ const deskChoices = deskOptions;
36
+ const changeDesk = (value: string) => {
37
+ if (!onDeskChange) return;
38
+ setSavingDesk(true); setDeskError(null);
39
+ onDeskChange(value === NO_DESK_VALUE ? null : value).catch((reason: unknown) => setDeskError(reason instanceof Error ? reason.message : "Could not save the desk.")).finally(() => setSavingDesk(false));
40
+ };
21
41
  const facts: DetailField[] = [
22
42
  { label: "Status", value: assignment.status },
23
43
  { label: "Type", value: assignment.assignmentTypeKey },
@@ -37,6 +57,20 @@ export function NewsroomAssignmentDetail({ assignment, pageHref, showTitle = tru
37
57
  <dl className="newsroom-assignment-detail-facts">
38
58
  {facts.map(field => <div key={field.label}><dt>{field.label}</dt><dd>{field.value}</dd></div>)}
39
59
  </dl>
60
+ {onDeskChange && <label className="newsroom-assignment-detail-desk">
61
+ <span>Desk</span>
62
+ <Select onValueChange={(value) => { if (typeof value === "string") changeDesk(value); }} value={deskChoices.some(option => option.value === currentDesk) ? currentDesk : NO_DESK_VALUE} disabled={savingDesk}>
63
+ <SelectTrigger aria-label="Desk" className="w-full" data-newsroom-assignment-desk>
64
+ <SelectValue>{deskChoices.find(option => option.value === currentDesk)?.label ?? "No desk"}</SelectValue>
65
+ </SelectTrigger>
66
+ <SelectContent>
67
+ <SelectItem value={NO_DESK_VALUE}>No desk</SelectItem>
68
+ {(["core", "rotating"] as const).map(kind => deskChoices.some(option => option.kind === kind) ? <SelectGroup key={kind}><SelectLabel>{kind === "core" ? "Core desks" : "Rotating desks"}</SelectLabel>{deskChoices.filter(option => option.kind === kind).map(option => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup> : null)}
69
+ </SelectContent>
70
+ </Select>
71
+ {currentDesk && !deskChoices.some(option => option.value === currentDesk) ? <small className="newsroom-desk-legacy-value">Old section value: {currentDesk}</small> : null}
72
+ {deskError && <p role="alert" className="newsroom-assignment-detail-error">{deskError}</p>}
73
+ </label>}
40
74
  <div className="newsroom-assignment-detail-prose">
41
75
  {prose.map(field => <section key={field.label}><h3>{field.label}</h3><p>{field.value}</p></section>)}
42
76
  </div>
@@ -1,10 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
5
6
  import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
6
7
  import "@kanbus/ui/styles/kanban.css";
7
8
  import "./newsroom-newsprint-board.css";
9
+ import { BoardCardExcerpts } from "./board-card-excerpts";
8
10
  import { PlusIcon } from "lucide-react";
9
11
  import { newsroomHref } from "../lib/newsroom-base-path";
10
12
  import {
@@ -21,10 +23,10 @@ import {
21
23
  type NativeBoardIssue,
22
24
  } from "../lib/newsroom-board";
23
25
  import type { AssignmentRecord, NewsroomSectionRecord } from "../lib/category-repository";
24
- import { displayNewsroomSectionShortTitle } from "../lib/newsroom-sections";
26
+ import { displayNewsroomSectionShortTitle, newsroomDeskOptions } from "../lib/newsroom-sections";
25
27
  import { getContentItemName, getContentItemStageDefinitions, SITE_BRAND } from "../lib/site-brand";
26
28
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
27
- import { loadNewsroomAssignmentPage, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
29
+ import { loadNewsroomAssignmentPage, loadNewsroomPublishedContentItemVersions, saveNewsroomAssignmentDesk } from "./news-desk-taxonomy-client";
28
30
  import { usePagedContentItemVersions } from "./use-paged-content-item-versions";
29
31
  import type { ContentItemVersionRecord } from "../lib/content-item-lifecycle";
30
32
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
@@ -36,6 +38,8 @@ import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell"
36
38
  import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
37
39
  import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reader-settings";
38
40
 
41
+ /** A board with a story or assignment open lives at `?open=<id>`, distinct from the standalone page for that record. */
42
+ const SELECTED_ISSUE_PARAMETER = "open";
39
43
  const STATIC_MOTION = { mode: "static" } as const;
40
44
  const BOARD_REFRESH_INTERVAL_MILLISECONDS = 30_000;
41
45
  const COLUMN_LOAD_MORE_DISTANCE_PIXELS = 240;
@@ -63,11 +67,21 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
63
67
  const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
64
68
  const [assignments, setAssignments] = useState<AssignmentRecord[]>([]);
65
69
  const [view, setView] = useState<"board" | "list">("board");
66
- const [selected, setSelected] = useState<string | null>(null);
70
+ const router = useRouter();
71
+ const pathname = usePathname();
72
+ const searchParams = useSearchParams();
73
+ const selected = searchParams.get(SELECTED_ISSUE_PARAMETER);
74
+ const setSelected = useCallback((issueId: string | null) => {
75
+ const next = new URLSearchParams(searchParams.toString());
76
+ if (issueId) next.set(SELECTED_ISSUE_PARAMETER, issueId); else next.delete(SELECTED_ISSUE_PARAMETER);
77
+ const query = next.toString();
78
+ router.push(query ? `${pathname}?${query}` : pathname, { scroll: false });
79
+ }, [pathname, router, searchParams]);
67
80
  const [supportingRecordsLoading, setSupportingRecordsLoading] = useState(false);
68
81
  const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
69
82
  const generation = useRef(0);
70
83
  const boardBodyRef = useRef<HTMLDivElement | null>(null);
84
+ const [boardBodyElement, setBoardBodyElement] = useState<HTMLDivElement | null>(null);
71
85
  const [typeFilter, setTypeFilter] = useState<string | null>("all");
72
86
  const showsContentItems = subject !== "assignment";
73
87
  const contentItemColumnKeyPrefix = `${contentItemBoardType()}/`;
@@ -88,7 +102,7 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
88
102
  }, []);
89
103
  useEffect(() => {
90
104
  const request = ++generation.current;
91
- setAssignmentBoardIssues([]); setPublishedVersions([]); setAssignments([]); setSelected(null); setSupportingRecordsError(null);
105
+ setAssignmentBoardIssues([]); setPublishedVersions([]); setAssignments([]); setSupportingRecordsError(null);
92
106
  if (!allowed) { setSupportingRecordsLoading(false); return; }
93
107
  const loadSupportingRecords = () => subject !== "assignment"
94
108
  ? Promise.all([loadNewsroomPublishedContentItemVersions(), loadEveryNewsroomAssignment()]).then(([published, everyAssignment]) => {
@@ -139,6 +153,13 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
139
153
  });
140
154
  const deskTitle = session?.shell.dashboard?.newsroomSections?.find(section => section.id === deskId)?.title ?? (deskId ? displayNewsroomSectionShortTitle({ id: deskId } as NewsroomSectionRecord) : "Desk");
141
155
  const pageTitle = subject === "contentItem" ? getContentItemName().plural : subject === "desk" ? deskTitle : "Assignments";
156
+ const deskOptions = useMemo(() => newsroomDeskOptions(session?.shell.dashboard?.newsroomSections ?? []), [session?.shell.dashboard?.newsroomSections]);
157
+ const changeAssignmentDesk = async (record: AssignmentRecord, deskId: string | null) => {
158
+ await saveNewsroomAssignmentDesk(record.id, deskId);
159
+ const moved = { ...record, sectionId: deskId, sectionKey: deskId };
160
+ setAssignments(current => current.map(candidate => candidate.id === record.id ? moved : candidate));
161
+ setAssignmentBoardIssues(current => current.map(candidate => candidate.nativeId === record.id ? { ...candidate } : candidate));
162
+ };
142
163
  const issue = issues.find(item => item.id === selected);
143
164
  if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
144
165
  return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : subject === "desk" ? "desks" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
@@ -155,7 +176,8 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
155
176
  <div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
156
177
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
157
178
  {subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
158
- <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
179
+ <div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={node => { boardBodyRef.current = node; setBoardBodyElement(node); }} onScrollCapture={handleBoardScroll}>
180
+ <BoardCardExcerpts excerpts={issues.flatMap(item => item.kind === "contentItem" && item.excerpt ? [{ issueId: item.id, text: item.excerpt }] : [])} scope={boardBodyElement} />
159
181
  <IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
160
182
  motion={lowMotion ? STATIC_MOTION : undefined}
161
183
  collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
@@ -170,7 +192,7 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
170
192
  : (() => {
171
193
  const record = assignments.find(candidate => candidate.id === issue.nativeId);
172
194
  return record
173
- ? <NewsroomAssignmentDetail assignment={record} pageHref={getNewsroomNavHref(issue.href)} showTitle={false} />
195
+ ? <NewsroomAssignmentDetail assignment={record} deskOptions={deskOptions} onDeskChange={deskId => changeAssignmentDesk(record, deskId)} pageHref={getNewsroomNavHref(issue.href)} showTitle={false} />
174
196
  : <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>;
175
197
  })()}
176
198
  </IssueDetailPanel>}
@@ -36,10 +36,11 @@
36
36
  --issue-priority-bg: var(--org-paper) !important;
37
37
  background: transparent !important;
38
38
  border-radius: 0;
39
- border-bottom: 1px solid var(--org-line) !important;
39
+ border: 0 !important;
40
40
  box-shadow: none !important;
41
41
  }
42
- .papyrus-newsprint-board .issue-accent-bar { border-bottom: 1px solid var(--org-line); background: var(--org-paper) !important; color: var(--org-ink) !important; }
42
+ .papyrus-newsprint-board .issue-card + .issue-card { border-top: 1px solid var(--org-line) !important; }
43
+ .papyrus-newsprint-board .issue-accent-bar { border-bottom: 0; background: var(--org-paper) !important; color: var(--org-ink) !important; }
43
44
  .papyrus-newsprint-board .issue-accent-id { font: 400 9px Newsroom UI, system-ui, sans-serif; letter-spacing: .8px; white-space: nowrap; color: var(--org-ink) !important; }
44
45
  .papyrus-newsprint-board .issue-accent-priority { border-radius: 0; color: var(--org-muted) !important; background: transparent !important; font-size: 8px; }
45
46
  .papyrus-newsprint-board .issue-card h3 { font: 700 19px/1.2 Newsroom Display, Georgia, serif !important; letter-spacing: -.2px; }
@@ -47,3 +48,9 @@
47
48
  .papyrus-newsprint-board .issue-card[data-selected=true] h3 { color: var(--org-ink) !important; }
48
49
  .papyrus-newsprint-board .kb-issue-detail { background: var(--org-paper); border: 1px solid var(--org-line); border-radius: 0; box-shadow: none; }
49
50
  .papyrus-newsprint-board .kb-issue-detail h2 { font: 700 28px/1.2 Newsroom Display, Georgia, serif; border-bottom: 3px double var(--org-ink); padding-bottom: 12px; }
51
+ .papyrus-newsprint-board .issue-card .issue-accent-bar { height: auto !important; margin: 0 !important; width: 100% !important; padding: 0 !important; min-height: 0; }
52
+ .papyrus-newsprint-board .issue-card .issue-card-content { padding-top: 2px !important; gap: 4px; }
53
+ .papyrus-newsprint-board .issue-card h3 { margin: 0; }
54
+ .board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
55
+ .board-card-excerpt[data-expanded="true"] p { display: block; -webkit-line-clamp: unset; }
56
+ .board-card-excerpt button { background: none; border: 0; padding: 0; font: inherit; font-size: 12px; text-decoration: underline; text-underline-offset: 3px; color: var(--org-ink); cursor: pointer; }
@@ -5,7 +5,6 @@ import {
5
5
  ClipboardListIcon,
6
6
  FileTextIcon,
7
7
  LayersIcon,
8
- LightbulbIcon,
9
8
  MessageSquareIcon,
10
9
  NetworkIcon,
11
10
  SearchIcon,
@@ -30,6 +29,7 @@ import {
30
29
  buildNewsroomSectionHref,
31
30
  displayNewsroomSectionShortTitle,
32
31
  isEnabledNewsroomSection,
32
+ newsroomDeskKind,
33
33
  sortNewsroomSections,
34
34
  } from "../lib/newsroom-sections";
35
35
  import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
@@ -49,7 +49,6 @@ const DESTINATION_ICONS: Record<DestinationKey, typeof BookOpenIcon> = {
49
49
  assignments: ClipboardListIcon,
50
50
  references: BookOpenIcon,
51
51
  messages: MessageSquareIcon,
52
- insights: LightbulbIcon,
53
52
  topics: LayersIcon,
54
53
  concepts: NetworkIcon,
55
54
  administration: SettingsIcon,
@@ -102,6 +101,8 @@ export function NewsroomOpsOverview({
102
101
  () => sortNewsroomSections(newsroomSections).filter(isEnabledNewsroomSection),
103
102
  [newsroomSections],
104
103
  );
104
+ const coreDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "core"), [desks]);
105
+ const rotatingDesks = useMemo(() => desks.filter(desk => newsroomDeskKind(desk) === "rotating"), [desks]);
105
106
 
106
107
  const destinationCounts: Partial<Record<DestinationKey, number | null>> = {
107
108
  references: referenceCounts.pending ?? 0,
@@ -174,11 +175,11 @@ export function NewsroomOpsOverview({
174
175
  href={href}
175
176
  key={lineageId}
176
177
  >
177
- <Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
178
178
  <div className="min-w-0 flex-1">
179
179
  <p className="truncate text-sm font-medium text-foreground">{referenceDisplayTitle(reference)}</p>
180
180
  <p className="text-xs text-muted-foreground">Review source intake · {formatReferenceListDate(reference)}</p>
181
181
  </div>
182
+ <Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
182
183
  </Link>
183
184
  );
184
185
  })}
@@ -195,11 +196,11 @@ export function NewsroomOpsOverview({
195
196
  href={href}
196
197
  key={assignment.id}
197
198
  >
198
- <Badge className="mt-0.5 shrink-0" variant="secondary">{assignment.status}</Badge>
199
199
  <div className="min-w-0 flex-1">
200
200
  <p className="truncate text-sm font-medium text-foreground">{assignment.title}</p>
201
201
  <p className="text-xs text-muted-foreground">Assignment · {assignment.assignmentTypeKey}</p>
202
202
  </div>
203
+ <Badge className="mt-0.5 shrink-0" variant="secondary">{assignment.status}</Badge>
203
204
  </Link>
204
205
  );
205
206
  })}
@@ -216,8 +217,9 @@ export function NewsroomOpsOverview({
216
217
  {desks.length ? (
217
218
  <section aria-label="Desks" className="space-y-3" data-newsroom-ops-desks>
218
219
  <h2 className="text-sm font-medium text-muted-foreground">Desks</h2>
220
+ {coreDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="core">Core desks</h3> : null}
219
221
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
220
- {desks.map((section) => (
222
+ {coreDesks.map((section) => (
221
223
  <Link
222
224
  className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
223
225
  data-newsroom-ops-desk={section.id}
@@ -235,6 +237,26 @@ export function NewsroomOpsOverview({
235
237
  </Link>
236
238
  ))}
237
239
  </div>
240
+ {rotatingDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="rotating">Rotating desks <small>appear for a while, then rotate out</small></h3> : null}
241
+ {rotatingDesks.length ? <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
242
+ {rotatingDesks.map((section) => (
243
+ <Link
244
+ className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
245
+ data-newsroom-ops-desk={section.id}
246
+ href={buildNewsroomSectionHref(section.id, demo)}
247
+ key={section.id}
248
+ >
249
+ <Card className="h-full transition-colors hover:border-primary/30">
250
+ <CardHeader className="p-4">
251
+ <CardTitle className="text-base font-semibold">{section.title}</CardTitle>
252
+ <CardDescription className="uppercase tracking-wide text-[0.65rem]">
253
+ {displayNewsroomSectionShortTitle(section)}
254
+ </CardDescription>
255
+ </CardHeader>
256
+ </Card>
257
+ </Link>
258
+ ))}
259
+ </div> : null}
238
260
  </section>
239
261
  ) : null}
240
262
  </div>