@drawnagency/primitives 0.1.11 → 0.1.13
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-6SK5BLG3.js → chunk-46QI4FDZ.js} +1 -1
- package/dist/{chunk-XQXZHDNR.js → chunk-EAEX6DS7.js} +4 -1
- package/dist/{chunk-32H6Q6CX.js → chunk-P24YUT3O.js} +1 -1
- package/dist/components/editor/AudienceIndicator.d.ts +9 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -0
- package/dist/components/editor/IndicatorPill.d.ts +18 -0
- package/dist/components/editor/IndicatorPill.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +29 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -0
- package/dist/components/editor/index.d.ts +3 -1
- package/dist/components/editor/index.d.ts.map +1 -1
- package/dist/components/shared/Popover.d.ts +1 -1
- package/dist/components/shared/Popover.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/BuildStatusIndicator.d.ts +9 -0
- package/dist/components/shell/BuildStatusIndicator.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 +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts +11 -0
- package/dist/hooks/useBuildStatus.d.ts.map +1 -0
- package/dist/hooks/useContentLifecycle.d.ts +13 -0
- package/dist/hooks/useContentLifecycle.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +6 -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 +8 -6
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/editor/{AudiencePicker.tsx → AudienceIndicator.tsx} +25 -49
- package/src/components/editor/IndicatorPill.tsx +119 -0
- package/src/components/editor/SectionWrapper.tsx +59 -13
- package/src/components/editor/StatusIndicator.tsx +148 -0
- package/src/components/editor/index.ts +3 -1
- package/src/components/sections/SectionLayout.tsx +1 -1
- package/src/components/shared/Navigation.tsx +3 -3
- package/src/components/shared/Popover.tsx +26 -4
- package/src/components/shared/PopoverItem.tsx +1 -1
- package/src/components/shared/SegmentedControl.tsx +43 -0
- package/src/components/shared/SplitButton.tsx +97 -0
- package/src/components/shell/BuildStatusIndicator.tsx +67 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +180 -52
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useBuildStatus.ts +139 -0
- package/src/hooks/useContentLifecycle.ts +34 -0
- package/src/hooks/useEditorPublish.ts +234 -66
- package/src/lib/dexie.ts +43 -2
- package/src/lib/registry.ts +6 -1
- package/src/schemas/site-config.ts +5 -1
- package/dist/components/editor/AudiencePicker.d.ts +0 -9
- package/dist/components/editor/AudiencePicker.d.ts.map +0 -1
- package/dist/components/editor/StatusBadge.d.ts +0 -7
- package/dist/components/editor/StatusBadge.d.ts.map +0 -1
- package/dist/components/editor/StatusPicker.d.ts +0 -9
- package/dist/components/editor/StatusPicker.d.ts.map +0 -1
- package/src/components/editor/StatusBadge.tsx +0 -30
- package/src/components/editor/StatusPicker.tsx +0 -86
|
@@ -1,22 +1,18 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
2
1
|
import { Check } from "lucide-react";
|
|
3
2
|
import { cn } from "../../lib/cn";
|
|
4
|
-
import {
|
|
3
|
+
import { IndicatorPill } from "./IndicatorPill";
|
|
5
4
|
import { PopoverItem } from "../shared/PopoverItem";
|
|
6
5
|
import type { Audience } from "../../auth/types";
|
|
7
6
|
|
|
8
|
-
interface
|
|
7
|
+
interface AudienceIndicatorProps {
|
|
9
8
|
access: string[];
|
|
10
9
|
audiences: Audience[];
|
|
11
10
|
onChange: (access: string[]) => void;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
export function
|
|
15
|
-
const [open, setOpen] = useState(false);
|
|
16
|
-
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
17
|
-
|
|
13
|
+
export function AudienceIndicator({ access, audiences, onChange }: AudienceIndicatorProps) {
|
|
18
14
|
const selected = audiences.filter((a) => access.includes(a.name));
|
|
19
|
-
const
|
|
15
|
+
const visibleDots = selected.slice(0, 3);
|
|
20
16
|
|
|
21
17
|
function toggle(name: string) {
|
|
22
18
|
const next = access.includes(name)
|
|
@@ -25,47 +21,27 @@ export function AudiencePicker({ access, audiences, onChange }: Props) {
|
|
|
25
21
|
onChange(next);
|
|
26
22
|
}
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
ref={buttonRef}
|
|
32
|
-
type="button"
|
|
33
|
-
onClick={() => setOpen((v) => !v)}
|
|
34
|
-
className={cn(
|
|
35
|
-
"cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
|
|
36
|
-
"border border-base-200 hover:bg-base-accent",
|
|
37
|
-
selected.length === 0 && "text-base-contrast-light",
|
|
38
|
-
)}
|
|
39
|
-
>
|
|
40
|
-
{selected.length === 0 && <span>No audience</span>}
|
|
41
|
-
{selected.length >= 1 && (
|
|
42
|
-
<span className="flex -space-x-1.5">
|
|
43
|
-
{visibleCircles.map((a) => (
|
|
44
|
-
<span
|
|
45
|
-
key={a.name}
|
|
46
|
-
className="h-3 w-3 rounded-full border border-base"
|
|
47
|
-
style={{ backgroundColor: a.color ?? "#9ca3af" }}
|
|
48
|
-
/>
|
|
49
|
-
))}
|
|
50
|
-
</span>
|
|
51
|
-
)}
|
|
52
|
-
{selected.length === 1 && <span>{selected[0].displayName}</span>}
|
|
53
|
-
{selected.length >= 2 && <span>{selected.length} audiences</span>}
|
|
54
|
-
</button>
|
|
24
|
+
const pillDots = visibleDots.map((a) => ({
|
|
25
|
+
color: a.color ?? "#9ca3af",
|
|
26
|
+
}));
|
|
55
27
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
28
|
+
let labelText: string;
|
|
29
|
+
if (selected.length === 0) labelText = "No audience";
|
|
30
|
+
else if (selected.length === 1) labelText = selected[0].displayName;
|
|
31
|
+
else labelText = `${selected.length} audiences`;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<IndicatorPill
|
|
35
|
+
dots={pillDots}
|
|
36
|
+
label={labelText}
|
|
37
|
+
buttonClassName={selected.length === 0 ? "text-base-contrast-light" : undefined}
|
|
38
|
+
clickContent={
|
|
39
|
+
audiences.length === 0 ? (
|
|
40
|
+
<div className="px-3 py-2 text-xs text-base-contrast-light whitespace-nowrap">
|
|
65
41
|
No audiences configured. Add audiences in Site Settings → Viewer Access.
|
|
66
42
|
</div>
|
|
67
43
|
) : (
|
|
68
|
-
<ul role="list" className="py-1">
|
|
44
|
+
<ul role="list" className="w-full overflow-hidden py-1">
|
|
69
45
|
{audiences.map((a) => {
|
|
70
46
|
const checked = access.includes(a.name);
|
|
71
47
|
return (
|
|
@@ -90,14 +66,14 @@ export function AudiencePicker({ access, audiences, onChange }: Props) {
|
|
|
90
66
|
className="h-3 w-3 shrink-0 rounded-full border border-base-200"
|
|
91
67
|
style={{ backgroundColor: a.color ?? "#9ca3af" }}
|
|
92
68
|
/>
|
|
93
|
-
<span className="
|
|
69
|
+
<span className="font-medium text-base-contrast">{a.displayName}</span>
|
|
94
70
|
</PopoverItem>
|
|
95
71
|
</li>
|
|
96
72
|
);
|
|
97
73
|
})}
|
|
98
74
|
</ul>
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
/>
|
|
102
78
|
);
|
|
103
79
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useRef, useState, useCallback, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { Popover } from "../shared/Popover";
|
|
4
|
+
|
|
5
|
+
interface DotDef {
|
|
6
|
+
color?: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface IndicatorPillProps {
|
|
11
|
+
dots: DotDef[];
|
|
12
|
+
label: string;
|
|
13
|
+
/** Shown on hover above the pill. Currently unused — kept for planned tooltip feature. */
|
|
14
|
+
hoverContent?: ReactNode;
|
|
15
|
+
clickContent?: ReactNode | ((onClose: () => void) => ReactNode);
|
|
16
|
+
className?: string;
|
|
17
|
+
buttonClassName?: string;
|
|
18
|
+
ariaLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function IndicatorPill({
|
|
22
|
+
dots,
|
|
23
|
+
label,
|
|
24
|
+
hoverContent,
|
|
25
|
+
clickContent,
|
|
26
|
+
className,
|
|
27
|
+
buttonClassName,
|
|
28
|
+
ariaLabel,
|
|
29
|
+
}: IndicatorPillProps) {
|
|
30
|
+
const [hoverOpen, setHoverOpen] = useState(false);
|
|
31
|
+
const [clickOpen, setClickOpen] = useState(false);
|
|
32
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
33
|
+
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
34
|
+
|
|
35
|
+
const clearHoverTimeout = useCallback(() => {
|
|
36
|
+
if (hoverTimeout.current) {
|
|
37
|
+
clearTimeout(hoverTimeout.current);
|
|
38
|
+
hoverTimeout.current = null;
|
|
39
|
+
}
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
function handleMouseEnter() {
|
|
43
|
+
clearHoverTimeout();
|
|
44
|
+
if (!clickOpen && hoverContent) {
|
|
45
|
+
setHoverOpen(true);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleMouseLeave() {
|
|
50
|
+
clearHoverTimeout();
|
|
51
|
+
hoverTimeout.current = setTimeout(() => {
|
|
52
|
+
setHoverOpen(false);
|
|
53
|
+
}, 150);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleClick() {
|
|
57
|
+
if (!clickContent) return;
|
|
58
|
+
setHoverOpen(false);
|
|
59
|
+
clearHoverTimeout();
|
|
60
|
+
setClickOpen((v) => !v);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function handleClickClose() {
|
|
64
|
+
setClickOpen(false);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn("relative", className)}>
|
|
69
|
+
<button
|
|
70
|
+
ref={buttonRef}
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={handleClick}
|
|
73
|
+
onMouseEnter={handleMouseEnter}
|
|
74
|
+
onMouseLeave={handleMouseLeave}
|
|
75
|
+
aria-haspopup={clickContent ? "true" : undefined}
|
|
76
|
+
aria-expanded={clickContent ? clickOpen : undefined}
|
|
77
|
+
aria-label={ariaLabel}
|
|
78
|
+
className={cn(
|
|
79
|
+
"cursor-pointer inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium",
|
|
80
|
+
"border border-base-200 hover:bg-base-accent",
|
|
81
|
+
buttonClassName,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{dots.length > 0 && (
|
|
85
|
+
<span className="flex -space-x-1">
|
|
86
|
+
{dots.map((dot, i) => (
|
|
87
|
+
<span
|
|
88
|
+
key={i}
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
className={cn("h-3 w-3 rounded-full border border-base", dot.className)}
|
|
91
|
+
style={dot.color ? { backgroundColor: dot.color } : undefined}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
<span>{label}</span>
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
{/* Hover tooltip — above the button */}
|
|
100
|
+
{hoverOpen && hoverContent && (
|
|
101
|
+
<div
|
|
102
|
+
className="absolute bottom-full right-0 z-50 mb-1 rounded-full bg-base-accent px-3 py-1 text-xs text-base-contrast-light whitespace-nowrap"
|
|
103
|
+
>
|
|
104
|
+
{hoverContent}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Click dropdown — below the button */}
|
|
109
|
+
<Popover
|
|
110
|
+
isOpen={clickOpen}
|
|
111
|
+
onClose={handleClickClose}
|
|
112
|
+
anchorRef={buttonRef}
|
|
113
|
+
className="min-w-full max-w-xs"
|
|
114
|
+
>
|
|
115
|
+
{typeof clickContent === "function" ? clickContent(handleClickClose) : clickContent}
|
|
116
|
+
</Popover>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -4,14 +4,14 @@ import { DragHandle } from "./DragHandle";
|
|
|
4
4
|
import { InsertButton } from "./InsertButton";
|
|
5
5
|
import { DeleteButton } from "./DeleteButton";
|
|
6
6
|
import { SettingsButton } from "./SettingsButton";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { AudiencePicker } from "./AudiencePicker";
|
|
7
|
+
import { StatusIndicator } from "./StatusIndicator";
|
|
8
|
+
import { AudienceIndicator } from "./AudienceIndicator";
|
|
10
9
|
import { SettingsForm } from "./SettingsForm";
|
|
11
10
|
import { useEditorContext } from "../shell/EditorContext";
|
|
12
11
|
import { useEditorModal } from "../shell/EditorModalContext";
|
|
13
12
|
import type { WrapperProps } from "../../lib/registry";
|
|
14
13
|
import { cn } from "../../lib/cn";
|
|
14
|
+
import { Button } from "../shared/Button";
|
|
15
15
|
|
|
16
16
|
export function SectionWrapper({
|
|
17
17
|
sectionId,
|
|
@@ -30,6 +30,11 @@ export function SectionWrapper({
|
|
|
30
30
|
onReorder,
|
|
31
31
|
onRequestInsert,
|
|
32
32
|
onDelete,
|
|
33
|
+
isDeleted,
|
|
34
|
+
onUndoDelete,
|
|
35
|
+
mainStatus,
|
|
36
|
+
contentDiffersFromMain,
|
|
37
|
+
isLocalOnly,
|
|
33
38
|
children,
|
|
34
39
|
}: WrapperProps) {
|
|
35
40
|
const { isEditMode, showAllChrome } = useEditorContext();
|
|
@@ -175,8 +180,35 @@ export function SectionWrapper({
|
|
|
175
180
|
}
|
|
176
181
|
}
|
|
177
182
|
|
|
183
|
+
if (isDeleted && isEditMode) {
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
ref={blockRef}
|
|
187
|
+
className="relative opacity-30 pointer-events-none"
|
|
188
|
+
data-section-id={sectionId}
|
|
189
|
+
data-section-type={sectionType}
|
|
190
|
+
>
|
|
191
|
+
<div className="pointer-events-auto absolute right-0 bottom-full z-30 mb-1">
|
|
192
|
+
<Button
|
|
193
|
+
variant="secondary"
|
|
194
|
+
size="sm"
|
|
195
|
+
onClick={onUndoDelete}
|
|
196
|
+
className="pointer-events-auto"
|
|
197
|
+
>
|
|
198
|
+
Undo delete
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
{children}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (isDeleted) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
178
210
|
if (!isEditMode) {
|
|
179
|
-
if (status && status !== "
|
|
211
|
+
if (status && status !== "live") {
|
|
180
212
|
return (
|
|
181
213
|
<div
|
|
182
214
|
className="relative"
|
|
@@ -184,7 +216,17 @@ export function SectionWrapper({
|
|
|
184
216
|
data-section-type={sectionType}
|
|
185
217
|
>
|
|
186
218
|
<div className="pointer-events-none absolute right-0 bottom-full z-30 mb-1">
|
|
187
|
-
<
|
|
219
|
+
<span
|
|
220
|
+
className={cn(
|
|
221
|
+
"rounded-full px-2 py-0.5 text-xs font-medium",
|
|
222
|
+
status === "live" ? "bg-status-live-bg text-status-live-text"
|
|
223
|
+
: status === "draft" ? "bg-status-draft-bg text-status-draft-text"
|
|
224
|
+
: "bg-status-archived-bg text-status-archived-text",
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
{status}
|
|
228
|
+
{dirty && <span className="ml-1 opacity-70">· Unsaved</span>}
|
|
229
|
+
</span>
|
|
188
230
|
</div>
|
|
189
231
|
{children}
|
|
190
232
|
</div>
|
|
@@ -245,17 +287,21 @@ export function SectionWrapper({
|
|
|
245
287
|
)}
|
|
246
288
|
>
|
|
247
289
|
<div className="pointer-events-auto">
|
|
248
|
-
<
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
290
|
+
<StatusIndicator
|
|
291
|
+
mainStatus={mainStatus ?? null}
|
|
292
|
+
savedStatus={status as string}
|
|
293
|
+
contentDiffers={contentDiffersFromMain ?? false}
|
|
294
|
+
isLocalOnly={isLocalOnly ?? false}
|
|
295
|
+
status={status as "draft" | "live" | "archived"}
|
|
296
|
+
dirty={dirty}
|
|
297
|
+
onChange={(s) => onStatusChange?.(s)}
|
|
252
298
|
/>
|
|
253
299
|
</div>
|
|
254
300
|
<div className="pointer-events-auto">
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
onChange={(
|
|
301
|
+
<AudienceIndicator
|
|
302
|
+
access={access}
|
|
303
|
+
audiences={audiences}
|
|
304
|
+
onChange={(newAccess) => onAccessChange?.(newAccess)}
|
|
259
305
|
/>
|
|
260
306
|
</div>
|
|
261
307
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { IndicatorPill } from "./IndicatorPill";
|
|
4
|
+
import { PopoverItem } from "../shared/PopoverItem";
|
|
5
|
+
|
|
6
|
+
type StatusColor = "draft" | "live" | "archived" | "modified";
|
|
7
|
+
type Status = "draft" | "live" | "archived";
|
|
8
|
+
|
|
9
|
+
interface DotDef {
|
|
10
|
+
color: StatusColor;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface DeriveInput {
|
|
14
|
+
mainStatus: string | null;
|
|
15
|
+
savedStatus: string;
|
|
16
|
+
contentDiffers: boolean;
|
|
17
|
+
isLocalOnly: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DeriveResult {
|
|
21
|
+
dots: DotDef[];
|
|
22
|
+
unsaved: boolean;
|
|
23
|
+
description: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const descriptions: Record<string, string> = {
|
|
27
|
+
"live": "Live — synced",
|
|
28
|
+
"live,modified": "Live — unpublished content edits",
|
|
29
|
+
"live,draft": "Live on site, changed to draft",
|
|
30
|
+
"live,archived": "Live on site, will be hidden on publish",
|
|
31
|
+
"draft": "Draft — editor only",
|
|
32
|
+
"draft,modified": "Draft — unpublished content edits",
|
|
33
|
+
"draft,live": "Draft, will become live on publish",
|
|
34
|
+
"archived": "Archived",
|
|
35
|
+
"archived,modified": "Archived — unpublished content edits",
|
|
36
|
+
"archived,live": "Archived, will become live on publish",
|
|
37
|
+
"modified": "Unpublished content edits",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, isLocalOnly }: DeriveInput): DeriveResult {
|
|
41
|
+
const unsaved = isLocalOnly;
|
|
42
|
+
|
|
43
|
+
if (!mainStatus) {
|
|
44
|
+
return {
|
|
45
|
+
dots: [{ color: savedStatus as StatusColor }],
|
|
46
|
+
unsaved,
|
|
47
|
+
description: `${savedStatus} — new section`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (mainStatus !== savedStatus) {
|
|
52
|
+
const key = `${mainStatus},${savedStatus}`;
|
|
53
|
+
return {
|
|
54
|
+
dots: [{ color: mainStatus as StatusColor }, { color: savedStatus as StatusColor }],
|
|
55
|
+
unsaved,
|
|
56
|
+
description: descriptions[key] ?? `${mainStatus} → ${savedStatus}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (contentDiffers) {
|
|
61
|
+
const key = `${mainStatus},modified`;
|
|
62
|
+
return {
|
|
63
|
+
dots: [{ color: mainStatus as StatusColor }, { color: "modified" }],
|
|
64
|
+
unsaved,
|
|
65
|
+
description: descriptions[key] ?? `${mainStatus} — with edits`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
dots: [{ color: mainStatus as StatusColor }],
|
|
71
|
+
unsaved: false,
|
|
72
|
+
description: descriptions[mainStatus] ?? mainStatus,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dotColorClasses: Record<StatusColor, string> = {
|
|
77
|
+
live: "bg-green-500",
|
|
78
|
+
draft: "bg-gray-400",
|
|
79
|
+
archived: "bg-white border-gray-300",
|
|
80
|
+
modified: "bg-orange-400",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
const STATUSES: Status[] = ["draft", "live", "archived"];
|
|
85
|
+
|
|
86
|
+
interface StatusIndicatorProps {
|
|
87
|
+
mainStatus: string | null;
|
|
88
|
+
savedStatus: string;
|
|
89
|
+
contentDiffers: boolean;
|
|
90
|
+
isLocalOnly: boolean;
|
|
91
|
+
status: Status;
|
|
92
|
+
dirty?: boolean;
|
|
93
|
+
onChange: (status: Status) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function StatusIndicator({
|
|
97
|
+
mainStatus,
|
|
98
|
+
savedStatus,
|
|
99
|
+
contentDiffers,
|
|
100
|
+
isLocalOnly,
|
|
101
|
+
status,
|
|
102
|
+
dirty,
|
|
103
|
+
onChange,
|
|
104
|
+
}: StatusIndicatorProps) {
|
|
105
|
+
const display = deriveStatusDisplay({ mainStatus, savedStatus, contentDiffers, isLocalOnly });
|
|
106
|
+
|
|
107
|
+
const pillDots = display.dots.map((d) => ({
|
|
108
|
+
className: dotColorClasses[d.color],
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
const unsavedFromDirtyOrLocal = dirty || display.unsaved;
|
|
112
|
+
const labelText = `${status.charAt(0).toUpperCase() + status.slice(1)}${unsavedFromDirtyOrLocal ? " (Unsaved)" : ""}`;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<IndicatorPill
|
|
116
|
+
dots={pillDots}
|
|
117
|
+
label={labelText}
|
|
118
|
+
ariaLabel={display.description}
|
|
119
|
+
buttonClassName="text-base-contrast-light"
|
|
120
|
+
clickContent={(onClose) => (
|
|
121
|
+
<ul role="radiogroup" aria-label="Section status" className="w-full overflow-hidden py-1">
|
|
122
|
+
{STATUSES.map((s) => {
|
|
123
|
+
const checked = s === status;
|
|
124
|
+
return (
|
|
125
|
+
<li key={s}>
|
|
126
|
+
<PopoverItem
|
|
127
|
+
role="radio"
|
|
128
|
+
aria-checked={checked}
|
|
129
|
+
onClick={() => {
|
|
130
|
+
if (s !== status) onChange(s);
|
|
131
|
+
onClose();
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<span
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
className={cn("h-3 w-3 shrink-0 rounded-full border border-base-200", dotColorClasses[s])}
|
|
137
|
+
/>
|
|
138
|
+
<span className="flex-1 font-medium capitalize text-base-contrast">{s}</span>
|
|
139
|
+
{checked && <Check size={14} strokeWidth={3} className="text-primary" />}
|
|
140
|
+
</PopoverItem>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ul>
|
|
145
|
+
)}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -3,5 +3,7 @@ export { InsertButton } from "./InsertButton";
|
|
|
3
3
|
export { DeleteButton } from "./DeleteButton";
|
|
4
4
|
export { SettingsButton } from "./SettingsButton";
|
|
5
5
|
export { SettingsForm } from "./SettingsForm";
|
|
6
|
-
export {
|
|
6
|
+
export { IndicatorPill } from "./IndicatorPill";
|
|
7
|
+
export { StatusIndicator, deriveStatusDisplay } from "./StatusIndicator";
|
|
8
|
+
export { AudienceIndicator } from "./AudienceIndicator";
|
|
7
9
|
export { SectionWrapper } from "./SectionWrapper";
|
|
@@ -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}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
|
|
@@ -7,7 +7,7 @@ interface PopoverProps {
|
|
|
7
7
|
onClose: () => void;
|
|
8
8
|
anchorRef: RefObject<HTMLElement | null>;
|
|
9
9
|
children: ReactNode;
|
|
10
|
-
align?: "start" | "end";
|
|
10
|
+
align?: "start" | "end" | "auto";
|
|
11
11
|
className?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
@@ -16,13 +16,33 @@ export function Popover({
|
|
|
16
16
|
onClose,
|
|
17
17
|
anchorRef,
|
|
18
18
|
children,
|
|
19
|
-
align = "
|
|
19
|
+
align = "auto",
|
|
20
20
|
className,
|
|
21
21
|
}: PopoverProps) {
|
|
22
22
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
const [resolvedAlign, setResolvedAlign] = useState<"start" | "end">("start");
|
|
23
24
|
|
|
24
25
|
useFocusTrap(panelRef, isOpen);
|
|
25
26
|
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
if (!isOpen || align !== "auto") {
|
|
29
|
+
if (!isOpen) setResolvedAlign("start");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const panel = panelRef.current;
|
|
33
|
+
if (!panel) return;
|
|
34
|
+
panel.style.left = "0";
|
|
35
|
+
panel.style.right = "auto";
|
|
36
|
+
const rect = panel.getBoundingClientRect();
|
|
37
|
+
if (rect.right > window.innerWidth) {
|
|
38
|
+
setResolvedAlign("end");
|
|
39
|
+
} else {
|
|
40
|
+
setResolvedAlign("start");
|
|
41
|
+
}
|
|
42
|
+
panel.style.left = "";
|
|
43
|
+
panel.style.right = "";
|
|
44
|
+
}, [isOpen, align]);
|
|
45
|
+
|
|
26
46
|
useEffect(() => {
|
|
27
47
|
if (!isOpen) return;
|
|
28
48
|
function onKeyDown(e: KeyboardEvent) {
|
|
@@ -44,6 +64,8 @@ export function Popover({
|
|
|
44
64
|
|
|
45
65
|
if (!isOpen) return null;
|
|
46
66
|
|
|
67
|
+
const effectiveAlign = align === "auto" ? resolvedAlign : align;
|
|
68
|
+
|
|
47
69
|
return (
|
|
48
70
|
<div
|
|
49
71
|
ref={panelRef}
|
|
@@ -51,7 +73,7 @@ export function Popover({
|
|
|
51
73
|
aria-modal="false"
|
|
52
74
|
className={cn(
|
|
53
75
|
"absolute top-full z-50 mt-1 rounded-md border border-base-200 bg-base shadow-lg",
|
|
54
|
-
|
|
76
|
+
effectiveAlign === "end" ? "right-0" : "left-0",
|
|
55
77
|
className,
|
|
56
78
|
)}
|
|
57
79
|
>
|
|
@@ -12,7 +12,7 @@ export const PopoverItem = forwardRef<HTMLButtonElement, PopoverItemProps>(
|
|
|
12
12
|
ref={ref}
|
|
13
13
|
type="button"
|
|
14
14
|
className={cn(
|
|
15
|
-
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left hover:bg-base-accent",
|
|
15
|
+
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left text-xs hover:bg-base-accent",
|
|
16
16
|
className,
|
|
17
17
|
)}
|
|
18
18
|
{...rest}
|
|
@@ -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
|
+
}
|