@scalemule/news-publication 0.0.30 → 0.0.32

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.
@@ -75,7 +75,11 @@ function useReviewDrafts(session, revision, initial, request) {
75
75
  const flushRef = react.useRef(flush);
76
76
  flushRef.current = flush;
77
77
  react.useEffect(() => {
78
- if (!revision) return;
78
+ if (!revision) {
79
+ entries.current = {};
80
+ redraw();
81
+ return;
82
+ }
79
83
  const id = `${session}:${revision}`;
80
84
  active.current = id;
81
85
  const next = {};
@@ -292,12 +296,29 @@ function StoryReview({
292
296
  publicationFooter
293
297
  }) {
294
298
  const base = `/api/news/review/${encodeURIComponent(sessionId)}`;
295
- const [review, setReview] = react.useState(null), [error, setError] = react.useState(""), [notice, setNotice] = react.useState(""), [busy, setBusy] = react.useState(false), [panel, setPanel] = react.useState("article"), [verification, setVerification] = react.useState(false), [code, setCode] = react.useState("");
299
+ 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
+ const [shareUrl, setShareUrl] = react.useState(""), [canShare, setCanShare] = react.useState(false);
301
+ const [contributorName, setContributorName] = react.useState(""), [contributorEmail, setContributorEmail] = react.useState("");
302
+ const [identityStep, setIdentityStep] = react.useState("name"), [requestedPanel, setRequestedPanel] = react.useState("comment");
303
+ function setPanel(next) {
304
+ if (review?.identity_required && !["article", "history", "identity", "share"].includes(next)) {
305
+ setRequestedPanel(next);
306
+ setPanelState("identity");
307
+ } else setPanelState(next);
308
+ }
309
+ const identity = review?.session_id || sessionId;
296
310
  const [rights, setRights] = react.useState(false), [compare, setCompare] = react.useState(""), [compareTo, setCompareTo] = react.useState(""), [editingMedia, setEditingMedia] = react.useState(""), [commentTarget, setCommentTarget] = react.useState("STORY");
297
- const pendingPhoto = usePendingPhoto(review ? `${sessionId}:${review.revision.id}` : void 0);
311
+ const pendingPhoto = usePendingPhoto(review && !review.identity_required ? `${identity}:${review.revision.id}` : void 0);
298
312
  const file = pendingPhoto.photo?.file || null;
299
313
  const galleryRef = react.useRef(null);
300
314
  const cameraRef = react.useRef(null);
315
+ const errorRef = react.useRef(null);
316
+ react.useEffect(() => {
317
+ if (error) {
318
+ errorRef.current?.scrollIntoView({ block: "center" });
319
+ errorRef.current?.focus({ preventScroll: true });
320
+ }
321
+ }, [error]);
301
322
  const noticeRef = react.useRef(null);
302
323
  react.useEffect(() => {
303
324
  if (notice) {
@@ -329,7 +350,7 @@ function StoryReview({
329
350
  }
330
351
  }, [panel]);
331
352
  const emptyMediaInfo = { caption: "", who_is_pictured: "", creator: "", when_taken: "", where_taken: "", credit: "", alt: "" };
332
- const drafts = useReviewDrafts(sessionId, review?.revision.id, review?.response_drafts, request);
353
+ const drafts = useReviewDrafts(identity, review?.identity_required ? void 0 : review?.revision.id, review?.response_drafts, request);
333
354
  const currentDraft = drafts.entries[panel];
334
355
  const message = currentDraft?.data.message || "";
335
356
  const answers = drafts.entries.questions?.data.answers || {};
@@ -382,10 +403,23 @@ function StoryReview({
382
403
  let active = true;
383
404
  void (async () => {
384
405
  try {
385
- const token = window.location.hash.slice(1);
386
- if (token) {
387
- await request("/session", { token });
388
- window.history.replaceState(null, "", window.location.pathname);
406
+ 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
+ let exchange = {};
408
+ try {
409
+ exchange = await request("/session", token ? { token } : {});
410
+ } catch (e) {
411
+ if (token) throw e;
412
+ }
413
+ const address = exchange.share_url || (token ? `/review/${encodeURIComponent(sessionId)}?key=${encodeURIComponent(token)}` : void 0);
414
+ if (address) {
415
+ const canonical = new URL(address, window.location.origin);
416
+ if (canonical.origin === window.location.origin) {
417
+ window.history.replaceState(null, "", canonical.pathname + canonical.search);
418
+ if (active) {
419
+ setShareUrl(canonical.href);
420
+ setCanShare(exchange.can_share === true);
421
+ }
422
+ }
389
423
  }
390
424
  const data = await request();
391
425
  if (active) {
@@ -406,6 +440,18 @@ function StoryReview({
406
440
  active = false;
407
441
  };
408
442
  }, [sessionId]);
443
+ async function sharePrivateLink() {
444
+ if (!shareUrl) return;
445
+ if (navigator.share) {
446
+ try {
447
+ await navigator.share({ title: "Private story preview", url: shareUrl });
448
+ return;
449
+ } catch (e) {
450
+ if (e.name === "AbortError") return;
451
+ }
452
+ }
453
+ setPanel("share");
454
+ }
409
455
  async function send(action, success) {
410
456
  if (!review || busy) return;
411
457
  if (action.action === "APPROVE" && file) {
@@ -532,6 +578,7 @@ function StoryReview({
532
578
  const selected = revisions.find((r) => r.id === compare), selectedAfter = revisions.find(
533
579
  (r) => r.id === (compareTo || review?.revision.id)
534
580
  );
581
+ const errorMessage = error ? /* @__PURE__ */ jsxRuntime.jsx("p", { ref: errorRef, tabIndex: -1, role: "alert", className: "sm-review-alert", children: error }) : null;
535
582
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `sm-review-frame${publicationHeader ? " sm-review-publication-frame" : ""}`, children: [
536
583
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "sm-review-banner-wrap", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-private", children: [
537
584
  /* @__PURE__ */ jsxRuntime.jsx("strong", { children: review?.published ? "Private review" : "Private preview \xB7 Not published" }),
@@ -542,7 +589,7 @@ function StoryReview({
542
589
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: review?.publication.name || publicationName })
543
590
  ] }) }),
544
591
  /* @__PURE__ */ jsxRuntime.jsxs("main", { className: `sm-story-review${celebration ? " sm-review-celebration" : ""}`, children: [
545
- error && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "alert", className: "sm-review-alert", children: error }),
592
+ (!review || panel === "article" || verification) && errorMessage,
546
593
  notice && !(approved && finalEditor && notice === approvalThanks) && /* @__PURE__ */ jsxRuntime.jsx("p", { ref: noticeRef, tabIndex: -1, role: "status", className: "sm-review-notice", children: notice }),
547
594
  verification && /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "sm-review-form", children: [
548
595
  /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Verify your email" }),
@@ -610,8 +657,36 @@ function StoryReview({
610
657
  }
611
658
  )
612
659
  ] }),
660
+ !review && error && /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
661
+ "Ask the person who invited you to use ",
662
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: "Share private preview" }),
663
+ " or copy the complete address from their open preview. You do not need an account to read it."
664
+ ] }),
613
665
  !review && !error && !verification && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", children: "Opening your private preview\u2026" }),
614
666
  review && document && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
667
+ canShare && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-sharing", children: [
668
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Private family preview. Share only with people you want to invite." }),
669
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => void sharePrivateLink(), children: "Share private preview" })
670
+ ] }),
671
+ review.participant && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-identity-status", children: [
672
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
673
+ "Contributing as ",
674
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: review.reviewer })
675
+ ] }),
676
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy || pending.length > 0 || !!file, onClick: async () => {
677
+ setBusy(true);
678
+ try {
679
+ await request("/leave", {});
680
+ setPanelState("article");
681
+ setIdentityStep("name");
682
+ await load();
683
+ } catch (e) {
684
+ setError(e.message);
685
+ } finally {
686
+ setBusy(false);
687
+ }
688
+ }, children: "Not you?" })
689
+ ] }),
615
690
  celebration ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-intro", children: [
