@scalemule/news-publication 0.0.33 → 0.0.35

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/README.md CHANGED
@@ -111,3 +111,20 @@ The review endpoint returns the decision for the exact invited revision. Editors
111
111
  ### Publication framing for private previews (0.0.28)
112
112
 
113
113
  Pass `publicationHeader` and `publicationFooter` React nodes to `StoryReview` to reuse a publication's real navigation, masthead, and footer. The private banner remains above the publication header and the review controls remain separate from the story. The standalone masthead remains the default. The host must suppress analytics on private review routes; public article indexing and sharing features are not mounted by these slots. This patch changes presentation only; review permissions and publication gates are unchanged.
114
+
115
+ ### Private review sign-in and email options
116
+
117
+ `StoryReview` accepts `readerAccountUrl` and a client `renderSignIn(returnTo)`
118
+ function so the host publication can reuse its existing account and sign-in
119
+ components. Hosts must implement the `/api/news/review/:session/account`
120
+ server adapter to connect the authenticated, verified reader through Blog’s
121
+ `join-account` endpoint. Never trust a browser-supplied email or verification flag.
122
+ The private return path belongs in the host’s signed, HttpOnly OAuth flow cookie,
123
+ not the provider’s authorization URL. Contributor access does not imply subject
124
+ approval authority.
125
+
126
+ Optional questions can be browsed and copied before identity verification.
127
+ `feedbackEmail` enables mail-app links containing the story title, exact revision
128
+ reference and optional answers, never the invitation credential. Configure a
129
+ monitored mailbox; received email must be recorded by the newsroom. Online
130
+ answers retain their existing autosave and explicit-send behavior.
@@ -158,7 +158,18 @@ function useReviewDrafts(session, revision, initial, request) {
158
158
  }
159
159
  };
160
160
  }
