@drawnagency/primitives 0.1.10 → 0.1.12

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 (51) hide show
  1. package/dist/{chunk-32H6Q6CX.js → chunk-2YYC2VJY.js} +1 -1
  2. package/dist/{chunk-XQXZHDNR.js → chunk-PHCEJP7I.js} +1 -1
  3. package/dist/{chunk-6SK5BLG3.js → chunk-Q7OKHD6I.js} +1 -1
  4. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  5. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  6. package/dist/components/editor/StatusDots.d.ts +25 -0
  7. package/dist/components/editor/StatusDots.d.ts.map +1 -0
  8. package/dist/components/editor/StatusPicker.d.ts +1 -1
  9. package/dist/components/editor/StatusPicker.d.ts.map +1 -1
  10. package/dist/components/editor/index.d.ts +1 -0
  11. package/dist/components/editor/index.d.ts.map +1 -1
  12. package/dist/components/shared/SegmentedControl.d.ts +13 -0
  13. package/dist/components/shared/SegmentedControl.d.ts.map +1 -0
  14. package/dist/components/shared/SplitButton.d.ts +17 -0
  15. package/dist/components/shared/SplitButton.d.ts.map +1 -0
  16. package/dist/components/shell/EditorContext.d.ts +2 -0
  17. package/dist/components/shell/EditorContext.d.ts.map +1 -1
  18. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  19. package/dist/hooks/index.d.ts +1 -0
  20. package/dist/hooks/index.d.ts.map +1 -1
  21. package/dist/hooks/useContentLifecycle.d.ts +13 -0
  22. package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
  23. package/dist/hooks/useEditorPublish.d.ts +5 -1
  24. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  25. package/dist/index.js +3 -3
  26. package/dist/lib/dexie.d.ts +8 -1
  27. package/dist/lib/dexie.d.ts.map +1 -1
  28. package/dist/lib/index.js +2 -2
  29. package/dist/lib/registry.d.ts +6 -1
  30. package/dist/lib/registry.d.ts.map +1 -1
  31. package/dist/schemas/index.js +2 -2
  32. package/dist/schemas/site-config.d.ts +2 -2
  33. package/package.json +1 -1
  34. package/src/components/brandguide/DoDontList.tsx +1 -1
  35. package/src/components/editor/SectionWrapper.tsx +44 -2
  36. package/src/components/editor/StatusBadge.tsx +2 -2
  37. package/src/components/editor/StatusDots.tsx +131 -0
  38. package/src/components/editor/StatusPicker.tsx +6 -6
  39. package/src/components/editor/index.ts +1 -0
  40. package/src/components/sections/SectionLayout.tsx +1 -1
  41. package/src/components/shared/Navigation.tsx +3 -3
  42. package/src/components/shared/SegmentedControl.tsx +43 -0
  43. package/src/components/shared/SplitButton.tsx +97 -0
  44. package/src/components/shell/EditorContext.tsx +5 -1
  45. package/src/components/shell/EditorShell.tsx +157 -52
  46. package/src/hooks/index.ts +1 -0
  47. package/src/hooks/useContentLifecycle.ts +34 -0
  48. package/src/hooks/useEditorPublish.ts +230 -66
  49. package/src/lib/dexie.ts +43 -2
  50. package/src/lib/registry.ts +6 -1
  51. package/src/schemas/site-config.ts +1 -1
