@drawnagency/primitives 0.1.12 → 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-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/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/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
|
@@ -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";
|
|
@@ -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
|
}
|
|
@@ -2,9 +2,13 @@ import { z } from "zod";
|
|
|
2
2
|
import { MediaConfigSchema } from "./media";
|
|
3
3
|
import { HexColorSchema } from "./shared";
|
|
4
4
|
|
|
5
|
+
const StatusSchema = z.enum(["draft", "live", "archived", "published"]).transform(
|
|
6
|
+
(val) => val === "published" ? "live" as const : val,
|
|
7
|
+
);
|
|
8
|
+
|
|
5
9
|
export const SectionMetaSchema = z.object({
|
|
6
10
|
type: z.string(),
|
|
7
|
-
status:
|
|
11
|
+
status: StatusSchema,
|
|
8
12
|
access: z.array(z.string()),
|
|
9
13
|
});
|
|
10
14
|
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import type { Audience } from "../../auth/types";
|
|
2
|
-
interface Props {
|
|
3
|
-
access: string[];
|
|
4
|
-
audiences: Audience[];
|
|
5
|
-
onChange: (access: string[]) => void;
|
|
6
|
-
}
|
|
7
|
-
export declare function AudiencePicker({ access, audiences, onChange }: Props): import("react/jsx-runtime").JSX.Element;
|
|
8
|
-
export {};
|
|
9
|
-
//# sourceMappingURL=AudiencePicker.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"AudiencePicker.d.ts","sourceRoot":"","sources":["../../../src/components/editor/AudiencePicker.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEjD,UAAU,KAAK;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACtC;AAED,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAyFpE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"StatusBadge.d.ts","sourceRoot":"","sources":["../../../src/components/editor/StatusBadge.tsx"],"names":[],"mappings":"AAIA,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAQD,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,gBAAgB,2CAc9D"}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
type StatusColor = "draft" | "live" | "archived" | "modified";
|
|
2
|
-
interface DotDef {
|
|
3
|
-
color: StatusColor;
|
|
4
|
-
}
|
|
5
|
-
interface DeriveInput {
|
|
6
|
-
mainStatus: string | null;
|
|
7
|
-
savedStatus: string;
|
|
8
|
-
contentDiffers: boolean;
|
|
9
|
-
isLocalOnly: boolean;
|
|
10
|
-
}
|
|
11
|
-
interface DeriveResult {
|
|
12
|
-
dots: DotDef[];
|
|
13
|
-
label: "unsaved" | null;
|
|
14
|
-
description: string;
|
|
15
|
-
}
|
|
16
|
-
export declare function deriveStatusDots({ mainStatus, savedStatus, contentDiffers, isLocalOnly }: DeriveInput): DeriveResult;
|
|
17
|
-
interface StatusDotsProps {
|
|
18
|
-
mainStatus: string | null;
|
|
19
|
-
savedStatus: string;
|
|
20
|
-
contentDiffers: boolean;
|
|
21
|
-
isLocalOnly: boolean;
|
|
22
|
-
}
|
|
23
|
-
export declare function StatusDots(props: StatusDotsProps): import("react/jsx-runtime").JSX.Element;
|
|
24
|
-
export {};
|
|
25
|
-
//# sourceMappingURL=StatusDots.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"StatusDots.d.ts","sourceRoot":"","sources":["../../../src/components/editor/StatusDots.tsx"],"names":[],"mappings":"AAIA,KAAK,WAAW,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,GAAG,UAAU,CAAC;AAE9D,UAAU,MAAM;IACd,KAAK,EAAE,WAAW,CAAC;CACpB;AAED,UAAU,WAAW;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,UAAU,YAAY;IACpB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,SAAS,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;CACrB;AAqBD,wBAAgB,gBAAgB,CAAC,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,EAAE,WAAW,GAAG,YAAY,CA8CpH;AAED,UAAU,eAAe;IACvB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,OAAO,CAAC;IACxB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,2CAiChD"}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
type Status = "draft" | "live" | "archived";
|
|
2
|
-
interface Props {
|
|
3
|
-
status: Status;
|
|
4
|
-
dirty?: boolean;
|
|
5
|
-
onChange: (status: Status) => void;
|
|
6
|
-
}
|
|
7
|
-
export declare function StatusPicker({ status, dirty, onChange }: Props): import("react/jsx-runtime").JSX.Element;
|
|
8
|
-
export {};
|
|
9
|
-
//# sourceMappingURL=StatusPicker.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"StatusPicker.d.ts","sourceRoot":"","sources":["../../../src/components/editor/StatusPicker.tsx"],"names":[],"mappings":"AAMA,KAAK,MAAM,GAAG,OAAO,GAAG,MAAM,GAAG,UAAU,CAAC;AAE5C,UAAU,KAAK;IACb,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAgBD,wBAAgB,YAAY,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,KAAK,2CAyD9D"}
|