161
+ async function encodePendingPhoto(value) {
162
+ const { file, ...details } = value;
163
+ return { ...details, file: { bytes: await file.arrayBuffer(), name: file.name, type: file.type, lastModified: file.lastModified } };
164
+ }
165
+ function decodePendingPhoto(value) {
166
+ if (!value) return void 0;
167
+ if (value.file instanceof File) return value;
168
+ const file = value.file;
169
+ return { ...value, file: new File([file.bytes], file.name, { type: file.type, lastModified: file.lastModified }) };
170
+ }
161
171
  async function stored(key, value) {
172
+ const encoded = value ? await encodePendingPhoto(value) : value;
162
173
  const db = await new Promise((resolve, reject) => {
163
174
  const request = indexedDB.open("story-review-pending-media", 1);
164
175
  request.onupgradeneeded = () => request.result.createObjectStore("photos");
@@ -169,8 +180,14 @@ async function stored(key, value) {
169
180
  return await new Promise((resolve, reject) => {
170
181
  const tx = db.transaction("photos", value === void 0 ? "readonly" : "readwrite");
171
182
  const store = tx.objectStore("photos");
172
- const request = value === void 0 ? store.get(key) : value === null ? store.delete(key) : store.put(value, key);
173
- tx.oncomplete = () => resolve(value === void 0 ? request.result : void 0);
183
+ const request = value === void 0 ? store.get(key) : value === null ? store.delete(key) : store.put(encoded, key);
184
+ tx.oncomplete = () => {
185
+ try {
186
+ resolve(value === void 0 ? decodePendingPhoto(request.result) : void 0);
187
+ } catch (error) {
188
+ reject(error);
189
+ }
190
+ };
174
191
  tx.onerror = () => reject(tx.error);
175
192
  tx.onabort = () => reject(tx.error);
176
193
  });
@@ -274,6 +291,23 @@ function proseDiff(before, after) {
274
291
  emit("same", a.slice(endA).join(""));
275
292
  return out;
276
293
  }
294
+
295
+ // src/story-review-email.ts
296
+ function reviewEmailText(review, answers) {
297
+ const header = `Private story feedback \u2014 not published
298
+ ${review.revision.document.title}
299
+ Preview v${review.revision.number} \xB7 Reference ${review.revision.id}
300
+
301
+ Your name:
302
+
303
+ `;
304
+ return header + (answers === void 0 ? "My feedback:\n" : "These questions are optional. Answer only what you would like to share.\n\n" + review.questions.map((q, i) => `${i + 1}. ${q.prompt}
305
+ ${answers[q.id] || ""}`).join("\n\n"));
306
+ }
307
+ function reviewEmailLink(email, title, text) {
308
+ if (!email || !/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(email)) return void 0;
309
+ return `mailto:${encodeURIComponent(email)}?subject=${encodeURIComponent(`Private story feedback: ${title}`)}&body=${encodeURIComponent(text)}`;
310
+ }
277
311
  function EditorialNotices({ editorial }) {
278
312
  return editorial ? /* @__PURE__ */ jsxRuntime.jsxs("aside", { "aria-label": "Editorial notices", children: [
279
313
  editorial.content_kind && editorial.content_kind !== "EDITORIAL" && /* @__PURE__ */ jsxRuntime.jsx("p", { children: human(editorial.content_kind) }),
@@ -293,15 +327,40 @@ function StoryReview({
293
327
  publicationName,
294
328
  nameplateUrl,
295
329
  publicationHeader,
296
- publicationFooter
330
+ publicationFooter,
331
+ readerAccountUrl,
332
+ renderSignIn,
333
+ feedbackEmail
297
334
  }) {
298
335
  const base = `/api/news/review/${encodeURIComponent(sessionId)}`;
299
336
  const [review, setReview] = react.useState(null), [error, setError] = react.useState(""), [notice, setNotice] = react.useState(""), [busy, setBusy] = react.useState(false), [panel, setPanelState] = react.useState("article"), [verification, setVerification] = react.useState(false), [code, setCode] = react.useState("");
300
337
  const [shareUrl, setShareUrl] = react.useState(""), [canShare, setCanShare] = react.useState(false);
301
338
  const [contributorName, setContributorName] = react.useState(""), [contributorEmail, setContributorEmail] = react.useState("");
302
339
  const [identityStep, setIdentityStep] = react.useState("name"), [requestedPanel, setRequestedPanel] = react.useState("comment");
340
+ const [reader, setReader] = react.useState(null);
341
+ const [readerLoading, setReaderLoading] = react.useState(!!readerAccountUrl);
342
+ const [useEmailCode, setUseEmailCode] = react.useState(!renderSignIn);
343
+ const [returnPanel, setReturnPanel] = react.useState("");
344
+ const resumed = react.useRef(false);
345
+ const [copiedQuestions, setCopiedQuestions] = react.useState(false);
346
+ react.useEffect(() => {
347
+ if (!readerAccountUrl) return;
348
+ let active = true;
349
+ void fetch(readerAccountUrl, { credentials: "same-origin", cache: "no-store", signal: AbortSignal.timeout(15e3) }).then(async (response) => {
350
+ if (response.ok) {
351
+ const data = await response.json();
352
+ if (active) setReader(data.user || null);
353
+ }
354
+ }).catch(() => {
355
+ }).finally(() => {
356
+ if (active) setReaderLoading(false);
357
+ });
358
+ return () => {
359
+ active = false;
360
+ };
361
+ }, [readerAccountUrl]);
303
362
  function setPanel(next) {
304
- if (review?.identity_required && !["article", "history", "identity", "share"].includes(next)) {
363
+ if (review?.identity_required && !["article", "history", "identity", "share", "question-list"].includes(next)) {
305
364
  setRequestedPanel(next);
306
365
  setPanelState("identity");
307
366
  } else setPanelState(next);
@@ -399,10 +458,41 @@ function StoryReview({
399
458
  setReview(data);
400
459
  }
401
460
  }
461
+ async function connectReader(target = requestedPanel) {
462
+ setBusy(true);
463
+ setError("");
464
+ try {
465
+ await request("/account", {});
466
+ await load();
467
+ setPanelState(target);
468
+ setReturnPanel("");
469
+ const url = new URL(window.location.href);
470
+ url.searchParams.delete("respond");
471
+ window.history.replaceState(null, "", url.pathname + url.search);
472
+ } catch (e) {
473
+ setError(e.message);
474
+ setPanelState("identity");
475
+ } finally {
476
+ setBusy(false);
477
+ }
478
+ }
479
+ react.useEffect(() => {
480
+ if (!returnPanel || !review || !reader?.email_verified || resumed.current) return;
481
+ resumed.current = true;
482
+ void connectReader(returnPanel);
483
+ }, [returnPanel, review, reader]);
402
484
  react.useEffect(() => {
403
485
  let active = true;
404
486
  void (async () => {
405
487
  try {
488
+ const returnUrl = new URL(window.location.href);
489
+ if (returnUrl.searchParams.get("signin") === "retry") setError("Sign-in wasn\u2019t completed. Try again or use an email code.");
490
+ const respond = returnUrl.searchParams.get("respond") || "";
491
+ if (["feedback", "changes", "questions", "media", "comment", "edits"].includes(respond)) {
492
+ setRequestedPanel(respond);
493
+ setReturnPanel(respond);
494
+ setPanelState("identity");
495
+ }
406
496
  const token = new URL(window.location.href).searchParams.get("key") || (/^[a-f0-9]{64}$/.test(window.location.hash.slice(1)) ? window.location.hash.slice(1) : void 0);
407
497
  let exchange = {};
408
498
  try {
@@ -414,7 +504,9 @@ function StoryReview({
414
504
  if (address) {
415
505
  const canonical = new URL(address, window.location.origin);
416
506
  if (canonical.origin === window.location.origin) {
417
- window.history.replaceState(null, "", canonical.pathname + canonical.search);
507
+ const address2 = new URL(canonical);
508
+ if (respond) address2.searchParams.set("respond", respond);
509
+ window.history.replaceState(null, "", address2.pathname + address2.search);
418
510
  if (active) {
419
511
  setShareUrl(canonical.href);
420
512
  setCanShare(exchange.can_share === true);
@@ -579,6 +671,15 @@ function StoryReview({
579
671
  (r) => r.id === (compareTo || review?.revision.id)
580
672
  );
581
673
  const errorMessage = error ? /* @__PURE__ */ jsxRuntime.jsx("p", { ref: errorRef, tabIndex: -1, role: "alert", className: "sm-review-alert", children: error }) : null;
674
+ const emailLink = review ? reviewEmailLink(feedbackEmail, review.revision.document.title, reviewEmailText(review)) : void 0;
675
+ const questionText = review ? reviewEmailText(review, answers) : "";
676
+ const questionsEmail = review ? reviewEmailLink(feedbackEmail, review.revision.document.title, questionText) : void 0;
677
+ const signInReturn = shareUrl ? new URL(shareUrl).pathname + new URL(shareUrl).search + `&respond=${encodeURIComponent(requestedPanel)}` : "";
678
+ const emailAlternative = emailLink && /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "sm-review-email-alternative", children: [
679
+ "Prefer email? ",
680
+ /* @__PURE__ */ jsxRuntime.jsx("a", { href: emailLink, children: "Email the newsroom" }),
681
+ ". Your email app will open; send your message there."
682
+ ] });
582
683
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `sm-review-frame${publicationHeader ? " sm-review-publication-frame" : ""}`, children: [
583
684
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "sm-review-banner-wrap", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-private", children: [
584
685
  /* @__PURE__ */ jsxRuntime.jsx("strong", { children: review?.published ? "Private review" : "Private preview \xB7 Not published" }),
@@ -779,7 +880,7 @@ function StoryReview({
779
880
  {
780
881
  onClick: () => {
781
882
  setMoreQuestions(false);
782
- setPanel("questions");
883
+ setPanel(review.identity_required ? "question-list" : "questions");
783
884
  },
784
885
  children: "Tell Us More"
785
886
  }
@@ -792,7 +893,8 @@ function StoryReview({
792
893
  children: "Prefer Not to Publish"
793
894
  }
794
895
  ),
795
- can("COMMENT") && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "sm-review-quiet", onClick: () => setPanel("comment"), children: "Leave a Comment" })
896
+ can("COMMENT") && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "sm-review-quiet", onClick: () => setPanel("comment"), children: "Leave a Comment" }),
897
+ emailAlternative
796
898
  ] }),
797
899
  /* @__PURE__ */ jsxRuntime.jsxs(
798
900
  "nav",
@@ -815,7 +917,7 @@ function StoryReview({
815
917
  can("UPLOAD_MEDIA") && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => openMedia(), children: celebration ? "Add Photos" : "Add photos" }),
816
918
  !celebration && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
817
919
  can("PROPOSE_EDITS") && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("edits"), children: "Suggest edits" }),
818
- can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("questions"), children: "Answer questions" }),
920
+ can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel(review.identity_required ? "question-list" : "questions"), children: "Answer questions" }),
819
921
  can("COMMENT") && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("comment"), children: "Leave a comment" })
820
922
  ] })
821
923
  ]
@@ -837,7 +939,10 @@ function StoryReview({
837
939
  panel === "share" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Share private preview", children: [
838
940
  /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Share with your family" }),
839
941
  errorMessage,
840
- /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Anyone with this link can read the private preview. To send feedback or photos, they will enter their own name and verify their email. No account is needed." }),
942
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
943
+ "Anyone with this link can read the private preview. ",
944
+ renderSignIn ? "To contribute, they can continue with Google or use an email code." : "To send feedback or photos, they will enter their own name and verify their email. No account is needed."
945
+ ] }),
841
946
  /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "review-share-url", children: "Private preview link" }),
842
947
  /* @__PURE__ */ jsxRuntime.jsx("input", { id: "review-share-url", readOnly: true, value: shareUrl, onFocus: (e) => e.currentTarget.select() }),
843
948
  /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: async () => {
@@ -850,11 +955,48 @@ function StoryReview({
850
955
  }, children: "Copy private link" }),
851
956
  /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setPanel("article"), children: "Back to the story" })
852
957
  ] }),