616
691
  review.revision.number > 1 ? /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
617
692
  /* @__PURE__ */ jsxRuntime.jsx("strong", { children: "Updated preview" }),
@@ -694,7 +769,10 @@ function StoryReview({
694
769
  ] }),
695
770
  celebration && /* @__PURE__ */ jsxRuntime.jsxs("section", { id: "review-options", className: "sm-review-welcome", children: [
696
771
  /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Help us make this just right" }),
697
- /* @__PURE__ */ jsxRuntime.jsx("p", { children: "We prepared this announcement as a starting point so you don't have to write anything yourself. If everything looks right, you can simply approve it." }),
772
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
773
+ "We prepared this announcement as a starting point so you don't have to write anything yourself. ",
774
+ can("APPROVE") ? "If everything looks right, you can simply approve it." : "Read it at your own pace, and let us know if anything needs a change."
775
+ ] }),
698
776
  /* @__PURE__ */ jsxRuntime.jsx("p", { children: "If you'd like, you can also correct a detail, add photographs, or tell us a little more about the couple. We'll take care of turning any additional information into the next draft." }),
699
777
  can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(
700
778
  "button",
@@ -722,6 +800,7 @@ function StoryReview({
722
800
  className: `sm-review-actions${celebration ? " sm-review-concierge-actions" : ""}${panel !== "article" ? " sm-review-actions-editing" : ""}`,
723
801
  "aria-label": "Review actions",
724
802
  children: [
803
+ !can("APPROVE") && can("COMMENT") && (review.identity_required || review.participant) && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("feedback"), children: "Looks good to me" }),
725
804
  can("APPROVE") && !review.published && /* @__PURE__ */ jsxRuntime.jsx(
726
805
  "button",
727
806
  {
@@ -755,7 +834,97 @@ function StoryReview({
755
834
  ] }, name)),
756
835
  file && !pending.some(([name]) => name === "media") && /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setPanel("media"), children: "Continue your photo upload" })
