@scalemule/news-publication 0.0.30 → 0.0.31

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) {
@@ -542,7 +588,7 @@ function StoryReview({
542
588
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: review?.publication.name || publicationName })
543
589
  ] }) }),
544
590
  /* @__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 }),
591
+ error && /* @__PURE__ */ jsxRuntime.jsx("p", { ref: errorRef, tabIndex: -1, role: "alert", className: "sm-review-alert", children: error }),
546
592
  notice && !(approved && finalEditor && notice === approvalThanks) && /* @__PURE__ */ jsxRuntime.jsx("p", { ref: noticeRef, tabIndex: -1, role: "status", className: "sm-review-notice", children: notice }),
547
593
  verification && /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "sm-review-form", children: [
548
594
  /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Verify your email" }),
@@ -610,8 +656,36 @@ function StoryReview({
610
656
  }
611
657
  )
612
658
  ] }),
659
+ !review && error && /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
660
+ "Ask the person who invited you to use ",
661
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: "Share private preview" }),
662
+ " or copy the complete address from their open preview. You do not need an account to read it."
663
+ ] }),
613
664
  !review && !error && !verification && /* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", children: "Opening your private preview\u2026" }),
614
665
  review && document && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
666
+ canShare && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-sharing", children: [
667
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Private family preview. Share only with people you want to invite." }),
668
+ /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => void sharePrivateLink(), children: "Share private preview" })
669
+ ] }),
670
+ review.participant && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-identity-status", children: [
671
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
672
+ "Contributing as ",
673
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: review.reviewer })
674
+ ] }),
675
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy || pending.length > 0 || !!file, onClick: async () => {
676
+ setBusy(true);
677
+ try {
678
+ await request("/leave", {});
679
+ setPanelState("article");
680
+ setIdentityStep("name");
681
+ await load();
682
+ } catch (e) {
683
+ setError(e.message);
684
+ } finally {
685
+ setBusy(false);
686
+ }
687
+ }, children: "Not you?" })
688
+ ] }),
615
689
  celebration ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "sm-review-intro", children: [
616
690
  review.revision.number > 1 ? /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
617
691
  /* @__PURE__ */ jsxRuntime.jsx("strong", { children: "Updated preview" }),
@@ -694,7 +768,10 @@ function StoryReview({
694
768
  ] }),
695
769
  celebration && /* @__PURE__ */ jsxRuntime.jsxs("section", { id: "review-options", className: "sm-review-welcome", children: [
696
770
  /* @__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." }),
771
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
772
+ "We prepared this announcement as a starting point so you don't have to write anything yourself. ",
773
+ 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."
774
+ ] }),
698
775
  /* @__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
776
  can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(
700
777
  "button",
@@ -722,6 +799,7 @@ function StoryReview({
722
799
  className: `sm-review-actions${celebration ? " sm-review-concierge-actions" : ""}${panel !== "article" ? " sm-review-actions-editing" : ""}`,
723
800
  "aria-label": "Review actions",
724
801
  children: [
802
+ !can("APPROVE") && can("COMMENT") && (review.identity_required || review.participant) && /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("feedback"), children: "Looks good to me" }),
725
803
  can("APPROVE") && !review.published && /* @__PURE__ */ jsxRuntime.jsx(
726
804
  "button",
727
805
  {
@@ -755,7 +833,94 @@ function StoryReview({
755
833
  ] }, name)),
756
834
  file && !pending.some(([name]) => name === "media") && /* @__PURE__ */ jsxRuntime.jsx("button", { onClick: () => setPanel("media"), children: "Continue your photo upload" })
757
835
  ] }),