958
+ panel === "question-list" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Optional story questions", children: [
959
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-form-heading", children: [
960
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Tell us a little more" }),
961
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setPanel("article"), children: "Close" })
962
+ ] }),
963
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Completely optional. Read the questions first, then answer online or copy them into an email. Even one answer helps." }),
964
+ /* @__PURE__ */ jsxRuntime.jsx("ol", { className: "sm-review-question-list", children: review.questions.filter((q, i) => moreQuestions || i < 5 || q.required).map((q) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: q.prompt }, q.id)) }),
965
+ !moreQuestions && review.questions.length > 5 && /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setMoreQuestions(true), children: "See all questions" }),
966
+ /* @__PURE__ */ jsxRuntime.jsx("button", { className: "sm-review-primary", onClick: () => setPanel("questions"), children: "Answer online" }),
967
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Sign in to save your answers as you go. You can return whenever it suits you." }),
968
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: async () => {
969
+ try {
970
+ await navigator.clipboard.writeText(questionText);
971
+ setNotice("Questions copied. Paste them into an email and answer as many as you like.");
972
+ } catch {
973
+ setCopiedQuestions(true);
974
+ }
975
+ }, children: "Copy all questions" }),
976
+ copiedQuestions && /* @__PURE__ */ jsxRuntime.jsxs("label", { children: [
977
+ "Copy these questions",
978
+ /* @__PURE__ */ jsxRuntime.jsx("textarea", { readOnly: true, rows: 10, value: questionText, onFocus: (e) => e.currentTarget.select() })
979
+ ] }),
980
+ questionsEmail && /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
981
+ /* @__PURE__ */ jsxRuntime.jsx("a", { href: questionsEmail, children: "Answer by email" }),
982
+ " \u2014 opens your email app with the questions ready."
983
+ ] })
984
+ ] }),
853
985
  panel === "identity" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Introduce yourself", children: [
854
- /* @__PURE__ */ jsxRuntime.jsx("h2", { children: identityStep === "code" ? "Check your email" : "Who is helping with the story?" }),
986
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: identityStep === "code" ? "Check your email" : "Continue to help with the story" }),
855
987
  errorMessage,