@@ -0,0 +1,131 @@
1
+ import { useRef, useState } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { Popover } from "../shared/Popover";
4
+
5
+ type StatusColor = "draft" | "live" | "archived" | "modified";
6
+
7
+ interface DotDef {
8
+ color: StatusColor;
9
+ }
10
+
11
+ interface DeriveInput {
12
+ mainStatus: string | null;
13
+ savedStatus: string;
14
+ contentDiffers: boolean;
15
+ isLocalOnly: boolean;
16
+ }
17
+
18
+ interface DeriveResult {
19
+ dots: DotDef[];
20
+ label: "unsaved" | null;
21
+ description: string;
22
+ }
23
+
24
+ const dotColorClasses: Record<StatusColor, string> = {
25
+ live: "bg-green-500",
26
+ draft: "bg-gray-400",
27
+ archived: "bg-white border-gray-300",
28
+ modified: "bg-orange-400",
29
+ };
30
+
31
+ const descriptions: Record<string, string> = {
32
+ "live": "Live — synced",
33
+ "live,modified": "Live — unpublished content edits",
34
+ "live,draft": "Live on site, changed to draft",
35
+ "live,archived": "Live on site, will be hidden on publish",
36
+ "draft": "Draft — editor only",
37
+ "draft,live": "Draft, will become live on publish",
38
+ "archived": "Archived",
39
+ "archived,live": "Archived, will become live on publish",
40
+ "modified": "Unpublished content edits",
41
+ };
42
+
43
+ export function deriveStatusDots({ mainStatus, savedStatus, contentDiffers, isLocalOnly }: DeriveInput): DeriveResult {
44
+ const label: "unsaved" | null = isLocalOnly ? "unsaved" : null;
45
+
46
+ // New section — no main status
47
+ if (!mainStatus) {
48
+ return {
49
+ dots: [{ color: savedStatus as StatusColor }],
50
+ label,
51
+ description: `${savedStatus} — new section`,
52
+ };
53
+ }
54
+
55
+ // Status changed between main and saved
56
+ if (mainStatus !== savedStatus) {
57
+ const key = `${mainStatus},${savedStatus}`;
58
+ return {
59
+ dots: [{ color: mainStatus as StatusColor }, { color: savedStatus as StatusColor }],
60
+ label,
61
+ description: descriptions[key] ?? `${mainStatus} → ${savedStatus}`,
62
+ };
63
+ }
64
+
65
+ // Same status — check content differences
66
+ if (contentDiffers) {
67
+ // Only show orange (modified) dot for live sections
68
+ if (mainStatus === "live") {
69
+ return {
70
+ dots: [{ color: "live" }, { color: "modified" }],
71
+ label,
72
+ description: descriptions["live,modified"]!,
73
+ };
74
+ }
75
+ // Draft/archived with content changes — no second dot
76
+ return {
77
+ dots: [{ color: mainStatus as StatusColor }],
78
+ label,
79
+ description: `${mainStatus} — with edits`,
80
+ };
81
+ }
82
+
83
+ // Fully synced
84
+ return {
85
+ dots: [{ color: mainStatus as StatusColor }],
86
+ label: null,
87
+ description: descriptions[mainStatus] ?? mainStatus,
88
+ };
89
+ }
90
+
91
+ interface StatusDotsProps {
92
+ mainStatus: string | null;
93
+ savedStatus: string;
94
+ contentDiffers: boolean;
95
+ isLocalOnly: boolean;
96
+ }
97
+
98
+ export function StatusDots(props: StatusDotsProps) {
99
+ const { dots, label, description } = deriveStatusDots(props);
100
+ const [open, setOpen] = useState(false);
101
+ const buttonRef = useRef<HTMLButtonElement>(null);
102
+
103
+ return (
104
+ <div className="relative">
105
+ <button
106
+ ref={buttonRef}
107
+ type="button"
108
+ onClick={() => setOpen((v) => !v)}
109
+ className="cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium border border-base-200 hover:bg-base-accent"
110
+ aria-label={description}
111
+ >
112
+ <span className="flex -space-x-1">
113
+ {dots.map((dot, i) => (
114
+ <span
115
+ key={i}
116
+ aria-hidden="true"
117
+ className={cn("h-3 w-3 rounded-full border border-base", dotColorClasses[dot.color])}
118
+ />
119
+ ))}
120
+ </span>
121
+ {label && <span className="opacity-70">(unsaved)</span>}
122
+ </button>
123
+
124
+ <Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={buttonRef} align="end" className="w-56">
125
+ <div className="px-3 py-2 text-xs text-base-contrast">
126
+ {description}
127
+ </div>
128
+ </Popover>
129
+ </div>
130
+ );
131
+ }
@@ -4,7 +4,7 @@ import { cn } from "../../lib/cn";
4
4
  import { Popover } from "../shared/Popover";
5
5
  import { PopoverItem } from "../shared/PopoverItem";
6
6
 
7
- type Status = "draft" | "published" | "archived";
7
+ type Status = "draft" | "live" | "archived";
8
8
 
