@anthusai/papyrus 5.49.0 → 5.50.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/components/needs-me-bodies.tsx +3 -2
- package/components/needs-me-detail.tsx +2 -1
- package/components/needs-me-reference-detail.tsx +13 -15
- package/components/newsroom-access-gate.tsx +4 -13
- package/components/newsroom-references-view.tsx +12 -14
- package/components/reference-capture-panel.tsx +14 -6
- 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/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-reference-ratings.ts +9 -4
- package/lib/source-frameable.ts +69 -0
- package/package.json +4 -2
- package/routes.manifest.json +12 -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
|
+
}
|
|
@@ -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}
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { referenceDisplayTitle, referenceLineageId, formatReferenceListDate } from "../lib/newsroom-references";
|
|
4
4
|
import { referenceMetadataField } from "../lib/reference-display";
|
|
5
|
+
import { referenceDek, referencePreviewsByLineage } from "../lib/reference-preview";
|
|
5
6
|
import { DEFAULT_TEASER_WORDS } from "../lib/reference-capture";
|
|
6
7
|
import { referenceReasonCode, normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
7
8
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
8
9
|
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
9
|
-
import { DefaultOutcome, type DetailProps } from "./needs-me-bodies";
|
|
10
|
+
import { DefaultOutcome, Heading, type DetailProps } from "./needs-me-bodies";
|
|
10
11
|
import { decidedChoiceOf } from "./needs-me-shared";
|
|
11
12
|
import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
|
|
12
13
|
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
@@ -20,7 +21,10 @@ function cardPrediction(item: NeedsMeEntry, predictionLoading: boolean): CardPre
|
|
|
20
21
|
return predictionLoading ? { state: "loading" } : { state: "none" };
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* A Reference, or a Reference whose capture may be cut off: the References detail's own pieces, in the same order. What the item is
|
|
26
|
+
* comes first (title, blurb, link, badges, summary), then the rating, then what was captured and how well.
|
|
27
|
+
*/
|
|
24
28
|
export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "reference" }> }) {
|
|
25
29
|
const { item, payload, ratings, disabledReason, attachments, captureTeaserLine = DEFAULT_TEASER_WORDS, onCorrectCapture, predictionLoading } = props;
|
|
26
30
|
const reference: ReferenceRecord = payload.reference;
|
|
@@ -29,26 +33,23 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
|
|
|
29
33
|
const previewAttachments = useReferencePreviewAttachments(reference, attachments);
|
|
30
34
|
const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, onCorrectCapture);
|
|
31
35
|
const url = reference.sourceUri?.trim() && /^https?:\/\//i.test(reference.sourceUri.trim()) ? reference.sourceUri.trim() : null;
|
|
32
|
-
const summary = referenceMetadataField(null, reference.metadata, "summary");
|
|
36
|
+
const summary = capture.summary ?? referenceMetadataField(null, reference.metadata, "summary");
|
|
37
|
+
const dek = referenceDek(reference, referencePreviewsByLineage(previewAttachments).get(lineageId));
|
|
33
38
|
const incomplete = item.type === "incomplete_capture";
|
|
34
|
-
const capturePanel = <ReferenceCapturePanel capture={capture} startEditing={incomplete} />;
|
|
35
39
|
return (
|
|
36
40
|
<div className="space-y-4" data-reference-curation-status={status} data-news-desk-reference-detail={lineageId}>
|
|
37
|
-
{incomplete ? (
|
|
38
|
-
<div className="space-y-2" data-needs-me-capture-first>
|
|
39
|
-
<p className="text-sm text-muted-foreground">The captured text looks cut off or missing. Paste the full text below and say whether the capture was complete, or rate the Reference as it is.</p>
|
|
40
|
-
{capturePanel}
|
|
41
|
-
</div>
|
|
42
|
-
) : null}
|
|
43
41
|
<ReviewCard
|
|
44
42
|
decided={decidedChoiceOf(item)}
|
|
45
43
|
disabledReason={disabledReason}
|
|
46
44
|
initialNote={status === "pending" ? "" : reference.curationStatusReason ?? ""}
|
|
47
45
|
initialReason={referenceReasonCode(reference) ?? null}
|
|
46
|
+
dek={dek}
|
|
48
47
|
itemId={item.id}
|
|
48
|
+
meta={<Heading item={item} />}
|
|
49
49
|
onDecide={async () => undefined}
|
|
50
50
|
prediction={cardPrediction(item, predictionLoading)}
|
|
51
51
|
ratings={ratings}
|
|
52
|
+
sourcePreview={<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />}
|
|
52
53
|
summary={summary}
|
|
53
54
|
title={referenceDisplayTitle(reference)}
|
|
54
55
|
titleHref={url}
|
|
@@ -56,11 +57,8 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
|
|
|
56
57
|
{payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
|
|
57
58
|
</ReviewCard>
|
|
58
59
|
<DefaultOutcome item={item} />
|
|
59
|
-
{incomplete ?
|
|
60
|
-
<
|
|
61
|
-
<h3 className="text-sm font-medium text-foreground">Source preview</h3>
|
|
62
|
-
<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
|
|
63
|
-
</section>
|
|
60
|
+
{incomplete ? <p className="text-sm text-muted-foreground" data-needs-me-capture-note>The captured text looks cut off or missing. Read it below. If it is incomplete, choose Paste or correct the text and say whether the capture was complete, or rate the Reference as it is.</p> : null}
|
|
61
|
+
<ReferenceCapturePanel capture={capture} showSummary={false} />
|
|
64
62
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
|
65
63
|
<div>
|
|
66
64
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
|
|
@@ -6,7 +6,7 @@ import { SITE_BRAND } from "../lib/site-brand";
|
|
|
6
6
|
import type { NewsDeskShellState } from "../lib/news-desk-session";
|
|
7
7
|
import { cn } from "../lib/utils";
|
|
8
8
|
import { NewsroomOpsShell } from "./newsroom-ops-shell";
|
|
9
|
-
import {
|
|
9
|
+
import { NotificationSkeleton } from "./notification-cards";
|
|
10
10
|
import { ReaderAuthControl } from "./reader-auth-control";
|
|
11
11
|
import { buttonVariants } from "./ui/button";
|
|
12
12
|
import { ReferencesPageLoading } from "./references-page-loading";
|
|
@@ -28,19 +28,10 @@ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false
|
|
|
28
28
|
pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
29
29
|
showNavigation={showSectionTabs || referencesPage}
|
|
30
30
|
>
|
|
31
|
-
{
|
|
32
|
-
|
|
33
|
-
<div className="h-6 w-48 rounded bg-muted" />
|
|
34
|
-
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
35
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
36
|
-
{Array.from({ length: 4 }, (_, index) => <div className="h-32 rounded-xl border bg-card p-4" key={index}>
|
|
37
|
-
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
38
|
-
</div>)}
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
)) : (
|
|
31
|
+
{/* The loading state is the feed's own skeleton, so what loads replaces it in place. */}
|
|
32
|
+
{pending && referencesPage ? <ReferencesPageLoading /> : pending ? <NotificationSkeleton bare={!showSectionTabs} count={4} /> : (
|
|
42
33
|
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
43
|
-
<div className="space-y-3 rounded-xl
|
|
34
|
+
<div className="space-y-3 rounded-xl bg-card p-6">
|
|
44
35
|
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
45
36
|
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
|
|
46
37
|
<p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
|
|
@@ -50,7 +50,7 @@ import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capt
|
|
|
50
50
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
51
51
|
import { captureStatusesByLineage } from "../lib/reference-capture";
|
|
52
52
|
import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
|
|
53
|
-
import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
53
|
+
import { referenceDek, referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
54
54
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
55
55
|
import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
|
|
56
56
|
import { cn } from "../lib/utils";
|
|
@@ -262,28 +262,31 @@ function ReferenceDetailPanel({
|
|
|
262
262
|
data-news-desk-reference-detail={lineageId}
|
|
263
263
|
>
|
|
264
264
|
<div className="space-y-4 p-4" data-reference-curation-status={status}>
|
|
265
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
266
|
-
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
267
|
-
{reference.corpusId ? <Badge variant="outline">{reference.corpusId}</Badge> : null}
|
|
268
|
-
</div>
|
|
269
|
-
|
|
270
265
|
<ReviewCard
|
|
271
266
|
decided={status === "accepted" ? "accept" : status === "rejected" ? "reject" : null}
|
|
267
|
+
dek={referenceDek(reference, referencePreviewsByLineage(previewAttachments).get(lineageId))}
|
|
272
268
|
disabledReason={disabled ? "Editor access is required to change this reference." : null}
|
|
273
269
|
initialNote={sourceNote}
|
|
274
270
|
initialReason={sourceReason}
|
|
275
271
|
itemId={lineageId}
|
|
272
|
+
meta={(
|
|
273
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
274
|
+
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
275
|
+
{reference.corpusId ? <Badge variant="secondary">{reference.corpusId}</Badge> : null}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
276
278
|
ratings={ratings}
|
|
277
279
|
onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
|
|
278
280
|
onDecide={decide}
|
|
279
281
|
prediction={cardPrediction(decision, predictionState)}
|
|
280
|
-
|
|
282
|
+
sourcePreview={<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />}
|
|
283
|
+
summary={capture.summary ?? summaryOf(reference)}
|
|
281
284
|
title={referenceDisplayTitle(reference)}
|
|
282
285
|
titleHref={url}
|
|
283
286
|
>
|
|
284
287
|
{decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={decision} /></div> : null}
|
|
285
288
|
{sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
|
|
286
|
-
<details className="rounded-
|
|
289
|
+
<details className="rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-sm">
|
|
287
290
|
<summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
|
|
288
291
|
<div className="mt-3 space-y-3">
|
|
289
292
|
{sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
|
|
@@ -304,12 +307,7 @@ function ReferenceDetailPanel({
|
|
|
304
307
|
|
|
305
308
|
<Separator />
|
|
306
309
|
|
|
307
|
-
<ReferenceCapturePanel capture={capture} />
|
|
308
|
-
|
|
309
|
-
<section aria-label="Source preview" className="space-y-2">
|
|
310
|
-
<h3 className="text-sm font-medium text-foreground">Source preview</h3>
|
|
311
|
-
<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
|
|
312
|
-
</section>
|
|
310
|
+
<ReferenceCapturePanel capture={capture} showSummary={false} />
|
|
313
311
|
|
|
314
312
|
<dl className="grid gap-3 text-sm">
|
|
315
313
|
<div>
|
|
@@ -4,6 +4,7 @@ import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon }
|
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
5
|
import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
7
8
|
import type { CaptureView } from "./reference-capture-client";
|
|
8
9
|
|
|
9
10
|
const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
|
|
@@ -66,7 +67,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
66
67
|
<div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-correction>
|
|
67
68
|
<label className="block text-sm font-medium">
|
|
68
69
|
The text
|
|
69
|
-
<
|
|
70
|
+
<AutoGrowTextarea
|
|
70
71
|
aria-label="Captured text"
|
|
71
72
|
className="mt-1 block min-h-40 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal"
|
|
72
73
|
data-capture-correction-text
|
|
@@ -92,7 +93,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
92
93
|
</div>
|
|
93
94
|
<label className="block text-sm font-medium">
|
|
94
95
|
Note (optional)
|
|
95
|
-
<
|
|
96
|
+
<AutoGrowTextarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
|
|
96
97
|
</label>
|
|
97
98
|
{reason ? <p className="text-xs text-muted-foreground" data-capture-correction-reason>{reason}</p> : null}
|
|
98
99
|
{phase === "error" ? <p className="text-xs text-destructive" role="alert">{message}</p> : null}
|
|
@@ -113,7 +114,12 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
/** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
|
|
116
|
-
export function ReferenceCapturePanel({ capture, startEditing = false }: {
|
|
117
|
+
export function ReferenceCapturePanel({ capture, showSummary = true, startEditing = false }: {
|
|
118
|
+
capture: CaptureView;
|
|
119
|
+
/** False where the summary is already shown above the rating, with the title. */
|
|
120
|
+
showSummary?: boolean;
|
|
121
|
+
startEditing?: boolean;
|
|
122
|
+
}) {
|
|
117
123
|
const [expanded, setExpanded] = useState(false);
|
|
118
124
|
const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
|
|
119
125
|
const [savedNote, setSavedNote] = useState(false);
|
|
@@ -137,9 +143,11 @@ export function ReferenceCapturePanel({ capture, startEditing = false }: { captu
|
|
|
137
143
|
</div>
|
|
138
144
|
{capture.loading ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Reading the stored text…</p> : null}
|
|
139
145
|
{capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
|
|
140
|
-
{text
|
|
146
|
+
{/* While the editor is open it holds the text, so the read-only copy steps aside: the text is never shown twice. */}
|
|
147
|
+
{text && !editing ? (
|
|
141
148
|
<div className="space-y-2">
|
|
142
|
-
|
|
149
|
+
{/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
|
|
150
|
+
<div className="whitespace-pre-wrap rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
|
|
143
151
|
{expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`}
|
|
144
152
|
</div>
|
|
145
153
|
{long ? (
|
|
@@ -149,7 +157,7 @@ export function ReferenceCapturePanel({ capture, startEditing = false }: { captu
|
|
|
149
157
|
) : null}
|
|
150
158
|
</div>
|
|
151
159
|
) : null}
|
|
152
|
-
{capture.summary ? (
|
|
160
|
+
{showSummary && capture.summary ? (
|
|
153
161
|
<div className="space-y-1" data-capture-summary>
|
|
154
162
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary{capture.summaryStale ? " (out of date: the text was replaced)" : ""}</h4>
|
|
155
163
|
<p className="text-sm">{capture.summary}</p>
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "lucide-react";
|
|
12
12
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
13
|
import { Button } from "./ui/button";
|
|
14
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
14
15
|
import { cn } from "../lib/utils";
|
|
15
16
|
import { RatingGrid } from "./review-rating-grid";
|
|
16
17
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
@@ -132,11 +133,14 @@ export function predictionFromDecision(decision: { label: string; positiveLabel:
|
|
|
132
133
|
export function ReviewCard({
|
|
133
134
|
children,
|
|
134
135
|
decided,
|
|
136
|
+
dek,
|
|
135
137
|
disabledReason,
|
|
136
138
|
initialNote = "",
|
|
137
139
|
initialReason = null,
|
|
138
140
|
itemId,
|
|
141
|
+
meta,
|
|
139
142
|
onArchive,
|
|
143
|
+
sourcePreview,
|
|
140
144
|
onDecide,
|
|
141
145
|
prediction,
|
|
142
146
|
summary,
|
|
@@ -149,14 +153,20 @@ export function ReviewCard({
|
|
|
149
153
|
ratings?: RatingController;
|
|
150
154
|
/** The saved human answer, or null while the item is undecided. */
|
|
151
155
|
decided: ReviewChoice | null;
|
|
156
|
+
/** The source's own subtitle or blurb, shown under the title. */
|
|
157
|
+
dek?: string | null;
|
|
152
158
|
/** When set, the controls explain in words why they cannot change anything. */
|
|
153
159
|
disabledReason?: string | null;
|
|
154
160
|
initialNote?: string;
|
|
155
161
|
initialReason?: ReferenceRejectionReasonCode | null;
|
|
156
162
|
itemId: string;
|
|
163
|
+
/** Badges (type, status) shown under the title and link. */
|
|
164
|
+
meta?: ReactNode;
|
|
157
165
|
onArchive?: () => Promise<void> | void;
|
|
158
166
|
onDecide: (choice: ReviewChoice, decision: ReviewCardDecision) => Promise<void> | void;
|
|
159
167
|
prediction: CardPrediction;
|
|
168
|
+
/** The live preview of the source, shown under its link. It renders nothing when there is none. */
|
|
169
|
+
sourcePreview?: ReactNode;
|
|
160
170
|
summary?: string | null;
|
|
161
171
|
title: string;
|
|
162
172
|
titleHref?: string | null;
|
|
@@ -192,15 +202,24 @@ export function ReviewCard({
|
|
|
192
202
|
|
|
193
203
|
return (
|
|
194
204
|
<section aria-label="Review" className="space-y-4" data-review-card={itemId}>
|
|
195
|
-
|
|
205
|
+
{/* Title, blurb, link, badges, summary: what the item is, read top to bottom before the rating. */}
|
|
206
|
+
<header className="space-y-2">
|
|
196
207
|
<h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
|
|
208
|
+
{dek && dek.trim() !== title.trim() ? <p className="text-base text-muted-foreground" data-review-dek>{dek}</p> : null}
|
|
197
209
|
{titleHref ? (
|
|
198
210
|
<a className="inline-flex max-w-full items-center gap-1 truncate text-sm text-primary underline-offset-4 hover:underline" href={titleHref} rel="noreferrer" target="_blank">
|
|
199
211
|
<span className="truncate">{titleHref}</span>
|
|
200
212
|
<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
201
213
|
</a>
|
|
202
214
|
) : null}
|
|
203
|
-
{
|
|
215
|
+
{sourcePreview}
|
|
216
|
+
{meta}
|
|
217
|
+
{summary ? (
|
|
218
|
+
<div className="space-y-1 pt-1" data-review-summary>
|
|
219
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary</h3>
|
|
220
|
+
<p className="text-sm leading-relaxed text-foreground">{summary}</p>
|
|
221
|
+
</div>
|
|
222
|
+
) : null}
|
|
204
223
|
</header>
|
|
205
224
|
|
|
206
225
|
<RatingGrid
|
|
@@ -217,9 +236,9 @@ export function ReviewCard({
|
|
|
217
236
|
|
|
218
237
|
<label className="block text-sm font-medium">
|
|
219
238
|
Why? (optional)
|
|
220
|
-
<
|
|
239
|
+
<AutoGrowTextarea
|
|
221
240
|
aria-label="Why? (optional)"
|
|
222
|
-
className="mt-1 block min-h-
|
|
241
|
+
className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
223
242
|
onBlur={commitNote}
|
|
224
243
|
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
225
244
|
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
@@ -232,22 +251,21 @@ export function ReviewCard({
|
|
|
232
251
|
<p className="text-sm font-medium">Reason for rejecting</p>
|
|
233
252
|
<div className="flex flex-wrap gap-2">
|
|
234
253
|
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
235
|
-
<
|
|
254
|
+
<button
|
|
236
255
|
aria-disabled={disabledReason ? true : undefined}
|
|
237
256
|
aria-pressed={reason === code}
|
|
238
|
-
className="min-h-10"
|
|
257
|
+
className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
|
|
239
258
|
data-review-reason={code}
|
|
240
259
|
key={code}
|
|
241
260
|
onClick={() => chooseReason(code)}
|
|
242
261
|
type="button"
|
|
243
|
-
variant={reason === code ? "default" : "outline"}
|
|
244
262
|
>
|
|
245
263
|
{reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
246
264
|
{REASON_LABELS[code]}
|
|
247
|
-
</
|
|
265
|
+
</button>
|
|
248
266
|
))}
|
|
249
267
|
</div>
|
|
250
|
-
{!reason ? <p className="text-sm text-muted-foreground">Choose a reason to save your Reject.</p> : null}
|
|
268
|
+
{!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
|
|
251
269
|
</div>
|
|
252
270
|
) : null}
|
|
253
271
|
|
|
@@ -87,6 +87,31 @@ function PdfSourcePreview({ preview }: { preview: Extract<ReferenceSourcePreview
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Session memory of the server's answers, so reopening an item does not ask again. */
|
|
91
|
+
const frameableAnswers = new Map<string, boolean>();
|
|
92
|
+
|
|
93
|
+
/** Whether the page allows being shown in a frame: null while asking. A refused frame would be an empty box, so it is not shown. */
|
|
94
|
+
function useFrameable(href: string | null): boolean | null {
|
|
95
|
+
const [answer, setAnswer] = useState<boolean | null>(() => (href ? frameableAnswers.get(href) ?? null : null));
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!href) return undefined;
|
|
98
|
+
const known = frameableAnswers.get(href);
|
|
99
|
+
if (known !== undefined) { setAnswer(known); return undefined; }
|
|
100
|
+
setAnswer(null);
|
|
101
|
+
let active = true;
|
|
102
|
+
fetch(`/api/newsroom/source-frameable?url=${encodeURIComponent(href)}`)
|
|
103
|
+
.then((response) => (response.ok ? response.json() : { frameable: false }))
|
|
104
|
+
.then((body: { frameable?: unknown }) => { frameableAnswers.set(href, body.frameable === true); if (active) setAnswer(body.frameable === true); })
|
|
105
|
+
.catch(() => { if (active) setAnswer(false); });
|
|
106
|
+
return () => { active = false; };
|
|
107
|
+
}, [href]);
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The live preview of the source, or nothing at all: a YouTube video or a PDF always has one; a web page only when the page allows
|
|
113
|
+
* being framed (asked of the server first). It sits under the link to the original, with no heading of its own.
|
|
114
|
+
*/
|
|
90
115
|
export function ReferenceSourcePreview({
|
|
91
116
|
attachments = [],
|
|
92
117
|
sourceUri,
|
|
@@ -95,6 +120,7 @@ export function ReferenceSourcePreview({
|
|
|
95
120
|
sourceUri?: string | null;
|
|
96
121
|
}) {
|
|
97
122
|
const preview = resolveReferenceSourcePreview(sourceUri, attachments);
|
|
123
|
+
const frameable = useFrameable(preview?.kind === "html" ? preview.href : null);
|
|
98
124
|
const pendingPdfPreview = !preview && attachments.some(
|
|
99
125
|
(attachment) => isPdfAttachment(attachment) && Boolean(attachment.storagePath),
|
|
100
126
|
);
|
|
@@ -139,6 +165,7 @@ export function ReferenceSourcePreview({
|
|
|
139
165
|
return <PdfSourcePreview preview={preview} />;
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
if (frameable !== true) return null;
|
|
142
169
|
return (
|
|
143
170
|
<div
|
|
144
171
|
className="news-desk-reference-source-preview news-desk-reference-source-preview--html"
|
|
@@ -153,11 +180,6 @@ export function ReferenceSourcePreview({
|
|
|
153
180
|
title={preview.label}
|
|
154
181
|
/>
|
|
155
182
|
</div>
|
|
156
|
-
<p className="news-desk-reference-source-preview__actions">
|
|
157
|
-
<a href={preview.href} rel="noopener noreferrer" target="_blank">
|
|
158
|
-
{preview.label}
|
|
159
|
-
</a>
|
|
160
|
-
</p>
|
|
161
183
|
</div>
|
|
162
184
|
);
|
|
163
185
|
}
|
|
@@ -13,6 +13,8 @@ import type { CardPrediction, ReviewChoice } from "./reference-review-card";
|
|
|
13
13
|
* Feedback is never colour alone: before you answer the predicted cell is neutral-accent with a check or cross; after, it turns
|
|
14
14
|
* green with a CircleCheck (Match) or red with a CircleX (Miss), and the confidence bar tints the same way. Your chosen button is
|
|
15
15
|
* filled. Flat tones and rounded shapes only, no borders.
|
|
16
|
+
* The grid sizes itself to its own width (a container query), not the window's: on a narrow card the label column shrinks to its
|
|
17
|
+
* word, the padding tightens, and the confidence wraps under "Accept" instead of spilling out of its cell.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
export type RatingFeedback = { phase: "idle" | "saving" | "saved" | "error"; message?: string };
|
|
@@ -81,9 +83,8 @@ export function RatingGrid({
|
|
|
81
83
|
return (
|
|
82
84
|
<div
|
|
83
85
|
className={cn(
|
|
84
|
-
"grid gap-x-2 gap-y-2",
|
|
85
|
-
showPrediction ? "grid-cols-[
|
|
86
|
-
showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
|
|
86
|
+
"@container/rating grid gap-x-2 gap-y-2",
|
|
87
|
+
showPrediction ? "grid-cols-[auto_minmax(0,1fr)_minmax(0,1fr)] @max-[17rem]/rating:gap-x-1.5" : "grid-cols-2",
|
|
87
88
|
)}
|
|
88
89
|
data-rating-grid
|
|
89
90
|
>
|
|
@@ -95,7 +96,7 @@ export function RatingGrid({
|
|
|
95
96
|
return (
|
|
96
97
|
<span
|
|
97
98
|
className={cn(
|
|
98
|
-
"flex flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center",
|
|
99
|
+
"flex min-w-0 flex-col items-center justify-center rounded-[16px] px-2 py-1.5 text-center @max-[17rem]/rating:px-1",
|
|
99
100
|
cell,
|
|
100
101
|
!predicted && "bg-[var(--feed-control)]/45 text-muted-foreground",
|
|
101
102
|
predicted && !verdict && "bg-[var(--feed-control)] font-semibold text-[var(--primary)]",
|
|
@@ -106,12 +107,21 @@ export function RatingGrid({
|
|
|
106
107
|
data-verdict={predicted && verdict ? verdict : undefined}
|
|
107
108
|
key={choice}
|
|
108
109
|
>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
{/* Wraps rather than overflows: when the cell is narrow, the confidence moves onto its own line. */}
|
|
111
|
+
<span className="flex max-w-full flex-wrap items-center justify-center gap-x-1.5 leading-tight">
|
|
112
|
+
<span className="inline-flex items-center gap-1">
|
|
113
|
+
{Icon ? <Icon aria-hidden="true" className="size-4 shrink-0" /> : null}
|
|
114
|
+
{WORD[choice]}
|
|
115
|
+
</span>
|
|
116
|
+
{/* The confidence and the verdict share one line, so a vote never makes the cell (or the card) taller. */}
|
|
117
|
+
{predicted && (confidenceText || verdict) ? (
|
|
118
|
+
<small className="inline-flex items-center gap-1 whitespace-nowrap text-xs">
|
|
119
|
+
{confidenceText ? <span className="font-normal opacity-80" data-cyclotron-confidence>{confidenceText}</span> : null}
|
|
120
|
+
{confidenceText && verdict ? <span aria-hidden="true" className="opacity-60">·</span> : null}
|
|
121
|
+
{verdict ? <span className="font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</span> : null}
|
|
122
|
+
</small>
|
|
123
|
+
) : null}
|
|
113
124
|
</span>
|
|
114
|
-
{predicted && verdict ? <small className="text-xs font-medium" data-verdict-word>{verdict === "match" ? "Match" : "Miss"}</small> : null}
|
|
115
125
|
{predicted && verdict ? <span className="sr-only">{verdict === "match" ? ", matches your answer" : ", differs from your answer"}</span> : null}
|
|
116
126
|
</span>
|
|
117
127
|
);
|
|
@@ -151,7 +161,7 @@ export function RatingGrid({
|
|
|
151
161
|
aria-disabled={disabled ? true : undefined}
|
|
152
162
|
aria-pressed={on}
|
|
153
163
|
className={cn(
|
|
154
|
-
"inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
164
|
+
"inline-flex min-w-0 items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring @max-[17rem]/rating:gap-1 @max-[17rem]/rating:px-1",
|
|
155
165
|
cell,
|
|
156
166
|
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
157
167
|
disabled && !on && "opacity-60",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef, type TextareaHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A textarea as tall as its text. It never scrolls inside itself, so on a phone a swipe over it scrolls the page instead of
|
|
7
|
+
* getting caught in the box. (CSS `field-sizing: content` does the same where supported; the height is set here for the rest.)
|
|
8
|
+
*/
|
|
9
|
+
export function AutoGrowTextarea({ className, style, value, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
10
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
11
|
+
useLayoutEffect(() => {
|
|
12
|
+
const element = ref.current;
|
|
13
|
+
if (!element) return;
|
|
14
|
+
element.style.height = "auto";
|
|
15
|
+
element.style.height = `${element.scrollHeight}px`;
|
|
16
|
+
}, [value]);
|
|
17
|
+
return <textarea {...props} className={className} ref={ref} rows={props.rows ?? 2} style={{ ...style, overflow: "hidden", resize: "none" }} value={value} />;
|
|
18
|
+
}
|
|
@@ -110,7 +110,9 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
110
110
|
|
|
111
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
112
112
|
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
113
|
-
const { note, reasonCode } = values(id);
|
|
113
|
+
const { note, reasonCode: chosenCode } = values(id);
|
|
114
|
+
// A typed reason is a reason: a Reject with words but no reason button is filed under "other" rather than left waiting.
|
|
115
|
+
const reasonCode = chosenCode ?? (choice === "reject" && note.trim() ? "other" : null);
|
|
114
116
|
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
115
117
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
116
118
|
inFlight.current.add(id);
|
|
@@ -120,16 +122,19 @@ export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNot
|
|
|
120
122
|
try {
|
|
121
123
|
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
122
124
|
if (latest.current.sticky?.(id)) decidedHere.current.add(id);
|
|
123
|
-
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
125
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept. While another
|
|
126
|
+
// save is due (changed mid-save), the store keeps everything: the record may not show this save yet, and the next one sends it.
|
|
124
127
|
const now = itemsRef.current[id] ?? EMPTY;
|
|
125
|
-
|
|
128
|
+
const follow = !dirty.current.has(id);
|
|
129
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(follow && now.note === started.note ? { note: undefined } : {}), ...(follow && now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
126
130
|
} catch (failure) {
|
|
127
131
|
// The typed reason stays in the store; only the vote is undone.
|
|
128
132
|
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
129
133
|
} finally {
|
|
130
134
|
inFlight.current.delete(id);
|
|
131
135
|
}
|
|
132
|
-
|
|
136
|
+
// Changed while saving (a reason chip tapped mid-save): save again. The record may not show the vote yet, so fall back to it.
|
|
137
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id) ?? choice; if (again) void commit(id, again); }
|
|
133
138
|
}, [patch, saveNoteOnly]);
|
|
134
139
|
|
|
135
140
|
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a source page lets another site show it in a frame. Most news sites refuse (X-Frame-Options or a CSP frame-ancestors
|
|
6
|
+
* rule), and a refused frame is just an empty box, which the browser cannot detect from outside. So the newsroom asks here,
|
|
7
|
+
* on the server, before it shows a web page's live preview.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** True when the response headers allow framing by any site. A rule naming specific origins is treated as a refusal. */
|
|
11
|
+
export function frameableFromHeaders(headers: Headers): boolean {
|
|
12
|
+
const xfo = headers.get("x-frame-options")?.trim().toLowerCase();
|
|
13
|
+
if (xfo && (xfo.includes("deny") || xfo.includes("sameorigin") || xfo.startsWith("allow-from"))) return false;
|
|
14
|
+
const csp = headers.get("content-security-policy");
|
|
15
|
+
if (csp) {
|
|
16
|
+
for (const directive of csp.split(";")) {
|
|
17
|
+
const [name, ...sources] = directive.trim().split(/\s+/);
|
|
18
|
+
if (name?.toLowerCase() !== "frame-ancestors") continue;
|
|
19
|
+
if (!sources.some((source) => source === "*" || source === "https:" || source === "http:")) return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Loopback, private, link-local and other non-public addresses, which the server must never be made to fetch. */
|
|
26
|
+
export function isPrivateAddress(address: string): boolean {
|
|
27
|
+
const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
28
|
+
if (isIP(v4) === 4) {
|
|
29
|
+
const [a, b] = v4.split(".").map(Number);
|
|
30
|
+
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
|
|
31
|
+
|| (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
32
|
+
}
|
|
33
|
+
const v6 = address.toLowerCase();
|
|
34
|
+
return v6 === "::" || v6 === "::1" || v6.startsWith("fc") || v6.startsWith("fd") || v6.startsWith("fe80");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A plain public http(s) address on the default port whose host resolves only to public addresses, else null. */
|
|
38
|
+
export async function publicHttpUrl(raw: string): Promise<URL | null> {
|
|
39
|
+
let url: URL;
|
|
40
|
+
try { url = new URL(raw); } catch { return null; }
|
|
41
|
+
if ((url.protocol !== "https:" && url.protocol !== "http:") || url.port || url.username || url.password) return null;
|
|
42
|
+
try {
|
|
43
|
+
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
|
|
44
|
+
return addresses.length && addresses.every(({ address }) => !isPrivateAddress(address)) ? url : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Asks the page itself. Anything that fails (bad address, error status, timeout, too many redirects) counts as not frameable. */
|
|
51
|
+
export async function checkSourceFrameable(raw: string, { timeoutMs = 5000, maxRedirects = 4 } = {}): Promise<boolean> {
|
|
52
|
+
let next = await publicHttpUrl(raw);
|
|
53
|
+
for (let hop = 0; next && hop <= maxRedirects; hop += 1) {
|
|
54
|
+
let response: Response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(next, { method: "GET", redirect: "manual", signal: AbortSignal.timeout(timeoutMs), headers: { accept: "text/html" } });
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
void response.body?.cancel();
|
|
61
|
+
const location = response.headers.get("location");
|
|
62
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
63
|
+
next = await publicHttpUrl(new URL(location, next).toString());
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return response.ok && frameableFromHeaders(response.headers);
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -114,7 +114,6 @@
|
|
|
114
114
|
"./components/newsroom-mobile-navigation": "./components/newsroom-mobile-navigation.tsx",
|
|
115
115
|
"./components/newsroom-ops-overview": "./components/newsroom-ops-overview.tsx",
|
|
116
116
|
"./components/newsroom-ops-shell": "./components/newsroom-ops-shell.tsx",
|
|
117
|
-
"./components/newsroom-overview-skeleton": "./components/newsroom-overview-skeleton.tsx",
|
|
118
117
|
"./components/newsroom-references-view": "./components/newsroom-references-view.tsx",
|
|
119
118
|
"./components/newsroom-theme-fonts/broadsheet-green": "./components/newsroom-theme-fonts/broadsheet-green.tsx",
|
|
120
119
|
"./components/newsroom-theme-fonts/newspaper-gothic": "./components/newsroom-theme-fonts/newspaper-gothic.tsx",
|
|
@@ -164,6 +163,7 @@
|
|
|
164
163
|
"./components/story-reporting-trail-client": "./components/story-reporting-trail-client.ts",
|
|
165
164
|
"./components/story-reporting-trail": "./components/story-reporting-trail.tsx",
|
|
166
165
|
"./components/topic-steering-workspace": "./components/topic-steering-workspace.tsx",
|
|
166
|
+
"./components/ui/auto-grow-textarea": "./components/ui/auto-grow-textarea.tsx",
|
|
167
167
|
"./components/ui/badge": "./components/ui/badge.tsx",
|
|
168
168
|
"./components/ui/button-group": "./components/ui/button-group.tsx",
|
|
169
169
|
"./components/ui/button": "./components/ui/button.tsx",
|
|
@@ -368,6 +368,7 @@
|
|
|
368
368
|
"./lib/site-renderer": "./lib/site-renderer.ts",
|
|
369
369
|
"./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
|
|
370
370
|
"./lib/site-stack": "./lib/site-stack.ts",
|
|
371
|
+
"./lib/source-frameable": "./lib/source-frameable.ts",
|
|
371
372
|
"./lib/staff-groups": "./lib/staff-groups.ts",
|
|
372
373
|
"./lib/staging-gate": "./lib/staging-gate.ts",
|
|
373
374
|
"./lib/staging-gated-path": "./lib/staging-gated-path.ts",
|
|
@@ -419,6 +420,7 @@
|
|
|
419
420
|
"./app/api/newsroom/curation/context/route": "./app/api/newsroom/curation/context/route.ts",
|
|
420
421
|
"./app/api/newsroom/curation/priming/route": "./app/api/newsroom/curation/priming/route.ts",
|
|
421
422
|
"./app/api/newsroom/curation/publication/route": "./app/api/newsroom/curation/publication/route.ts",
|
|
423
|
+
"./app/api/newsroom/source-frameable/route": "./app/api/newsroom/source-frameable/route.ts",
|
|
422
424
|
"./app/api/reader/comment/delete/route": "./app/api/reader/comment/delete/route.ts",
|
|
423
425
|
"./app/api/reader/comment/edit/route": "./app/api/reader/comment/edit/route.ts",
|
|
424
426
|
"./app/api/reader/comment/route": "./app/api/reader/comment/route.ts",
|
|
@@ -495,7 +497,7 @@
|
|
|
495
497
|
"optional": true
|
|
496
498
|
}
|
|
497
499
|
},
|
|
498
|
-
"version": "5.
|
|
500
|
+
"version": "5.50.0",
|
|
499
501
|
"dependencies": {
|
|
500
502
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
501
503
|
"@aws-crypto/sha256-js": "5.2.0",
|
package/routes.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "5.
|
|
2
|
+
"version": "5.50.0",
|
|
3
3
|
"routes": [
|
|
4
4
|
{
|
|
5
5
|
"file": "app/%5F_preview/[[...path]]/route.ts",
|
|
@@ -285,6 +285,17 @@
|
|
|
285
285
|
"runtime": "\"nodejs\""
|
|
286
286
|
}
|
|
287
287
|
},
|
|
288
|
+
{
|
|
289
|
+
"file": "app/api/newsroom/source-frameable/route.ts",
|
|
290
|
+
"hasDefault": false,
|
|
291
|
+
"exports": [
|
|
292
|
+
"GET"
|
|
293
|
+
],
|
|
294
|
+
"config": {
|
|
295
|
+
"dynamic": "\"force-dynamic\"",
|
|
296
|
+
"runtime": "\"nodejs\""
|
|
297
|
+
}
|
|
298
|
+
},
|
|
288
299
|
{
|
|
289
300
|
"file": "app/api/reader/comment/delete/route.ts",
|
|
290
301
|
"hasDefault": false,
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
|
|
5
|
-
/** Placeholder geometry for the overview while private records or access are unresolved. */
|
|
6
|
-
export function NewsroomOverviewSkeleton() {
|
|
7
|
-
return (
|
|
8
|
-
<div aria-label="Loading newsroom overview" className="space-y-8 font-sans" data-newsroom-overview-skeleton role="status">
|
|
9
|
-
<section className="newsroom-overview-feed-frame space-y-4 overflow-hidden rounded-xl border bg-card p-4" aria-label="Notification Center">
|
|
10
|
-
<div className="h-6 w-48 rounded bg-muted" />
|
|
11
|
-
<div className="h-4 w-3/4 max-w-xl rounded bg-muted" />
|
|
12
|
-
<div className="h-4 w-2/3 max-w-lg rounded bg-muted" />
|
|
13
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
14
|
-
{Array.from({ length: 4 }, (_, index) => <div className="h-28 rounded-xl border bg-card p-4" key={index}>
|
|
15
|
-
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
16
|
-
</div>)}
|
|
17
|
-
</div>
|
|
18
|
-
</section>
|
|
19
|
-
<section aria-label="Needs attention" className="space-y-3">
|
|
20
|
-
<div className="h-4 w-32 rounded bg-muted" />
|
|
21
|
-
<div className="h-16 rounded-xl border bg-card p-4"><div className="h-4 w-1/2 rounded bg-muted" /><div className="mt-2 h-3 w-1/3 rounded bg-muted" /></div>
|
|
22
|
-
</section>
|
|
23
|
-
</div>
|
|
24
|
-
);
|
|
25
|
-
}
|