@drawnagency/primitives 0.1.88 → 0.1.90
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-75P6HWPN.js → chunk-5BM2Y3NJ.js} +5 -3
- package/dist/{chunk-OEL57NYV.js → chunk-AGWE2LJG.js} +26 -2
- package/dist/{chunk-DRCPDOW3.js → chunk-CZ3YXGK5.js} +59 -0
- package/dist/{chunk-PTVY3Q7P.js → chunk-KBQNJZUX.js} +1 -1
- package/dist/{chunk-XPUBUR64.js → chunk-KMQZYORS.js} +28 -2
- package/dist/{chunk-WA3HFN54.js → chunk-MK7YB2A4.js} +1 -1
- package/dist/{chunk-EO7K45GV.js → chunk-NV4R6VHN.js} +1 -1
- package/dist/{chunk-XDG2K6UT.js → chunk-SIYQ37QY.js} +13 -5
- package/dist/{chunk-NEDOJHHR.js → chunk-XKRQMTE4.js} +11 -2
- package/dist/{chunk-SK6GGTTO.js → chunk-XUIJHYKI.js} +3 -3
- package/dist/{chunk-H2FENAQH.js → chunk-ZLG7S54V.js} +242 -83
- package/dist/components/chat/ChatPanel.d.ts +20 -3
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts +7 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/chat/chat-history.d.ts +24 -0
- package/dist/components/chat/chat-history.d.ts.map +1 -0
- package/dist/components/editor/AudienceIndicator.d.ts +6 -0
- package/dist/components/editor/AudienceIndicator.d.ts.map +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -1
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -1
- package/dist/components/editor/DragHandle.d.ts +3 -1
- package/dist/components/editor/DragHandle.d.ts.map +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/StatusIndicator.d.ts +7 -0
- package/dist/components/editor/StatusIndicator.d.ts.map +1 -1
- package/dist/components/editor/index.js +100 -78
- package/dist/components/primitives/index.js +5 -5
- package/dist/components/sections/Container/Container.d.ts +3 -2
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +4 -2
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts +8 -2
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/SectionLayout.d.ts +3 -1
- package/dist/components/sections/SectionLayout.d.ts.map +1 -1
- package/dist/components/sections/ViewRenderer.d.ts +3 -1
- package/dist/components/sections/ViewRenderer.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +8 -2
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +5 -5
- package/dist/components/sections/core-sections.d.ts +8 -2
- package/dist/components/sections/core-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.js +8 -8
- package/dist/components/sections/register.js +8 -8
- package/dist/components/shared/Navigation.d.ts +6 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/Tooltip.d.ts +8 -1
- package/dist/components/shared/Tooltip.d.ts.map +1 -1
- package/dist/components/shell/AudiencePreviewMenu.d.ts +7 -0
- package/dist/components/shell/AudiencePreviewMenu.d.ts.map +1 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts +10 -0
- package/dist/components/shell/AuthoringDefaultsMenu.d.ts.map +1 -0
- package/dist/components/shell/EditorContext.d.ts +3 -0
- package/dist/components/shell/EditorContext.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +3 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsChatbot.d.ts.map +1 -1
- package/dist/hooks/useBuildStatus.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +5 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/lib/authoring-defaults.d.ts +17 -0
- package/dist/lib/authoring-defaults.d.ts.map +1 -0
- package/dist/lib/chatbot.d.ts +10 -0
- package/dist/lib/chatbot.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +6 -0
- package/dist/lib/container-grid.d.ts.map +1 -1
- package/dist/lib/contrast.d.ts +5 -0
- package/dist/lib/contrast.d.ts.map +1 -1
- package/dist/lib/index.d.ts +2 -1
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +11 -3
- package/dist/lib/migrate-sections-transform.js +1 -1
- package/dist/media/compose-preview.d.ts +23 -0
- package/dist/media/compose-preview.d.ts.map +1 -0
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +4 -2
- package/dist/media/preview-inputs.d.ts +31 -0
- package/dist/media/preview-inputs.d.ts.map +1 -0
- package/dist/schemas/index.js +4 -2
- package/dist/schemas/media.d.ts +2 -0
- package/dist/schemas/media.d.ts.map +1 -1
- package/dist/schemas/site-config.d.ts +18 -0
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/ChatPanel.tsx +437 -65
- package/src/components/chat/ChatWidget.tsx +90 -32
- package/src/components/chat/chat-history.ts +105 -0
- package/src/components/editor/AudienceIndicator.tsx +50 -37
- package/src/components/editor/ChildBlockWrapper.tsx +8 -7
- package/src/components/editor/ColSpanControl.tsx +14 -11
- package/src/components/editor/DragHandle.tsx +5 -2
- package/src/components/editor/PagesModal.tsx +10 -10
- package/src/components/editor/SectionWrapper.tsx +32 -21
- package/src/components/editor/StatusIndicator.tsx +37 -24
- package/src/components/sections/Container/Container.tsx +185 -43
- package/src/components/sections/Container/ContainerSettingsForm.tsx +64 -24
- package/src/components/sections/Container/index.tsx +45 -20
- package/src/components/sections/SectionLayout.tsx +13 -3
- package/src/components/sections/ViewRenderer.tsx +11 -2
- package/src/components/shared/Navigation.tsx +18 -5
- package/src/components/shared/Tooltip.tsx +18 -2
- package/src/components/shell/AudiencePreviewMenu.tsx +73 -0
- package/src/components/shell/AuthoringDefaultsMenu.tsx +54 -0
- package/src/components/shell/EditorContext.tsx +5 -1
- package/src/components/shell/EditorShell.tsx +130 -13
- package/src/components/shell/MediaLibraryContext.tsx +1 -1
- package/src/components/shell/SiteSettingsChatbot.tsx +49 -1
- package/src/hooks/useBuildStatus.ts +35 -4
- package/src/hooks/useMediaPipeline.ts +7 -3
- package/src/lib/authoring-defaults.ts +42 -0
- package/src/lib/chatbot.ts +30 -0
- package/src/lib/container-grid.ts +14 -0
- package/src/lib/contrast.ts +12 -0
- package/src/lib/index.ts +2 -1
- package/src/lib/migrate-sections-transform.ts +5 -3
- package/src/media/compose-preview.ts +90 -0
- package/src/media/index.ts +1 -0
- package/src/media/preview-inputs.ts +77 -0
- package/src/schemas/media.ts +3 -0
- package/src/schemas/site-config.ts +27 -2
|
@@ -3,13 +3,16 @@ import { getSection } from "../../lib/registry";
|
|
|
3
3
|
|
|
4
4
|
ensureSectionsRegistered();
|
|
5
5
|
import { SectionLayout } from "./SectionLayout";
|
|
6
|
+
import { audiencePasses } from "../../lib/pages";
|
|
6
7
|
import type { LoadedSection } from "../../lib/loader";
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
sections: LoadedSection[];
|
|
11
|
+
/** Audience-preview mode: dim sections this audience can't access. */
|
|
12
|
+
previewAudience?: string | null;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
export default function ViewRenderer({ sections }: Props) {
|
|
15
|
+
export default function ViewRenderer({ sections, previewAudience = null }: Props) {
|
|
13
16
|
return (
|
|
14
17
|
<>
|
|
15
18
|
{sections.map(({ section, meta }) => {
|
|
@@ -17,7 +20,13 @@ export default function ViewRenderer({ sections }: Props) {
|
|
|
17
20
|
if (!def) return null;
|
|
18
21
|
const Component = def.component;
|
|
19
22
|
return (
|
|
20
|
-
<SectionLayout
|
|
23
|
+
<SectionLayout
|
|
24
|
+
key={section.id}
|
|
25
|
+
type={section.type}
|
|
26
|
+
status={meta.status}
|
|
27
|
+
dimNonPublished
|
|
28
|
+
dimmed={previewAudience !== null && !audiencePasses(meta.access, previewAudience)}
|
|
29
|
+
>
|
|
21
30
|
<Component
|
|
22
31
|
content={section}
|
|
23
32
|
options={"options" in section ? (section.options as Record<string, unknown>) : undefined}
|
|
@@ -18,9 +18,21 @@ interface Props {
|
|
|
18
18
|
// Optional same-island handler (tests). In production the editor nav is a
|
|
19
19
|
// separate island from EditorShell, so a click dispatches pageSelectEvent.
|
|
20
20
|
onPageSelect?: (pageId: string) => void;
|
|
21
|
+
/** Viewport width where the drawer becomes a fixed sidebar. Pages that dock
|
|
22
|
+
* the chat sidebar pass "xl" so tablet widths keep the hamburger and give
|
|
23
|
+
* the freed column to chat. Static per page render — deliberately keyed to
|
|
24
|
+
* chat being ENABLED, not open, so the nav never flaps when chat toggles. */
|
|
25
|
+
desktopBreakpoint?: "lg" | "xl";
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
// Both branches are literal class strings so Tailwind's scanner sees them.
|
|
29
|
+
const BP_CLASSES = {
|
|
30
|
+
lg: { hidden: "lg:hidden", leftAuto: "lg:left-auto", translate: "lg:translate-x-0", block: "lg:block" },
|
|
31
|
+
xl: { hidden: "xl:hidden", leftAuto: "xl:left-auto", translate: "xl:translate-x-0", block: "xl:block" },
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export default function Navigation({ siteNav: initialNav, siteName, darkMode, lastUpdated, logo, onPageSelect, desktopBreakpoint = "lg" }: Props) {
|
|
35
|
+
const bp = BP_CLASSES[desktopBreakpoint];
|
|
24
36
|
const [isOpen, setIsOpen] = useState(false);
|
|
25
37
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
26
38
|
const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
|
|
@@ -205,7 +217,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
205
217
|
|
|
206
218
|
return (
|
|
207
219
|
<>
|
|
208
|
-
<header className="fixed top-0 left-0 right-0 z-50 bg-base
|
|
220
|
+
<header className={cn("fixed top-0 left-0 right-0 z-50 bg-base", bp.hidden)}>
|
|
209
221
|
<div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
|
|
210
222
|
<div className="flex flex-col items-start gap-1">
|
|
211
223
|
{logoMark}
|
|
@@ -221,11 +233,12 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
221
233
|
</div>
|
|
222
234
|
</header>
|
|
223
235
|
|
|
224
|
-
{isOpen && <div className="fixed inset-0 z-40 bg-black/50
|
|
236
|
+
{isOpen && <div className={cn("fixed inset-0 z-40 bg-black/50", bp.hidden)} onClick={() => setIsOpen(false)} />}
|
|
225
237
|
|
|
226
|
-
<nav className={cn("fixed top-0 left-0
|
|
238
|
+
<nav className={cn("fixed top-0 left-0 z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform nav-sidebar",
|
|
239
|
+
bp.leftAuto, bp.translate,
|
|
227
240
|
isOpen ? "translate-x-0" : "-translate-x-full")}>
|
|
228
|
-
<div className="hidden px-4 py-4
|
|
241
|
+
<div className={cn("hidden px-4 py-4", bp.block)}>
|
|
229
242
|
{logoMark}
|
|
230
243
|
<span className="text-lg font-bold text-primary">{currentSiteName}</span>
|
|
231
244
|
</div>
|
|
@@ -5,14 +5,30 @@ interface TooltipProps {
|
|
|
5
5
|
content: ReactNode;
|
|
6
6
|
children: ReactNode;
|
|
7
7
|
className?: string;
|
|
8
|
+
/** Where the bubble sits relative to the trigger:
|
|
9
|
+
* - "bottom" (default): below, centered.
|
|
10
|
+
* - "right": to the right (used by the container child rail, a narrow left gutter).
|
|
11
|
+
* - "bottom-clamp": below, centered on wide viewports but right-aligned under `xl`
|
|
12
|
+
* (where the top toolbar hits the viewport width) so a far-right button's bubble
|
|
13
|
+
* can't overflow off the right edge of the screen. */
|
|
14
|
+
side?: "bottom" | "right" | "bottom-clamp";
|
|
8
15
|
}
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
const sideClasses: Record<NonNullable<TooltipProps["side"]>, string> = {
|
|
18
|
+
bottom: "left-1/2 top-full mt-1.5 -translate-x-1/2",
|
|
19
|
+
right: "left-full top-1/2 ml-1.5 -translate-y-1/2",
|
|
20
|
+
"bottom-clamp": "top-full mt-1.5 left-1/2 -translate-x-1/2 max-xl:left-auto max-xl:right-0 max-xl:translate-x-0",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function Tooltip({ content, children, className, side = "bottom" }: TooltipProps) {
|
|
11
24
|
return (
|
|
12
25
|
<div className={cn("group/tooltip relative inline-flex", className)}>
|
|
13
26
|
{children}
|
|
14
27
|
<span
|
|
15
|
-
className=
|
|
28
|
+
className={cn(
|
|
29
|
+
"pointer-events-none absolute z-50 opacity-0 transition-opacity delay-300 group-hover/tooltip:opacity-100",
|
|
30
|
+
sideClasses[side],
|
|
31
|
+
)}
|
|
16
32
|
role="tooltip"
|
|
17
33
|
>
|
|
18
34
|
<span className="block whitespace-nowrap rounded bg-tooltip px-2 py-1 text-center text-xs leading-relaxed text-tooltip-muted">
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { Check, UsersIcon } from "lucide-react";
|
|
3
|
+
import { IconButton } from "../shared/IconButton";
|
|
4
|
+
import { Popover } from "../shared/Popover";
|
|
5
|
+
import { PopoverItem } from "../shared/PopoverItem";
|
|
6
|
+
import { useEditorContext } from "./EditorContext";
|
|
7
|
+
import type { Audience } from "../../auth/types";
|
|
8
|
+
|
|
9
|
+
interface AudiencePreviewMenuProps {
|
|
10
|
+
audiences: Audience[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function AudiencePreviewMenu({ audiences }: AudiencePreviewMenuProps) {
|
|
14
|
+
const { previewAudience, setPreviewAudience } = useEditorContext();
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
// A previewed audience can be deleted mid-session; the badge falls back grey.
|
|
18
|
+
const active = previewAudience !== null ? audiences.find((a) => a.name === previewAudience) : undefined;
|
|
19
|
+
|
|
20
|
+
function select(name: string | null) {
|
|
21
|
+
setPreviewAudience(name);
|
|
22
|
+
setOpen(false);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div ref={wrapperRef} className="relative">
|
|
27
|
+
<IconButton
|
|
28
|
+
icon={<UsersIcon size={16} />}
|
|
29
|
+
label="Preview as audience"
|
|
30
|
+
size="md"
|
|
31
|
+
onClick={() => setOpen((v) => !v)}
|
|
32
|
+
aria-pressed={previewAudience !== null}
|
|
33
|
+
aria-haspopup="true"
|
|
34
|
+
aria-expanded={open}
|
|
35
|
+
className="border border-base-200 bg-base-accent"
|
|
36
|
+
/>
|
|
37
|
+
{previewAudience !== null && (
|
|
38
|
+
<span
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
data-testid="audience-preview-badge"
|
|
41
|
+
className="pointer-events-none absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-base"
|
|
42
|
+
style={{ backgroundColor: active?.color ?? "#9ca3af" }}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
<Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={wrapperRef} className="w-48">
|
|
46
|
+
<ul role="radiogroup" aria-label="Preview as audience" className="w-full overflow-hidden py-1">
|
|
47
|
+
<li>
|
|
48
|
+
<PopoverItem role="radio" aria-checked={previewAudience === null} onClick={() => select(null)}>
|
|
49
|
+
<span className="flex-1 font-medium text-base-contrast">All audiences</span>
|
|
50
|
+
{previewAudience === null && <Check size={14} strokeWidth={3} className="text-primary" />}
|
|
51
|
+
</PopoverItem>
|
|
52
|
+
</li>
|
|
53
|
+
{audiences.map((a) => {
|
|
54
|
+
const checked = previewAudience === a.name;
|
|
55
|
+
return (
|
|
56
|
+
<li key={a.name}>
|
|
57
|
+
<PopoverItem role="radio" aria-checked={checked} onClick={() => select(a.name)}>
|
|
58
|
+
<span
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
className="h-3 w-3 shrink-0 rounded-full border border-base-200"
|
|
61
|
+
style={{ backgroundColor: a.color ?? "#9ca3af" }}
|
|
62
|
+
/>
|
|
63
|
+
<span className="flex-1 font-medium text-base-contrast">{a.displayName}</span>
|
|
64
|
+
{checked && <Check size={14} strokeWidth={3} className="text-primary" />}
|
|
65
|
+
</PopoverItem>
|
|
66
|
+
</li>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</ul>
|
|
70
|
+
</Popover>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { SlidersHorizontalIcon } from "lucide-react";
|
|
3
|
+
import { IconButton } from "../shared/IconButton";
|
|
4
|
+
import { Popover } from "../shared/Popover";
|
|
5
|
+
import { StatusRadioList } from "../editor/StatusIndicator";
|
|
6
|
+
import { AudienceChecklist } from "../editor/AudienceIndicator";
|
|
7
|
+
import type { AuthoringDefaults } from "../../lib/authoring-defaults";
|
|
8
|
+
import type { Audience } from "../../auth/types";
|
|
9
|
+
|
|
10
|
+
interface AuthoringDefaultsMenuProps {
|
|
11
|
+
defaults: AuthoringDefaults;
|
|
12
|
+
audiences: Audience[];
|
|
13
|
+
onChange: (next: AuthoringDefaults) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function AuthoringDefaultsMenu({ defaults, audiences, onChange }: AuthoringDefaultsMenuProps) {
|
|
17
|
+
const [open, setOpen] = useState(false);
|
|
18
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
|
|
20
|
+
function toggleAudience(name: string) {
|
|
21
|
+
const access = defaults.access.includes(name)
|
|
22
|
+
? defaults.access.filter((n) => n !== name)
|
|
23
|
+
: [...defaults.access, name];
|
|
24
|
+
onChange({ ...defaults, access });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div ref={wrapperRef} className="relative">
|
|
29
|
+
<IconButton
|
|
30
|
+
icon={<SlidersHorizontalIcon size={16} />}
|
|
31
|
+
label="Authoring defaults"
|
|
32
|
+
size="md"
|
|
33
|
+
onClick={() => setOpen((v) => !v)}
|
|
34
|
+
aria-haspopup="true"
|
|
35
|
+
aria-expanded={open}
|
|
36
|
+
className="border border-base-200 bg-base-accent"
|
|
37
|
+
/>
|
|
38
|
+
<Popover isOpen={open} onClose={() => setOpen(false)} anchorRef={wrapperRef} className="w-56">
|
|
39
|
+
<div className="px-3 pt-2 pb-1 text-xs font-semibold text-base-contrast-light">
|
|
40
|
+
New sections start as
|
|
41
|
+
</div>
|
|
42
|
+
<StatusRadioList
|
|
43
|
+
status={defaults.status}
|
|
44
|
+
onChange={(status) => onChange({ ...defaults, status })}
|
|
45
|
+
ariaLabel="Default status for new sections"
|
|
46
|
+
/>
|
|
47
|
+
<div className="border-t border-base-200 px-3 pt-2 pb-1 text-xs font-semibold text-base-contrast-light">
|
|
48
|
+
Default audiences
|
|
49
|
+
</div>
|
|
50
|
+
<AudienceChecklist access={defaults.access} audiences={audiences} onToggle={toggleAudience} />
|
|
51
|
+
</Popover>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -17,11 +17,14 @@ interface EditorContextValue {
|
|
|
17
17
|
viewBranch: "saved" | "live";
|
|
18
18
|
historyState: HistoryState;
|
|
19
19
|
historyPageId: string | null;
|
|
20
|
+
/** Audience-preview mode: dim sections this audience can't access. null = off. */
|
|
21
|
+
previewAudience: string | null;
|
|
20
22
|
toggleEditMode: () => void;
|
|
21
23
|
toggleShowAllChrome: () => void;
|
|
22
24
|
setViewBranch: (branch: "saved" | "live") => void;
|
|
23
25
|
setHistoryState: (state: HistoryState) => void;
|
|
24
26
|
setHistoryPageId: (pageId: string | null) => void;
|
|
27
|
+
setPreviewAudience: (audience: string | null) => void;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
const EditorContext = createContext<EditorContextValue | null>(null);
|
|
@@ -36,6 +39,7 @@ export function EditorProvider({ children }: EditorProviderProps) {
|
|
|
36
39
|
const [viewBranch, setViewBranchState] = useState<"saved" | "live">("saved");
|
|
37
40
|
const [historyState, setHistoryStateRaw] = useState<HistoryState>(null);
|
|
38
41
|
const [historyPageId, setHistoryPageId] = useState<string | null>(null);
|
|
42
|
+
const [previewAudience, setPreviewAudience] = useState<string | null>(null);
|
|
39
43
|
const setHistoryState = useCallback((state: HistoryState) => {
|
|
40
44
|
// null = "auto": resolved to the current page (or home) at render time
|
|
41
45
|
setHistoryPageId(null);
|
|
@@ -54,7 +58,7 @@ export function EditorProvider({ children }: EditorProviderProps) {
|
|
|
54
58
|
const setViewBranch = useCallback((b: "saved" | "live") => setViewBranchState(b), []);
|
|
55
59
|
|
|
56
60
|
return (
|
|
57
|
-
<EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, historyState, historyPageId, toggleEditMode, toggleShowAllChrome, setViewBranch, setHistoryState, setHistoryPageId }}>
|
|
61
|
+
<EditorContext.Provider value={{ isEditMode, showAllChrome, viewBranch, historyState, historyPageId, previewAudience, toggleEditMode, toggleShowAllChrome, setViewBranch, setHistoryState, setHistoryPageId, setPreviewAudience }}>
|
|
58
62
|
{children}
|
|
59
63
|
</EditorContext.Provider>
|
|
60
64
|
);
|
|
@@ -10,12 +10,17 @@ import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
|
|
|
10
10
|
import type { Audience } from "../../auth/types";
|
|
11
11
|
import type { MediaManifest } from "../../media/types";
|
|
12
12
|
import type { QueueItem } from "../../media/queue";
|
|
13
|
+
import { composePreviewImage } from "../../media/compose-preview";
|
|
14
|
+
import { derivePreviewInputs, servedPreviewSize } from "../../media/preview-inputs";
|
|
15
|
+
import { resolveMedia } from "../../media/resolve";
|
|
16
|
+
import { getMediaProvider } from "../../media";
|
|
13
17
|
// Sanctioned outside->inside import: document/ is a sealed subtree, but the
|
|
14
18
|
// Document mode toggle wiring needs the patch shape it edits (mirrors the same
|
|
15
19
|
// sanctioned import in schemas/site-config.ts and lib/pages.ts).
|
|
16
20
|
import type { DocumentOptions } from "../../document/engine/settings";
|
|
17
21
|
import { SiteConfigSchema, IndexSchema } from "../../schemas/site-config";
|
|
18
22
|
import { ensureSanitizer } from "../../lib/sanitize";
|
|
23
|
+
import { resolveChatbotName } from "../../lib/chatbot";
|
|
19
24
|
import { EditorProvider, useEditorContext, type HistoryState } from "./EditorContext";
|
|
20
25
|
import { makeBlockMoveDispatch } from "./blockMoveDispatch";
|
|
21
26
|
import { ROOT_CONTAINER_ID, resolveDropIndex } from "../../lib/block-dnd";
|
|
@@ -85,7 +90,7 @@ import { generateSiteNav } from "../../lib/nav";
|
|
|
85
90
|
import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
|
|
86
91
|
import {
|
|
87
92
|
homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
|
|
88
|
-
reorderSectionInPage,
|
|
93
|
+
reorderSectionInPage, audiencePasses,
|
|
89
94
|
setHomePage, setPageArchived, setPageFields, setPageAudience, setPageDocument, deletePage, addPage, reorderPages,
|
|
90
95
|
moveSection as moveSectionReducer,
|
|
91
96
|
} from "../../lib/pages";
|
|
@@ -96,6 +101,7 @@ import { UploadRejectionAlert, formatRejections } from "../shared/UploadRejectio
|
|
|
96
101
|
import { Button } from "../shared/Button";
|
|
97
102
|
import { SplitButton } from "../shared/SplitButton";
|
|
98
103
|
import { IconButton } from "../shared/IconButton";
|
|
104
|
+
import { Tooltip } from "../shared/Tooltip";
|
|
99
105
|
import { SegmentedControl } from "../shared/SegmentedControl";
|
|
100
106
|
import { computeDisplaySections } from "./view-branch";
|
|
101
107
|
import { SettingsIcon } from "../shared/icons";
|
|
@@ -105,6 +111,9 @@ import { HistoryToolbar } from "./HistoryToolbar";
|
|
|
105
111
|
import { RestoreModal } from "./RestoreModal";
|
|
106
112
|
import { EditorLoading } from "./EditorLoading";
|
|
107
113
|
import ViewRenderer from "../sections/ViewRenderer";
|
|
114
|
+
import { AuthoringDefaultsMenu } from "./AuthoringDefaultsMenu";
|
|
115
|
+
import { AudiencePreviewMenu } from "./AudiencePreviewMenu";
|
|
116
|
+
import { loadAuthoringDefaults, saveAuthoringDefaults, filterAccessToKnown, type AuthoringDefaults } from "../../lib/authoring-defaults";
|
|
108
117
|
|
|
109
118
|
export { useMediaLibrary } from "./MediaLibraryContext";
|
|
110
119
|
|
|
@@ -168,6 +177,11 @@ export default function EditorShell({
|
|
|
168
177
|
const [sections, setSections] = useState<LoadedSection[]>([]);
|
|
169
178
|
const [siteIndex, setSiteIndex] = useState<SiteIndex>({ siteId, pages: [], sections: {} });
|
|
170
179
|
const [audiences, setAudiences] = useState<Audience[]>(initialAudiences);
|
|
180
|
+
const [authoringDefaults, setAuthoringDefaults] = useState<AuthoringDefaults>(loadAuthoringDefaults);
|
|
181
|
+
const onAuthoringDefaultsChange = useCallback((next: AuthoringDefaults) => {
|
|
182
|
+
setAuthoringDefaults(next);
|
|
183
|
+
saveAuthoringDefaults(next);
|
|
184
|
+
}, []);
|
|
171
185
|
const [localChangesExist, setLocalChangesExist] = useState(false);
|
|
172
186
|
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
|
|
173
187
|
const [showSiteSettings, setShowSiteSettings] = useState(false);
|
|
@@ -240,6 +254,15 @@ export default function EditorShell({
|
|
|
240
254
|
useEffect(() => { sectionsRef.current = sections; }, [sections]);
|
|
241
255
|
useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
|
|
242
256
|
useEffect(() => { void ensureSanitizer(); }, []);
|
|
257
|
+
// Mirrors siteConfig so the async preview-image upload callback (below)
|
|
258
|
+
// writes onto the latest config instead of a stale closure.
|
|
259
|
+
const siteConfigRef = useRef<SiteConfig | null>(siteConfig);
|
|
260
|
+
useEffect(() => { siteConfigRef.current = siteConfig; }, [siteConfig]);
|
|
261
|
+
// Guards the debounced preview-image composition effect (below): skips
|
|
262
|
+
// recomposing on the very first run when a preview already exists, and
|
|
263
|
+
// dedupes concurrent runs against the same composition signature.
|
|
264
|
+
const previewInitializedRef = useRef(false);
|
|
265
|
+
const lastPreviewSigRef = useRef<string | null>(null);
|
|
243
266
|
|
|
244
267
|
// --- Collaboration refs ---
|
|
245
268
|
// Freshest dirty-section set for the inbound remote-section handler (never
|
|
@@ -1143,7 +1166,14 @@ export default function EditorShell({
|
|
|
1143
1166
|
const content = resolveInsertedContent(selection, getAllSections(), newId);
|
|
1144
1167
|
if (!content) return;
|
|
1145
1168
|
const id = newId();
|
|
1146
|
-
|
|
1169
|
+
// Access is constrained to the audiences this page can offer (all site
|
|
1170
|
+
// audiences on an unrestricted page), so stale or out-of-scope default
|
|
1171
|
+
// names never land in a section's access list.
|
|
1172
|
+
const meta = {
|
|
1173
|
+
type: content.type,
|
|
1174
|
+
status: authoringDefaults.status,
|
|
1175
|
+
access: filterAccessToKnown(authoringDefaults.access, pageAudiences),
|
|
1176
|
+
};
|
|
1147
1177
|
const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
|
|
1148
1178
|
|
|
1149
1179
|
setSections((prev) => [...prev, newSection]);
|
|
@@ -1159,7 +1189,7 @@ export default function EditorShell({
|
|
|
1159
1189
|
// save-time sync by design).
|
|
1160
1190
|
void collabRef.current?.requestEdit(id);
|
|
1161
1191
|
},
|
|
1162
|
-
[persistence, resolvedActivePage],
|
|
1192
|
+
[persistence, resolvedActivePage, authoringDefaults, pageAudiences],
|
|
1163
1193
|
);
|
|
1164
1194
|
|
|
1165
1195
|
// Runs after the confirmation modal is accepted. Removes the section from the
|
|
@@ -1332,6 +1362,60 @@ export default function EditorShell({
|
|
|
1332
1362
|
setLocalChangesExist(true);
|
|
1333
1363
|
}, [applySiteConfigPreview, persistence]);
|
|
1334
1364
|
|
|
1365
|
+
// Recompose the social-preview raster (siteConfig.previewImage) when brand
|
|
1366
|
+
// inputs change. Debounced so text edits don't compose per keystroke. The
|
|
1367
|
+
// new previewImage rides the next normal save like any other config change.
|
|
1368
|
+
useEffect(() => {
|
|
1369
|
+
if (!siteConfig) return;
|
|
1370
|
+
const resolveLogoUrl = (imageId: string): string | null => {
|
|
1371
|
+
const pending = mediaPipeline.pendingLocalUrls[imageId];
|
|
1372
|
+
if (pending) return pending;
|
|
1373
|
+
const resolved = resolveMedia({ imageId }, mediaManifest, [1200], getMediaProvider());
|
|
1374
|
+
return resolved?.src ?? null;
|
|
1375
|
+
};
|
|
1376
|
+
const { inputs, signature } = derivePreviewInputs(siteConfig, resolveLogoUrl);
|
|
1377
|
+
|
|
1378
|
+
// On first run: if a preview already exists, seed the signature and skip
|
|
1379
|
+
// (don't recompose/re-upload on every editor open). If none exists yet,
|
|
1380
|
+
// fall through to compose one. Subsequent runs recompose only on change.
|
|
1381
|
+
if (!previewInitializedRef.current) {
|
|
1382
|
+
previewInitializedRef.current = true;
|
|
1383
|
+
if (siteConfig.previewImage) {
|
|
1384
|
+
lastPreviewSigRef.current = signature;
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (signature === lastPreviewSigRef.current) return;
|
|
1389
|
+
|
|
1390
|
+
const timer = setTimeout(() => {
|
|
1391
|
+
lastPreviewSigRef.current = signature; // guard against duplicate concurrent runs
|
|
1392
|
+
(async () => {
|
|
1393
|
+
try {
|
|
1394
|
+
const blob = await composePreviewImage(inputs);
|
|
1395
|
+
const file = new File([blob], "site-preview.webp", { type: "image/webp" });
|
|
1396
|
+
const { width, height } = servedPreviewSize(siteConfigRef.current!.media.sizes);
|
|
1397
|
+
mediaPipeline.contextValue.uploadFileWithCallback(file, (imageId) => {
|
|
1398
|
+
handleSiteConfigChange({ ...siteConfigRef.current!, previewImage: { imageId, width, height } });
|
|
1399
|
+
}, { hidden: true });
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
console.error("[EditorShell] preview-image composition failed:", err);
|
|
1402
|
+
// Composition/upload failed — un-mark the signature so the next effect
|
|
1403
|
+
// run (next brand-input change, or this same one on remount) retries
|
|
1404
|
+
// instead of being permanently skipped as "already done".
|
|
1405
|
+
lastPreviewSigRef.current = null;
|
|
1406
|
+
}
|
|
1407
|
+
})();
|
|
1408
|
+
}, 900);
|
|
1409
|
+
return () => clearTimeout(timer);
|
|
1410
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1411
|
+
}, [
|
|
1412
|
+
siteConfig?.logo,
|
|
1413
|
+
siteConfig?.primaryColor,
|
|
1414
|
+
siteConfig?.siteName,
|
|
1415
|
+
siteConfig?.headingFont,
|
|
1416
|
+
siteConfig?.darkMode,
|
|
1417
|
+
]);
|
|
1418
|
+
|
|
1335
1419
|
const handleHistoryRestore = useCallback(async (historyContent: {
|
|
1336
1420
|
sections: LoadedSection[];
|
|
1337
1421
|
index: SiteIndex;
|
|
@@ -1444,6 +1528,9 @@ export default function EditorShell({
|
|
|
1444
1528
|
collabPeers={collab.peers}
|
|
1445
1529
|
collabSelfId={collabBootstrap?.userId ?? null}
|
|
1446
1530
|
collabStatus={collab.status}
|
|
1531
|
+
audiences={audiences}
|
|
1532
|
+
authoringDefaults={authoringDefaults}
|
|
1533
|
+
onAuthoringDefaultsChange={onAuthoringDefaultsChange}
|
|
1447
1534
|
/>
|
|
1448
1535
|
|
|
1449
1536
|
{/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
|
|
@@ -1512,6 +1599,8 @@ export default function EditorShell({
|
|
|
1512
1599
|
{chatbotPlatformEnabled && siteConfig && (
|
|
1513
1600
|
<ChatWidget
|
|
1514
1601
|
siteName={siteConfig.siteName}
|
|
1602
|
+
botName={resolveChatbotName(siteConfig.chatbot, null)}
|
|
1603
|
+
thinkingPhrases={siteConfig.chatbot.thinkingPhrases}
|
|
1515
1604
|
tasks={siteConfig.chatbot.tasks.map((t) => ({ id: t.id, label: t.label }))}
|
|
1516
1605
|
isEditor
|
|
1517
1606
|
draftChatbot={siteConfig.chatbot}
|
|
@@ -1628,7 +1717,7 @@ export default function EditorShell({
|
|
|
1628
1717
|
items={[
|
|
1629
1718
|
...Object.values(mediaManifest.images),
|
|
1630
1719
|
...mediaPipeline.pendingMediaItems,
|
|
1631
|
-
].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id))}
|
|
1720
|
+
].filter((item) => !mediaPipeline.pendingDeletions.includes(item.id) && !item.hidden)}
|
|
1632
1721
|
onSelect={(id) => {
|
|
1633
1722
|
if (mediaSelectCallback) {
|
|
1634
1723
|
mediaSelectCallback(id);
|
|
@@ -1819,7 +1908,7 @@ function HistoryOrEditorContent({
|
|
|
1819
1908
|
activeSections: LoadedSection[];
|
|
1820
1909
|
activePageId: string | null;
|
|
1821
1910
|
}) {
|
|
1822
|
-
const { historyState, historyPageId, setHistoryPageId } = useEditorContext();
|
|
1911
|
+
const { historyState, historyPageId, setHistoryPageId, previewAudience } = useEditorContext();
|
|
1823
1912
|
const wasInHistory = useRef(false);
|
|
1824
1913
|
|
|
1825
1914
|
const historyPage = historyState ? resolveHistoryPage(historyState, historyPageId, activePageId) : null;
|
|
@@ -1852,7 +1941,7 @@ function HistoryOrEditorContent({
|
|
|
1852
1941
|
const pageSections = historyState.sections
|
|
1853
1942
|
.filter((s) => position.has(s.section.id))
|
|
1854
1943
|
.sort((a, b) => (position.get(a.section.id) ?? 0) - (position.get(b.section.id) ?? 0));
|
|
1855
|
-
return <ViewRenderer sections={pageSections} />;
|
|
1944
|
+
return <ViewRenderer sections={pageSections} previewAudience={previewAudience} />;
|
|
1856
1945
|
}
|
|
1857
1946
|
return <>{children}</>;
|
|
1858
1947
|
}
|
|
@@ -1931,7 +2020,7 @@ function EditorContent({
|
|
|
1931
2020
|
getEditIntent: (id: string) => () => Promise<boolean>;
|
|
1932
2021
|
onTakeover: (id: string) => void;
|
|
1933
2022
|
}) {
|
|
1934
|
-
const { isEditMode, viewBranch } = useEditorContext();
|
|
2023
|
+
const { isEditMode, viewBranch, previewAudience } = useEditorContext();
|
|
1935
2024
|
const { openModal, closeModal } = useEditorModal();
|
|
1936
2025
|
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
|
|
1937
2026
|
const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
|
|
@@ -2022,7 +2111,12 @@ function EditorContent({
|
|
|
2022
2111
|
)}
|
|
2023
2112
|
|
|
2024
2113
|
<ErrorBoundary label={`${definition.label} (${section.type})`}>
|
|
2025
|
-
<SectionLayout
|
|
2114
|
+
<SectionLayout
|
|
2115
|
+
type={section.type}
|
|
2116
|
+
status={meta.status}
|
|
2117
|
+
dimNonPublished={!isEditMode}
|
|
2118
|
+
dimmed={previewAudience !== null && !audiencePasses(meta.access, previewAudience)}
|
|
2119
|
+
>
|
|
2026
2120
|
<SectionWrapper
|
|
2027
2121
|
sectionId={section.id}
|
|
2028
2122
|
sectionType={section.type}
|
|
@@ -2270,6 +2364,9 @@ function EditorToolbar({
|
|
|
2270
2364
|
collabPeers,
|
|
2271
2365
|
collabSelfId,
|
|
2272
2366
|
collabStatus,
|
|
2367
|
+
audiences,
|
|
2368
|
+
authoringDefaults,
|
|
2369
|
+
onAuthoringDefaultsChange,
|
|
2273
2370
|
}: {
|
|
2274
2371
|
buttonState: "synced" | "publish" | "saveAndPublish";
|
|
2275
2372
|
localChangesExist: boolean;
|
|
@@ -2292,6 +2389,9 @@ function EditorToolbar({
|
|
|
2292
2389
|
collabPeers: CollabPeer[];
|
|
2293
2390
|
collabSelfId: string | null;
|
|
2294
2391
|
collabStatus: CollabStatus;
|
|
2392
|
+
audiences: Audience[];
|
|
2393
|
+
authoringDefaults: AuthoringDefaults;
|
|
2394
|
+
onAuthoringDefaultsChange: (next: AuthoringDefaults) => void;
|
|
2295
2395
|
}) {
|
|
2296
2396
|
const { isEditMode, viewBranch, setViewBranch, toggleEditMode, historyState, setHistoryState, historyPageId, setHistoryPageId } = useEditorContext();
|
|
2297
2397
|
|
|
@@ -2362,11 +2462,28 @@ function EditorToolbar({
|
|
|
2362
2462
|
</div>
|
|
2363
2463
|
<div className="flex items-center justify-end gap-2">
|
|
2364
2464
|
<ProcessingIndicator items={processingItems} />
|
|
2365
|
-
<
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
<
|
|
2369
|
-
|
|
2465
|
+
<Tooltip side="bottom" content="Authoring defaults">
|
|
2466
|
+
<AuthoringDefaultsMenu defaults={authoringDefaults} audiences={audiences} onChange={onAuthoringDefaultsChange} />
|
|
2467
|
+
</Tooltip>
|
|
2468
|
+
<Tooltip side="bottom" content="Preview as audience">
|
|
2469
|
+
<AudiencePreviewMenu audiences={audiences} />
|
|
2470
|
+
</Tooltip>
|
|
2471
|
+
<div aria-hidden="true" className="mx-1 h-6 w-px self-center bg-base-200" />
|
|
2472
|
+
<Tooltip side="bottom" content="Media library">
|
|
2473
|
+
<IconButton icon={<ImageIcon size={16} />} label="Media library" size="md" onClick={onMediaClick} className="border border-base-200 bg-base-accent" />
|
|
2474
|
+
</Tooltip>
|
|
2475
|
+
<Tooltip side="bottom" content="Reorder sections">
|
|
2476
|
+
<IconButton icon={<ListOrderedIcon size={16} />} label="Reorder sections" size="md" onClick={onOrderingClick} className="border border-base-200 bg-base-accent" />
|
|
2477
|
+
</Tooltip>
|
|
2478
|
+
<Tooltip side="bottom" content="Pages">
|
|
2479
|
+
<IconButton icon={<FilesIcon size={16} />} label="Pages" size="md" onClick={onPagesClick} className="border border-base-200 bg-base-accent" />
|
|
2480
|
+
</Tooltip>
|
|
2481
|
+
<Tooltip side="bottom" content="Toggle controls">
|
|
2482
|
+
<ShowAllChromeToggle />
|
|
2483
|
+
</Tooltip>
|
|
2484
|
+
<Tooltip side="bottom-clamp" content="Site settings">
|
|
2485
|
+
<IconButton icon={<SettingsIcon size={16} />} label="Site settings" size="md" onClick={onSettingsClick} className="border border-base-200 bg-base-accent" />
|
|
2486
|
+
</Tooltip>
|
|
2370
2487
|
</div>
|
|
2371
2488
|
</div>
|
|
2372
2489
|
</div>
|
|
@@ -5,7 +5,7 @@ import type { SiteConfig } from "../../schemas/site-config";
|
|
|
5
5
|
export interface MediaLibraryContextValue {
|
|
6
6
|
openSelectModal: (onSelect: (imageId: string) => void) => void;
|
|
7
7
|
uploadFile: (file: File) => void;
|
|
8
|
-
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void) => string;
|
|
8
|
+
uploadFileWithCallback: (file: File, onComplete: (imageId: string) => void, opts?: { hidden?: boolean }) => string;
|
|
9
9
|
reportRejectedUploads: (files: { name: string; reason: "size" | "type" }[]) => void;
|
|
10
10
|
manifest: MediaManifest;
|
|
11
11
|
pendingItems: MediaItem[];
|