@drawnagency/primitives 0.1.119 → 0.1.121

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.
Files changed (30) hide show
  1. package/dist/{chunk-G6O3TYPJ.js → chunk-HZTPF6OK.js} +10 -3
  2. package/dist/{chunk-YKKGVSJH.js → chunk-UZBFVVTC.js} +1 -1
  3. package/dist/components/editor/AudienceIndicator.d.ts +3 -1
  4. package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
  5. package/dist/components/editor/IndicatorPill.d.ts +5 -1
  6. package/dist/components/editor/IndicatorPill.d.ts.map +1 -1
  7. package/dist/components/editor/SectionBulkEditModal.d.ts +25 -0
  8. package/dist/components/editor/SectionBulkEditModal.d.ts.map +1 -0
  9. package/dist/components/editor/StatusIndicator.d.ts +3 -1
  10. package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
  11. package/dist/components/editor/index.js +16 -8
  12. package/dist/components/sections/register-schemas.js +2 -2
  13. package/dist/components/sections/register.js +2 -2
  14. package/dist/components/shared/Popover.d.ts +1 -1
  15. package/dist/components/shared/Popover.d.ts.map +1 -1
  16. package/dist/components/shared/SectionSummaryRow.d.ts +1 -1
  17. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  18. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  19. package/package.json +1 -1
  20. package/src/components/editor/AudienceIndicator.tsx +8 -4
  21. package/src/components/editor/IndicatorPill.tsx +8 -0
  22. package/src/components/editor/SectionBulkEditModal.tsx +333 -0
  23. package/src/components/editor/StatusIndicator.tsx +3 -2
  24. package/src/components/shared/Popover.tsx +17 -4
  25. package/src/components/shared/SectionSummaryRow.tsx +1 -1
  26. package/src/components/shell/EditorModal.tsx +5 -2
  27. package/src/components/shell/EditorShell.tsx +11 -7
  28. package/dist/components/editor/SectionOrderingModal.d.ts +0 -10
  29. package/dist/components/editor/SectionOrderingModal.d.ts.map +0 -1
  30. package/src/components/editor/SectionOrderingModal.tsx +0 -80
@@ -712,14 +712,18 @@ function Popover({
712
712
  panel.style.left = "";
713
713
  panel.style.right = "";
714
714
  }, [isOpen, align]);
