@anthusai/papyrus 5.49.0 → 5.51.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/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/components/markdown-context.tsx +412 -0
- package/components/needs-me-bodies.tsx +3 -2
- package/components/needs-me-detail.tsx +2 -1
- package/components/needs-me-feed.tsx +36 -14
- package/components/needs-me-reference-detail.tsx +13 -15
- package/components/newsroom-access-gate.tsx +7 -16
- package/components/newsroom-ops-shell.tsx +0 -22
- package/components/newsroom-organizer.css +19 -0
- package/components/newsroom-references-view.tsx +19 -23
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-panel.tsx +17 -7
- package/components/reference-feed.tsx +8 -4
- package/components/reference-review-card.tsx +27 -9
- package/components/reference-source-preview.tsx +27 -5
- package/components/review-rating-grid.tsx +20 -10
- package/components/topic-steering-workspace.tsx +3 -428
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-reference-ratings.ts +9 -4
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-references.ts +25 -1
- package/lib/reference-capture.ts +3 -2
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/package.json +7 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { checkSourceFrameable } from "../../../../lib/source-frameable";
|
|
3
|
+
|
|
4
|
+
export const runtime = "nodejs";
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
/** GET ?url=<source page> -> { frameable }: whether the newsroom can show that page's live preview. Cached for a day. */
|
|
8
|
+
export async function GET(request: Request) {
|
|
9
|
+
const url = new URL(request.url).searchParams.get("url") ?? "";
|
|
10
|
+
const frameable = url ? await checkSourceFrameable(url) : false;
|
|
11
|
+
return NextResponse.json({ frameable }, { headers: { "cache-control": "public, max-age=86400" } });
|
|
12
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { fetchSourceIcon } from "../../../../lib/source-icon";
|
|
2
|
+
|
|
3
|
+
export const runtime = "nodejs";
|
|
4
|
+
export const dynamic = "force-dynamic";
|
|
5
|
+
|
|
6
|
+
/** GET ?host=<source host> -> the source's own icon, or 404 so the newsroom shows its letter mark. Cached for a week. */
|
|
7
|
+
export async function GET(request: Request) {
|
|
8
|
+
const host = (new URL(request.url).searchParams.get("host") ?? "").toLowerCase();
|
|
9
|
+
const icon = host ? await fetchSourceIcon(host) : null;
|
|
10
|
+
if (!icon) return new Response(null, { status: 404, headers: { "cache-control": "public, max-age=86400" } });
|
|
11
|
+
return new Response(icon.body, {
|
|
12
|
+
headers: {
|
|
13
|
+
"cache-control": "public, max-age=604800",
|
|
14
|
+
"content-type": icon.contentType,
|
|
15
|
+
// An SVG icon is shown only through <img>, but never let it run as a document on our origin.
|
|
16
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
17
|
+
"x-content-type-options": "nosniff",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -41,6 +41,8 @@ export default async function NewsDeskNestedSectionPage({ params, searchParams }
|
|
|
41
41
|
if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
|
|
42
42
|
if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
|
|
43
43
|
if (section === "administration" && selection[0] === "analysis") redirect(toPublicNewsroomPath("/newsroom/assignments"));
|
|
44
|
+
// A Notification Center deep link (`/needs-me/<item id>`): the console home, which opens that item from the address.
|
|
45
|
+
if (section === "needs-me") return <NewsDeskPage searchParams={searchParams} />;
|
|
44
46
|
if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
|
|
45
47
|
return <NewsDeskPage section={section} selectionPath={selection} searchParams={searchParams} />;
|
|
46
48
|
}
|
|
@@ -43,6 +43,7 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
|
|
|
43
43
|
export default async function NewsDeskSectionPage({ params, searchParams }: NewsDeskSectionPageProps) {
|
|
44
44
|
const { section } = await params;
|
|
45
45
|
if (section === "work") redirect(toPublicNewsroomPath(`/newsroom/${contentItemBoardPathSegment()}`));
|
|
46
|
+
if (section === "needs-me") redirect(toPublicNewsroomPath("/newsroom"));
|
|
46
47
|
if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
|
|
47
48
|
if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
|
|
48
49
|
if (section === contentItemBoardPathSegment()) return <NewsroomBoardView subject="contentItem" />;
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { useMemo, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** Renders Markdown as React elements (headings, lists, tables, code, links), never as HTML, so stored text cannot inject markup.
|
|
4
|
+
* Links open in a new tab and only http(s) and mailto destinations become links. */
|
|
5
|
+
export function MarkdownContext({ className, text }: { className?: string; text: string }) {
|
|
6
|
+
const blocks = useMemo(() => parseMarkdownBlocks(normalizeMarkdownForRendering(text)), [text]);
|
|
7
|
+
return (
|
|
8
|
+
<div className={className ? `news-desk-markdown-context ${className}` : "news-desk-markdown-context"}>
|
|
9
|
+
{blocks.map((block, index) => renderMarkdownBlock(block, index))}
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type MarkdownBlock =
|
|
15
|
+
| { type: "code"; text: string }
|
|
16
|
+
| { type: "heading"; depth: number; text: string }
|
|
17
|
+
| { type: "list"; items: string[]; ordered: boolean }
|
|
18
|
+
| { type: "table"; headers: string[]; rows: string[][] }
|
|
19
|
+
| { type: "paragraph"; text: string };
|
|
20
|
+
|
|
21
|
+
function normalizeMarkdownForRendering(text: string): string {
|
|
22
|
+
const trimmed = text.trim();
|
|
23
|
+
const fencedMarkdown = /^```(?:markdown|md|mdx)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed);
|
|
24
|
+
const unwrapped = fencedMarkdown ? (fencedMarkdown[1] ?? "") : text;
|
|
25
|
+
return normalizeMultilineMarkdownLinks(unwrapped);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseMarkdownBlocks(text: string): MarkdownBlock[] {
|
|
29
|
+
const blocks: MarkdownBlock[] = [];
|
|
30
|
+
const lines = text.split(/\r?\n/);
|
|
31
|
+
let paragraph: string[] = [];
|
|
32
|
+
let list: string[] = [];
|
|
33
|
+
let listOrdered = false;
|
|
34
|
+
let code: string[] = [];
|
|
35
|
+
let inCode = false;
|
|
36
|
+
const flushParagraph = () => {
|
|
37
|
+
if (paragraph.length) {
|
|
38
|
+
blocks.push({ type: "paragraph", text: paragraph.join(" ").trim() });
|
|
39
|
+
paragraph = [];
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const flushList = () => {
|
|
43
|
+
if (list.length) {
|
|
44
|
+
blocks.push({ type: "list", items: list, ordered: listOrdered });
|
|
45
|
+
list = [];
|
|
46
|
+
listOrdered = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
50
|
+
const line = lines[lineIndex];
|
|
51
|
+
if (line.trim().startsWith("```")) {
|
|
52
|
+
if (inCode) {
|
|
53
|
+
blocks.push({ type: "code", text: code.join("\n") });
|
|
54
|
+
code = [];
|
|
55
|
+
inCode = false;
|
|
56
|
+
} else {
|
|
57
|
+
flushParagraph();
|
|
58
|
+
flushList();
|
|
59
|
+
inCode = true;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (inCode) {
|
|
64
|
+
code.push(line);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
if (!trimmed) {
|
|
69
|
+
flushParagraph();
|
|
70
|
+
flushList();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
lineIndex + 1 < lines.length
|
|
75
|
+
&& looksLikeMarkdownTableHeaderLine(line)
|
|
76
|
+
&& isMarkdownTableDividerLine(lines[lineIndex + 1] ?? "")
|
|
77
|
+
) {
|
|
78
|
+
flushParagraph();
|
|
79
|
+
flushList();
|
|
80
|
+
const headerCells = splitMarkdownTableCells(line);
|
|
81
|
+
const dividerCells = splitMarkdownTableCells(lines[lineIndex + 1] ?? "");
|
|
82
|
+
const columnCount = Math.max(headerCells.length, dividerCells.length, 1);
|
|
83
|
+
const headers = normalizeMarkdownTableCells(headerCells, columnCount);
|
|
84
|
+
const rows: string[][] = [];
|
|
85
|
+
lineIndex += 2;
|
|
86
|
+
while (lineIndex < lines.length) {
|
|
87
|
+
const rowLine = lines[lineIndex] ?? "";
|
|
88
|
+
if (!looksLikeMarkdownTableRowLine(rowLine)) {
|
|
89
|
+
lineIndex -= 1;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
rows.push(normalizeMarkdownTableCells(splitMarkdownTableCells(rowLine), columnCount));
|
|
93
|
+
lineIndex += 1;
|
|
94
|
+
}
|
|
95
|
+
if (lineIndex >= lines.length) lineIndex -= 1;
|
|
96
|
+
blocks.push({ type: "table", headers, rows });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const heading = /^(#{1,4})\s+(.+)$/.exec(trimmed);
|
|
100
|
+
if (heading) {
|
|
101
|
+
flushParagraph();
|
|
102
|
+
flushList();
|
|
103
|
+
blocks.push({ type: "heading", depth: heading[1].length, text: heading[2].trim() });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const bullet = /^(?:([-*+])\s+|(\d+)[.)]\s+)(.+)$/.exec(trimmed);
|
|
107
|
+
if (bullet) {
|
|
108
|
+
flushParagraph();
|
|
109
|
+
const ordered = Boolean(bullet[2]);
|
|
110
|
+
if (list.length && listOrdered !== ordered) flushList();
|
|
111
|
+
listOrdered = ordered;
|
|
112
|
+
list.push((bullet[3] ?? "").trim());
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
flushList();
|
|
116
|
+
paragraph.push(trimmed);
|
|
117
|
+
}
|
|
118
|
+
flushParagraph();
|
|
119
|
+
flushList();
|
|
120
|
+
if (code.length) blocks.push({ type: "code", text: code.join("\n") });
|
|
121
|
+
return blocks;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderMarkdownBlock(block: MarkdownBlock, index: number): ReactNode {
|
|
125
|
+
const key = `${block.type}-${index}`;
|
|
126
|
+
if (block.type === "code") return <pre className="news-desk-analysis-command" key={key}>{block.text}</pre>;
|
|
127
|
+
if (block.type === "list") {
|
|
128
|
+
const ListTag = block.ordered ? "ol" : "ul";
|
|
129
|
+
return (
|
|
130
|
+
<ListTag key={key}>
|
|
131
|
+
{block.items.map((item, itemIndex) => <li key={`${key}-${itemIndex}`}>{renderInlineMarkdown(item)}</li>)}
|
|
132
|
+
</ListTag>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (block.type === "heading") {
|
|
136
|
+
if (block.depth <= 1) return <h3 key={key}>{block.text}</h3>;
|
|
137
|
+
if (block.depth === 2) return <h4 key={key}>{block.text}</h4>;
|
|
138
|
+
return <h5 key={key}>{block.text}</h5>;
|
|
139
|
+
}
|
|
140
|
+
if (block.type === "table") {
|
|
141
|
+
return (
|
|
142
|
+
<table key={key}>
|
|
143
|
+
<thead>
|
|
144
|
+
<tr>
|
|
145
|
+
{block.headers.map((header, headerIndex) => (
|
|
146
|
+
<th key={`${key}-head-${headerIndex}`} scope="col">{renderInlineMarkdown(header)}</th>
|
|
147
|
+
))}
|
|
148
|
+
</tr>
|
|
149
|
+
</thead>
|
|
150
|
+
{block.rows.length ? (
|
|
151
|
+
<tbody>
|
|
152
|
+
{block.rows.map((row, rowIndex) => (
|
|
153
|
+
<tr key={`${key}-row-${rowIndex}`}>
|
|
154
|
+
{row.map((cell, cellIndex) => (
|
|
155
|
+
<td key={`${key}-row-${rowIndex}-cell-${cellIndex}`}>{renderInlineMarkdown(cell)}</td>
|
|
156
|
+
))}
|
|
157
|
+
</tr>
|
|
158
|
+
))}
|
|
159
|
+
</tbody>
|
|
160
|
+
) : null}
|
|
161
|
+
</table>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return <p key={key}>{renderInlineMarkdown(block.text)}</p>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderInlineMarkdown(text: string): ReactNode[] {
|
|
168
|
+
const nodes: ReactNode[] = [];
|
|
169
|
+
let index = 0;
|
|
170
|
+
let plain = "";
|
|
171
|
+
const flushPlain = () => {
|
|
172
|
+
if (!plain) return;
|
|
173
|
+
nodes.push(plain);
|
|
174
|
+
plain = "";
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
while (index < text.length) {
|
|
178
|
+
const linkLike = parseMarkdownLinkLikeAt(text, index);
|
|
179
|
+
if (linkLike) {
|
|
180
|
+
flushPlain();
|
|
181
|
+
if (!linkLike.image) {
|
|
182
|
+
const href = normalizeInlineMarkdownHref(linkLike.hrefRaw);
|
|
183
|
+
const label = collapseMarkdownLinkLabel(linkLike.label);
|
|
184
|
+
if (href) {
|
|
185
|
+
nodes.push(
|
|
186
|
+
<a href={href} key={`${index}-link`} rel="noopener noreferrer" target="_blank">
|
|
187
|
+
{renderInlineMarkdown(label)}
|
|
188
|
+
</a>,
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
nodes.push(linkLike.raw);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
index += linkLike.length;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (text.startsWith("**", index) || text.startsWith("__", index)) {
|
|
199
|
+
const marker = text.slice(index, index + 2);
|
|
200
|
+
const end = text.indexOf(marker, index + 2);
|
|
201
|
+
if (end > index + 2) {
|
|
202
|
+
flushPlain();
|
|
203
|
+
nodes.push(<strong key={`${index}-strong`}>{renderInlineMarkdown(text.slice(index + 2, end))}</strong>);
|
|
204
|
+
index = end + 2;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (text.startsWith("~~", index)) {
|
|
210
|
+
const end = text.indexOf("~~", index + 2);
|
|
211
|
+
if (end > index + 2) {
|
|
212
|
+
flushPlain();
|
|
213
|
+
nodes.push(<del key={`${index}-strike`}>{renderInlineMarkdown(text.slice(index + 2, end))}</del>);
|
|
214
|
+
index = end + 2;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (text[index] === "*" || text[index] === "_") {
|
|
220
|
+
const marker = text[index];
|
|
221
|
+
const end = text.indexOf(marker, index + 1);
|
|
222
|
+
if (end > index + 1) {
|
|
223
|
+
flushPlain();
|
|
224
|
+
nodes.push(<em key={`${index}-em`}>{renderInlineMarkdown(text.slice(index + 1, end))}</em>);
|
|
225
|
+
index = end + 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (text[index] === "`") {
|
|
231
|
+
const end = text.indexOf("`", index + 1);
|
|
232
|
+
if (end > index + 1) {
|
|
233
|
+
flushPlain();
|
|
234
|
+
nodes.push(<code key={`${index}-code`}>{text.slice(index + 1, end)}</code>);
|
|
235
|
+
index = end + 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const autolinkMatch = /^(<https?:\/\/[^>\s]+>|https?:\/\/[^\s<]+)/.exec(text.slice(index));
|
|
241
|
+
if (autolinkMatch) {
|
|
242
|
+
flushPlain();
|
|
243
|
+
const token = autolinkMatch[1];
|
|
244
|
+
const href = normalizeInlineMarkdownHref(token);
|
|
245
|
+
if (href) {
|
|
246
|
+
nodes.push(
|
|
247
|
+
<a href={href} key={`${index}-autolink`} rel="noopener noreferrer" target="_blank">
|
|
248
|
+
{href}
|
|
249
|
+
</a>,
|
|
250
|
+
);
|
|
251
|
+
} else {
|
|
252
|
+
nodes.push(token);
|
|
253
|
+
}
|
|
254
|
+
index += token.length;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
plain += text[index];
|
|
259
|
+
index += 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
flushPlain();
|
|
263
|
+
return nodes;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeInlineMarkdownHref(rawHref: string): string | null {
|
|
267
|
+
const href = extractMarkdownHrefDestination(rawHref);
|
|
268
|
+
if (!href) return null;
|
|
269
|
+
if (/^(https?:\/\/|mailto:)/i.test(href)) return href;
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeMultilineMarkdownLinks(text: string): string {
|
|
274
|
+
let normalized = "";
|
|
275
|
+
let index = 0;
|
|
276
|
+
while (index < text.length) {
|
|
277
|
+
const linkLike = parseMarkdownLinkLikeAt(text, index);
|
|
278
|
+
if (!linkLike || linkLike.image || !linkLike.raw.includes("\n")) {
|
|
279
|
+
normalized += text[index];
|
|
280
|
+
index += 1;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const href = extractMarkdownHrefDestination(linkLike.hrefRaw);
|
|
284
|
+
const label = collapseMarkdownLinkLabel(linkLike.label);
|
|
285
|
+
if (!href || !label) {
|
|
286
|
+
normalized += linkLike.raw;
|
|
287
|
+
index += linkLike.length;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
normalized += `[${label}](${href})`;
|
|
291
|
+
index += linkLike.length;
|
|
292
|
+
}
|
|
293
|
+
return normalized;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function collapseMarkdownLinkLabel(label: string): string {
|
|
297
|
+
const withoutImages = label.replace(/!\[[^\]]*\]\((?:[^)(]|\([^)(]*\))*\)/g, " ");
|
|
298
|
+
return withoutImages
|
|
299
|
+
.replace(/\r?\n+/g, " ")
|
|
300
|
+
.replace(/(^|\s)#{1,6}\s+/g, "$1")
|
|
301
|
+
.replace(/\s+/g, " ")
|
|
302
|
+
.trim();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function extractMarkdownHrefDestination(rawHref: string): string {
|
|
306
|
+
const trimmed = rawHref.trim();
|
|
307
|
+
if (!trimmed) return "";
|
|
308
|
+
if (trimmed.startsWith("<")) {
|
|
309
|
+
const closing = trimmed.indexOf(">");
|
|
310
|
+
if (closing > 1) return trimmed.slice(1, closing).trim();
|
|
311
|
+
}
|
|
312
|
+
const match = /^(\S+)/.exec(trimmed);
|
|
313
|
+
return match?.[1]?.trim() ?? "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function parseMarkdownLinkLikeAt(
|
|
317
|
+
source: string,
|
|
318
|
+
startIndex: number,
|
|
319
|
+
): { hrefRaw: string; image: boolean; label: string; length: number; raw: string } | null {
|
|
320
|
+
const image = source.startsWith("![", startIndex);
|
|
321
|
+
const link = source[startIndex] === "[";
|
|
322
|
+
if (!image && !link) return null;
|
|
323
|
+
const labelStart = startIndex + (image ? 2 : 1);
|
|
324
|
+
const labelEnd = findMatchingBracketIndex(source, labelStart, "[", "]");
|
|
325
|
+
if (labelEnd < 0) return null;
|
|
326
|
+
let next = labelEnd + 1;
|
|
327
|
+
while (next < source.length && /\s/.test(source[next])) next += 1;
|
|
328
|
+
if (source[next] !== "(") return null;
|
|
329
|
+
const hrefStart = next + 1;
|
|
330
|
+
const hrefEnd = findMatchingBracketIndex(source, hrefStart, "(", ")");
|
|
331
|
+
if (hrefEnd < 0) return null;
|
|
332
|
+
const raw = source.slice(startIndex, hrefEnd + 1);
|
|
333
|
+
return {
|
|
334
|
+
image,
|
|
335
|
+
label: source.slice(labelStart, labelEnd),
|
|
336
|
+
hrefRaw: source.slice(hrefStart, hrefEnd),
|
|
337
|
+
length: raw.length,
|
|
338
|
+
raw,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function findMatchingBracketIndex(source: string, contentStart: number, open: string, close: string): number {
|
|
343
|
+
let depth = 1;
|
|
344
|
+
for (let index = contentStart; index < source.length; index += 1) {
|
|
345
|
+
const ch = source[index];
|
|
346
|
+
if (ch === "\\") {
|
|
347
|
+
index += 1;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (ch === open) {
|
|
351
|
+
depth += 1;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (ch !== close) continue;
|
|
355
|
+
depth -= 1;
|
|
356
|
+
if (depth === 0) return index;
|
|
357
|
+
}
|
|
358
|
+
return -1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function looksLikeMarkdownTableHeaderLine(line: string): boolean {
|
|
362
|
+
return /\|/.test(line.trim());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function looksLikeMarkdownTableRowLine(line: string): boolean {
|
|
366
|
+
const trimmed = line.trim();
|
|
367
|
+
if (!trimmed) return false;
|
|
368
|
+
if (!/\|/.test(trimmed)) return false;
|
|
369
|
+
if (isMarkdownTableDividerLine(trimmed)) return false;
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isMarkdownTableDividerLine(line: string): boolean {
|
|
374
|
+
const cells = splitMarkdownTableCells(line);
|
|
375
|
+
if (!cells.length) return false;
|
|
376
|
+
return cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function splitMarkdownTableCells(line: string): string[] {
|
|
380
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
381
|
+
if (!trimmed) return [];
|
|
382
|
+
const cells: string[] = [];
|
|
383
|
+
let current = "";
|
|
384
|
+
let escaping = false;
|
|
385
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
386
|
+
const ch = trimmed[index];
|
|
387
|
+
if (escaping) {
|
|
388
|
+
current += ch;
|
|
389
|
+
escaping = false;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (ch === "\\") {
|
|
393
|
+
escaping = true;
|
|
394
|
+
current += ch;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (ch === "|") {
|
|
398
|
+
cells.push(current.trim());
|
|
399
|
+
current = "";
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
current += ch;
|
|
403
|
+
}
|
|
404
|
+
cells.push(current.trim());
|
|
405
|
+
return cells;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeMarkdownTableCells(cells: string[], columnCount: number): string[] {
|
|
409
|
+
const normalized = cells.slice(0, columnCount);
|
|
410
|
+
while (normalized.length < columnCount) normalized.push("");
|
|
411
|
+
return normalized;
|
|
412
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
3
4
|
import { CheckIcon, CircleCheckIcon, CircleXIcon, ExternalLinkIcon, FileSearchIcon, Loader2Icon, LockIcon, MailIcon } from "lucide-react";
|
|
4
5
|
import Link from "next/link";
|
|
5
6
|
import { useEffect, useState, type ReactNode } from "react";
|
|
@@ -95,9 +96,9 @@ export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason,
|
|
|
95
96
|
|
|
96
97
|
return (
|
|
97
98
|
<section aria-label="Your decision" className="space-y-3 rounded-[20px] bg-[var(--feed-footer)] p-3" data-needs-me-decision {...(locked ? {} : { "data-review-controls": "" })}>
|
|
98
|
-
<
|
|
99
|
+
<AutoGrowTextarea
|
|
99
100
|
aria-label="Why? (optional)"
|
|
100
|
-
className="block min-h-20 w-full
|
|
101
|
+
className="block min-h-20 w-full rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
101
102
|
data-needs-me-why
|
|
102
103
|
onBlur={() => { if (!disabledReason) ratings.commitNote(item.id); }}
|
|
103
104
|
onChange={(event) => ratings.setNote(item.id, event.target.value)}
|
|
@@ -12,7 +12,8 @@ export function NeedsMeDetail(props: DetailProps) {
|
|
|
12
12
|
const { payload } = item;
|
|
13
13
|
return (
|
|
14
14
|
<div className="flex flex-col gap-4 p-4 font-sans" data-needs-me-detail={item.id} data-needs-me-detail-type={item.type}>
|
|
15
|
-
|
|
15
|
+
{/* A Reference puts its title first and carries these badges under it. */}
|
|
16
|
+
{payload.kind === "reference" ? null : <Heading item={item} />}
|
|
16
17
|
{payload.kind === "reference" && isReferenceLike(item) ? <ReferenceBody {...props} payload={payload} /> : null}
|
|
17
18
|
{payload.kind === "draft" ? <DraftBody {...props} payload={payload} /> : null}
|
|
18
19
|
{payload.kind === "request" ? <RequestBody {...props} payload={payload} /> : null}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Loader2Icon, LockIcon, MailIcon, NewspaperIcon, SlidersHorizontalIcon, Undo2Icon, type LucideIcon,
|
|
6
6
|
} from "lucide-react";
|
|
7
7
|
import Link from "next/link";
|
|
8
|
+
import { usePathname } from "next/navigation";
|
|
8
9
|
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
9
10
|
import { ScrollArea } from "./ui/scroll-area";
|
|
10
11
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "./ui/sheet";
|
|
@@ -20,13 +21,15 @@ import { cannotMoveReason } from "../lib/review-drafts";
|
|
|
20
21
|
import type { CyclotronEntry } from "../lib/review-requests";
|
|
21
22
|
import { referenceReasonCode, REFERENCE_REJECTION_REASON_CODES, normalizeReferenceCurationStatus, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
22
23
|
import { formatFeedAge } from "../lib/reference-preview";
|
|
24
|
+
import { buildNeedsMeItemPath, parseNeedsMeItemIdFromNewsroomPathname } from "../lib/newsroom-index-filters";
|
|
25
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
23
26
|
import type { FeedFreshness } from "../lib/reference-feed-ranking";
|
|
24
27
|
import type { RecentDecision } from "../lib/notification-feed";
|
|
25
28
|
import { getKanbusTheme, SITE_BRAND } from "../lib/site-brand";
|
|
26
29
|
import { NeedsMeDetail, type DecisionExtras } from "./needs-me-detail";
|
|
27
30
|
import { FlagFooter, decidedChoiceOf, isReferenceLike, recordedAnswer } from "./needs-me-shared";
|
|
28
31
|
import { NotificationSkeleton, RecentDecisions } from "./notification-cards";
|
|
29
|
-
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SourceMark } from "./reference-feed";
|
|
32
|
+
import { FEED_GRID, FEED_SURFACE_PROPS, FeedImage, PILL, REASON_LABELS, SELECTED_RING, SourceMark } from "./reference-feed";
|
|
30
33
|
import { predictionFromDecision, ReviewQueueBar, useQueueGestures, type CardPrediction, type ReviewChoice } from "./reference-review-card";
|
|
31
34
|
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
32
35
|
import { RatingGrid } from "./review-rating-grid";
|
|
@@ -118,18 +121,18 @@ export function NeedsMeCard({ item, now, onOpen, predictionLoading, ratings, sel
|
|
|
118
121
|
aria-labelledby={titleId}
|
|
119
122
|
className={cn(
|
|
120
123
|
"relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
|
|
121
|
-
selected ? "bg-[var(--feed-control)]
|
|
124
|
+
selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
|
|
122
125
|
)}
|
|
123
126
|
data-feed-card={item.id}
|
|
124
127
|
data-feed-rated={decided ?? undefined}
|
|
125
128
|
data-feed-selected={selected || undefined}
|
|
129
|
+
style={selected ? SELECTED_RING : undefined}
|
|
126
130
|
data-needs-me-card={item.type}
|
|
127
131
|
data-needs-me-state={item.decisionState}
|
|
128
132
|
data-newsroom-card-id={item.id}
|
|
129
133
|
data-review-draft={draftId}
|
|
130
134
|
data-review-request={requestId}
|
|
131
135
|
>
|
|
132
|
-
{selected ? <span aria-hidden="true" className="absolute inset-y-0 left-0 z-10 w-1.5 bg-[var(--primary)]" data-feed-selected-bar /> : null}
|
|
133
136
|
<button
|
|
134
137
|
aria-label={`Open: ${item.title}`}
|
|
135
138
|
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
@@ -301,7 +304,10 @@ export function NeedsMeView({
|
|
|
301
304
|
const themeAttributes = { "data-kanbus-theme": getKanbusTheme(SITE_BRAND), "data-color-mode": themeMode };
|
|
302
305
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
303
306
|
const [filter, setFilter] = useState<NeedsMeFilter>(initialFilter);
|
|
304
|
-
|
|
307
|
+
// The open item lives in the address (`/needs-me/<id>`), so it can be linked, bookmarked and reopened with Back.
|
|
308
|
+
const pathname = usePathname() ?? (typeof window === "undefined" ? null : window.location.pathname);
|
|
309
|
+
const routeId = useMemo(() => parseNeedsMeItemIdFromNewsroomPathname(pathname) ?? "", [pathname]);
|
|
310
|
+
const [selectedId, setSelectedId] = useState(routeId);
|
|
305
311
|
const [caughtUp, setCaughtUp] = useState(false);
|
|
306
312
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
307
313
|
const [recipeOpen, setRecipeOpen] = useState(false);
|
|
@@ -357,21 +363,37 @@ export function NeedsMeView({
|
|
|
357
363
|
const previousId = caughtUp ? queue[queue.length - 1] ?? null : selectedIndex > 0 ? queue[selectedIndex - 1] : null;
|
|
358
364
|
const nextId = selectedIndex >= 0 && selectedIndex < queue.length - 1 ? queue[selectedIndex + 1] : null;
|
|
359
365
|
|
|
366
|
+
const showInAddress = (id: string, mode: "push" | "replace") => {
|
|
367
|
+
if (demo || typeof window === "undefined") return;
|
|
368
|
+
const next = id ? buildNeedsMeItemPath(id) : getNewsroomNavHref("/newsroom");
|
|
369
|
+
if (`${window.location.pathname}${window.location.search}` === next) return;
|
|
370
|
+
if (mode === "push") window.history.pushState(null, "", next);
|
|
371
|
+
else window.history.replaceState(null, "", next);
|
|
372
|
+
};
|
|
360
373
|
const select = (item: NeedsMeEntry) => {
|
|
361
374
|
setCaughtUp(false);
|
|
362
375
|
setSelectedId(item.id);
|
|
363
376
|
if (isMobile) setSheetOpen(true);
|
|
377
|
+
showInAddress(item.id, "push");
|
|
364
378
|
onOpened?.(item);
|
|
365
379
|
};
|
|
366
380
|
const goNext = () => {
|
|
367
|
-
if (nextId) { setCaughtUp(false); setSelectedId(nextId); setScrollTarget({ id: nextId, nonce: Date.now() }); const next = byId.get(nextId); if (next) onOpened?.(next); }
|
|
368
|
-
else if (!caughtUp) { setCaughtUp(true); setSelectedId(""); }
|
|
381
|
+
if (nextId) { setCaughtUp(false); setSelectedId(nextId); showInAddress(nextId, "replace"); setScrollTarget({ id: nextId, nonce: Date.now() }); const next = byId.get(nextId); if (next) onOpened?.(next); }
|
|
382
|
+
else if (!caughtUp) { setCaughtUp(true); setSelectedId(""); showInAddress("", "replace"); }
|
|
369
383
|
};
|
|
370
384
|
const goPrevious = () => {
|
|
371
|
-
if (previousId) { setCaughtUp(false); setSelectedId(previousId); setScrollTarget({ id: previousId, nonce: Date.now() }); }
|
|
385
|
+
if (previousId) { setCaughtUp(false); setSelectedId(previousId); showInAddress(previousId, "replace"); setScrollTarget({ id: previousId, nonce: Date.now() }); }
|
|
372
386
|
};
|
|
373
387
|
const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
|
|
374
|
-
const closeDetail = () => { setCaughtUp(false); setSheetOpen(false); setSelectedId(""); };
|
|
388
|
+
const closeDetail = () => { setCaughtUp(false); setSheetOpen(false); setSelectedId(""); showInAddress("", "replace"); };
|
|
389
|
+
|
|
390
|
+
// Back and Forward (and a link opened in place) follow the address.
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
setSelectedId(routeId);
|
|
393
|
+
if (routeId) { setCaughtUp(false); if (isMobile) setSheetOpen(true); }
|
|
394
|
+
}, [routeId, isMobile]);
|
|
395
|
+
// A linked item that is not waiting any more (decided, or not yours) says so once everything is read, instead of an empty pane.
|
|
396
|
+
const linkedGone = Boolean(routeId) && settled && !byId.has(routeId);
|
|
375
397
|
|
|
376
398
|
// Previous / Next scrolls the feed to the same card, smoothly. Selecting a card by clicking it never scrolls.
|
|
377
399
|
useEffect(() => {
|
|
@@ -404,14 +426,15 @@ export function NeedsMeView({
|
|
|
404
426
|
) : null;
|
|
405
427
|
|
|
406
428
|
return (
|
|
407
|
-
<div className="flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-
|
|
408
|
-
<div className="@container min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto md:pr-
|
|
429
|
+
<div className="flex flex-col gap-5 md:h-full md:flex-row md:items-stretch" data-notification-center {...FEED_SURFACE_PROPS}>
|
|
430
|
+
<div className="@container min-w-0 flex-1 space-y-6 pb-6 md:min-h-0 md:overflow-y-auto px-1.5 pt-1.5 md:pr-2.5" data-needs-me-main data-needs-me-scroll>
|
|
409
431
|
<header className="space-y-4">
|
|
410
432
|
<div className="space-y-1.5">
|
|
411
433
|
<h1 className="text-4xl font-bold tracking-tight text-foreground sm:text-5xl" data-notification-title>What needs me?</h1>
|
|
412
434
|
<p className="text-lg text-muted-foreground" data-notification-summary role="status">{waitingTextFor(counts)}</p>
|
|
413
435
|
</div>
|
|
414
436
|
<ShapingStrip stats={shaping} />
|
|
437
|
+
{linkedGone ? <Note data-needs-me-link-gone role="status">The item in this link is not waiting for you any more: it was decided, or it is not yours to decide.</Note> : null}
|
|
415
438
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
416
439
|
<div aria-label="Show" className="inline-flex max-w-full overflow-x-auto rounded-full bg-[var(--feed-card)] p-1" data-notification-filters role="group">
|
|
417
440
|
{NEEDS_ME_FILTERS.map((key) => {
|
|
@@ -542,9 +565,10 @@ export function NeedsMeView({
|
|
|
542
565
|
{below}
|
|
543
566
|
</div>
|
|
544
567
|
|
|
568
|
+
{/* The pane appears once something is selected (or when the queue is done); until then the feed has the whole width. */}
|
|
545
569
|
<aside
|
|
546
570
|
aria-label="Detail"
|
|
547
|
-
className="hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:
|
|
571
|
+
className={cn("hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:h-full", (selected || caughtUp) && "md:flex", " md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]")}
|
|
548
572
|
data-feed-flat=""
|
|
549
573
|
data-needs-me-detail-shell
|
|
550
574
|
data-detail-open={selected ? "true" : "false"}
|
|
@@ -552,9 +576,7 @@ export function NeedsMeView({
|
|
|
552
576
|
<div className="flex shrink-0 items-center justify-between gap-2 px-4 pb-1 pt-3"><span className="text-sm font-medium text-foreground">Detail</span></div>
|
|
553
577
|
<div className="flex min-h-0 flex-1 flex-col" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}>
|
|
554
578
|
<ScrollArea className="min-h-0 flex-1">
|
|
555
|
-
{isMobile ? null : selected ? <div key={selected.id}>{detailBody(selected)}</div> : caughtUp ? caughtUpPanel :
|
|
556
|
-
<p className="p-4 text-sm text-muted-foreground" data-needs-me-detail-empty>Select an item to read it here and decide.</p>
|
|
557
|
-
)}
|
|
579
|
+
{isMobile ? null : selected ? <div key={selected.id}>{detailBody(selected)}</div> : caughtUp ? caughtUpPanel : null}
|
|
558
580
|
</ScrollArea>
|
|
559
581
|
</div>
|
|
560
582
|
{queueBar}
|