@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.
@@ -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
- const { note, reasonCode } = values(id);
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
- // What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
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
- patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
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
- if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
98
- }, [patch]);
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
- useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
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
  }
@@ -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
  /**
@@ -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: lines.join(" ") || null,
272
+ feedback: draftFeedback(decisions),
248
273
  steer: rejected.some((decision) => decision.notThisKind) ? "not_this_kind" : null,
249
274
  drafts: decisions,
250
275
  links,
@@ -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.48.0",
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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.48.0",
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
- }