758
- panel !== "article" && /* @__PURE__ */ jsxRuntime.jsxs(
836
+ panel === "share" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Share private preview", children: [
837
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Share with your family" }),
838
+ /* @__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." }),
839
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "review-share-url", children: "Private preview link" }),
840
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "review-share-url", readOnly: true, value: shareUrl, onFocus: (e) => e.currentTarget.select() }),
841
+ /* @__PURE__ */ jsxRuntime.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__ */ jsxRuntime.jsx("button", { onClick: () => setPanel("article"), children: "Back to the story" })
850
+ ] }),
851
+ panel === "identity" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Introduce yourself", children: [
852
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: identityStep === "code" ? "Check your email" : "Who is helping with the story?" }),
853
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Your name will accompany your feedback for the newsroom. Your email stays private. No password or account is needed." }),
854
+ identityStep === "name" ? /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
855
+ e.preventDefault();
856
+ setBusy(true);
857
+ setError("");
858
+ try {
859
+ await request("/join", { name: contributorName, email: contributorEmail });
860
+ await request("/verify", {});
861
+ setIdentityStep("code");
862
+ setCode("");
863
+ } catch (e2) {
864
+ setError(e2.message);
865
+ } finally {
866
+ setBusy(false);
867
+ }
868
+ }, children: [
869
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-name", children: "Your name" }),
870
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "contributor-name", autoComplete: "name", value: contributorName, maxLength: 150, required: true, onChange: (e) => setContributorName(e.target.value) }),
871
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-email", children: "Your email" }),
872
+ /* @__PURE__ */ jsxRuntime.jsx("input", { id: "contributor-email", type: "email", autoComplete: "email", value: contributorEmail, maxLength: 320, required: true, onChange: (e) => setContributorEmail(e.target.value) }),
873
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "We'll email you a code to confirm it's you." }),
874
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, children: busy ? "Sending your code\u2026" : "Email me a code" })
875
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs("form", { onSubmit: async (e) => {
876
+ e.preventDefault();
877
+ setBusy(true);
878
+ setError("");
879
+ try {
880
+ await request("/verify", { code });
881
+ await load();
882
+ setCode("");
883
+ setPanelState(requestedPanel);
884
+ } catch (e2) {
885
+ setError(e2.message);
886
+ } finally {
887
+ setBusy(false);
888
+ }
889
+ }, children: [
890
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
891
+ "Enter the eight-digit code sent to ",
892
+ /* @__PURE__ */ jsxRuntime.jsx("strong", { children: contributorEmail }),
893
+ ". It expires in ten minutes."
894
+ ] }),
895
+ /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: "contributor-code", children: "Email verification code" }),
896
+ /* @__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, "")) }),
897
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, children: busy ? "Verifying\u2026" : "Verify and continue" }),
898
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: busy, onClick: async () => {
899
+ setBusy(true);
900
+ try {
901
+ await request("/verify", {});
902
+ setNotice("A new code was sent. Check your inbox and spam folder.");
903
+ } catch (e) {
904
+ setError(e.message);
905
+ } finally {
906
+ setBusy(false);
907
+ }
908
+ }, children: "Send a new code" }),
909
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", disabled: busy, onClick: () => setIdentityStep("name"), children: "Use a different email" })
910
+ ] }),
911
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Just read the story" })
912
+ ] }),
913
+ panel === "feedback" && /* @__PURE__ */ jsxRuntime.jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Family feedback", children: [
914
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { children: "Looks good to you?" }),
915
+ /* @__PURE__ */ jsxRuntime.jsxs("p", { children: [
916
+ "We'll send your feedback as ",
917
+ review.reviewer,
918
+ ". The newsroom will request approval from the designated family reviewer separately. This will not publish the story."
919
+ ] }),
920
+ /* @__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" }),
921
+ /* @__PURE__ */ jsxRuntime.jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Back to the story" })
922
+ ] }),
923
+ !["article", "identity", "share", "feedback"].includes(panel) && /* @__PURE__ */ jsxRuntime.jsxs(
759
924
  "section",
760
925
  {
761
926
  ref: responseRef,
@@ -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) {
@@ -540,7 +586,7 @@ function StoryReview({
540
586
  /* @__PURE__ */ jsx("span", { children: review?.publication.name || publicationName })
541
587
  ] }) }),
542
588
  /* @__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 }),
589
+ error && /* @__PURE__ */ jsx("p", { ref: errorRef, tabIndex: -1, role: "alert", className: "sm-review-alert", children: error }),
544
590
  notice && !(approved && finalEditor && notice === approvalThanks) && /* @__PURE__ */ jsx("p", { ref: noticeRef, tabIndex: -1, role: "status", className: "sm-review-notice", children: notice }),
545
591
  verification && /* @__PURE__ */ jsxs("section", { className: "sm-review-form", children: [
546
592
  /* @__PURE__ */ jsx("h2", { children: "Verify your email" }),
@@ -608,8 +654,36 @@ function StoryReview({
608
654
  }
609
655
  )
610
656
  ] }),
