@anthusai/papyrus 5.48.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 +36 -25
- package/components/needs-me-detail.tsx +2 -1
- package/components/needs-me-feed.tsx +30 -13
- package/components/needs-me-reference-detail.tsx +13 -15
- package/components/needs-me-shared.tsx +9 -0
- package/components/newsroom-access-gate.tsx +4 -13
- package/components/newsroom-ops-overview.tsx +36 -30
- package/components/newsroom-ops-shell.tsx +4 -4
- package/components/newsroom-organizer.css +23 -11
- package/components/newsroom-references-view.tsx +15 -17
- package/components/notification-center.tsx +63 -11
- package/components/reference-capture-panel.tsx +14 -6
- package/components/reference-review-card.tsx +32 -10
- 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 +63 -9
- package/lib/review-client.ts +16 -0
- package/lib/review-drafts.ts +33 -8
- package/lib/review-requests.ts +25 -0
- 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
|
@@ -41,20 +41,29 @@ export type RatingController = {
|
|
|
41
41
|
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
42
|
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
43
|
*/
|
|
44
|
-
export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
44
|
+
export function useRatingStore({ save, saved, seed, needsReason, sticky, saveNote }: {
|
|
45
45
|
save: RatingSave;
|
|
46
46
|
saved: (id: string) => ReviewChoice | null;
|
|
47
47
|
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
48
|
/** Whether a Reject on this item must wait for a reason code. Default yes (a Reference); an AI draft or an email asks nothing. */
|
|
49
49
|
needsReason?: (id: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* True for an item whose decision, once made, cannot be sent again (an AI draft moves its Proposal, a review request closes, a held email is
|
|
52
|
+
* queued). A reason typed after such a decision goes to `saveNote` instead, onto the decision that exists.
|
|
53
|
+
*/
|
|
54
|
+
sticky?: (id: string) => boolean;
|
|
55
|
+
/** Saves the reason of an item that is already decided and sticky. Throws a sentence when it cannot. */
|
|
56
|
+
saveNote?: (id: string, note: string) => Promise<void>;
|
|
50
57
|
}): RatingController {
|
|
51
58
|
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
52
59
|
const itemsRef = useRef(items);
|
|
53
|
-
const latest = useRef({ save, saved, seed, needsReason });
|
|
54
|
-
latest.current = { save, saved, seed, needsReason };
|
|
60
|
+
const latest = useRef({ save, saved, seed, needsReason, sticky, saveNote });
|
|
61
|
+
latest.current = { save, saved, seed, needsReason, sticky, saveNote };
|
|
55
62
|
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
56
63
|
const inFlight = useRef<Set<string>>(new Set());
|
|
57
64
|
const dirty = useRef<Set<string>>(new Set());
|
|
65
|
+
const decidedHere = useRef<Set<string>>(new Set());
|
|
66
|
+
const noteInFlight = useRef<Set<string>>(new Set());
|
|
58
67
|
|
|
59
68
|
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
60
69
|
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
@@ -75,8 +84,35 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
75
84
|
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
76
85
|
};
|
|
77
86
|
|
|
87
|
+
/** True once a sticky item has its decision: from then on only its reason can change. */
|
|
88
|
+
const finalDecision = (id: string) => Boolean(latest.current.sticky?.(id) && latest.current.saveNote && (decidedHere.current.has(id) || latest.current.saved(id)));
|
|
89
|
+
|
|
90
|
+
/** A reason typed after the decision: saved onto it. The text stays in the store until it is saved, and a failed save never drops it. */
|
|
91
|
+
const saveNoteOnly = useCallback(async (id: string): Promise<void> => {
|
|
92
|
+
if (noteInFlight.current.has(id)) { dirty.current.add(`note:${id}`); return; }
|
|
93
|
+
noteInFlight.current.add(id);
|
|
94
|
+
dirty.current.delete(`note:${id}`);
|
|
95
|
+
const started = itemsRef.current[id]?.note;
|
|
96
|
+
const { note } = values(id);
|
|
97
|
+
patch(id, { feedback: { phase: "saving" } });
|
|
98
|
+
try {
|
|
99
|
+
await latest.current.saveNote!(id, note.trim());
|
|
100
|
+
const now = itemsRef.current[id];
|
|
101
|
+
patch(id, { feedback: { phase: "saved" }, ...(now?.note === started ? { note: undefined } : {}) });
|
|
102
|
+
} catch (failure) {
|
|
103
|
+
dirty.current.add(`note:${id}`); // still unsaved: tapping away, or the next pause, tries again
|
|
104
|
+
patch(id, { feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your note could not be saved. It is kept here; keep typing or tap away to try again." } });
|
|
105
|
+
} finally {
|
|
106
|
+
noteInFlight.current.delete(id);
|
|
107
|
+
}
|
|
108
|
+
if (itemsRef.current[id]?.feedback.phase !== "error" && dirty.current.delete(`note:${id}`)) void saveNoteOnly(id);
|
|
109
|
+
}, [patch]);
|
|
110
|
+
|
|
78
111
|
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
79
|
-
|
|
112
|
+
if (finalDecision(id)) { void saveNoteOnly(id); return; }
|
|
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);
|
|
80
116
|
if (choice === "reject" && !reasonCode && (latest.current.needsReason?.(id) ?? true)) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
81
117
|
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
82
118
|
inFlight.current.add(id);
|
|
@@ -85,21 +121,26 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
85
121
|
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
86
122
|
try {
|
|
87
123
|
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
88
|
-
|
|
124
|
+
if (latest.current.sticky?.(id)) decidedHere.current.add(id);
|
|
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.
|
|
89
127
|
const now = itemsRef.current[id] ?? EMPTY;
|
|
90
|
-
|
|
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 } : {}) });
|
|
91
130
|
} catch (failure) {
|
|
92
131
|
// The typed reason stays in the store; only the vote is undone.
|
|
93
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." } });
|
|
94
133
|
} finally {
|
|
95
134
|
inFlight.current.delete(id);
|
|
96
135
|
}
|
|
97
|
-
|
|
98
|
-
|
|
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); }
|
|
138
|
+
}, [patch, saveNoteOnly]);
|
|
99
139
|
|
|
100
140
|
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
101
141
|
|
|
102
142
|
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
143
|
+
if (finalDecision(id)) return; // a decision that cannot be sent again is only ever given a reason, never a second vote
|
|
103
144
|
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
104
145
|
void commit(id, choice);
|
|
105
146
|
}, [commit]);
|
|
@@ -128,7 +169,20 @@ export function useRatingStore({ save, saved, seed, needsReason }: {
|
|
|
128
169
|
|
|
129
170
|
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
130
171
|
|
|
131
|
-
|
|
172
|
+
// Leaving the page (or closing the view) saves what was typed and not yet saved, rather than dropping it.
|
|
173
|
+
const leaving = useRef<() => void>(() => undefined);
|
|
174
|
+
leaving.current = () => {
|
|
175
|
+
for (const key of [...dirty.current]) {
|
|
176
|
+
if (!key.startsWith("note:")) continue;
|
|
177
|
+
const id = key.slice(5);
|
|
178
|
+
const choice = effective(id);
|
|
179
|
+
if (choice && dirty.current.delete(key)) void commit(id, choice);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
useEffect(() => () => {
|
|
183
|
+
for (const timer of Object.values(timers.current)) clearTimeout(timer);
|
|
184
|
+
leaving.current();
|
|
185
|
+
}, []);
|
|
132
186
|
|
|
133
187
|
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
134
188
|
}
|
package/lib/review-client.ts
CHANGED
|
@@ -149,6 +149,22 @@ export async function saveDraftDecision(request: ReviewRequest, draftId: string,
|
|
|
149
149
|
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Changes only the Why of an already decided draft or answered request (see `draftReasonUpdate` and `answerReasonUpdate`). The condition is that the
|
|
154
|
+
* request is still in the state it was read in, so a change cannot overwrite a request that closed or timed out meanwhile.
|
|
155
|
+
*/
|
|
156
|
+
export async function saveReviewReason(request: ReviewRequest, update: Record<string, unknown>): Promise<ReviewRequest> {
|
|
157
|
+
const result = await run(
|
|
158
|
+
`mutation Reason($input: UpdateReviewRequestInput!, $condition: ModelReviewRequestConditionInput) { updateReviewRequest(input: $input, condition: $condition) { ${FIELDS} } }`,
|
|
159
|
+
{ input: update, condition: { state: { eq: request.state } } },
|
|
160
|
+
);
|
|
161
|
+
if (result.errors?.some((entry) => /conditional/i.test(`${entry.errorType ?? ""} ${entry.message ?? ""}`))) {
|
|
162
|
+
throw new Error("This request changed while you were writing. Your note is kept here; refresh, then save it again.");
|
|
163
|
+
}
|
|
164
|
+
if (result.errors?.length || !result.data?.updateReviewRequest) throw new Error("Your note could not be saved. It is kept here; keep typing or tap away to try again.");
|
|
165
|
+
return normalizeReviewRow(result.data.updateReviewRequest);
|
|
166
|
+
}
|
|
167
|
+
|
|
152
168
|
export type DraftLoad = { drafts: Record<string, DraftView>; missing: string[]; failed: string[] };
|
|
153
169
|
|
|
154
170
|
/**
|
package/lib/review-drafts.ts
CHANGED
|
@@ -215,6 +215,38 @@ function quote(text: string): string {
|
|
|
215
215
|
return `"${text.replace(/"/g, "'")}"`;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** The words of an answer that covers several drafts: each rejection and each Why in a sentence, the plain Accepts of a mixed batch in one more. */
|
|
219
|
+
export function draftFeedback(decisions: readonly (DraftDecision & { id: string })[]): string | null {
|
|
220
|
+
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
221
|
+
const lines = decisions
|
|
222
|
+
.filter((decision) => decision.choice === "reject" || decision.why)
|
|
223
|
+
.map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
|
|
224
|
+
if (rejected.length > 0 && rejected.length < decisions.length) {
|
|
225
|
+
const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
|
|
226
|
+
if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
|
|
227
|
+
}
|
|
228
|
+
return lines.join(" ") || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The update that changes only the Why of a draft that is already decided. The Why is stored where the decision is: on the request's link
|
|
233
|
+
* for the draft and, once the request has closed, in its answer (`drafts` and the `feedback` sentence the idea pipeline reads back).
|
|
234
|
+
* The choice, the steering chip, who decided and when stay as they were. Applied with the condition that the state is unchanged.
|
|
235
|
+
*/
|
|
236
|
+
export function draftReasonUpdate(request: ReviewRequest, draftId: string, decision: DraftDecision, editor: string, now: Date): Record<string, unknown> {
|
|
237
|
+
const at = now.toISOString();
|
|
238
|
+
const why = decision.why?.trim() || null;
|
|
239
|
+
const next: DraftDecision = { ...decision, why };
|
|
240
|
+
const history = [...request.history, { at, event: "reason_edited", by: editor, note: `${decision.choice} ${draftId}` }].slice(-50);
|
|
241
|
+
const update: Record<string, unknown> = { id: request.id, links: JSON.stringify(withDecision(request, draftId, next)), updatedAt: at, history: JSON.stringify(history) };
|
|
242
|
+
if (request.answer?.drafts?.length && request.state === "answered") {
|
|
243
|
+
const known = request.answer.drafts;
|
|
244
|
+
const drafts = known.some((entry) => entry.id === draftId) ? known.map((entry) => (entry.id === draftId ? { ...entry, why } : entry)) : [...known, { ...next, id: draftId }];
|
|
245
|
+
update.answer = JSON.stringify({ ...request.answer, drafts, feedback: draftFeedback(drafts) });
|
|
246
|
+
}
|
|
247
|
+
return update;
|
|
248
|
+
}
|
|
249
|
+
|
|
218
250
|
/**
|
|
219
251
|
* The answer that closes a request once every draft that can be decided has been: Accept on all is an approval, Reject on all a rejection,
|
|
220
252
|
* a mix is an answer with a note naming each draft. A draft rejected as the wrong kind of story makes the answer a "not this kind" steer, which
|
|
@@ -230,21 +262,14 @@ export function batchAnswer(request: ReviewRequest, drafts: DraftLookup, fallbac
|
|
|
230
262
|
decisions.push({ ...decision, title: decision.title ?? drafts[id]?.headline, id });
|
|
231
263
|
}
|
|
232
264
|
const rejected = decisions.filter((decision) => decision.choice === "reject");
|
|
233
|
-
const lines = decisions
|
|
234
|
-
.filter((decision) => decision.choice === "reject" || decision.why)
|
|
235
|
-
.map((decision) => `${decision.choice === "accept" ? "Accepted" : "Rejected"} ${quote(decision.title ?? decision.id)}${decision.notThisKind ? " as not this kind of story" : ""}${decision.why ? `: ${decision.why}` : ""}.`);
|
|
236
265
|
const mixed = rejected.length > 0 && rejected.length < decisions.length;
|
|
237
|
-
if (mixed) {
|
|
238
|
-
const kept = decisions.filter((decision) => decision.choice === "accept" && !decision.why).map((decision) => quote(decision.title ?? decision.id));
|
|
239
|
-
if (kept.length) lines.push(`Accepted ${kept.join(", ")}.`);
|
|
240
|
-
}
|
|
241
266
|
const links = request.links.some((link) => link.type === "proposal") ? request.links.map((link) => {
|
|
242
267
|
const decision = decisions.find((entry) => entry.id === link.id);
|
|
243
268
|
return decision ? { ...link, decision } : link;
|
|
244
269
|
}) : [...request.links, ...decisions.map((decision) => ({ type: "proposal", id: decision.id, decision }))];
|
|
245
270
|
return {
|
|
246
271
|
decision: !rejected.length ? "approve" : mixed ? "feedback" : "reject",
|
|
247
|
-
feedback:
|
|
272
|
+
feedback: draftFeedback(decisions),
|
|
248
273
|
steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
|
|
249
274
|
drafts: decisions,
|
|
250
275
|
links,
|
package/lib/review-requests.ts
CHANGED
|
@@ -108,6 +108,31 @@ export function answerUpdate(request: ReviewRequest, input: AnswerInput, by: str
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The update that changes only the note of a request that already has an answer (a hard gate, a stage review, an acknowledged flag).
|
|
113
|
+
* The decision, the steering signal, who answered and when stay as they were. Applied with the condition that the state is unchanged,
|
|
114
|
+
* so it cannot overwrite a request that timed out or was withdrawn in the meantime.
|
|
115
|
+
*/
|
|
116
|
+
export function answerReasonUpdate(request: ReviewRequest, why: string, editor: string, now: Date): Record<string, unknown> {
|
|
117
|
+
if (!request.answer) throw new Error("This request has no answer yet; answer it first.");
|
|
118
|
+
const at = now.toISOString();
|
|
119
|
+
const answer: ReviewAnswer = { ...request.answer, feedback: why.trim() || null };
|
|
120
|
+
const history = [...request.history, { at, event: "reason_edited", by: editor, note: request.answer.decision }].slice(-MAX_HISTORY);
|
|
121
|
+
return { id: request.id, answer: JSON.stringify(answer), updatedAt: at, history: JSON.stringify(history) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A request with a stored update applied in memory, for the demo and the fixtures; the live data API returns the saved row instead. */
|
|
125
|
+
export function applyReviewUpdate(request: ReviewRequest, update: Record<string, unknown>): ReviewRequest {
|
|
126
|
+
return {
|
|
127
|
+
...request,
|
|
128
|
+
...(typeof update.state === "string" ? { state: update.state as ReviewState } : {}),
|
|
129
|
+
...(update.answer ? { answer: JSON.parse(String(update.answer)) } : {}),
|
|
130
|
+
...(update.links ? { links: JSON.parse(String(update.links)) } : {}),
|
|
131
|
+
history: update.history ? JSON.parse(String(update.history)) : request.history,
|
|
132
|
+
updatedAt: String(update.updatedAt ?? request.updatedAt),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
111
136
|
// --- words ------------------------------------------------------------------------------------------------
|
|
112
137
|
|
|
113
138
|
export function formatWhen(iso: string | null | undefined): string {
|
|
@@ -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
|
-
}
|