856
- /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Your name will accompany your feedback for the newsroom. Your email stays private. No password or account is needed." }),
857
- identityStep === "name" ? /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
988
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Your name accompanies your feedback. Your email stays private." }),
989
+ identityStep === "name" && renderSignIn && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
990
+ readerLoading ? /* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", children: "Checking your sign-in\u2026" }) : reader?.email_verified ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
991
+ /* @__PURE__ */ jsxRuntime.jsxs("button", { className: "sm-review-primary", disabled: busy, onClick: () => void connectReader(), children: [
992
+ "Continue as ",
993
+ reader.full_name || reader.email
994
+ ] }),
995
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Your reader account is ready. No email code is needed." })
996
+ ] }) : renderSignIn(signInReturn),
997
+ !useEmailCode && /* @__PURE__ */ jsxRuntime.jsx("button", { className: "sm-review-quiet", onClick: () => setUseEmailCode(true), children: "Use an email code instead" })
998
+ ] }),
999
+ identityStep === "name" && useEmailCode && /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
858
1000
  e.preventDefault();
859
1001
  setBusy(true);
860
1002
  setError("");
@@ -874,8 +1016,10 @@ function StoryReview({
874
1016
  /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-email", children: "Your email" }),
875
1017
  /* @__PURE__ */ jsxRuntime.jsx("input", { id: "contributor-email", type: "email", autoComplete: "email", value: contributorEmail, maxLength: 320, required: true, onChange: (e) => setContributorEmail(e.target.value) }),
876
1018
  /* @__PURE__ */ jsxRuntime.jsx("p", { children: "We'll email you a code to confirm it's you." }),
1019
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "No password or account is needed for this option." }),
877
1020
  /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, children: busy ? "Sending your code\u2026" : "Email me a code" })
878
- ] }) : /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
1021
+ ] }),
1022
+ identityStep === "code" && /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
879
1023
  e.preventDefault();
880
1024
  setBusy(true);
881
1025
  setError("");
@@ -911,6 +1055,7 @@ function StoryReview({
911
1055
  }, children: "Send a new code" }),
912
1056
  /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: busy, onClick: () => setIdentityStep("name"), children: "Use a different email" })
913
1057
  ] }),
1058
+ emailAlternative,
914
1059
  /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Just read the story" })
915
1060
  ] }),
916
1061
  panel === "feedback" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Family feedback", children: [
@@ -924,7 +1069,7 @@ function StoryReview({
924
1069
  /* @__PURE__ */ jsxRuntime.jsx("button", { className: "sm-review-primary", disabled: busy, onClick: () => void send({ action: "COMMENT", message: "This draft looks good to me.", target: { kind: "STORY" } }, "Thank you. Your feedback has been sent to the newsroom. The story is still private.", false), children: "Send my feedback" }),
925
1070
  /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Back to the story" })
926
1071
  ] }),
927
- !["article", "identity", "share", "feedback"].includes(panel) && /* @__PURE__ */ jsxRuntime.jsxs(
1072
+ !["article", "identity", "share", "feedback", "question-list"].includes(panel) && /* @__PURE__ */ jsxRuntime.jsxs(
928
1073
  "section",
929
1074
  {
930
1075
  ref: responseRef,
@@ -1045,7 +1190,11 @@ function StoryReview({
1045
1190
  onClick: () => setMoreQuestions(true),
1046
1191
  children: "Answer More Questions"
1047
1192
  }
1048
- )
1193
+ ),
1194
+ questionsEmail && /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
1195
+ /* @__PURE__ */ jsxRuntime.jsx("a", { href: questionsEmail, children: "Email these answers instead" }),
1196
+ ". Send them from your email app; your online draft stays saved."
1197
+ ] })
1049
1198
  ]
1050
1199
  }
1051
1200
  ),
@@ -32,13 +32,17 @@ type Revision = {
32
32
  document: Document;
33
33
  content_html: string;
34
34
  };
