@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.
- package/dist/{chunk-32H6Q6CX.js → chunk-2YYC2VJY.js} +1 -1
- package/dist/{chunk-XQXZHDNR.js → chunk-PHCEJP7I.js} +1 -1
- package/dist/{chunk-6SK5BLG3.js → chunk-Q7OKHD6I.js} +1 -1
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusDots.d.ts +25 -0
- package/dist/components/editor/StatusDots.d.ts.map +1 -0
- package/dist/components/editor/StatusPicker.d.ts +1 -1
- package/dist/components/editor/StatusPicker.d.ts.map +1 -1
- package/dist/components/editor/index.d.ts +1 -0
- package/dist/components/editor/index.d.ts.map +1 -1
- package/dist/components/shared/SegmentedControl.d.ts +13 -0
- package/dist/components/shared/SegmentedControl.d.ts.map +1 -0
- package/dist/components/shared/SplitButton.d.ts +17 -0
- package/dist/components/shared/SplitButton.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +2 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useContentLifecycle.d.ts +13 -0
- package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +5 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/dexie.d.ts +8 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/lib/registry.d.ts +6 -1
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +2 -2
- package/package.json +1 -1
- package/src/components/brandguide/DoDontList.tsx +1 -1
- package/src/components/editor/SectionWrapper.tsx +44 -2
- package/src/components/editor/StatusBadge.tsx +2 -2
- package/src/components/editor/StatusDots.tsx +131 -0
- package/src/components/editor/StatusPicker.tsx +6 -6
- package/src/components/editor/index.ts +1 -0
- package/src/components/sections/SectionLayout.tsx +1 -1
- package/src/components/shared/Navigation.tsx +3 -3
- package/src/components/shared/SegmentedControl.tsx +43 -0
- package/src/components/shared/SplitButton.tsx +97 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +157 -52
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useContentLifecycle.ts +34 -0
- package/src/hooks/useEditorPublish.ts +230 -66
- package/src/lib/dexie.ts +43 -2
- package/src/lib/registry.ts +6 -1
- 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" | "
|
|
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", "
|
|
15
|
+
const STATUSES: Status[] = ["draft", "live", "archived"];
|
|
16
16
|
|
|
17
17
|
const statusClasses: Record<Status, string> = {
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
draft: "bg-
|
|
26
|
-
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) {
|
|
@@ -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 !== "
|
|
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 !== "
|
|
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 !== "
|
|
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 !== "
|
|
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
|
);
|