657
+ !review && error && /* @__PURE__ */ jsxs("p", { children: [
658
+ "Ask the person who invited you to use ",
659
+ /* @__PURE__ */ jsx("strong", { children: "Share private preview" }),
660
+ " or copy the complete address from their open preview. You do not need an account to read it."
661
+ ] }),
611
662
  !review && !error && !verification && /* @__PURE__ */ jsx("p", { role: "status", children: "Opening your private preview\u2026" }),
612
663
  review && document && /* @__PURE__ */ jsxs(Fragment, { children: [
664
+ canShare && /* @__PURE__ */ jsxs("div", { className: "sm-review-sharing", children: [
665
+ /* @__PURE__ */ jsx("p", { children: "Private family preview. Share only with people you want to invite." }),
666
+ /* @__PURE__ */ jsx("button", { onClick: () => void sharePrivateLink(), children: "Share private preview" })
667
+ ] }),
668
+ review.participant && /* @__PURE__ */ jsxs("div", { className: "sm-review-identity-status", children: [
669
+ /* @__PURE__ */ jsxs("span", { children: [
670
+ "Contributing as ",
671
+ /* @__PURE__ */ jsx("strong", { children: review.reviewer })
672
+ ] }),
673
+ /* @__PURE__ */ jsx("button", { disabled: busy || pending.length > 0 || !!file, onClick: async () => {
674
+ setBusy(true);
675
+ try {
676
+ await request("/leave", {});
677
+ setPanelState("article");
678
+ setIdentityStep("name");
679
+ await load();
680
+ } catch (e) {
681
+ setError(e.message);
682
+ } finally {
683
+ setBusy(false);
684
+ }
685
+ }, children: "Not you?" })
686
+ ] }),
613
687
  celebration ? /* @__PURE__ */ jsxs("div", { className: "sm-review-intro", children: [
614
688
  review.revision.number > 1 ? /* @__PURE__ */ jsxs("p", { children: [
615
689
  /* @__PURE__ */ jsx("strong", { children: "Updated preview" }),
@@ -692,7 +766,10 @@ function StoryReview({
692
766
  ] }),
693
767
  celebration && /* @__PURE__ */ jsxs("section", { id: "review-options", className: "sm-review-welcome", children: [
694
768
  /* @__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." }),
769
+ /* @__PURE__ */ jsxs("p", { children: [
770
+ "We prepared this announcement as a starting point so you don't have to write anything yourself. ",
771
+ 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."
772
+ ] }),
696
773
  /* @__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
774
  can("ANSWER_QUESTIONS") && review.questions.length > 0 && /* @__PURE__ */ jsx(
698
775
  "button",
@@ -720,6 +797,7 @@ function StoryReview({
720
797
  className: `sm-review-actions${celebration ? " sm-review-concierge-actions" : ""}${panel !== "article" ? " sm-review-actions-editing" : ""}`,
721
798
  "aria-label": "Review actions",
722
799
  children: [
800
+ !can("APPROVE") && can("COMMENT") && (review.identity_required || review.participant) && /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("feedback"), children: "Looks good to me" }),
723
801
  can("APPROVE") && !review.published && /* @__PURE__ */ jsx(
724
802
  "button",
725
803
  {
@@ -753,7 +831,94 @@ function StoryReview({
753
831
  ] }, name)),
754
832
  file && !pending.some(([name]) => name === "media") && /* @__PURE__ */ jsx("button", { onClick: () => setPanel("media"), children: "Continue your photo upload" })
755
833
  ] }),
756
- panel !== "article" && /* @__PURE__ */ jsxs(
834
+ panel === "share" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Share private preview", children: [
835
+ /* @__PURE__ */ jsx("h2", { children: "Share with your family" }),
836
+ /* @__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." }),
837
+ /* @__PURE__ */ jsx("label", { htmlFor: "review-share-url", children: "Private preview link" }),
838
+ /* @__PURE__ */ jsx("input", { id: "review-share-url", readOnly: true, value: shareUrl, onFocus: (e) => e.currentTarget.select() }),
839
+ /* @__PURE__ */ jsx("button", { onClick: async () => {
840
+ try {
841
+ await navigator.clipboard.writeText(shareUrl);
842
+ setNotice("Private link copied. Paste it into your message to family.");
843
+ } catch {
844
+ setNotice("Select the link above, then choose Copy on your device.");
845
+ }
846
+ }, children: "Copy private link" }),
847
+ /* @__PURE__ */ jsx("button", { onClick: () => setPanel("article"), children: "Back to the story" })
848
+ ] }),
849
+ panel === "identity" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Introduce yourself", children: [
850
+ /* @__PURE__ */ jsx("h2", { children: identityStep === "code" ? "Check your email" : "Who is helping with the story?" }),
851
+ /* @__PURE__ */ jsx("p", { children: "Your name will accompany your feedback for the newsroom. Your email stays private. No password or account is needed." }),
852
+ identityStep === "name" ? /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
853
+ e.preventDefault();
854
+ setBusy(true);
855
+ setError("");
856
+ try {
857
+ await request("/join", { name: contributorName, email: contributorEmail });
858
+ await request("/verify", {});
859
+ setIdentityStep("code");
860
+ setCode("");
861
+ } catch (e2) {
862
+ setError(e2.message);
863
+ } finally {
864
+ setBusy(false);
865
+ }
866
+ }, children: [
867
+ /* @__PURE__ */ jsx("label", { htmlFor: "contributor-name", children: "Your name" }),
868
+ /* @__PURE__ */ jsx("input", { id: "contributor-name", autoComplete: "name", value: contributorName, maxLength: 150, required: true, onChange: (e) => setContributorName(e.target.value) }),
869
+ /* @__PURE__ */ jsx("label", { htmlFor: "contributor-email", children: "Your email" }),
870
+ /* @__PURE__ */ jsx("input", { id: "contributor-email", type: "email", autoComplete: "email", value: contributorEmail, maxLength: 320, required: true, onChange: (e) => setContributorEmail(e.target.value) }),
871
+ /* @__PURE__ */ jsx("p", { children: "We'll email you a code to confirm it's you." }),
872
+ /* @__PURE__ */ jsx("button", { disabled: busy, children: busy ? "Sending your code\u2026" : "Email me a code" })
873
+ ] }) : /* @__PURE__ */ jsxs("form", { onSubmit: async (e) => {
874
+ e.preventDefault();
875
+ setBusy(true);
876
+ setError("");
877
+ try {
878
+ await request("/verify", { code });
879
+ await load();
880
+ setCode("");
881
+ setPanelState(requestedPanel);
882
+ } catch (e2) {
883
+ setError(e2.message);
884
+ } finally {
885
+ setBusy(false);
886
+ }
887
+ }, children: [
888
+ /* @__PURE__ */ jsxs("p", { children: [
889
+ "Enter the eight-digit code sent to ",
890
+ /* @__PURE__ */ jsx("strong", { children: contributorEmail }),
891
+ ". It expires in ten minutes."
892
+ ] }),
893
+ /* @__PURE__ */ jsx("label", { htmlFor: "contributor-code", children: "Email verification code" }),
894
+ /* @__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, "")) }),
895
+ /* @__PURE__ */ jsx("button", { disabled: busy, children: busy ? "Verifying\u2026" : "Verify and continue" }),
896
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: async () => {
897
+ setBusy(true);
898
+ try {
899
+ await request("/verify", {});
900
+ setNotice("A new code was sent. Check your inbox and spam folder.");
901
+ } catch (e) {
902
+ setError(e.message);
903
+ } finally {
904
+ setBusy(false);
905
+ }
906
+ }, children: "Send a new code" }),
907
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: () => setIdentityStep("name"), children: "Use a different email" })
908
+ ] }),
909
+ /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Just read the story" })
910
+ ] }),
911
+ panel === "feedback" && /* @__PURE__ */ jsxs("section", { ref: responseRef, tabIndex: -1, className: "sm-review-form", "aria-label": "Family feedback", children: [
912
+ /* @__PURE__ */ jsx("h2", { children: "Looks good to you?" }),
913
+ /* @__PURE__ */ jsxs("p", { children: [
914
+ "We'll send your feedback as ",
915
+ review.reviewer,
916
+ ". The newsroom will request approval from the designated family reviewer separately. This will not publish the story."
917
+ ] }),
918
+ /* @__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" }),
919
+ /* @__PURE__ */ jsx("button", { disabled: busy, onClick: () => setPanel("article"), children: "Back to the story" })
920
+ ] }),
921
+ !["article", "identity", "share", "feedback"].includes(panel) && /* @__PURE__ */ jsxs(
757
922
  "section",
758
923
  {
759
924
  ref: responseRef,
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.31",
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; }