9
9
  interface Props {
10
10
  status: Status;
@@ -12,18 +12,18 @@ interface Props {
12
12
  onChange: (status: Status) => void;
13
13
  }
14
14
 
15
- const STATUSES: Status[] = ["draft", "published", "archived"];
15
+ const STATUSES: Status[] = ["draft", "live", "archived"];
16
16
 
17
17
  const statusClasses: Record<Status, string> = {
18
- published: "bg-status-published-bg text-status-published-text",
18
+ live: "bg-status-live-bg text-status-live-text",
19
19
  draft: "bg-status-draft-bg text-status-draft-text",
20
20
  archived: "bg-status-archived-bg text-status-archived-text",
21
21
  };
22
22
 
23
23
  const dotClasses: Record<Status, string> = {
24
- published: "bg-status-published-bg",
25
- draft: "bg-status-draft-bg",
26
- archived: "bg-status-archived-bg",
24
+ live: "bg-green-500",
25
+ draft: "bg-gray-400",
26
+ archived: "bg-white",
27
27
  };
28
28
 
29
29
  export function StatusPicker({ status, dirty, onChange }: Props) {
@@ -5,3 +5,4 @@ export { SettingsButton } from "./SettingsButton";
5
5
  export { SettingsForm } from "./SettingsForm";
6
6
  export { StatusBadge } from "./StatusBadge";
7
7
  export { SectionWrapper } from "./SectionWrapper";
8
+ export { StatusDots, deriveStatusDots } from "./StatusDots";
@@ -19,7 +19,7 @@ interface Props {
19
19
  }
20
20
 
21
21
  export function SectionLayout({ type, status, dimNonPublished, children }: Props) {
22
- const isDimmed = dimNonPublished && status && status !== "published";
22
+ const isDimmed = dimNonPublished && status && status !== "live";
23
23
 
24
24
  return (
25
25
  <div className={cn("section-wrapper group", getSectionCategory(type), isDimmed && "opacity-50")}>
@@ -121,7 +121,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
121
121
  className={cn(
122
122
  "cursor-pointer w-full rounded px-3 py-2 text-left text-sm font-bold transition-colors",
123
123
  isActiveParent ? "text-primary" : "text-base-contrast hover:text-primary",
124
- !isEditMode && parent.status && parent.status !== "published" && "opacity-50",
124
+ !isEditMode && parent.status && parent.status !== "live" && "opacity-50",
125
125
  )}
126
126
  >
127
127
  {parent.label}
@@ -140,7 +140,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
140
140
  className={cn(
141
141
  "cursor-pointer w-full rounded px-2 py-1 text-left text-sm transition-colors",
142
142
  isActiveChild ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
143
- !isEditMode && child.status && child.status !== "published" && "opacity-50",
143
+ !isEditMode && child.status && child.status !== "live" && "opacity-50",
144
144
  )}
145
145
  >
146
146
  {child.label}
@@ -157,7 +157,7 @@ export default function Navigation({ navLinks: initialNavLinks, siteName, darkMo
157
157
  className={cn(
158
158
  "cursor-pointer block w-full px-2 py-1 text-left text-xs transition-colors",
159
159
  activeGrandchildId === gid ? "font-bold text-primary" : "text-base-contrast-light hover:text-primary",
160
- !isEditMode && gc.status && gc.status !== "published" && "opacity-50",
160
+ !isEditMode && gc.status && gc.status !== "live" && "opacity-50",
161
161
  )}
162
162
  >
163
163
  {gc.label}
@@ -0,0 +1,43 @@
1
+ import { cn } from "../../lib/cn";
2
+
3
+ export interface SegmentedOption {
4
+ value: string;
5
+ label: string;
6
+ }
7
+
8
+ interface SegmentedControlProps {
9
+ options: SegmentedOption[];
10
+ value: string;
11
+ onChange: (value: string) => void;
12
+ className?: string;
13
+ }
14
+
15
+ export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) {
16
+ return (
17
+ <div role="radiogroup" className={cn("inline-flex rounded border border-base-200 bg-base-accent", className)}>
18
+ {options.map((option) => {
19
+ const selected = option.value === value;
20
+ return (
21
+ <button
22
+ key={option.value}
23
+ type="button"
24
+ role="radio"
25
+ aria-checked={selected}
26
+ aria-label={option.label}
27
+ onClick={() => {
28
+ if (!selected) onChange(option.value);
29
+ }}
30
+ className={cn(
31
+ "cursor-pointer px-3 py-1 text-xs font-medium transition-colors first:rounded-l last:rounded-r",
32
+ selected
33
+ ? "bg-base-contrast text-base-accent"
34
+ : "text-base-contrast-light hover:text-base-contrast",
35
+ )}
36
+ >
37
+ {option.label}
38
+ </button>
39
+ );
40
+ })}
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,97 @@
1
+ import { useRef, useState } from "react";
2
+ import { ChevronDown } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { Popover } from "./Popover";
5
+ import { PopoverItem } from "./PopoverItem";
6
+
7
+ export interface SplitButtonOption {
8
+ label: string;
9
+ onClick: () => void;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ interface SplitButtonProps {
14
+ label: string;
15
+ onClick: () => void;
16
+ options: SplitButtonOption[];
17
+ disabled?: boolean;
18
+ isLoading?: boolean;
19
+ loadingLabel?: string;
20
+ className?: string;
21
+ }
22
+
23
+ export function SplitButton({
24
+ label,
25
+ onClick,
26
+ options,
27
+ disabled = false,
28
+ isLoading = false,
29
+ loadingLabel,
30
+ className,
31
+ }: SplitButtonProps) {
32
+ const [open, setOpen] = useState(false);
33
+ const chevronRef = useRef<HTMLButtonElement>(null);
34
+ const resolvedDisabled = disabled || isLoading;
35
+ const displayLabel = isLoading && loadingLabel ? loadingLabel : label;
36
+ const hasOptions = options.length > 0 && !resolvedDisabled;
37
+
38
+ return (
39
+ <div className={cn("relative inline-flex", className)}>
40
+ <button
41
+ type="button"
42
+ onClick={onClick}
43
+ disabled={resolvedDisabled}
44
+ className={cn(
45
+ "inline-flex items-center px-4 py-1.5 text-xs font-medium transition-colors",
46
+ "bg-base-contrast text-base-accent",
47
+ hasOptions ? "rounded-l" : "rounded",
48
+ resolvedDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-base-contrast/90",
49
+ )}
50
+ aria-label={displayLabel}
51
+ >
52
+ {displayLabel}
53
+ </button>
54
+ {hasOptions && (
55
+ <>
56
+ <button
57
+ ref={chevronRef}
58
+ type="button"
59
+ onClick={() => setOpen((v) => !v)}
60
+ disabled={resolvedDisabled}
61
+ className={cn(
62
+ "inline-flex items-center border-l border-base-accent/20 px-2 py-1.5 transition-colors",
63
+ "bg-base-contrast text-base-accent rounded-r",
64
+ resolvedDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-base-contrast/90",
65
+ )}
66
+ aria-label="More actions"
67
+ aria-haspopup="true"
68
+ aria-expanded={open}
69
+ >
70
+ <ChevronDown size={14} />
71
+ </button>
72
+ <Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={chevronRef} align="end" className="w-44">
73
+ <ul role="menu" className="py-1">
74
+ {options.map((option) => (
75
+ <li key={option.label}>
76
+ <PopoverItem
77
+ role="menuitem"
78
+ aria-disabled={option.disabled}
79
+ onClick={() => {
80
+ if (!option.disabled) {
81
+ option.onClick();
82
+ setOpen(false);
83
+ }
84
+ }}
85
+ className={cn(option.disabled && "opacity-50 cursor-not-allowed")}
86
+ >
87
+ <span className="text-sm font-medium text-base-contrast">{option.label}</span>
88
+ </PopoverItem>
89
+ </li>
90
+ ))}
91
+ </ul>
92
+ </Popover>
93
+ </>
94
+ )}
95
+ </div>
96
+ );
97
+ }
@@ -4,8 +4,10 @@ import { editModeEvent } from "../../lib/events";
4
4
  interface EditorContextValue {
5
5
  isEditMode: boolean;
6
6
  showAllChrome: boolean;
7
+ viewBranch: "saved" | "live";
7
8
  toggleEditMode: () => void;
8
9
  toggleShowAllChrome: () => void;
10
+ setViewBranch: (branch: "saved" | "live") => void;
9
11
  }
10
12
 
11
13
  const EditorContext = createContext<EditorContextValue | null>(null);
@@ -17,6 +19,7 @@ interface EditorProviderProps {
17
19
  export function EditorProvider({ children }: EditorProviderProps) {
18
20
  const [isEditMode, setIsEditMode] = useState(true);
19
21
  const [showAllChrome, setShowAllChrome] = useState(false);
22
+ const [viewBranch, setViewBranchState] = useState<"saved" | "live">("saved");
20
23
 
21
24
  const toggleEditMode = useCallback(() => {
22
25
  setIsEditMode((prev) => {
@@ -27,9 +30,10 @@ export function EditorProvider({ children }: EditorProviderProps) {
27
30
  }, []);
28
31
 
29
32
  const toggleShowAllChrome = useCallback(() => setShowAllChrome((prev) => !prev), []);
33
+ const setViewBranch = useCallback((b: "saved" | "live") => setViewBranchState(b), []);
30
34
 
31
35
  return (
32
- <EditorContext.Provider value={{ isEditMode, showAllChrome, toggleEditMode, toggleShowAllChrome }}>
36
+ <EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, toggleEditMode, toggleShowAllChrome, setViewBranch }}>
33
37
  {children}
34
38
  </EditorContext.Provider>
35
39
  );