@drawnagency/primitives 0.1.12 → 0.1.14
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-Q7OKHD6I.js → chunk-46QI4FDZ.js} +1 -1
- package/dist/{chunk-PHCEJP7I.js → chunk-EAEX6DS7.js} +4 -1
- package/dist/{chunk-2YYC2VJY.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.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 -2
- package/dist/components/editor/index.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.d.ts +1 -1
- package/dist/components/sections/register-schemas.d.ts.map +1 -1
- package/dist/components/sections/register.d.ts +1 -1
- package/dist/components/sections/register.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/shell/BuildStatusIndicator.d.ts +9 -0
- package/dist/components/shell/BuildStatusIndicator.d.ts.map +1 -0
- 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/useBuildStatus.d.ts +11 -0
- package/dist/hooks/useBuildStatus.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +2 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/lib/index.js +2 -2
- package/dist/schemas/index.js +2 -2
- package/dist/schemas/site-config.d.ts +6 -4
- 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 +18 -14
- package/src/components/editor/StatusIndicator.tsx +148 -0
- package/src/components/editor/index.ts +3 -2
- package/src/components/sections/register-schemas.ts +8 -2
- package/src/components/sections/register.ts +8 -2
- package/src/components/shared/Popover.tsx +26 -4
- package/src/components/shared/PopoverItem.tsx +1 -1
- package/src/components/shared/SplitButton.tsx +2 -2
- package/src/components/shell/BuildStatusIndicator.tsx +67 -0
- package/src/components/shell/EditorShell.tsx +23 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useBuildStatus.ts +139 -0
- package/src/hooks/useEditorPublish.ts +6 -2
- 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/StatusDots.d.ts +0 -25
- package/dist/components/editor/StatusDots.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/StatusDots.tsx +0 -131
- package/src/components/editor/StatusPicker.tsx +0 -86
|
@@ -4,10 +4,8 @@ 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";
|
|
10
|
-
import { StatusDots } from "./StatusDots";
|
|
7
|
+
import { StatusIndicator } from "./StatusIndicator";
|
|
8
|
+
import { AudienceIndicator } from "./AudienceIndicator";
|
|
11
9
|
import { SettingsForm } from "./SettingsForm";
|
|
12
10
|
import { useEditorContext } from "../shell/EditorContext";
|
|
13
11
|
import { useEditorModal } from "../shell/EditorModalContext";
|
|
@@ -218,7 +216,17 @@ export function SectionWrapper({
|
|
|
218
216
|
data-section-type={sectionType}
|
|
219
217
|
>
|
|
220
218
|
<div className="pointer-events-none absolute right-0 bottom-full z-30 mb-1">
|
|
221
|
-
<
|
|
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>
|
|
222
230
|
</div>
|
|
223
231
|
{children}
|
|
224
232
|
</div>
|
|
@@ -279,27 +287,23 @@ export function SectionWrapper({
|
|
|
279
287
|
)}
|
|
280
288
|
>
|
|
281
289
|
<div className="pointer-events-auto">
|
|
282
|
-
<
|
|
290
|
+
<StatusIndicator
|
|
283
291
|
mainStatus={mainStatus ?? null}
|
|
284
292
|
savedStatus={status as string}
|
|
285
293
|
contentDiffers={contentDiffersFromMain ?? false}
|
|
286
294
|
isLocalOnly={isLocalOnly ?? false}
|
|
295
|
+
status={status as "draft" | "live" | "archived"}
|
|
296
|
+
dirty={dirty}
|
|
297
|
+
onChange={(s) => onStatusChange?.(s)}
|
|
287
298
|
/>
|
|
288
299
|
</div>
|
|
289
300
|
<div className="pointer-events-auto">
|
|
290
|
-
<
|
|
301
|
+
<AudienceIndicator
|
|
291
302
|
access={access}
|
|
292
303
|
audiences={audiences}
|
|
293
304
|
onChange={(newAccess) => onAccessChange?.(newAccess)}
|
|
294
305
|
/>
|
|
295
306
|
</div>
|
|
296
|
-
<div className="pointer-events-auto">
|
|
297
|
-
<StatusPicker
|
|
298
|
-
status={status as "draft" | "live" | "archived"}
|
|
299
|
-
dirty={dirty}
|
|
300
|
-
onChange={(s) => onStatusChange?.(s)}
|
|
301
|
-
/>
|
|
302
|
-
</div>
|
|
303
307
|
|
|
304
308
|
{hasSettings && (
|
|
305
309
|
<SettingsButton onClick={handleSettingsClick} />
|
|
@@ -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,6 +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";
|
|
8
|
-
export { StatusDots, deriveStatusDots } from "./StatusDots";
|
|
@@ -14,6 +14,12 @@ import iconList from "./IconList";
|
|
|
14
14
|
const allDefs = [linkHeading, subHeading, subSubHeading, prose, mediaGrid,
|
|
15
15
|
splitContent, button, colors, doDontList, doDontImageGrid, iconList,
|
|
16
16
|
];
|
|
17
|
-
allDefs.forEach((def) => registerSchema(def.type, def.schema));
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
let registered = false;
|
|
19
|
+
|
|
20
|
+
export function ensureSchemasRegistered(): number {
|
|
21
|
+
if (registered) return allDefs.length;
|
|
22
|
+
allDefs.forEach((def) => registerSchema(def.type, def.schema));
|
|
23
|
+
registered = true;
|
|
24
|
+
return allDefs.length;
|
|
25
|
+
}
|
|
@@ -14,6 +14,12 @@ import iconList from "./IconList";
|
|
|
14
14
|
const allDefs = [linkHeading, subHeading, subSubHeading, prose, mediaGrid,
|
|
15
15
|
splitContent, button, colors, doDontList, doDontImageGrid, iconList,
|
|
16
16
|
];
|
|
17
|
-
allDefs.forEach(registerSection);
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
let registered = false;
|
|
19
|
+
|
|
20
|
+
export function ensureSectionsRegistered(): number {
|
|
21
|
+
if (registered) return allDefs.length;
|
|
22
|
+
allDefs.forEach(registerSection);
|
|
23
|
+
registered = true;
|
|
24
|
+
return allDefs.length;
|
|
25
|
+
}
|
|
@@ -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}
|
|
@@ -69,7 +69,7 @@ export function SplitButton({
|
|
|
69
69
|
>
|
|
70
70
|
<ChevronDown size={14} />
|
|
71
71
|
</button>
|
|
72
|
-
<Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={chevronRef}
|
|
72
|
+
<Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={chevronRef} className="w-full">
|
|
73
73
|
<ul role="menu" className="py-1">
|
|
74
74
|
{options.map((option) => (
|
|
75
75
|
<li key={option.label}>
|
|
@@ -84,7 +84,7 @@ export function SplitButton({
|
|
|
84
84
|
}}
|
|
85
85
|
className={cn(option.disabled && "opacity-50 cursor-not-allowed")}
|
|
86
86
|
>
|
|
87
|
-
<span className="
|
|
87
|
+
<span className="font-medium text-base-contrast">{option.label}</span>
|
|
88
88
|
</PopoverItem>
|
|
89
89
|
</li>
|
|
90
90
|
))}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface BuildStatusIndicatorProps {
|
|
5
|
+
state: "idle" | "building" | "ready" | "error";
|
|
6
|
+
deployUrl: string | null;
|
|
7
|
+
visible: boolean;
|
|
8
|
+
onDismiss: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const stateConfig = {
|
|
12
|
+
building: {
|
|
13
|
+
label: "Deploying...",
|
|
14
|
+
className: "border-orange-500 text-orange-600 hover:bg-orange-50",
|
|
15
|
+
dotClassName: "bg-orange-500 animate-pulse",
|
|
16
|
+
},
|
|
17
|
+
ready: {
|
|
18
|
+
label: "Live",
|
|
19
|
+
className: "border-green-600 text-green-600 hover:bg-green-50",
|
|
20
|
+
dotClassName: "bg-green-600",
|
|
21
|
+
},
|
|
22
|
+
error: {
|
|
23
|
+
label: "Deploy failed",
|
|
24
|
+
className: "border-red-600 text-red-600 hover:bg-red-50",
|
|
25
|
+
dotClassName: "bg-red-600",
|
|
26
|
+
},
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export function BuildStatusIndicator({
|
|
30
|
+
state,
|
|
31
|
+
deployUrl,
|
|
32
|
+
visible,
|
|
33
|
+
onDismiss,
|
|
34
|
+
}: BuildStatusIndicatorProps) {
|
|
35
|
+
if (!visible || state === "idle") return null;
|
|
36
|
+
|
|
37
|
+
const config = stateConfig[state];
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<span className="inline-flex items-center gap-0">
|
|
41
|
+
<a
|
|
42
|
+
href={deployUrl ?? "#"}
|
|
43
|
+
target="_blank"
|
|
44
|
+
rel="noopener noreferrer"
|
|
45
|
+
aria-label={config.label}
|
|
46
|
+
className={cn(
|
|
47
|
+
"inline-flex items-center gap-1.5 rounded-l px-3 py-1.5 text-xs font-medium border transition-colors",
|
|
48
|
+
config.className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<span className={cn("h-1.5 w-1.5 rounded-full", config.dotClassName)} />
|
|
52
|
+
{config.label}
|
|
53
|
+
</a>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={onDismiss}
|
|
57
|
+
aria-label="Dismiss build status"
|
|
58
|
+
className={cn(
|
|
59
|
+
"inline-flex items-center rounded-r border border-l-0 px-1.5 py-1.5 transition-colors cursor-pointer",
|
|
60
|
+
config.className,
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<X size={12} />
|
|
64
|
+
</button>
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
import { useEditorPersistence } from "../../hooks/useEditorPersistence";
|
|
37
37
|
import { useEditorPublish } from "../../hooks/useEditorPublish";
|
|
38
38
|
import { useContentLifecycle } from "../../hooks/useContentLifecycle";
|
|
39
|
+
import { useBuildStatus } from "../../hooks/useBuildStatus";
|
|
40
|
+
import { BuildStatusIndicator } from "./BuildStatusIndicator";
|
|
39
41
|
import { useMediaPipeline } from "../../hooks/useMediaPipeline";
|
|
40
42
|
import { formatTimestamp } from "../../lib/timestamp";
|
|
41
43
|
import { generateNavLinks } from "../../lib/nav";
|
|
@@ -123,6 +125,8 @@ export default function EditorShell({
|
|
|
123
125
|
markSectionDirty: persistence.markSectionDirty,
|
|
124
126
|
});
|
|
125
127
|
|
|
128
|
+
const buildStatus = useBuildStatus();
|
|
129
|
+
|
|
126
130
|
const { isPublishing, publishFeedback, handleSave, handlePublish, handleSaveAndPublish } = useEditorPublish({
|
|
127
131
|
flushNow: persistence.flushNow,
|
|
128
132
|
cancelPendingFlush: persistence.cancelPendingFlush,
|
|
@@ -159,6 +163,7 @@ export default function EditorShell({
|
|
|
159
163
|
setSavedSha(newSavedSha);
|
|
160
164
|
setMainSha((prev) => newMainSha ?? prev);
|
|
161
165
|
},
|
|
166
|
+
onPublishComplete: buildStatus.startTracking,
|
|
162
167
|
});
|
|
163
168
|
|
|
164
169
|
const { buttonState } = useContentLifecycle({
|
|
@@ -581,6 +586,10 @@ export default function EditorShell({
|
|
|
581
586
|
setShowMediaLibrary(true);
|
|
582
587
|
}}
|
|
583
588
|
processingItems={mediaPipeline.processingItems}
|
|
589
|
+
buildState={buildStatus.state}
|
|
590
|
+
buildDeployUrl={buildStatus.deployUrl}
|
|
591
|
+
buildVisible={buildStatus.visible}
|
|
592
|
+
onBuildDismiss={buildStatus.dismiss}
|
|
584
593
|
/>
|
|
585
594
|
|
|
586
595
|
<EditorContent
|
|
@@ -879,6 +888,10 @@ function EditorToolbar({
|
|
|
879
888
|
onSettingsClick,
|
|
880
889
|
onMediaClick,
|
|
881
890
|
processingItems,
|
|
891
|
+
buildState,
|
|
892
|
+
buildDeployUrl,
|
|
893
|
+
buildVisible,
|
|
894
|
+
onBuildDismiss,
|
|
882
895
|
}: {
|
|
883
896
|
buttonState: "synced" | "publish" | "saveAndPublish";
|
|
884
897
|
localChangesExist: boolean;
|
|
@@ -891,6 +904,10 @@ function EditorToolbar({
|
|
|
891
904
|
onSettingsClick: () => void;
|
|
892
905
|
onMediaClick: () => void;
|
|
893
906
|
processingItems: QueueItem[];
|
|
907
|
+
buildState: "idle" | "building" | "ready" | "error";
|
|
908
|
+
buildDeployUrl: string | null;
|
|
909
|
+
buildVisible: boolean;
|
|
910
|
+
onBuildDismiss: () => void;
|
|
894
911
|
}) {
|
|
895
912
|
const { isEditMode, viewBranch, setViewBranch, toggleEditMode } = useEditorContext();
|
|
896
913
|
|
|
@@ -945,6 +962,12 @@ function EditorToolbar({
|
|
|
945
962
|
Discard Changes
|
|
946
963
|
</Button>
|
|
947
964
|
)}
|
|
965
|
+
<BuildStatusIndicator
|
|
966
|
+
state={buildState}
|
|
967
|
+
deployUrl={buildDeployUrl}
|
|
968
|
+
visible={buildVisible}
|
|
969
|
+
onDismiss={onBuildDismiss}
|
|
970
|
+
/>
|
|
948
971
|
</div>
|
|
949
972
|
<div className="flex items-center gap-2">
|
|
950
973
|
<ProcessingIndicator items={processingItems} />
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { useActiveHeadings } from "./useActiveHeadings";
|
|
2
|
+
export { useBuildStatus } from "./useBuildStatus";
|
|
2
3
|
export { useContentLifecycle } from "./useContentLifecycle";
|
|
3
4
|
export { useEditorPersistence } from "./useEditorPersistence";
|
|
4
5
|
export { useEditorPublish } from "./useEditorPublish";
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
type BuildState = "idle" | "building" | "ready" | "error";
|
|
4
|
+
|
|
5
|
+
interface BuildStatusResponse {
|
|
6
|
+
state: "building" | "ready" | "error";
|
|
7
|
+
deployUrl: string;
|
|
8
|
+
commitSha: string | null;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BuildStatusResult {
|
|
13
|
+
state: BuildState;
|
|
14
|
+
deployUrl: string | null;
|
|
15
|
+
visible: boolean;
|
|
16
|
+
dismiss: () => void;
|
|
17
|
+
startTracking: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const POLL_INTERVAL = 5000;
|
|
21
|
+
const AUTO_DISMISS_DELAY = 10000;
|
|
22
|
+
|
|
23
|
+
export function useBuildStatus(): BuildStatusResult {
|
|
24
|
+
const [state, setState] = useState<BuildState>("idle");
|
|
25
|
+
const [deployUrl, setDeployUrl] = useState<string | null>(null);
|
|
26
|
+
const [dismissed, setDismissed] = useState(false);
|
|
27
|
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
28
|
+
const dismissRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
29
|
+
const isPolling = useRef(false);
|
|
30
|
+
|
|
31
|
+
const stopPolling = useCallback(() => {
|
|
32
|
+
if (pollRef.current) {
|
|
33
|
+
clearInterval(pollRef.current);
|
|
34
|
+
pollRef.current = null;
|
|
35
|
+
}
|
|
36
|
+
isPolling.current = false;
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const fetchStatus = useCallback(async (): Promise<BuildStatusResponse | null> => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch("/api/build-status");
|
|
42
|
+
if (!res.ok) return null;
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
const validStates = ["building", "ready", "error"];
|
|
45
|
+
if (!data || !validStates.includes(data.state)) return null;
|
|
46
|
+
return data as BuildStatusResponse;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleStatusUpdate = useCallback(
|
|
53
|
+
(data: BuildStatusResponse | null, isInitialLoad: boolean) => {
|
|
54
|
+
if (!data) {
|
|
55
|
+
if (isInitialLoad) {
|
|
56
|
+
setState("idle");
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isInitialLoad && data.state === "ready") {
|
|
62
|
+
setState("idle");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setState(data.state);
|
|
67
|
+
setDeployUrl(data.deployUrl);
|
|
68
|
+
setDismissed(false);
|
|
69
|
+
|
|
70
|
+
if (data.state === "ready" || data.state === "error") {
|
|
71
|
+
stopPolling();
|
|
72
|
+
|
|
73
|
+
if (data.state === "ready") {
|
|
74
|
+
dismissRef.current = setTimeout(() => {
|
|
75
|
+
setDismissed(true);
|
|
76
|
+
}, AUTO_DISMISS_DELAY);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[stopPolling],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const startPolling = useCallback(() => {
|
|
84
|
+
if (isPolling.current) return;
|
|
85
|
+
isPolling.current = true;
|
|
86
|
+
|
|
87
|
+
pollRef.current = setInterval(async () => {
|
|
88
|
+
const data = await fetchStatus();
|
|
89
|
+
handleStatusUpdate(data, false);
|
|
90
|
+
}, POLL_INTERVAL);
|
|
91
|
+
}, [fetchStatus, handleStatusUpdate]);
|
|
92
|
+
|
|
93
|
+
// Initial fetch on mount
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
|
|
97
|
+
async function check() {
|
|
98
|
+
const data = await fetchStatus();
|
|
99
|
+
if (cancelled) return;
|
|
100
|
+
handleStatusUpdate(data, true);
|
|
101
|
+
|
|
102
|
+
if (data && data.state === "building") {
|
|
103
|
+
startPolling();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
check();
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
cancelled = true;
|
|
111
|
+
stopPolling();
|
|
112
|
+
if (dismissRef.current) clearTimeout(dismissRef.current);
|
|
113
|
+
};
|
|
114
|
+
}, [fetchStatus, handleStatusUpdate, startPolling, stopPolling]);
|
|
115
|
+
|
|
116
|
+
const dismiss = useCallback(() => {
|
|
117
|
+
setDismissed(true);
|
|
118
|
+
stopPolling();
|
|
119
|
+
if (dismissRef.current) {
|
|
120
|
+
clearTimeout(dismissRef.current);
|
|
121
|
+
dismissRef.current = null;
|
|
122
|
+
}
|
|
123
|
+
}, [stopPolling]);
|
|
124
|
+
|
|
125
|
+
const startTracking = useCallback(() => {
|
|
126
|
+
setState("building");
|
|
127
|
+
setDeployUrl(null);
|
|
128
|
+
setDismissed(false);
|
|
129
|
+
if (dismissRef.current) {
|
|
130
|
+
clearTimeout(dismissRef.current);
|
|
131
|
+
dismissRef.current = null;
|
|
132
|
+
}
|
|
133
|
+
startPolling();
|
|
134
|
+
}, [startPolling]);
|
|
135
|
+
|
|
136
|
+
const visible = state !== "idle" && !dismissed;
|
|
137
|
+
|
|
138
|
+
return { state, deployUrl, visible, dismiss, startTracking };
|
|
139
|
+
}
|
|
@@ -31,6 +31,7 @@ interface PublishDeps {
|
|
|
31
31
|
pendingMediaDeletions: string[];
|
|
32
32
|
onMediaPublished: (publishedItems: MediaItem[], publishedDeletions: string[]) => void;
|
|
33
33
|
onShasUpdated: (savedSha: string | null, mainSha: string | null) => void;
|
|
34
|
+
onPublishComplete?: () => void;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
interface GatheredMedia {
|
|
@@ -55,6 +56,7 @@ export function useEditorPublish({
|
|
|
55
56
|
pendingMediaDeletions,
|
|
56
57
|
onMediaPublished,
|
|
57
58
|
onShasUpdated,
|
|
59
|
+
onPublishComplete,
|
|
58
60
|
}: PublishDeps) {
|
|
59
61
|
const [isPublishing, setIsPublishing] = useState(false);
|
|
60
62
|
const [publishFeedback, setPublishFeedback] = useState<string | null>(null);
|
|
@@ -237,13 +239,14 @@ export function useEditorPublish({
|
|
|
237
239
|
|
|
238
240
|
onShasUpdated(null, sha);
|
|
239
241
|
showFeedback("Published", 3000);
|
|
242
|
+
onPublishComplete?.();
|
|
240
243
|
} catch (error) {
|
|
241
244
|
console.error("Publish failed:", error);
|
|
242
245
|
showFeedback("Publish failed", 5000);
|
|
243
246
|
} finally {
|
|
244
247
|
setIsPublishing(false);
|
|
245
248
|
}
|
|
246
|
-
}, [onShasUpdated, showFeedback]);
|
|
249
|
+
}, [onShasUpdated, showFeedback, onPublishComplete]);
|
|
247
250
|
|
|
248
251
|
const handleSaveAndPublish = useCallback(async () => {
|
|
249
252
|
if (!siteConfig) return;
|
|
@@ -339,13 +342,14 @@ export function useEditorPublish({
|
|
|
339
342
|
|
|
340
343
|
onShasUpdated(null, sha);
|
|
341
344
|
showFeedback("Published", 3000);
|
|
345
|
+
onPublishComplete?.();
|
|
342
346
|
} catch (error) {
|
|
343
347
|
console.error("Publish failed:", error);
|
|
344
348
|
showFeedback("Publish failed", 5000);
|
|
345
349
|
} finally {
|
|
346
350
|
setIsPublishing(false);
|
|
347
351
|
}
|
|
348
|
-
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback]);
|
|
352
|
+
}, [flushNow, cancelPendingFlush, isConfigDirty, clearConfigDirty, siteIndexRef, siteConfig, sections, deletedSectionIds, onSuccess, mediaManifest, pendingMediaItems, pendingMediaDeletions, onMediaPublished, onShasUpdated, showFeedback, onPublishComplete]);
|
|
349
353
|
|
|
350
354
|
return { isPublishing, publishFeedback, handleSave, handlePublish, handleSaveAndPublish };
|
|
351
355
|
}
|