715
- useStackedEscape(onClose, { enabled: isOpen });
715
+ const escapeLayerRef = useStackedEscape(onClose, { enabled: isOpen });
716
+ useLayoutEffect(() => {
717
+ escapeLayerRef.current = isOpen ? anchorRef.current?.closest("[data-modal-layer]") ?? null : null;
718
+ }, [isOpen, anchorRef, escapeLayerRef]);
716
719
  useEffect3(() => {
717
720
  if (!isOpen) return;
718
721
  function onMouseDown(e) {
719
722
  const target = e.target;
720
723
  if (panelRef.current?.contains(target)) return;
721
724
  if (anchorRef.current?.contains(target)) return;
722
- if (target instanceof Element && target.closest("[data-modal-layer]")) return;
725
+ const layer = target instanceof Element ? target.closest("[data-modal-layer]") : null;
726
+ if (layer && !layer.contains(anchorRef.current)) return;
723
727
  onClose();
724
728
  }
725
729
  document.addEventListener("mousedown", onMouseDown);
@@ -738,6 +742,7 @@ function Popover({
738
742
  ref: panelRef,
739
743
  role: "dialog",
740
744
  "aria-modal": "false",
745
+ "data-popover": "",
741
746
  style: {
742
747
  position: "fixed",
743
748
  left: `${fixedPos.left}px`,
@@ -757,8 +762,10 @@ function Popover({
757
762
  ref: panelRef,
758
763
  role: "dialog",
759
764
  "aria-modal": "false",
765
+ "data-popover": "",
760
766
  className: cn(
761
- "absolute top-full z-50 mt-1 rounded-md border border-base-200 bg-base shadow-lg",
767
+ "absolute z-50 rounded-md border border-base-200 bg-base shadow-lg",
768
+ placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
762
769
  effectiveAlign === "end" ? "right-0" : "left-0",
763
770
  className
764
771
  ),
@@ -12,7 +12,7 @@ import {
12
12
  canDropBlock,
13
13
  isBlockDragData,
14
14
  useOptionalEditorContext
15
- } from "./chunk-G6O3TYPJ.js";
15
+ } from "./chunk-HZTPF6OK.js";
16
16
  import {
17
17
  EditableRichText,
18
18
  defineHeadingSection,
@@ -3,8 +3,10 @@ interface AudienceChecklistProps {
3
3
  access: string[];
4
4
  audiences: Audience[];
5
5
  onToggle: (name: string) => void;
6
+ /** Bulk edit: audiences only some of the selected sections have. */
7
+ partial?: string[];
6
8
  }
7
- export declare function AudienceChecklist({ access, audiences, onToggle }: AudienceChecklistProps): import("react/jsx-runtime").JSX.Element;
9
+ export declare function AudienceChecklist({ access, audiences, onToggle, partial }: AudienceChecklistProps): import("react/jsx-runtime").JSX.Element;
8
10
  interface AudienceIndicatorProps {
9
11
  access: string[];
10
12
  audiences: Audience[];
@@ -1 +1 @@
1
- {"version":3,"file":"AudienceIndicator.d.ts","sourceRoot":"","sources":["../../../src/components/editor/AudienceIndicator.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,UAAU,sBAAsB;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CAyCxF;AAED,UAAU,sBAAsB;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACrC,qFAAqF;IACrF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CA+BlG"}
1
+ {"version":3,"file":"AudienceIndicator.d.ts","sourceRoot":"","sources":["../../../src/components/editor/AudienceIndicator.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,UAAU,sBAAsB;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,oEAAoE;IACpE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAY,EAAE,EAAE,sBAAsB,2CA2CtG;AAED,UAAU,sBAAsB;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACrC,qFAAqF;IACrF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,EAAE,sBAAsB,2CA+BlG"}
@@ -15,7 +15,11 @@ interface IndicatorPillProps {
15
15
  /** Display-only mode: dots/label still render but the popovers never open
16
16
  * (e.g. while another editor holds the section lock). */
17
17
  disabled?: boolean;
18
+ /** Open the click popover above the pill — for pills in a bottom-pinned bar. */
19
+ placement?: "bottom" | "top";
20
+ /** Which pill edge the click popover lines up with. */
21
+ align?: "start" | "end" | "auto";
18
22
  }
19
- export declare function IndicatorPill({ dots, label, hoverContent, clickContent, className, buttonClassName, ariaLabel, disabled, }: IndicatorPillProps): import("react/jsx-runtime").JSX.Element;
23
+ export declare function IndicatorPill({ dots, label, hoverContent, clickContent, className, buttonClassName, ariaLabel, disabled, placement, align, }: IndicatorPillProps): import("react/jsx-runtime").JSX.Element;
20
24
  export {};
21
25
  //# sourceMappingURL=IndicatorPill.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"IndicatorPill.d.ts","sourceRoot":"","sources":["../../../src/components/editor/IndicatorPill.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA4C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIjF,UAAU,MAAM;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,kBAAkB;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,YAAY,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;8DAC0D;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,eAAe,EACf,SAAS,EACT,QAAQ,GACT,EAAE,kBAAkB,2CAmGpB"}
1
+ {"version":3,"file":"IndicatorPill.d.ts","sourceRoot":"","sources":["../../../src/components/editor/IndicatorPill.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA4C,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIjF,UAAU,MAAM;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,kBAAkB;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,0FAA0F;IAC1F,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,YAAY,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,OAAO,EAAE,MAAM,IAAI,KAAK,SAAS,CAAC,CAAC;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;8DAC0D;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,SAAS,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;IAC7B,uDAAuD;IACvD,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;CAClC;AAED,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,eAAe,EACf,SAAS,EACT,QAAQ,EACR,SAAoB,EACpB,KAAK,GACN,EAAE,kBAAkB,2CAqGpB"}
@@ -0,0 +1,25 @@
1
+ import type { LoadedSection } from "../../lib/loader";
2
+ import type { MediaManifest } from "../../media/types";
3
+ import type { Audience } from "../../auth/types";
4
+ type Status = "draft" | "live" | "archived";
5
+ interface SectionBulkEditModalProps {
6
+ sections: LoadedSection[];
7
+ mediaManifest: MediaManifest;
8
+ audiences: Audience[];
9
+ /** Sections another editor holds. They stay draggable but can't be selected,
10
+ * matching the canvas, where a held section's status and audience are locked. */
11
+ locks: ReadonlyMap<string, {
12
+ email: string;
13
+ }>;
14
+ onReorder: (fromIndex: number, toIndex: number) => void;
15
+ onStatusChange: (sectionId: string, status: Status) => void;
16
+ onAccessChange: (sectionId: string, access: string[]) => void;
17
+ }
18
+ /**
19
+ * The toolbar's "Edit sections" panel: drag rows to reorder, or tick several and
20
+ * set their status and audience at once from the bar that appears. Every change
21
+ * applies to the working copy immediately, like the per-section pills it reuses.
22
+ */
23
+ export declare function SectionBulkEditModal({ sections, mediaManifest, audiences, locks, onReorder, onStatusChange, onAccessChange, }: SectionBulkEditModalProps): import("react/jsx-runtime").JSX.Element;
24
+ export {};
25
+ //# sourceMappingURL=SectionBulkEditModal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SectionBulkEditModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/SectionBulkEditModal.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAajD,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAI5C,UAAU,yBAAyB;IACjC,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,aAAa,EAAE,aAAa,CAAC;IAC7B,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB;sFACkF;IAClF,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC9C,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5D,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CAC/D;AAiHD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,QAAQ,EACR,aAAa,EACb,SAAS,EACT,KAAK,EACL,SAAS,EACT,cAAc,EACd,cAAc,GACf,EAAE,yBAAyB,2CA8K3B"}
@@ -15,8 +15,10 @@ interface DeriveResult {
15
15
  description: string;
16
16
  }
17
17
  export declare function deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, isLocalOnly }: DeriveInput): DeriveResult;
18
+ export declare const dotColorClasses: Record<StatusColor, string>;
18
19
  interface StatusRadioListProps {
19
- status: Status;
20
+ /** null when several sections with different statuses are being set at once. */
21
+ status: Status | null;
20
22
  onChange: (status: Status) => void;
21
23
  onClose?: () => void;
22
24
  ariaLabel?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"StatusIndicator.d.ts","sourceRoot":"","sources":["../../../src/components/editor/StatusIndicator.tsx"],"names":[],"mappings":"AAKA,KAAK,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAC9D,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAE5C,UAAU,MAAM;IACd,KAAK,EAAE,WAAW,CAAC;CACpB;AAED,UAAU,WAAW;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,UAAU,YAAY;IACpB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,wBAAgB,mBAAmB,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,EAAE,WAAW,GAAG,YAAY,CAkCvH;AAYD,UAAU,oBAAoB;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,SAA4B,EAAE,EAAE,oBAAoB,2CA2BhH;AAED,UAAU,oBAAoB;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,kFAAkF;IAClF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,EAC9B,UAAU,EACV,WAAW,EACX,cAAc,EACd,WAAW,EACX,MAAM,EACN,KAAK,EACL,QAAQ,EACR,QAAQ,GACT,EAAE,oBAAoB,2CAsBtB"}
1
+ {"version":3,"file":"StatusIndicator.d.ts","sourceRoot":"","sources":["../../../src/components/editor/StatusIndicator.tsx"],"names":[],"mappings":"AAKA,KAAK,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAC9D,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAE5C,UAAU,MAAM;IACd,KAAK,EAAE,WAAW,CAAC;CACpB;AAED,UAAU,WAAW;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,UAAU,YAAY;IACpB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB;AAgBD,wBAAgB,mBAAmB,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,EAAE,WAAW,GAAG,YAAY,CAkCvH;AAED,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAKvD,CAAC;AAKF,UAAU,oBAAoB;IAC5B,gFAAgF;IAChF,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,SAA4B,EAAE,EAAE,oBAAoB,2CA2BhH;AAED,UAAU,oBAAoB;IAC5B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,kFAAkF;IAClF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,EAC9B,UAAU,EACV,WAAW,EACX,cAAc,EACd,WAAW,EACX,MAAM,EACN,KAAK,EACL,QAAQ,EACR,QAAQ,GACT,EAAE,oBAAoB,2CAsBtB"}
@@ -12,7 +12,7 @@ import {
12
12
  canDropBlock,
13
13
  isBlockDragData,
14
14
  useEditorContext
15
- } from "../../chunk-G6O3TYPJ.js";
15
+ } from "../../chunk-HZTPF6OK.js";
16
16
  import "../../chunk-GGYY3JQJ.js";
17
17
  import {
18
18
  CheckboxBox
@@ -71,7 +71,9 @@ function IndicatorPill({
71
71
  className,
72
72
  buttonClassName,
73
73
  ariaLabel,
74
- disabled
74
+ disabled,
75
+ placement = "bottom",
76
+ align
75
77
  }) {
76
78
  const [hoverOpen, setHoverOpen] = useState(false);
77
79
  const [clickOpen, setClickOpen] = useState(false);
@@ -154,6 +156,8 @@ function IndicatorPill({
154
156
  isOpen: clickOpen,
155
157
  onClose: handleClickClose,
156
158
  anchorRef: buttonRef,
159
+ placement,
160
+ align,
157
161
  className: "min-w-full max-w-xs",
158
162
  children: typeof clickContent === "function" ? clickContent(handleClickClose) : clickContent
159
163
  }
@@ -272,30 +276,34 @@ function StatusIndicator({
272
276
  }
273
277
 
274
278
  // src/components/editor/AudienceIndicator.tsx
275
- import { Check as Check2 } from "lucide-react";
279
+ import { Check as Check2, Minus } from "lucide-react";
276
280
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
277
- function AudienceChecklist({ access, audiences, onToggle }) {
281
+ function AudienceChecklist({ access, audiences, onToggle, partial = [] }) {
278
282
  if (audiences.length === 0) {
279
283
  return /* @__PURE__ */ jsx4("div", { className: "px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap", children: "No audiences configured. Add audiences in Site Settings \u2192 Viewer Access." });
280
284
  }
281
285
  return /* @__PURE__ */ jsx4("ul", { role: "list", className: "w-full overflow-hidden py-1", children: audiences.map((a) => {
282
286
  const checked = access.includes(a.name);
287
+ const mixed = !checked && partial.includes(a.name);
283
288
  return /* @__PURE__ */ jsx4("li", { children: /* @__PURE__ */ jsxs4(
284
289
  PopoverItem,
285
290
  {
286
291
  role: "checkbox",
287
- "aria-checked": checked,
292
+ "aria-checked": mixed ? "mixed" : checked,
288
293
  onClick: () => onToggle(a.name),
289
294
  children: [
290
- /* @__PURE__ */ jsx4(
295
+ /* @__PURE__ */ jsxs4(
291
296
  "span",
292
297
  {
293
298
  "aria-hidden": "true",
294
299
  className: cn(
295
300
  "flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors",
296
- checked ? "border-primary bg-primary text-primary-contrast" : "border-base-300 bg-base"
301
+ checked || mixed ? "border-primary bg-primary text-primary-contrast" : "border-base-300 bg-base"
297
302
  ),
298
- children: checked && /* @__PURE__ */ jsx4(Check2, { size: 14, strokeWidth: 3 })
303
+ children: [
304
+ checked && /* @__PURE__ */ jsx4(Check2, { size: 14, strokeWidth: 3 }),
305
+ mixed && /* @__PURE__ */ jsx4(Minus, { size: 14, strokeWidth: 3 })
306
+ ]
299
307
  }
300
308
  ),
301
309
  /* @__PURE__ */ jsx4(
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-YKKGVSJH.js";
4
- import "../../chunk-G6O3TYPJ.js";
3
+ } from "../../chunk-UZBFVVTC.js";
4
+ import "../../chunk-HZTPF6OK.js";
5
5
  import "../../chunk-UNFPATDE.js";
6
6
  import "../../chunk-GGYY3JQJ.js";
7
7
  import "../../chunk-RIN2QCYJ.js";
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  allSectionDefs
3
- } from "../../chunk-YKKGVSJH.js";
4
- import "../../chunk-G6O3TYPJ.js";
3
+ } from "../../chunk-UZBFVVTC.js";
4
+ import "../../chunk-HZTPF6OK.js";
5
5
  import "../../chunk-UNFPATDE.js";
6
6
  import "../../chunk-GGYY3JQJ.js";
7
7
  import "../../chunk-RIN2QCYJ.js";
@@ -13,7 +13,7 @@ interface PopoverProps {
13
13
  * panel wider than the sidebar is clipped and adds a horizontal scrollbar.
14
14
  */
15
15
  portal?: boolean;
16
- /** Which side of the anchor to open toward. Only used with `portal`. */
16
+ /** Which side of the anchor to open toward. */
17
17
  placement?: "bottom" | "top";
18
18
  }
19
19
  export declare function Popover({ isOpen, onClose, anchorRef, children, align, className, portal, placement, }: PopoverProps): import("react/jsx-runtime").JSX.Element | null;
@@ -1 +1 @@
1
- {"version":3,"file":"Popover.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Popover.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAgD,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAMrG,UAAU,YAAY;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACzC,QAAQ,EAAE,SAAS,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,wEAAwE;IACxE,SAAS,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;CAC9B;AAED,wBAAgB,OAAO,CAAC,EACtB,MAAM,EACN,OAAO,EACP,SAAS,EACT,QAAQ,EACR,KAAc,EACd,SAAS,EACT,MAAc,EACd,SAAoB,GACrB,EAAE,YAAY,kDA8Id"}
1
+ {"version":3,"file":"Popover.d.ts","sourceRoot":"","sources":["../../../src/components/shared/Popover.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAgD,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAMrG,UAAU,YAAY;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IACzC,QAAQ,EAAE,SAAS,CAAC;IACpB,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,QAAQ,GAAG,KAAK,CAAC;CAC9B;AAED,wBAAgB,OAAO,CAAC,EACtB,MAAM,EACN,OAAO,EACP,SAAS,EACT,QAAQ,EACR,KAAc,EACd,SAAS,EACT,MAAc,EACd,SAAoB,GACrB,EAAE,YAAY,kDA2Jd"}
@@ -15,7 +15,7 @@ interface Props {
15
15
  }
16
16
  /**
17
17
  * One section as a compact card: type over label, with a thumbnail cluster.
18
- * Shared by the reorder modal (which wraps it in a drag handle + drop
18
+ * Shared by the Edit sections modal (which wraps it in a drag handle + drop
19
19
  * indicator) and the Audience Details page (which dims unavailable rows), so
20
20
  * both read as the same list of the same site.
21
21
  */
@@ -1 +1 @@
1
- {"version":3,"file":"EditorModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorModal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AASjE,UAAU,gBAAgB;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,UAAU,GAAG,OAAO,GAAG,UAAU,CAAC;IACzC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,QAAgB,EAAE,IAAiB,EAAE,SAAiB,EAAE,MAAc,EAAE,QAAQ,EAAE,EAAE,gBAAgB,sCAqFzJ"}
1
+ {"version":3,"file":"EditorModal.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorModal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAS9E,UAAU,gBAAgB;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,UAAU,GAAG,OAAO,GAAG,UAAU,CAAC;IACzC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,QAAgB,EAAE,IAAiB,EAAE,SAAiB,EAAE,MAAc,EAAE,QAAQ,EAAE,EAAE,gBAAgB,sCAwFzJ"}
@@ -1 +1 @@
1
- {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAWjD,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AA4CtF,OAAO,KAAK,EAAE,eAAe,EAA2D,MAAM,oBAAoB,CAAC;AA6DnH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AA8BxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAKjC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC1C;AAQD,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,EAC9B,eAAe,GAChB,EAAE,KAAK,2CA4rEP;AA8ED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,YAAY,CAAC,EACvC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;EAK/B"}
1
+ {"version":3,"file":"EditorShell.d.ts","sourceRoot":"","sources":["../../../src/components/shell/EditorShell.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAWjD,OAAO,EAAoC,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAC;AA4CtF,OAAO,KAAK,EAAE,eAAe,EAA2D,MAAM,oBAAoB,CAAC;AA6DnH,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AA8BxD,UAAU,KAAK;IACb,OAAO,EAAE,MAAM,CAAC;IAIhB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,YAAY,EAAE;QACZ,KAAK,EAAE,OAAO,CAAC;QACf,aAAa,EAAE,OAAO,CAAC;QACvB,YAAY,EAAE,OAAO,CAAC;QACtB,cAAc,EAAE,OAAO,CAAC;QACxB,kBAAkB,EAAE,OAAO,CAAC;QAC5B,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;KAAE,GAAG,IAAI,CAAC;IAIhE,sBAAsB,CAAC,EAAE,OAAO,CAAC;IAKjC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,CAAC;CAC1C;AAQD,MAAM,CAAC,OAAO,UAAU,WAAW,CAAC,EAClC,OAAO,EACP,YAAY,EACZ,MAAM,EACN,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EACZ,WAAW,EACX,sBAA8B,EAC9B,eAAe,GAChB,EAAE,KAAK,2CAgsEP;AA8ED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,WAAW,CAAC,YAAY,CAAC,EACvC,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,eAAe,EAAE,MAAM,GAAG,IAAI;;;;;;;;;EAK/B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.119",
3
+ "version": "0.1.121",
4
4
  "description": "Browser-safe foundation of the Brand Portal framework: Zod schemas, section components, editor shell, hooks, pure libs, collab contract.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,4 +1,4 @@
1
- import { Check } from "lucide-react";
1
+ import { Check, Minus } from "lucide-react";
2
2
  import { cn } from "../../lib/cn";
3
3
  import { IndicatorPill } from "./IndicatorPill";
4
4
  import { PopoverItem } from "../shared/PopoverItem";
@@ -8,9 +8,11 @@ interface AudienceChecklistProps {
8
8
  access: string[];
9
9
  audiences: Audience[];
10
10
  onToggle: (name: string) => void;
11
+ /** Bulk edit: audiences only some of the selected sections have. */
12
+ partial?: string[];
11
13
  }
12
14
 
13
- export function AudienceChecklist({ access, audiences, onToggle }: AudienceChecklistProps) {
15
+ export function AudienceChecklist({ access, audiences, onToggle, partial = [] }: AudienceChecklistProps) {
14
16
  if (audiences.length === 0) {
15
17
  return (
16
18
  <div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
@@ -22,23 +24,25 @@ export function AudienceChecklist({ access, audiences, onToggle }: AudienceCheck
22
24
  <ul role="list" className="w-full overflow-hidden py-1">
23
25
  {audiences.map((a) => {
24
26
  const checked = access.includes(a.name);
27
+ const mixed = !checked && partial.includes(a.name);
25
28
  return (
26
29
  <li key={a.name}>
27
30
  <PopoverItem
28
31
  role="checkbox"
29
- aria-checked={checked}
32
+ aria-checked={mixed ? "mixed" : checked}
30
33
  onClick={() => onToggle(a.name)}
31
34
  >
32
35
  <span
33
36
  aria-hidden="true"
34
37
  className={cn(
35
38
  "flex h-5 w-5 shrink-0 items-center justify-center rounded border transition-colors",
36
- checked
39
+ checked || mixed
37
40
  ? "border-primary bg-primary text-primary-contrast"
38
41
  : "border-base-300 bg-base",
39
42
  )}
40
43
  >
41
44
  {checked && <Check size={14} strokeWidth={3} />}
45
+ {mixed && <Minus size={14} strokeWidth={3} />}
42
46
  </span>
43
47
  <span
44
48
  className="h-3 w-3 shrink-0 rounded-full border border-base-200"
@@ -19,6 +19,10 @@ interface IndicatorPillProps {
19
19
  /** Display-only mode: dots/label still render but the popovers never open
20
20
  * (e.g. while another editor holds the section lock). */
21
21
  disabled?: boolean;
22
+ /** Open the click popover above the pill — for pills in a bottom-pinned bar. */
23
+ placement?: "bottom" | "top";
24
+ /** Which pill edge the click popover lines up with. */
25
+ align?: "start" | "end" | "auto";
22
26
  }
23
27
 
24
28
  export function IndicatorPill({
@@ -30,6 +34,8 @@ export function IndicatorPill({
30
34
  buttonClassName,
31
35
  ariaLabel,
32
36
  disabled,
37
+ placement = "bottom",
38
+ align,
33
39
  }: IndicatorPillProps) {
34
40
  const [hoverOpen, setHoverOpen] = useState(false);
35
41
  const [clickOpen, setClickOpen] = useState(false);
@@ -123,6 +129,8 @@ export function IndicatorPill({
123
129
  isOpen={clickOpen}
124
130
  onClose={handleClickClose}
125
131
  anchorRef={buttonRef}
132
+ placement={placement}
133
+ align={align}
126
134
  className="min-w-full max-w-xs"
127
135
  >
128
136
  {typeof clickContent === "function" ? clickContent(handleClickClose) : clickContent}
@@ -0,0 +1,333 @@
1
+ import { useRef, useState } from "react";
2
+ import { Check, ChevronDown, Lock, Minus, X } from "lucide-react";
3
+ import type { LoadedSection } from "../../lib/loader";
4
+ import { getSection } from "../../lib/registry";
5
+ import type { MediaManifest } from "../../media/types";
6
+ import type { Audience } from "../../auth/types";
7
+ import { DragHandle } from "./DragHandle";
8
+ import { useSortableRow } from "../../hooks/useSortableRow";
9
+ import { DropEdgeIndicator } from "./DropEdgeIndicator";
10
+ import { IndicatorPill } from "./IndicatorPill";
11
+ import { StatusRadioList, dotColorClasses } from "./StatusIndicator";
12
+ import { AudienceChecklist } from "./AudienceIndicator";
13
+ import { SectionSummaryRow } from "../shared/SectionSummaryRow";
14
+ import { Popover } from "../shared/Popover";
15
+ import { PopoverItem } from "../shared/PopoverItem";
16
+ import { toThumbViews } from "../../lib/thumb-views";
17
+ import { cn } from "../../lib/cn";
18
+
19
+ type Status = "draft" | "live" | "archived";
20
+ const STATUSES: Status[] = ["draft", "live", "archived"];
21
+ type Coverage = "all" | "some" | "none";
22
+
23
+ interface SectionBulkEditModalProps {
24
+ sections: LoadedSection[];
25
+ mediaManifest: MediaManifest;
26
+ audiences: Audience[];
27
+ /** Sections another editor holds. They stay draggable but can't be selected,
28
+ * matching the canvas, where a held section's status and audience are locked. */
29
+ locks: ReadonlyMap<string, { email: string }>;
30
+ onReorder: (fromIndex: number, toIndex: number) => void;
31
+ onStatusChange: (sectionId: string, status: Status) => void;
32
+ onAccessChange: (sectionId: string, access: string[]) => void;
33
+ }
34
+
35
+ function audienceLabel(selected: Audience[]): string {
36
+ if (selected.length === 0) return "No audience";
37
+ if (selected.length === 1) return selected[0].displayName;
38
+ return `${selected.length} audiences`;
39
+ }
40
+
41
+ function coverage(sections: LoadedSection[], name: string): Coverage {
42
+ const n = sections.filter((s) => s.meta.access.includes(name)).length;
43
+ if (n === 0) return "none";
44
+ return n === sections.length ? "all" : "some";
45
+ }
46
+
47
+ function CheckBox({ state }: { state: Coverage }) {
48
+ return (
49
+ <span
50
+ aria-hidden="true"
51
+ className={cn(
52
+ "flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
53
+ state === "none" ? "border-base-300 bg-base" : "border-primary bg-primary text-primary-contrast",
54
+ )}
55
+ >
56
+ {state === "all" && <Check size={12} strokeWidth={3} />}
57
+ {state === "some" && <Minus size={12} strokeWidth={3} />}
58
+ </span>
59
+ );
60
+ }
61
+
62
+ function SectionRow({
63
+ section,
64
+ index,
65
+ mediaManifest,
66
+ audiences,
67
+ selected,
68
+ lockedBy,
69
+ onToggle,
70
+ onReorder,
71
+ }: {
72
+ section: LoadedSection;
73
+ index: number;
74
+ mediaManifest: MediaManifest;
75
+ audiences: Audience[];
76
+ selected: boolean;
77
+ lockedBy: string | null;
78
+ onToggle: (range: boolean) => void;
79
+ onReorder: (fromIndex: number, toIndex: number) => void;
80
+ }) {
81
+ const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
82
+ index,
83
+ dragType: "ordering-row",
84
+ onReorder,
85
+ });
86
+
87
+ const definition = getSection(section.section.type);
88
+ const label = definition?.getLabel?.(section.section) ?? definition?.label ?? section.section.type;
89
+ const typeLabel = definition?.label ?? section.section.type;
90
+ const status = section.meta.status as Status;
91
+ const access = audiences.filter((a) => section.meta.access.includes(a.name));
92
+
93
+ return (
94
+ <div ref={rowRef} className={cn("relative flex items-center gap-1", isDragging && "opacity-50")}>
95
+ <DropEdgeIndicator edge={closestEdge} />
96
+
97
+ {lockedBy ? (
98
+ <span title={`Being edited by ${lockedBy}`} className="flex h-8 w-8 shrink-0 items-center justify-center text-base-contrast-light">
99
+ <Lock size={14} aria-label={`${label} is being edited by ${lockedBy}`} />
100
+ </span>
101
+ ) : (
102
+ <button
103
+ type="button"
104
+ role="checkbox"
105
+ aria-checked={selected}
106
+ aria-label={`Select ${label}`}
107
+ onClick={(e) => onToggle(e.shiftKey)}
108
+ className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center"
109
+ >
110
+ <CheckBox state={selected ? "all" : "none"} />
111
+ </button>
112
+ )}
113
+
114
+ <div className="shrink-0 [&_[role=tooltip]]:hidden">
115
+ <DragHandle ref={handleRef} />
116
+ </div>
117
+
118
+ <SectionSummaryRow
119
+ label={label}
120
+ typeLabel={typeLabel}
121
+ thumbnails={toThumbViews(definition?.getThumbnails?.(section.section) ?? [], mediaManifest)}
122
+ className={cn(selected && "border-primary/60 bg-primary/5", isDragging && "outline outline-2 outline-primary/50")}
123
+ trailing={
124
+ <div className="flex shrink-0 flex-col items-end gap-0.5 text-xs text-base-contrast-light">
125
+ <span className="inline-flex items-center gap-1.5 capitalize">
126
+ <span aria-hidden="true" className={cn("h-2.5 w-2.5 rounded-full border border-base-200", dotColorClasses[status])} />
127
+ {status}
128
+ </span>
129
+ <span className="inline-flex items-center gap-1.5">
130
+ {access.length > 0 && (
131
+ <span aria-hidden="true" className="flex -space-x-1">
132
+ {access.slice(0, 3).map((a) => (
133
+ <span key={a.name} className="h-2.5 w-2.5 rounded-full border border-base" style={{ backgroundColor: a.color ?? "#9ca3af" }} />
134
+ ))}
135
+ </span>
136
+ )}
137
+ {audienceLabel(access)}
138
+ </span>
139
+ </div>
140
+ }
141
+ />
142
+ </div>
143
+ );
144
+ }
145
+
146
+ /**
147
+ * The toolbar's "Edit sections" panel: drag rows to reorder, or tick several and
148
+ * set their status and audience at once from the bar that appears. Every change
149
+ * applies to the working copy immediately, like the per-section pills it reuses.
150
+ */
151
+ export function SectionBulkEditModal({
152
+ sections,
153
+ mediaManifest,
154
+ audiences,
155
+ locks,
156
+ onReorder,
157
+ onStatusChange,
158
+ onAccessChange,
159
+ }: SectionBulkEditModalProps) {
160
+ const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
161
+ const [anchorId, setAnchorId] = useState<string | null>(null);
162
+ const [selectMenuOpen, setSelectMenuOpen] = useState(false);
163
+ const selectMenuRef = useRef<HTMLButtonElement>(null);
164
+
165
+ // Derived, so a section deleted or newly locked by a peer drops out of the selection.
166
+ const selectable = sections.filter((s) => !locks.has(s.section.id));
167
+ const selected = selectable.filter((s) => selectedIds.has(s.section.id));
168
+ const allSelected = selectable.length > 0 && selected.length === selectable.length;
169
+
170
+ function select(ids: string[]) {
171
+ setSelectedIds(new Set(ids));
172
+ }
173
+
174
+ function toggle(id: string, range: boolean) {
175
+ const ids = selectable.map((s) => s.section.id);
176
+ const next = new Set(selectedIds);
177
+ if (range && anchorId && ids.includes(anchorId)) {
178
+ const [a, b] = [ids.indexOf(anchorId), ids.indexOf(id)].sort((x, y) => x - y);
179
+ for (const rangeId of ids.slice(a, b + 1)) next.add(rangeId);
180
+ } else {
181
+ if (next.has(id)) next.delete(id);
182
+ else next.add(id);
183
+ setAnchorId(id);
184
+ }
185
+ setSelectedIds(next);
186
+ }
187
+
188
+ const statuses = STATUSES.filter((s) => selected.some((x) => x.meta.status === s));
189
+ const commonStatus = statuses.length === 1 ? statuses[0] : null;
190
+ const coverageByName = new Map(audiences.map((a) => [a.name, coverage(selected, a.name)]));
191
+ const fullAudiences = audiences.filter((a) => coverageByName.get(a.name) === "all");
192
+ const partialAudiences = audiences.filter((a) => coverageByName.get(a.name) === "some");
193
+
194
+ function toggleAudience(name: string) {
195
+ // A partly-covered audience is added to every selected section; a fully
196
+ // covered one is removed from all of them.
197
+ const add = coverageByName.get(name) !== "all";
198
+ for (const s of selected) {
199
+ const has = s.meta.access.includes(name);
200
+ if (add && !has) onAccessChange(s.section.id, [...s.meta.access, name]);
201
+ if (!add && has) onAccessChange(s.section.id, s.meta.access.filter((n) => n !== name));
202
+ }
203
+ }
204
+
205
+ const selectMenu: { label: string; ids: () => string[]; status?: Status }[] = [
206
+ { label: "All", ids: () => selectable.map((s) => s.section.id) },
207
+ { label: "None", ids: () => [] },
208
+ ...STATUSES.map((status) => ({
209
+ label: status,
210
+ status,
211
+ ids: () => selectable.filter((s) => s.meta.status === status).map((s) => s.section.id),
212
+ })),
213
+ ];
214
+
215
+ return (
216
+ <div className="flex min-h-0 flex-1 flex-col">
217
+ <div className="-mx-6 mb-3 flex items-center border-b border-base-200 px-6 pb-2 text-xs text-base-contrast-light">
218
+ <div className="relative flex items-center rounded hover:bg-base-accent">
219
+ <button
220
+ type="button"
221
+ role="checkbox"
222
+ aria-checked={allSelected ? true : selected.length > 0 ? "mixed" : false}
223
+ aria-label={allSelected ? "Deselect all" : "Select all"}
224
+ onClick={() => select(allSelected ? [] : selectable.map((s) => s.section.id))}
225
+ className="flex cursor-pointer items-center py-1.5 pr-1 pl-2"
226
+ >
227
+ <CheckBox state={allSelected ? "all" : selected.length > 0 ? "some" : "none"} />
228
+ </button>
229
+ <button
230
+ ref={selectMenuRef}
231
+ type="button"
232
+ aria-haspopup="dialog"
233
+ aria-expanded={selectMenuOpen}
234
+ onClick={() => setSelectMenuOpen((open) => !open)}
235
+ className="flex cursor-pointer items-center gap-1 py-1.5 pr-2 pl-1 hover:text-base-contrast"
236
+ >
237
+ Select
238
+ <ChevronDown size={12} aria-hidden="true" />
239
+ </button>
240
+ <Popover isOpen={selectMenuOpen} onClose={() => setSelectMenuOpen(false)} anchorRef={selectMenuRef} align="start" className="min-w-40 py-1">
241
+ {selectMenu.map(({ label, ids, status }) => (
242
+ <PopoverItem
243
+ key={label}
244
+ className={cn(status === "draft" && "mt-1 border-t border-base-200 pt-2")}
245
+ onClick={() => {
246
+ select(ids());
247
+ setSelectMenuOpen(false);
248
+ }}
249
+ >
250
+ {status && <span aria-hidden="true" className={cn("h-2.5 w-2.5 rounded-full border border-base-200", dotColorClasses[status])} />}
251
+ <span className="flex-1 capitalize">{label}</span>
252
+ {status && <span className="tabular-nums text-base-contrast-light">{ids().length}</span>}
253
+ </PopoverItem>
254
+ ))}
255
+ </Popover>
256
+ </div>
257
+ {selected.length > 0 && <span className="ml-2">{selected.length} of {sections.length} selected</span>}
258
+ </div>
259
+
260
+ <div className="flex flex-col gap-2 pb-2">
261
+ {sections.map((loadedSection, index) => {
262
+ const id = loadedSection.section.id;
263
+ return (
264
+ <SectionRow
265
+ key={id}
266
+ section={loadedSection}
267
+ index={index}
268
+ mediaManifest={mediaManifest}
269
+ audiences={audiences}
270
+ selected={selectedIds.has(id)}
271
+ lockedBy={locks.get(id)?.email ?? null}
272
+ onToggle={(range) => toggle(id, range)}
273
+ onReorder={onReorder}
274
+ />
275
+ );
276
+ })}
277
+ </div>
278
+
279
+ {/* Sticky insets count from inside the modal body's py-4, so -bottom-4 pins the
280
+ bar flush to the edge. Its popovers end-align: the body is overflow-y-auto,
281
+ which makes overflow-x auto too, and one growing rightward scrolls it sideways. */}
282
+ {selected.length > 0 && (
283
+ <div className="sticky -bottom-4 -mx-6 -mb-4 mt-auto flex items-center gap-2 border-t border-base-200 bg-base px-6 py-3 text-sm shadow-[0_-4px_12px_rgba(0,0,0,0.06)]">
284
+ <span className="font-medium text-base-contrast">{selected.length} selected</span>
285
+ <button
286
+ type="button"
287
+ onClick={() => select([])}
288
+ aria-label="Clear selection"
289
+ className="cursor-pointer rounded p-0.5 text-base-contrast-light hover:bg-base-accent"
290
+ >
291
+ <X size={14} />
292
+ </button>
293
+ <div className="ml-auto flex items-center gap-2">
294
+ <IndicatorPill
295
+ placement="top"
296
+ align="end"
297
+ dots={statuses.map((s) => ({ className: dotColorClasses[s] }))}
298
+ label={commonStatus ? commonStatus.charAt(0).toUpperCase() + commonStatus.slice(1) : "Mixed status"}
299
+ ariaLabel="Status of selected sections"
300
+ buttonClassName="text-base-contrast-light"
301
+ clickContent={(onClose) => (
302
+ <StatusRadioList
303
+ status={commonStatus}
304
+ ariaLabel="Status for selected sections"
305
+ onClose={onClose}
306
+ onChange={(status) => {
307
+ for (const s of selected) if (s.meta.status !== status) onStatusChange(s.section.id, status);
308
+ }}
309
+ />
310
+ )}
311
+ />
312
+ <IndicatorPill
313
+ placement="top"
314
+ align="end"
315
+ dots={[...fullAudiences, ...partialAudiences].map((a) => ({ color: a.color ?? "#9ca3af" }))}
316
+ label={partialAudiences.length > 0 ? "Mixed audiences" : audienceLabel(fullAudiences)}
317
+ ariaLabel="Audience of selected sections"
318
+ buttonClassName={fullAudiences.length + partialAudiences.length === 0 ? "text-base-contrast-light" : undefined}
319
+ clickContent={
320
+ <AudienceChecklist
321
+ audiences={audiences}
322
+ access={fullAudiences.map((a) => a.name)}
323
+ partial={partialAudiences.map((a) => a.name)}
324
+ onToggle={toggleAudience}
325
+ />
326
+ }
327
+ />
328
+ </div>
329
+ </div>
330
+ )}
331
+ </div>
332
+ );
333
+ }
@@ -73,7 +73,7 @@ export function deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, i
73
73
  };
74
74
  }
75
75
 
76
- const dotColorClasses: Record<StatusColor, string> = {
76
+ export const dotColorClasses: Record<StatusColor, string> = {
77
77
  live: "bg-status-live-dot",
78
78
  draft: "bg-status-draft-dot",
79
79
  archived: "bg-white border-status-archived-dot-border",
@@ -84,7 +84,8 @@ const dotColorClasses: Record<StatusColor, string> = {
84
84
  const STATUSES: Status[] = ["draft", "live", "archived"];
85
85
 
86
86
  interface StatusRadioListProps {
87
- status: Status;
87
+ /** null when several sections with different statuses are being set at once. */
88
+ status: Status | null;
88
89
  onChange: (status: Status) => void;
89
90
  onClose?: () => void;
90
91
  ariaLabel?: string;
@@ -18,7 +18,7 @@ interface PopoverProps {
18
18
  * panel wider than the sidebar is clipped and adds a horizontal scrollbar.
19
19
  */
20
20
  portal?: boolean;
21
- /** Which side of the anchor to open toward. Only used with `portal`. */
21
+ /** Which side of the anchor to open toward. */
22
22
  placement?: "bottom" | "top";
23
23
  }
24
24
 
@@ -111,7 +111,14 @@ export function Popover({
111
111
  // this the one keypress dismisses both, losing the navigator's zoom stack
112
112
  // — inconsistent with the X button, and with the mousedown exemption below
113
113
  // that exists for exactly this stacking.
114
- useStackedEscape(onClose, { enabled: isOpen });
114
+ const escapeLayerRef = useStackedEscape<HTMLElement>(onClose, { enabled: isOpen });
115
+ // A popover living INSIDE an EditorModal treats that modal's layer as its
116
+ // own, so only a modal stacked above it (one opened from this popover) takes
117
+ // Escape away. The modal, for its part, yields Escape while one of its
118
+ // popovers is open (EditorModal's `before`).
119
+ useLayoutEffect(() => {
120
+ escapeLayerRef.current = isOpen ? anchorRef.current?.closest<HTMLElement>("[data-modal-layer]") ?? null : null;
121
+ }, [isOpen, anchorRef, escapeLayerRef]);
115
122
 
116
123
  useEffect(() => {
117
124
  if (!isOpen) return;
@@ -122,7 +129,10 @@ export function Popover({
122
129
  // A modal opened from inside this popover portals to <body>, so it is
123
130
  // "outside" by DOM containment. Closing on its mousedowns would unmount
124
131
  // the modal along with the popover, and would break text selection in it.
125
- if (target instanceof Element && target.closest("[data-modal-layer]")) return;
132
+ // The modal this popover itself sits in doesn't count: a click elsewhere
133
+ // in it is an ordinary outside click.
134
+ const layer = target instanceof Element ? target.closest("[data-modal-layer]") : null;
135
+ if (layer && !layer.contains(anchorRef.current)) return;
126
136
  onClose();
127
137
  }
128
138
  document.addEventListener("mousedown", onMouseDown);
@@ -145,6 +155,7 @@ export function Popover({
145
155
  ref={panelRef}
146
156
  role="dialog"
147
157
  aria-modal="false"
158
+ data-popover=""
148
159
  style={{
149
160
  position: "fixed",
150
161
  left: `${fixedPos.left}px`,
@@ -164,8 +175,10 @@ export function Popover({
164
175
  ref={panelRef}
165
176
  role="dialog"
166
177
  aria-modal="false"
178
+ data-popover=""
167
179
  className={cn(
168
- "absolute top-full z-50 mt-1 rounded-md border border-base-200 bg-base shadow-lg",
180
+ "absolute z-50 rounded-md border border-base-200 bg-base shadow-lg",
181
+ placement === "top" ? "bottom-full mb-1" : "top-full mt-1",
169
182
  effectiveAlign === "end" ? "right-0" : "left-0",
170
183
  className,
171
184
  )}
@@ -20,7 +20,7 @@ const MAX_THUMBS = 3;
20
20
 
21
21
  /**
22
22
  * One section as a compact card: type over label, with a thumbnail cluster.
23
- * Shared by the reorder modal (which wraps it in a drag handle + drop
23
+ * Shared by the Edit sections modal (which wraps it in a drag handle + drop
24
24
  * indicator) and the Audience Details page (which dims unavailable rows), so
25
25
  * both read as the same list of the same site.
26
26
  */
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useRef, type ReactNode } from "react";
1
+ import { useCallback, useEffect, useId, useRef, type ReactNode } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { X } from "lucide-react";
4
4
  import { IconButton } from "../shared/IconButton";
@@ -31,7 +31,10 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
31
31
  // Another EditorModal opened on top of this one (a ConfirmDialog, say) owns
32
32
  // Escape — attaching layerRef to our own data-modal-layer element below is
33
33
  // what lets the hook tell "another layer" from "just me".
34
- const layerRef = useStackedEscape<HTMLDivElement>(onClose, { enabled: isOpen && !blocking });
34
+ // An open Popover inside this modal owns Escape: it closes first, the modal on
35
+ // the next press. Stable, so the keydown listener isn't re-bound every render.
36
+ const popoverOpenInside = useCallback(() => Boolean(panelRef.current?.querySelector("[data-popover]")), []);
37
+ const layerRef = useStackedEscape<HTMLDivElement>(onClose, { enabled: isOpen && !blocking, before: popoverOpenInside });
35
38
 
36
39
  useEffect(() => {
37
40
  if (!isOpen) return;
@@ -42,7 +42,7 @@ import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert
42
42
  ensureSectionsRegistered();
43
43
  import { BugReportFAB } from "./BugReportFAB";
44
44
  import { SectionWrapper } from "../editor/SectionWrapper";
45
- import { SectionOrderingModal } from "../editor/SectionOrderingModal";
45
+ import { SectionBulkEditModal } from "../editor/SectionBulkEditModal";
46
46
  import { MoveSectionModal } from "../editor/MoveSectionModal";
47
47
  import { SectionLayout } from "../sections/SectionLayout";
48
48
  import {
@@ -113,7 +113,7 @@ import { FabStack } from "../shared/FabStack";
113
113
  import { Tooltip } from "../shared/Tooltip";
114
114
  import { SegmentedControl } from "../shared/SegmentedControl";
115
115
  import { computeDisplaySections } from "./view-branch";
116
- import { ImageIcon, ListOrderedIcon, FilesIcon, JoystickIcon, Settings, X, Eye, Pencil } from "lucide-react";
116
+ import { ImageIcon, ListChecks, FilesIcon, JoystickIcon, Settings, X, Eye, Pencil } from "lucide-react";
117
117
  import { ErrorBoundary } from "../shared/ErrorBoundary";
118
118
  import { HistoryToolbar } from "./HistoryToolbar";
119
119
  import { RestoreModal } from "./RestoreModal";
@@ -244,7 +244,7 @@ export default function EditorShell({
244
244
  useEffect(() => { deletedSectionsRef.current = deletedSections; }, [deletedSections]);
245
245
  const [pendingDeleteSectionId, setPendingDeleteSectionId] = useState<string | null>(null);
246
246
  // One shared aria-live region for every reorder — sections (via
247
- // onReorderSections, the dispatch point shared by SectionOrderingModal's
247
+ // onReorderSections, the dispatch point shared by SectionBulkEditModal's
248
248
  // drag and SectionWrapper's Move up/down) and pages/groups (via
249
249
  // handleMoveNavEntry, shared the same way by PagesModal/NavGroupRow's drag
250
250
  // and their own Move up/down). The section canvas's own per-block drag
@@ -2361,13 +2361,17 @@ export default function EditorShell({
2361
2361
  <EditorModal
2362
2362
  isOpen={showOrderingModal}
2363
2363
  onClose={() => setShowOrderingModal(false)}
2364
- title="Reorder Sections"
2364
+ title="Edit sections"
2365
2365
  size="settings"
2366
2366
  >
2367
- <SectionOrderingModal
2367
+ <SectionBulkEditModal
2368
2368
  sections={activeSections}
2369
2369
  mediaManifest={mediaManifest}
2370
+ audiences={pageAudiences}
2371
+ locks={collab.locks}
2370
2372
  onReorder={onReorderSections}
2373
+ onStatusChange={onStatusChange}
2374
+ onAccessChange={onAccessChange}
2371
2375
  />
2372
2376
  </EditorModal>
2373
2377
  <EditorModal isOpen={showPagesModal} onClose={() => setShowPagesModal(false)} title="Pages" size="settings">
@@ -3132,8 +3136,8 @@ function EditorToolbar({
3132
3136
  <Tooltip side="bottom" content="Media library">
3133
3137
  <IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
3134
3138
  </Tooltip>
3135
- <Tooltip side="bottom" content="Reorder sections">
3136
- <IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
3139
+ <Tooltip side="bottom" content="Edit sections">
3140
+ <IconButton icon={<ListChecks size={16} />} label="Edit sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
3137
3141
  </Tooltip>
3138
3142
  <Tooltip side="bottom" content="Pages">
3139
3143
  <IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
@@ -1,10 +0,0 @@
1
- import type { LoadedSection } from "../../lib/loader";
2
- import type { MediaManifest } from "../../media/types";
3
- interface SectionOrderingModalProps {
4
- sections: LoadedSection[];
5
- mediaManifest: MediaManifest;
6
- onReorder: (fromIndex: number, toIndex: number) => void;
7
- }
8
- export declare function SectionOrderingModal({ sections, mediaManifest, onReorder, }: SectionOrderingModalProps): import("react/jsx-runtime").JSX.Element;
9
- export {};
10
- //# sourceMappingURL=SectionOrderingModal.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SectionOrderingModal.d.ts","sourceRoot":"","sources":["../../../src/components/editor/SectionOrderingModal.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAQvD,UAAU,yBAAyB;IACjC,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,aAAa,EAAE,aAAa,CAAC;IAC7B,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACzD;AA+CD,wBAAgB,oBAAoB,CAAC,EACnC,QAAQ,EACR,aAAa,EACb,SAAS,GACV,EAAE,yBAAyB,2CAc3B"}
@@ -1,80 +0,0 @@
1
- import type { LoadedSection } from "../../lib/loader";
2
- import { getSection } from "../../lib/registry";
3
- import type { MediaManifest } from "../../media/types";
4
- import { DragHandle } from "./DragHandle";
5
- import { useSortableRow } from "../../hooks/useSortableRow";
6
- import { DropEdgeIndicator } from "./DropEdgeIndicator";
7
- import { SectionSummaryRow } from "../shared/SectionSummaryRow";
8
- import { toThumbViews } from "../../lib/thumb-views";
9
- import { cn } from "../../lib/cn";
10
-
11
- interface SectionOrderingModalProps {
12
- sections: LoadedSection[];
13
- mediaManifest: MediaManifest;
14
- onReorder: (fromIndex: number, toIndex: number) => void;
15
- }
16
-
17
- function SectionRow({
18
- section,
19
- index,
20
- mediaManifest,
21
- onReorder,
22
- }: {
23
- section: LoadedSection;
24
- index: number;
25
- mediaManifest: MediaManifest;
26
- onReorder: (fromIndex: number, toIndex: number) => void;
27
- }) {
28
- const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
29
- index,
30
- dragType: "ordering-row",
31
- onReorder,
32
- });
33
-
34
- const definition = getSection(section.section.type);
35
- const label = definition?.getLabel?.(section.section) ?? definition?.label ?? section.section.type;
36
- const typeLabel = definition?.label ?? section.section.type;
37
-
38
- return (
39
- <div
40
- ref={rowRef}
41
- className={cn(
42
- "relative flex items-center gap-1",
43
- isDragging && "opacity-50",
44
- )}
45
- >
46
- <DropEdgeIndicator edge={closestEdge} />
47
-
48
- <div className="shrink-0 [&_[role=tooltip]]:hidden">
49
- <DragHandle ref={handleRef} />
50
- </div>
51
-
52
- <SectionSummaryRow
53
- label={label}
54
- typeLabel={typeLabel}
55
- thumbnails={toThumbViews(definition?.getThumbnails?.(section.section) ?? [], mediaManifest)}
56
- className={cn(isDragging && "outline outline-2 outline-primary/50")}
57
- />
58
- </div>
59
- );
60
- }
61
-
62
- export function SectionOrderingModal({
63
- sections,
64
- mediaManifest,
65
- onReorder,
66
- }: SectionOrderingModalProps) {
67
- return (
68
- <div className="flex flex-col gap-2 overflow-y-auto">
69
- {sections.map((loadedSection, index) => (
70
- <SectionRow
71
- key={loadedSection.section.id}
72
- section={loadedSection}
73
- index={index}
74
- mediaManifest={mediaManifest}
75
- onReorder={onReorder}
76
- />
77
- ))}
78
- </div>
79
- );
80
- }