35
- declare function StoryReview({ sessionId, publicationName, nameplateUrl, publicationHeader, publicationFooter, }: {
35
+ declare function StoryReview({ sessionId, publicationName, nameplateUrl, publicationHeader, publicationFooter, readerAccountUrl, renderSignIn, feedbackEmail, }: {
36
36
  sessionId: string;
37
37
  publicationName: string;
38
38
  nameplateUrl?: string;
39
39
  /** The publication supplies its real shell; private content still loads only through the review capability. */
40
40
  publicationHeader?: react__default.ReactNode;
41
41
  publicationFooter?: react__default.ReactNode;
42
+ /** Reuse the publication's existing account and sign-in components. */
43
+ readerAccountUrl?: string;
44
+ renderSignIn?: (returnTo: string) => react__default.ReactNode;
45
+ feedbackEmail?: string;
42
46
  }): react__default.JSX.Element;
43
47
  /** Paragraph comparisons preserve readable prose and identify title/dek and media changes. */
44
48
  declare function ReviewDiff({ before, after, }: {
@@ -32,13 +32,17 @@ type Revision = {
32
32
  document: Document;
33
33
  content_html: string;
34
34
  };
35
- declare function StoryReview({ sessionId, publicationName, nameplateUrl, publicationHeader, publicationFooter, }: {
35
+ declare function StoryReview({ sessionId, publicationName, nameplateUrl, publicationHeader, publicationFooter, readerAccountUrl, renderSignIn, feedbackEmail, }: {
36
36
  sessionId: string;
37
37
  publicationName: string;
38
38
  nameplateUrl?: string;
39
39
  /** The publication supplies its real shell; private content still loads only through the review capability. */
40
40
  publicationHeader?: react__default.ReactNode;
41
41
  publicationFooter?: react__default.ReactNode;
42
+ /** Reuse the publication's existing account and sign-in components. */
43
+ readerAccountUrl?: string;
44
+ renderSignIn?: (returnTo: string) => react__default.ReactNode;
45
+ feedbackEmail?: string;
42
46
  }): react__default.JSX.Element;
43
47
  /** Paragraph comparisons preserve readable prose and identify title/dek and media changes. */
44
48
  declare function ReviewDiff({ before, after, }: {
@@ -156,7 +156,18 @@ function useReviewDrafts(session, revision, initial, request) {
156
156
  }
157
157
  };
158
158
  }
159
+ async function encodePendingPhoto(value) {
160
+ const { file, ...details } = value;
161
+ return { ...details, file: { bytes: await file.arrayBuffer(), name: file.name, type: file.type, lastModified: file.lastModified } };
162
+ }
163
+ function decodePendingPhoto(value) {
164
+ if (!value) return void 0;
165
+ if (value.file instanceof File) return value;
166
+ const file = value.file;
167
+ return { ...value, file: new File([file.bytes], file.name, { type: file.type, lastModified: file.lastModified }) };
168
+ }
159
169
  async function stored(key, value) {
170
+ const encoded = value ? await encodePendingPhoto(value) : value;
160
171
  const db = await new Promise((resolve, reject) => {
161
172
  const request = indexedDB.open("story-review-pending-media", 1);
162
173
  request.onupgradeneeded = () => request.result.createObjectStore("photos");
@@ -167,8 +178,14 @@ async function stored(key, value) {
167
178
  return await new Promise((resolve, reject) => {
168
179
  const tx = db.transaction("photos", value === void 0 ? "readonly" : "readwrite");
169
180
  const store = tx.objectStore("photos");
170
- const request = value === void 0 ? store.get(key) : value === null ? store.delete(key) : store.put(value, key);
171
- tx.oncomplete = () => resolve(value === void 0 ? request.result : void 0);
181
+ const request = value === void 0 ? store.get(key) : value === null ? store.delete(key) : store.put(encoded, key);
182
+ tx.oncomplete = () => {
183
+ try {
184
+ resolve(value === void 0 ? decodePendingPhoto(request.result) : void 0);
185
+ } catch (error) {
186
+ reject(error);
187
+ }
188
+ };
172
189
  tx.onerror = () => reject(tx.error);
173
190
  tx.onabort = () => reject(tx.error);
174
191
  });
@@ -272,6 +289,23 @@ function proseDiff(before, after) {
272
289
  emit("same", a.slice(endA).join(""));
273
290
  return out;
274
291
  }
292
+
293
+ // src/story-review-email.ts
294
+ function reviewEmailText(review, answers) {
295
+ const header = `Private story feedback \u2014 not published
296
+ ${review.revision.document.title}
297
+ Preview v${review.revision.number} \xB7 Reference ${review.revision.id}
298
+
299
+ Your name:
300
+
301
+ `;
302
+ return header + (answers === void 0 ? "My feedback:\n" : "These questions are optional. Answer only what you would like to share.\n\n" + review.questions.map((q, i) => `${i + 1}. ${q.prompt}
303
+ ${answers[q.id] || ""}`).join("\n\n"));
304
+ }
305
+ function reviewEmailLink(email, title, text) {
306
+ if (!email || !/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(email)) return void 0;
307
+ return `mailto:${encodeURIComponent(email)}?subject=${encodeURIComponent(`Private story feedback: ${title}`)}&body=${encodeURIComponent(text)}`;
308
+ }
275
309
  function EditorialNotices({ editorial }) {
276
310
  return editorial ? /* @__PURE__ */ jsxs("aside", { "aria-label": "Editorial notices", children: [
277
311
  editorial.content_kind && editorial.content_kind !== "EDITORIAL" && /* @__PURE__ */ jsx("p", { children: human(editorial.content_kind) }),
@@ -291,15 +325,40 @@ function StoryReview({
291
325
  publicationName,
292
326
  nameplateUrl,
293
327
  publicationHeader,
294
- publicationFooter
328
+ publicationFooter,
329
+ readerAccountUrl,
330
+ renderSignIn,
331
+ feedbackEmail
295
332
  }) {
296
333
  const base = `/api/news/review/${encodeURIComponent(sessionId)}`;
297
334
  const [review, setReview] = useState(null), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false), [panel, setPanelState] = useState("article"), [verification, setVerification] = useState(false), [code, setCode] = useState("");
298
335
  const [shareUrl, setShareUrl] = useState(""), [canShare, setCanShare] = useState(false);
299
336
  const [contributorName, setContributorName] = useState(""), [contributorEmail, setContributorEmail] = useState("");
300
337
  const [identityStep, setIdentityStep] = useState("name"), [requestedPanel, setRequestedPanel] = useState("comment");
338
+ const [reader, setReader] = useState(null);
339
+ const [readerLoading, setReaderLoading] = useState(!!readerAccountUrl);
340
+ const [useEmailCode, setUseEmailCode] = useState(!renderSignIn);
341
+ const [returnPanel, setReturnPanel] = useState("");
342
+ const resumed = useRef(false);
343
+ const [copiedQuestions, setCopiedQuestions] = useState(false);
344
+ useEffect(() => {
345
+ if (!readerAccountUrl) return;
346
+ let active = true;
347
+ void fetch(readerAccountUrl, { credentials: "same-origin", cache: "no-store", signal: AbortSignal.timeout(15e3) }).then(async (response) => {
348
+ if (response.ok) {
349
+ const data = await response.json();
350
+ if (active) setReader(data.user || null);
351
+ }
352
+ }).catch(() => {
353
+ }).finally(() => {
354
+ if (active) setReaderLoading(false);
355
+ });
356
+ return () => {
357
+ active = false;
358
+ };
359
+ }, [readerAccountUrl]);
301
360
  function setPanel(next) {
302
- if (review?.identity_required && !["article", "history", "identity", "share"].includes(next)) {
361
+ if (review?.identity_required && !["article", "history", "identity", "share", "question-list"].includes(next)) {
303
362
  setRequestedPanel(next);
304
363
  setPanelState("identity");
305
364
  } else setPanelState(next);
@@ -397,10 +456,41 @@ function StoryReview({
397
456
  setReview(data);
398
457
  }
399
458
  }
459
+ async function connectReader(target = requestedPanel) {
460
+ setBusy(true);
461
+ setError("");
462
+ try {
463
+ await request("/account", {});
464
+ await load();
465
+ setPanelState(target);
466
+ setReturnPanel("");
467
+ const url = new URL(window.location.href);
468
+ url.searchParams.delete("respond");
469
+ window.history.replaceState(null, "", url.pathname + url.search);
470
+ } catch (e) {
471
+ setError(e.message);
472
+ setPanelState("identity");
473
+ } finally {
474
+ setBusy(false);
475
+ }
476
+ }
477
+ useEffect(() => {
478
+ if (!returnPanel || !review || !reader?.email_verified || resumed.current) return;
479
+ resumed.current = true;
480
+ void connectReader(returnPanel);
481
+ }, [returnPanel, review, reader]);
400
482
  useEffect(() => {
401
483
  let active = true;
402
484
  void (async () => {
403
485
  try {
486
+ const returnUrl = new URL(window.location.href);
487
+ if (returnUrl.searchParams.get("signin") === "retry") setError("Sign-in wasn\u2019t completed. Try again or use an email code.");
488
+ const respond = returnUrl.searchParams.get("respond") || "";
489
+ if (["feedback", "changes", "questions", "media", "comment", "edits"].includes(respond)) {
490
+ setRequestedPanel(respond);
491
+ setReturnPanel(respond);
492
+ setPanelState("identity");
493
+ }
404
494
  const token = new URL(window.location.href).searchParams.get("key") || (/^[a-f0-9]{64}$/.test(window.location.hash.slice(1)) ? window.location.hash.slice(1) : void 0);
405
495
  let exchange = {};
406
496
  try {
@@ -412,7 +502,9 @@ function StoryReview({
412
502
  if (address) {
413
503
  const canonical = new URL(address, window.location.origin);
414
504
  if (canonical.origin === window.location.origin) {
415
- window.history.replaceState(null, "", canonical.pathname + canonical.search);
505
+ const address2 = new URL(canonical);
506
+ if (respond) address2.searchParams.set("respond", respond);
507
+ window.history.replaceState(null, "", address2.pathname + address2.search);
416
508
  if (active) {
417
509
  setShareUrl(canonical.href);
418
510
  setCanShare(exchange.can_share === true);
@@ -577,6 +669,15 @@ function StoryReview({
577
669
  (r) => r.id === (compareTo || review?.revision.id)
578
670
  );
579
671
  const errorMessage = error ? /* @__PURE__ */ jsx("p", { ref: errorRef, tabIndex: -1, role: "alert", className: "sm-review-alert", children: error }) : null;
672
+ const emailLink = review ? reviewEmailLink(feedbackEmail, review.revision.document.title, reviewEmailText(review)) : void 0;
673
+ const questionText = review ? reviewEmailText(review, answers) : "";
674
+ const questionsEmail = review ? reviewEmailLink(feedbackEmail, review.revision.document.title, questionText) : void 0;
675
+ const signInReturn = shareUrl ? new URL(shareUrl).pathname + new URL(shareUrl).search + `&respond=${encodeURIComponent(requestedPanel)}` : "";
676
+ const emailAlternative = emailLink && /* @__PURE__ */ jsxs("p", { className: "sm-review-email-alternative", children: [
677
+ "Prefer email? ",
678
+ /* @__PURE__ */ jsx("a", { href: emailLink, children: "Email the newsroom" }),
679
+ ". Your email app will open; send your message there."
680
+ ] });
580
681
  return /* @__PURE__ */ jsxs("div", { className: `sm-review-frame${publicationHeader ? " sm-review-publication-frame" : ""}`, children: [
581
682
  /* @__PURE__ */ jsx("div", { className: "sm-review-banner-wrap", children: /* @__PURE__ */ jsxs("div", { className: "sm-review-private", children: [
582
683
  /* @__PURE__ */ jsx("strong", { children: review?.published ? "Private review" : "Private preview \xB7 Not published" }),
@@ -777,7 +878,7 @@ function StoryReview({
777
878
  {
778
879
  onClick: () => {
779
880
  setMoreQuestions(false);
780
- setPanel("questions");
881
+ setPanel(review.identity_required ? "question-list" : "questions");
781
882
  },
782
883
  children: "Tell Us More"
783
884
  }
@@ -790,7 +891,8 @@ function StoryReview({
790
891
  children: "Prefer Not to Publish"
791
892
  }
792
893
  ),
793
- can("COMMENT") && /* @__PURE__ */ jsx("button", { className: "sm-review-quiet", onClick: () => setPanel("comment"), children: "Leave a Comment" })
894
+ can("COMMENT") && /* @__PURE__ */ jsx("button", { className: "sm-review-quiet", onClick: () => setPanel("comment"), children: "Leave a Comment" }),
895
+ emailAlternative
794
896
  ] }),
795
897
  /* @__PURE__ */ jsxs(
796
898
  "nav",
@@ -813,7 +915,7 @@ function StoryReview({
813
915
  can("UPLOAD_MEDIA") && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => openMedia(), children: celebration ? "Add Photos" : "Add photos" }),
814
916
  !celebration && /* @__PURE__ */ jsxs(Fragment, { children: [
815
917
  can("PROPOSE_EDITS") && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("edits"), children: "Suggest edits" }),
816
- can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("questions"), children: "Answer questions" }),
918
+ can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel(review.identity_required ? "question-list" : "questions"), children: "Answer questions" }),
817
919
  can("COMMENT") && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("comment"), children: "Leave a comment" })
818
920
  ] })
819
921
  ]
@@ -835,7 +937,10 @@ function StoryReview({
835
937
  panel === "share" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Share private preview", children: [
836
938
  /* @__PURE__ */ jsx("h2", { children: "Share with your family" }),
837
939
  errorMessage,
838
- /* @__PURE__ */ jsx("p", { children: "Anyone with this link can read the private preview. To send feedback or photos, they will enter their own name and verify their email. No account is needed." }),
940
+ /* @__PURE__ */ jsxs("p", { children: [
941
+ "Anyone with this link can read the private preview. ",
942
+ renderSignIn ? "To contribute, they can continue with Google or use an email code." : "To send feedback or photos, they will enter their own name and verify their email. No account is needed."
943
+ ] }),
839
944
  /* @__PURE__ */ jsx("label", { htmlFor: "review-share-url", children: "Private preview link" }),
840
945
  /* @__PURE__ */ jsx("input", { id: "review-share-url", readOnly: true, value: shareUrl, onFocus: (e) => e.currentTarget.select() }),
841
946
  /* @__PURE__ */ jsx("button", { onClick: async () => {
@@ -848,11 +953,48 @@ function StoryReview({
848
953
  }, children: "Copy private link" }),
849
954
  /* @__PURE__ */ jsx("button", { onClick: () => setPanel("article"), children: "Back to the story" })
850
955
  ] }),
956
+ panel === "question-list" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Optional story questions", children: [
957
+ /* @__PURE__ */ jsxs("div", { className: "sm-review-form-heading", children: [
958
+ /* @__PURE__ */ jsx("h2", { children: "Tell us a little more" }),
959
+ /* @__PURE__ */ jsx("button", { onClick: () => setPanel("article"), children: "Close" })
960
+ ] }),
961
+ /* @__PURE__ */ jsx("p", { children: "Completely optional. Read the questions first, then answer online or copy them into an email. Even one answer helps." }),
962
+ /* @__PURE__ */ jsx("ol", { className: "sm-review-question-list", children: review.questions.filter((q, i) => moreQuestions || i < 5 || q.required).map((q) => /* @__PURE__ */ jsx("li", { children: q.prompt }, q.id)) }),
963
+ !moreQuestions && review.questions.length > 5 && /* @__PURE__ */ jsx("button", { onClick: () => setMoreQuestions(true), children: "See all questions" }),
964
+ /* @__PURE__ */ jsx("button", { className: "sm-review-primary", onClick: () => setPanel("questions"), children: "Answer online" }),
965
+ /* @__PURE__ */ jsx("p", { children: "Sign in to save your answers as you go. You can return whenever it suits you." }),
966
+ /* @__PURE__ */ jsx("button", { onClick: async () => {
967
+ try {
968
+ await navigator.clipboard.writeText(questionText);
969
+ setNotice("Questions copied. Paste them into an email and answer as many as you like.");
970
+ } catch {
971
+ setCopiedQuestions(true);
972
+ }
973
+ }, children: "Copy all questions" }),
974
+ copiedQuestions && /* @__PURE__ */ jsxs("label", { children: [
975
+ "Copy these questions",
976
+ /* @__PURE__ */ jsx("textarea", { readOnly: true, rows: 10, value: questionText, onFocus: (e) => e.currentTarget.select() })
977
+ ] }),
978
+ questionsEmail && /* @__PURE__ */ jsxs("p", { children: [
979
+ /* @__PURE__ */ jsx("a", { href: questionsEmail, children: "Answer by email" }),
980
+ " \u2014 opens your email app with the questions ready."
981
+ ] })
982
+ ] }),
851
983
  panel === "identity" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Introduce yourself", children: [
852
- /* @__PURE__ */ jsx("h2", { children: identityStep === "code" ? "Check your email" : "Who is helping with the story?" }),
984
+ /* @__PURE__ */ jsx("h2", { children: identityStep === "code" ? "Check your email" : "Continue to help with the story" }),
853
985
  errorMessage,
854
- /* @__PURE__ */ jsx("p", { children: "Your name will accompany your feedback for the newsroom. Your email stays private. No password or account is needed." }),
855
- identityStep === "name" ? /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
986
+ /* @__PURE__ */ jsx("p", { children: "Your name accompanies your feedback. Your email stays private." }),
987
+ identityStep === "name" && renderSignIn && /* @__PURE__ */ jsxs(Fragment, { children: [
988
+ readerLoading ? /* @__PURE__ */ jsx("p", { role: "status", children: "Checking your sign-in\u2026" }) : reader?.email_verified ? /* @__PURE__ */ jsxs(Fragment, { children: [
989
+ /* @__PURE__ */ jsxs("button", { className: "sm-review-primary", disabled: busy, onClick: () => void connectReader(), children: [
990
+ "Continue as ",
991
+ reader.full_name || reader.email
992
+ ] }),
993
+ /* @__PURE__ */ jsx("p", { children: "Your reader account is ready. No email code is needed." })
994
+ ] }) : renderSignIn(signInReturn),
995
+ !useEmailCode && /* @__PURE__ */ jsx("button", { className: "sm-review-quiet", onClick: () => setUseEmailCode(true), children: "Use an email code instead" })
996
+ ] }),
997
+ identityStep === "name" && useEmailCode && /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
856
998
  e.preventDefault();
857
999
  setBusy(true);
858
1000
  setError("");
@@ -872,8 +1014,10 @@ function StoryReview({
872
1014
  /* @__PURE__ */ jsx("label", { htmlFor: "contributor-email", children: "Your email" }),
873
1015
  /* @__PURE__ */ jsx("input", { id: "contributor-email", type: "email", autoComplete: "email", value: contributorEmail, maxLength: 320, required: true, onChange: (e) => setContributorEmail(e.target.value) }),
874
1016
  /* @__PURE__ */ jsx("p", { children: "We'll email you a code to confirm it's you." }),
1017
+ /* @__PURE__ */ jsx("p", { children: "No password or account is needed for this option." }),
875
1018
  /* @__PURE__ */ jsx("button", { disabled: busy, children: busy ? "Sending your code\u2026" : "Email me a code" })
876
- ] }) : /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
1019
+ ] }),
1020
+ identityStep === "code" && /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
877
1021
  e.preventDefault();
878
1022
  setBusy(true);
879
1023
  setError("");
@@ -909,6 +1053,7 @@ function StoryReview({
909
1053
  }, children: "Send a new code" }),
910
1054
  /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: () => setIdentityStep("name"), children: "Use a different email" })
911
1055
  ] }),
1056
+ emailAlternative,
912
1057
  /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Just read the story" })
913
1058
  ] }),
914
1059
  panel === "feedback" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Family feedback", children: [
@@ -922,7 +1067,7 @@ function StoryReview({
922
1067
  /* @__PURE__ */ jsx("button", { className: "sm-review-primary", disabled: busy, onClick: () => void send({ action: "COMMENT", message: "This draft looks good to me.", target: { kind: "STORY" } }, "Thank you. Your feedback has been sent to the newsroom. The story is still private.", false), children: "Send my feedback" }),
923
1068
  /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Back to the story" })
924
1069
  ] }),
925
- !["article", "identity", "share", "feedback"].includes(panel) && /* @__PURE__ */ jsxs(
1070
+ !["article", "identity", "share", "feedback", "question-list"].includes(panel) && /* @__PURE__ */ jsxs(
926
1071
  "section",
927
1072
  {
928
1073
  ref: responseRef,
@@ -1043,7 +1188,11 @@ function StoryReview({
1043
1188
  onClick: () => setMoreQuestions(true),
1044
1189
  children: "Answer More Questions"
1045
1190
  }
1046
- )
1191
+ ),
1192
+ questionsEmail && /* @__PURE__ */ jsxs("p", { children: [
1193
+ /* @__PURE__ */ jsx("a", { href: questionsEmail, children: "Email these answers instead" }),
1194
+ ". Send them from your email app; your online draft stays saved."
1195
+ ] })
1047
1196
  ]
1048
1197
  }
1049
1198
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.33",
3
+ "version": "0.0.35",
4
4
  "description": "Shared server framework for ScaleMule local news publications. Walnut Creek Times is the broadsheet default; each paper supplies its own identity and feature flags.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
package/story-review.css CHANGED
@@ -630,3 +630,8 @@
630
630
  .sm-review-sharing button, .sm-review-identity-status button { min-height:48px; font:inherit; padding:10px 16px; cursor:pointer; }
631
631
  .sm-review-form input#review-share-url { width:100%; font-size:16px; min-width:0; }
632
632
  .sm-review-form label[for^="contributor-"] { display:block; margin-top:16px; }
633
+
634
+ .sm-review-question-list { padding-left: 1.5rem; margin: 1.5rem 0; }
635
+ .sm-review-question-list li { padding-left: .4rem; margin: 0 0 1.2rem; line-height: 1.6; }
636
+ .sm-review-email-alternative { border-top: 1px solid var(--sm-review-line, #d7d7d7); padding-top: 1rem; margin-top: 1.5rem; line-height: 1.6; }
637
+ .sm-review-email-alternative a { font-weight: 700; text-decoration: underline; text-underline-offset: .2em; }