757
836
  ] }),
758
- panel !== "article" && /* @__PURE__ */ jsxRuntime.jsxs(
837
+ panel === "share" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Share private preview", children: [
838
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Share with your family" }),
839
+ 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." }),
841
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "review-share-url", children: "Private preview link" }),
842
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "review-share-url", readOnly: true, value: shareUrl, onFocus: (e) => e.currentTarget.select() }),
843
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: async () => {
844
+ try {
845
+ await navigator.clipboard.writeText(shareUrl);
846
+ setNotice("Private link copied. Paste it into your message to family.");
847
+ } catch {
848
+ setNotice("Select the link above, then choose Copy on your device.");
849
+ }
850
+ }, children: "Copy private link" }),
851
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setPanel("article"), children: "Back to the story" })
852
+ ] }),
853
+ 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?" }),
855
+ 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) => {
858
+ e.preventDefault();
859
+ setBusy(true);
860
+ setError("");
861
+ try {
862
+ await request("/join", { name: contributorName, email: contributorEmail });
863
+ await request("/verify", {});
864
+ setIdentityStep("code");
865
+ setCode("");
866
+ } catch (e2) {
867
+ setError(e2.message);
868
+ } finally {
869
+ setBusy(false);
870
+ }
871
+ }, children: [
872
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-name", children: "Your name" }),
873
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "contributor-name", autoComplete: "name", value: contributorName, maxLength: 150, required: true, onChange: (e) => setContributorName(e.target.value) }),
874
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-email", children: "Your email" }),
875
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "contributor-email", type: "email", autoComplete: "email", value: contributorEmail, maxLength: 320, required: true, onChange: (e) => setContributorEmail(e.target.value) }),
876
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "We'll email you a code to confirm it's you." }),
877
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, children: busy ? "Sending your code\u2026" : "Email me a code" })
878
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
879
+ e.preventDefault();
880
+ setBusy(true);
881
+ setError("");
882
+ try {
883
+ await request("/verify", { code });
884
+ await load();
885
+ setCode("");
886
+ setPanelState(requestedPanel);
887
+ } catch (e2) {
888
+ setError(e2.message);
889
+ } finally {
890
+ setBusy(false);
891
+ }
892
+ }, children: [
893
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
894
+ "Enter the eight-digit code sent to ",
895
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: contributorEmail }),
896
+ ". It expires in ten minutes."
897
+ ] }),
898
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-code", children: "Email verification code" }),
899
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "contributor-code", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9]{8}", maxLength: 8, value: code, required: true, onChange: (e) => setCode(e.target.value.replace(/\s/g, "")) }),
900
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, children: busy ? "Verifying\u2026" : "Verify and continue" }),
901
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: busy, onClick: async () => {
902
+ setBusy(true);
903
+ try {
904
+ await request("/verify", {});
905
+ setNotice("A new code was sent. Check your inbox and spam folder.");
906
+ } catch (e) {
907
+ setError(e.message);
908
+ } finally {
909
+ setBusy(false);
910
+ }
911
+ }, children: "Send a new code" }),
912
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: busy, onClick: () => setIdentityStep("name"), children: "Use a different email" })
913
+ ] }),
914
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Just read the story" })
915
+ ] }),
916
+ panel === "feedback" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Family feedback", children: [
917
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Looks good to you?" }),
918
+ errorMessage,
919
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
920
+ "We'll send your feedback as ",
921
+ review.reviewer,
922
+ ". The newsroom will request approval from the designated family reviewer separately. This will not publish the story."
923
+ ] }),
924
+ /* @__PURE__ */ jsxRuntime.jsx("button", { 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."), children: "Send my feedback" }),
925
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Back to the story" })
926
+ ] }),
927
+ !["article", "identity", "share", "feedback"].includes(panel) && /* @__PURE__ */ jsxRuntime.jsxs(
759
928
  "section",
760
929
  {
761
930
  ref: responseRef,
@@ -767,6 +936,7 @@ function StoryReview({
767
936
  /* @__PURE__ */ jsxRuntime.jsx("h2", { children: panel === "approve" ? "Approve this exact draft" : panel === "edits" ? "Propose changes" : panel === "media" ? photoToReplace ? "Change photo" : "Contribute a photograph" : panel === "questions" ? "Tell us a little more" : panel === "history" ? "Selected revision history" : panel === "decline" ? "Prefer not to publish" : panel === "changes" ? "Suggest a change" : "Message the newsroom" }),
768
937
  /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Close" })
769
938
  ] }),
939
+ errorMessage,
770
940
  panel !== "history" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-save-state", role: "status", "aria-live": "polite", children: [
771
941
  currentDraft?.status === "conflict" ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
772
942
  /* @__PURE__ */ jsxRuntime.jsx("p", { children: "A response was saved in another tab or device. Your writing here is still available. Choose which version to keep." }),
@@ -73,7 +73,11 @@ function useReviewDrafts(session, revision, initial, request) {
73
73
  const flushRef = useRef(flush);
74
74
  flushRef.current = flush;
75
75
  useEffect(() => {
76
- if (!revision) return;
76
+ if (!revision) {
77
+ entries.current = {};
78
+ redraw();
79
+ return;
80
+ }
77
81
  const id = `${session}:${revision}`;
78
82
  active.current = id;
79
83
  const next = {};
@@ -290,12 +294,29 @@ function StoryReview({
290
294
  publicationFooter
291
295
  }) {
292
296
  const base = `/api/news/review/${encodeURIComponent(sessionId)}`;
293
- const [review, setReview] = useState(null), [error, setError] = useState(""), [notice, setNotice] = useState(""), [busy, setBusy] = useState(false), [panel, setPanel] = useState("article"), [verification, setVerification] = useState(false), [code, setCode] = useState("");
297
+ 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
+ const [shareUrl, setShareUrl] = useState(""), [canShare, setCanShare] = useState(false);
299
+ const [contributorName, setContributorName] = useState(""), [contributorEmail, setContributorEmail] = useState("");
300
+ const [identityStep, setIdentityStep] = useState("name"), [requestedPanel, setRequestedPanel] = useState("comment");
301
+ function setPanel(next) {
302
+ if (review?.identity_required && !["article", "history", "identity", "share"].includes(next)) {
303
+ setRequestedPanel(next);
304
+ setPanelState("identity");
305
+ } else setPanelState(next);
306
+ }
307
+ const identity = review?.session_id || sessionId;
294
308
  const [rights, setRights] = useState(false), [compare, setCompare] = useState(""), [compareTo, setCompareTo] = useState(""), [editingMedia, setEditingMedia] = useState(""), [commentTarget, setCommentTarget] = useState("STORY");
295
- const pendingPhoto = usePendingPhoto(review ? `${sessionId}:${review.revision.id}` : void 0);
309
+ const pendingPhoto = usePendingPhoto(review && !review.identity_required ? `${identity}:${review.revision.id}` : void 0);
296
310
  const file = pendingPhoto.photo?.file || null;
297
311
  const galleryRef = useRef(null);
298
312
  const cameraRef = useRef(null);
313
+ const errorRef = useRef(null);
314
+ useEffect(() => {
315
+ if (error) {
316
+ errorRef.current?.scrollIntoView({ block: "center" });
317
+ errorRef.current?.focus({ preventScroll: true });
318
+ }
319
+ }, [error]);
299
320
  const noticeRef = useRef(null);
300
321
  useEffect(() => {
301
322
  if (notice) {
@@ -327,7 +348,7 @@ function StoryReview({
327
348
  }
328
349
  }, [panel]);
329
350
  const emptyMediaInfo = { caption: "", who_is_pictured: "", creator: "", when_taken: "", where_taken: "", credit: "", alt: "" };
330
- const drafts = useReviewDrafts(sessionId, review?.revision.id, review?.response_drafts, request);
351
+ const drafts = useReviewDrafts(identity, review?.identity_required ? void 0 : review?.revision.id, review?.response_drafts, request);
331
352
  const currentDraft = drafts.entries[panel];
332
353
  const message = currentDraft?.data.message || "";
333
354
  const answers = drafts.entries.questions?.data.answers || {};
@@ -380,10 +401,23 @@ function StoryReview({
380
401
  let active = true;
381
402
  void (async () => {
382
403
  try {
383
- const token = window.location.hash.slice(1);
384
- if (token) {
385
- await request("/session", { token });
386
- window.history.replaceState(null, "", window.location.pathname);
404
+ 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
+ let exchange = {};
406
+ try {
407
+ exchange = await request("/session", token ? { token } : {});
408
+ } catch (e) {
409
+ if (token) throw e;
410
+ }
411
+ const address = exchange.share_url || (token ? `/review/${encodeURIComponent(sessionId)}?key=${encodeURIComponent(token)}` : void 0);
412
+ if (address) {
413
+ const canonical = new URL(address, window.location.origin);
414
+ if (canonical.origin === window.location.origin) {
415
+ window.history.replaceState(null, "", canonical.pathname + canonical.search);
416
+ if (active) {
417
+ setShareUrl(canonical.href);
418
+ setCanShare(exchange.can_share === true);
419
+ }
420
+ }
387
421
  }
388
422
  const data = await request();
389
423
  if (active) {
@@ -404,6 +438,18 @@ function StoryReview({
404
438
  active = false;
405
439
  };
406
440
  }, [sessionId]);
441
+ async function sharePrivateLink() {
442
+ if (!shareUrl) return;
443
+ if (navigator.share) {
444
+ try {
445
+ await navigator.share({ title: "Private story preview", url: shareUrl });
446
+ return;
447
+ } catch (e) {
448
+ if (e.name === "AbortError") return;
449
+ }
450
+ }
451
+ setPanel("share");
452
+ }
407
453
  async function send(action, success) {
408
454
  if (!review || busy) return;
409
455
  if (action.action === "APPROVE" && file) {
@@ -530,6 +576,7 @@ function StoryReview({
530
576
  const selected = revisions.find((r) => r.id === compare), selectedAfter = revisions.find(
531
577
  (r) => r.id === (compareTo || review?.revision.id)
532
578
  );
579
+ const errorMessage = error ? /* @__PURE__ */ jsx("p", { ref: errorRef, tabIndex: -1, role: "alert", className: "sm-review-alert", children: error }) : null;
533
580
  return /* @__PURE__ */ jsxs("div", { className: `sm-review-frame${publicationHeader ? " sm-review-publication-frame" : ""}`, children: [
534
581
  /* @__PURE__ */ jsx("div", { className: "sm-review-banner-wrap", children: /* @__PURE__ */ jsxs("div", { className: "sm-review-private", children: [
535
582
  /* @__PURE__ */ jsx("strong", { children: review?.published ? "Private review" : "Private preview \xB7 Not published" }),
@@ -540,7 +587,7 @@ function StoryReview({
540
587
  /* @__PURE__ */ jsx("span", { children: review?.publication.name || publicationName })
541
588
  ] }) }),
542
589
  /* @__PURE__ */ jsxs("main", { className: `sm-story-review${celebration ? " sm-review-celebration" : ""}`, children: [
543
- error && /* @__PURE__ */ jsx("p", { role: "alert", className: "sm-review-alert", children: error }),
590
+ (!review || panel === "article" || verification) && errorMessage,
544
591
  notice && !(approved && finalEditor && notice === approvalThanks) && /* @__PURE__ */ jsx("p", { ref: noticeRef, tabIndex: -1, role: "status", className: "sm-review-notice", children: notice }),
545
592
  verification && /* @__PURE__ */ jsxs("section", { className: "sm-review-form", children: [
546
593
  /* @__PURE__ */ jsx("h2", { children: "Verify your email" }),
@@ -608,8 +655,36 @@ function StoryReview({
608
655
  }
609
656
  )
610
657
  ] }),
658
+ !review && error && /* @__PURE__ */ jsxs("p", { children: [
659
+ "Ask the person who invited you to use ",
660
+ /* @__PURE__ */ jsx("strong", { children: "Share private preview" }),
661
+ " or copy the complete address from their open preview. You do not need an account to read it."
662
+ ] }),
611
663
  !review && !error && !verification && /* @__PURE__ */ jsx("p", { role: "status", children: "Opening your private preview\u2026" }),
612
664
  review && document && /* @__PURE__ */ jsxs(Fragment, { children: [
665
+ canShare && /* @__PURE__ */ jsxs("div", { className: "sm-review-sharing", children: [
666
+ /* @__PURE__ */ jsx("p", { children: "Private family preview. Share only with people you want to invite." }),
667
+ /* @__PURE__ */ jsx("button", { onClick: () => void sharePrivateLink(), children: "Share private preview" })
668
+ ] }),
669
+ review.participant && /* @__PURE__ */ jsxs("div", { className: "sm-review-identity-status", children: [
670
+ /* @__PURE__ */ jsxs("span", { children: [
671
+ "Contributing as ",
672
+ /* @__PURE__ */ jsx("strong", { children: review.reviewer })
673
+ ] }),
674
+ /* @__PURE__ */ jsx("button", { disabled: busy || pending.length > 0 || !!file, onClick: async () => {
675
+ setBusy(true);
676
+ try {
677
+ await request("/leave", {});
678
+ setPanelState("article");
679
+ setIdentityStep("name");
680
+ await load();
681
+ } catch (e) {
682
+ setError(e.message);
683
+ } finally {
684
+ setBusy(false);
685
+ }
686
+ }, children: "Not you?" })
687
+ ] }),
613
688
  celebration ? /* @__PURE__ */ jsxs("div", { className: "sm-review-intro", children: [
614
689
  review.revision.number > 1 ? /* @__PURE__ */ jsxs("p", { children: [
615
690
  /* @__PURE__ */ jsx("strong", { children: "Updated preview" }),
@@ -692,7 +767,10 @@ function StoryReview({
692
767
  ] }),
693
768
  celebration && /* @__PURE__ */ jsxs("section", { id: "review-options", className: "sm-review-welcome", children: [
694
769
  /* @__PURE__ */ jsx("h2", { children: "Help us make this just right" }),
695
- /* @__PURE__ */ jsx("p", { children: "We prepared this announcement as a starting point so you don't have to write anything yourself. If everything looks right, you can simply approve it." }),
770
+ /* @__PURE__ */ jsxs("p", { children: [
771
+ "We prepared this announcement as a starting point so you don't have to write anything yourself. ",
772
+ can("APPROVE") ? "If everything looks right, you can simply approve it." : "Read it at your own pace, and let us know if anything needs a change."
773
+ ] }),
696
774
  /* @__PURE__ */ jsx("p", { children: "If you'd like, you can also correct a detail, add photographs, or tell us a little more about the couple. We'll take care of turning any additional information into the next draft." }),
697
775
  can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsx(
698
776
  "button",
@@ -720,6 +798,7 @@ function StoryReview({
720
798
  className: `sm-review-actions${celebration ? " sm-review-concierge-actions" : ""}${panel !== "article" ? " sm-review-actions-editing" : ""}`,
721
799
  "aria-label": "Review actions",
722
800
  children: [
801
+ !can("APPROVE") && can("COMMENT") && (review.identity_required || review.participant) && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("feedback"), children: "Looks good to me" }),
723
802
  can("APPROVE") && !review.published && /* @__PURE__ */ jsx(
724
803
  "button",
725
804
  {
@@ -753,7 +832,97 @@ function StoryReview({
753
832
  ] }, name)),
754
833
  file && !pending.some(([name]) => name === "media") && /* @__PURE__ */ jsx("button", { onClick: () => setPanel("media"), children: "Continue your photo upload" })
755
834
  ] }),
756
- panel !== "article" && /* @__PURE__ */ jsxs(
835
+ panel === "share" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Share private preview", children: [
836
+ /* @__PURE__ */ jsx("h2", { children: "Share with your family" }),
837
+ 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." }),
839
+ /* @__PURE__ */ jsx("label", { htmlFor: "review-share-url", children: "Private preview link" }),
840
+ /* @__PURE__ */ jsx("input", { id: "review-share-url", readOnly: true, value: shareUrl, onFocus: (e) => e.currentTarget.select() }),
841
+ /* @__PURE__ */ jsx("button", { onClick: async () => {
842
+ try {
843
+ await navigator.clipboard.writeText(shareUrl);
844
+ setNotice("Private link copied. Paste it into your message to family.");
845
+ } catch {
846
+ setNotice("Select the link above, then choose Copy on your device.");
847
+ }
848
+ }, children: "Copy private link" }),
849
+ /* @__PURE__ */ jsx("button", { onClick: () => setPanel("article"), children: "Back to the story" })
850
+ ] }),
851
+ 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?" }),
853
+ 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) => {
856
+ e.preventDefault();
857
+ setBusy(true);
858
+ setError("");
859
+ try {
860
+ await request("/join", { name: contributorName, email: contributorEmail });
861
+ await request("/verify", {});
862
+ setIdentityStep("code");
863
+ setCode("");
864
+ } catch (e2) {
865
+ setError(e2.message);
866
+ } finally {
867
+ setBusy(false);
868
+ }
869
+ }, children: [
870
+ /* @__PURE__ */ jsx("label", { htmlFor: "contributor-name", children: "Your name" }),
871
+ /* @__PURE__ */ jsx("input", { id: "contributor-name", autoComplete: "name", value: contributorName, maxLength: 150, required: true, onChange: (e) => setContributorName(e.target.value) }),
872
+ /* @__PURE__ */ jsx("label", { htmlFor: "contributor-email", children: "Your email" }),
873
+ /* @__PURE__ */ jsx("input", { id: "contributor-email", type: "email", autoComplete: "email", value: contributorEmail, maxLength: 320, required: true, onChange: (e) => setContributorEmail(e.target.value) }),
874
+ /* @__PURE__ */ jsx("p", { children: "We'll email you a code to confirm it's you." }),
875
+ /* @__PURE__ */ jsx("button", { disabled: busy, children: busy ? "Sending your code\u2026" : "Email me a code" })
876
+ ] }) : /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
877
+ e.preventDefault();
878
+ setBusy(true);
879
+ setError("");
880
+ try {
881
+ await request("/verify", { code });
882
+ await load();
883
+ setCode("");
884
+ setPanelState(requestedPanel);
885
+ } catch (e2) {
886
+ setError(e2.message);
887
+ } finally {
888
+ setBusy(false);
889
+ }
890
+ }, children: [
891
+ /* @__PURE__ */ jsxs("p", { children: [
892
+ "Enter the eight-digit code sent to ",
893
+ /* @__PURE__ */ jsx("strong", { children: contributorEmail }),
894
+ ". It expires in ten minutes."
895
+ ] }),
896
+ /* @__PURE__ */ jsx("label", { htmlFor: "contributor-code", children: "Email verification code" }),
897
+ /* @__PURE__ */ jsx("input", { id: "contributor-code", inputMode: "numeric", autoComplete: "one-time-code", pattern: "[0-9]{8}", maxLength: 8, value: code, required: true, onChange: (e) => setCode(e.target.value.replace(/\s/g, "")) }),
898
+ /* @__PURE__ */ jsx("button", { disabled: busy, children: busy ? "Verifying\u2026" : "Verify and continue" }),
899
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: async () => {
900
+ setBusy(true);
901
+ try {
902
+ await request("/verify", {});
903
+ setNotice("A new code was sent. Check your inbox and spam folder.");
904
+ } catch (e) {
905
+ setError(e.message);
906
+ } finally {
907
+ setBusy(false);
908
+ }
909
+ }, children: "Send a new code" }),
910
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: () => setIdentityStep("name"), children: "Use a different email" })
911
+ ] }),
912
+ /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Just read the story" })
913
+ ] }),
914
+ panel === "feedback" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Family feedback", children: [
915
+ /* @__PURE__ */ jsx("h2", { children: "Looks good to you?" }),
916
+ errorMessage,
917
+ /* @__PURE__ */ jsxs("p", { children: [
918
+ "We'll send your feedback as ",
919
+ review.reviewer,
920
+ ". The newsroom will request approval from the designated family reviewer separately. This will not publish the story."
921
+ ] }),
922
+ /* @__PURE__ */ jsx("button", { 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."), children: "Send my feedback" }),
923
+ /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Back to the story" })
924
+ ] }),
925
+ !["article", "identity", "share", "feedback"].includes(panel) && /* @__PURE__ */ jsxs(
757
926
  "section",
758
927
  {
759
928
  ref: responseRef,
@@ -765,6 +934,7 @@ function StoryReview({
765
934
  /* @__PURE__ */ jsx("h2", { children: panel === "approve" ? "Approve this exact draft" : panel === "edits" ? "Propose changes" : panel === "media" ? photoToReplace ? "Change photo" : "Contribute a photograph" : panel === "questions" ? "Tell us a little more" : panel === "history" ? "Selected revision history" : panel === "decline" ? "Prefer not to publish" : panel === "changes" ? "Suggest a change" : "Message the newsroom" }),
766
935
  /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Close" })
767
936
  ] }),
937
+ errorMessage,
768
938
  panel !== "history" && /* @__PURE__ */ jsxs("div", { className: "sm-review-save-state", role: "status", "aria-live": "polite", children: [
769
939
  currentDraft?.status === "conflict" ? /* @__PURE__ */ jsxs(Fragment, { children: [
770
940
  /* @__PURE__ */ jsx("p", { children: "A response was saved in another tab or device. Your writing here is still available. Choose which version to keep." }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalemule/news-publication",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
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
@@ -620,3 +620,10 @@
620
620
  .sm-review-photo-picker button { flex: 1 1 180px; margin: 0; }
621
621
  .sm-review-save-state button { margin: 8px 0 0; }
622
622
  }
623
+
624
+ /* Keep family sharing alongside the preview, separate from editorial copy. */
625
+ .sm-review-sharing, .sm-review-identity-status { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding:16px 0; border-bottom:1px solid var(--sm-rule, #d8d8d8); font:16px/1.5 system-ui,sans-serif; }
626
+ .sm-review-sharing p { flex:1 1 240px; margin:0; }
627
+ .sm-review-sharing button, .sm-review-identity-status button { min-height:48px; font:inherit; padding:10px 16px; cursor:pointer; }
628
+ .sm-review-form input#review-share-url { width:100%; font-size:16px; min-width:0; }
629
+ .sm-review-form label[for^="contributor-"] { display:block; margin-top:16px; }