@drawnagency/primitives 0.1.55 → 0.1.57
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/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/{chunk-24SUF2BC.js → chunk-ICLXLWQ5.js} +13 -74
- package/dist/chunk-NSCT3AMV.js +32 -0
- package/dist/{chunk-KDGYHU36.js → chunk-PRKUXM7E.js} +35 -10
- package/dist/{chunk-PUNXQK4M.js → chunk-PYWS3MOJ.js} +12 -2
- package/dist/chunk-TG43X7JO.js +123 -0
- package/dist/chunk-VKAGMEKE.js +90 -0
- package/dist/{chunk-B5VYSTPB.js → chunk-XTK4BR27.js} +1 -1
- package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
- package/dist/components/editor/ColSpanControl.d.ts +9 -0
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts +5 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
- package/dist/components/primitives/IconPicker.d.ts +7 -1
- package/dist/components/primitives/IconPicker.d.ts.map +1 -1
- package/dist/components/sections/Container/Container.d.ts +20 -0
- package/dist/components/sections/Container/Container.d.ts.map +1 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
- package/dist/components/sections/Container/index.d.ts +11 -0
- package/dist/components/sections/Container/index.d.ts.map +1 -0
- package/dist/components/sections/IconList/IconList.d.ts +1 -0
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
- package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts +1 -0
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/Media/MediaBlock.d.ts +19 -0
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
- package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
- package/dist/components/sections/Media/index.d.ts.map +1 -0
- package/dist/components/sections/Prose/index.d.ts.map +1 -1
- package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
- package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
- package/dist/components/sections/Spacer/index.d.ts +6 -0
- package/dist/components/sections/Spacer/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +140 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -0
- package/dist/components/sections/register-schemas.d.ts.map +1 -1
- package/dist/components/sections/register.d.ts.map +1 -1
- package/dist/components/shared/Tabs.d.ts +24 -0
- package/dist/components/shared/Tabs.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts +2 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
- package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
- package/dist/hooks/useBlockDnd.d.ts +48 -0
- package/dist/hooks/useBlockDnd.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 +69 -48
- package/dist/lib/block-dnd.d.ts +42 -0
- package/dist/lib/block-dnd.d.ts.map +1 -0
- package/dist/lib/block-move.d.ts +31 -0
- package/dist/lib/block-move.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +29 -0
- package/dist/lib/container-grid.d.ts.map +1 -0
- package/dist/lib/container-ops.d.ts +44 -0
- package/dist/lib/container-ops.d.ts.map +1 -0
- package/dist/lib/dexie.d.ts +12 -1
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +28 -3
- package/dist/lib/index.js +10 -7
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/migrate-sections-transform.d.ts +12 -0
- package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
- package/dist/lib/migrate-sections-transform.js +6 -0
- package/dist/lib/registry.d.ts +39 -2
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +26 -0
- package/dist/lib/sanitize.d.ts.map +1 -1
- package/dist/schemas/block.d.ts +20 -0
- package/dist/schemas/block.d.ts.map +1 -0
- package/dist/schemas/block.js +14 -0
- package/dist/schemas/index.js +10 -2
- package/dist/schemas/link.d.ts +7 -0
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/rich-text.d.ts +9 -0
- package/dist/schemas/rich-text.d.ts.map +1 -0
- package/dist/schemas/sections.d.ts +2 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts +31 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/types.d.ts +13 -1
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/auth/cookies.ts +6 -1
- package/src/components/brandguide/Colors.tsx +35 -33
- package/src/components/editor/ChildBlockWrapper.tsx +108 -0
- package/src/components/editor/ColSpanControl.tsx +56 -0
- package/src/components/editor/SectionWrapper.tsx +44 -20
- package/src/components/editor/SettingsForm.tsx +100 -73
- package/src/components/primitives/EditableGrid.tsx +40 -36
- package/src/components/primitives/IconPicker.tsx +116 -26
- package/src/components/sections/Container/Container.tsx +354 -0
- package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
- package/src/components/sections/Container/index.tsx +51 -0
- package/src/components/sections/IconList/IconList.tsx +113 -43
- package/src/components/sections/IconList/IconListSettings.tsx +2 -2
- package/src/components/sections/IconList/index.tsx +1 -1
- package/src/components/sections/Media/MediaBlock.tsx +103 -0
- package/src/components/sections/Media/index.tsx +85 -0
- package/src/components/sections/Prose/index.tsx +1 -0
- package/src/components/sections/Spacer/Spacer.tsx +6 -0
- package/src/components/sections/Spacer/index.tsx +18 -0
- package/src/components/sections/all-sections.ts +40 -0
- package/src/components/sections/register-schemas.ts +13 -18
- package/src/components/sections/register.ts +3 -17
- package/src/components/shared/Tabs.tsx +63 -0
- package/src/components/shell/EditorShell.tsx +147 -18
- package/src/components/shell/SiteSettingsModal.tsx +41 -51
- package/src/components/shell/blockMoveDispatch.ts +40 -0
- package/src/hooks/useBlockDnd.ts +144 -0
- package/src/hooks/useEditorPublish.ts +17 -4
- package/src/lib/block-dnd.ts +58 -0
- package/src/lib/block-move.ts +236 -0
- package/src/lib/container-grid.ts +58 -0
- package/src/lib/container-ops.ts +159 -0
- package/src/lib/dexie.ts +47 -0
- package/src/lib/loader.ts +16 -4
- package/src/lib/migrate-sections-transform.ts +147 -0
- package/src/lib/registry.ts +48 -2
- package/src/lib/sanitize.ts +22 -1
- package/src/schemas/block.ts +40 -0
- package/src/schemas/link.ts +19 -1
- package/src/schemas/rich-text.ts +11 -0
- package/src/schemas/sections.ts +5 -1
- package/src/schemas/shared.ts +16 -0
- package/src/schemas/site-config.ts +3 -3
- package/src/storage/index.ts +1 -0
- package/src/storage/types.ts +17 -0
- package/dist/components/brandguide/DoDontList.d.ts +0 -16
- package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
- package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
- package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
- package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
- package/dist/components/sections/DoDontList/index.d.ts +0 -21
- package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
- package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
- package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
- package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/index.d.ts +0 -13
- package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.tsx +0 -67
- package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
- package/src/components/primitives/MediaSettingsForms.tsx +0 -128
- package/src/components/sections/DoDontList/index.d.ts.map +0 -1
- package/src/components/sections/DoDontList/index.tsx +0 -45
- package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
- package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
- package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/index.tsx +0 -57
- package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
- package/src/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/sections/SplitContent/index.tsx +0 -55
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface TabItem {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
content: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface TabsProps {
|
|
11
|
+
tabs: TabItem[];
|
|
12
|
+
/** Initial active tab when uncontrolled. Defaults to the first tab. */
|
|
13
|
+
defaultTabId?: string;
|
|
14
|
+
/** When provided, the component is controlled by the parent. */
|
|
15
|
+
activeTabId?: string;
|
|
16
|
+
onTabChange?: (id: string) => void;
|
|
17
|
+
/**
|
|
18
|
+
* When true, the tab-bar row uses -mx-6 to break out of the parent's px-6 padding
|
|
19
|
+
* (e.g. EditorModal's content wrapper) and re-pads with px-6 so buttons stay aligned.
|
|
20
|
+
* The tab panel inherits the parent's padding unchanged.
|
|
21
|
+
* Use only when the parent container has px-6; leave false for noPadding contexts.
|
|
22
|
+
*/
|
|
23
|
+
fullBleedTabBar?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Tabs({ tabs, defaultTabId, activeTabId, onTabChange, fullBleedTabBar }: TabsProps) {
|
|
27
|
+
const [uncontrolledId, setUncontrolledId] = useState(defaultTabId ?? tabs[0]?.id);
|
|
28
|
+
const isControlled = activeTabId !== undefined;
|
|
29
|
+
const activeId = isControlled ? activeTabId : uncontrolledId;
|
|
30
|
+
const activeTab = tabs.find((t) => t.id === activeId) ?? tabs[0];
|
|
31
|
+
|
|
32
|
+
function handleSelect(id: string) {
|
|
33
|
+
if (!isControlled) setUncontrolledId(id);
|
|
34
|
+
onTabChange?.(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex flex-1 flex-col overflow-hidden">
|
|
39
|
+
<div className={cn("border-b border-base-200", fullBleedTabBar && "-mx-6 px-6")}>
|
|
40
|
+
<div className="flex" role="tablist">
|
|
41
|
+
{tabs.map((tab) => (
|
|
42
|
+
<button
|
|
43
|
+
key={tab.id}
|
|
44
|
+
type="button"
|
|
45
|
+
role="tab"
|
|
46
|
+
aria-selected={activeTab?.id === tab.id}
|
|
47
|
+
onClick={() => handleSelect(tab.id)}
|
|
48
|
+
className={cn(
|
|
49
|
+
"cursor-pointer px-4 py-2 text-sm font-medium border-b-2 -mb-px",
|
|
50
|
+
activeTab?.id === tab.id
|
|
51
|
+
? "border-brand text-brand"
|
|
52
|
+
: "border-transparent text-base-contrast-light hover:text-base-contrast",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{tab.label}
|
|
56
|
+
</button>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div role="tabpanel">{activeTab?.content}</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -3,8 +3,9 @@ import { Fragment, useState, useCallback, useEffect, useRef, useMemo, type React
|
|
|
3
3
|
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
|
4
4
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
|
5
5
|
import { autoScrollWindowForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
|
6
|
+
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
|
6
7
|
import type { LoadedSection } from "../../lib/loader";
|
|
7
|
-
import type { SectionContent } from "../../schemas/sections";
|
|
8
|
+
import type { Section, SectionContent } from "../../schemas/sections";
|
|
8
9
|
import type { SiteIndex, SiteConfig, Page } from "../../schemas/site-config";
|
|
9
10
|
import type { Audience } from "../../auth/types";
|
|
10
11
|
import type { MediaManifest } from "../../media/types";
|
|
@@ -12,6 +13,9 @@ import type { QueueItem } from "../../media/queue";
|
|
|
12
13
|
import { SiteConfigSchema } from "../../schemas/site-config";
|
|
13
14
|
import { ensureSanitizer } from "../../lib/sanitize";
|
|
14
15
|
import { EditorProvider, useEditorContext } from "./EditorContext";
|
|
16
|
+
import { makeBlockMoveDispatch } from "./blockMoveDispatch";
|
|
17
|
+
import { ROOT_CONTAINER_ID, resolveDropIndex } from "../../lib/block-dnd";
|
|
18
|
+
import type { BlockMove } from "../../lib/block-move";
|
|
15
19
|
import { EditorModalProvider, useEditorModal } from "./EditorModalContext";
|
|
16
20
|
import { EditorModal } from "./EditorModal";
|
|
17
21
|
import { SiteSettingsModal } from "./SiteSettingsModal";
|
|
@@ -19,8 +23,10 @@ import { MediaLibraryModal } from "./MediaLibraryModal";
|
|
|
19
23
|
import { MediaLibraryContext } from "./MediaLibraryContext";
|
|
20
24
|
import { ProcessingIndicator } from "./ProcessingIndicator";
|
|
21
25
|
import { SectionSkeleton } from "./SectionSkeleton";
|
|
26
|
+
import { SectionTypePicker } from "./SectionTypePicker";
|
|
22
27
|
import { ensureSectionsRegistered } from "../sections/register";
|
|
23
28
|
import { getSection, getAllSections } from "../../lib/registry";
|
|
29
|
+
import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
|
|
24
30
|
|
|
25
31
|
ensureSectionsRegistered();
|
|
26
32
|
import { BugReportFAB } from "./BugReportFAB";
|
|
@@ -80,6 +86,10 @@ type ShellState =
|
|
|
80
86
|
|
|
81
87
|
interface Props {
|
|
82
88
|
headSha: string;
|
|
89
|
+
// Head of the branch the editor actually loads (draft `saved` if it exists,
|
|
90
|
+
// else main). The content cache is keyed on this, so a load with drafts can
|
|
91
|
+
// hit the cache instead of refetching every section. Falls back to headSha.
|
|
92
|
+
draftHeadSha?: string;
|
|
83
93
|
siteId: string;
|
|
84
94
|
audiences: Audience[];
|
|
85
95
|
capabilities: {
|
|
@@ -95,6 +105,7 @@ interface Props {
|
|
|
95
105
|
|
|
96
106
|
export default function EditorShell({
|
|
97
107
|
headSha,
|
|
108
|
+
draftHeadSha,
|
|
98
109
|
siteId,
|
|
99
110
|
audiences: initialAudiences,
|
|
100
111
|
capabilities,
|
|
@@ -117,6 +128,12 @@ export default function EditorShell({
|
|
|
117
128
|
const [pendingDeleteSectionId, setPendingDeleteSectionId] = useState<string | null>(null);
|
|
118
129
|
const [savedSha, setSavedSha] = useState<string | null>(null);
|
|
119
130
|
const [mainSha, setMainSha] = useState<string | null>(null);
|
|
131
|
+
// Mirror savedSha into a ref so the save handler reads the latest draft
|
|
132
|
+
// version (the optimistic-concurrency base) without a stale closure.
|
|
133
|
+
const savedShaRef = useRef<string | null>(null);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
savedShaRef.current = savedSha;
|
|
136
|
+
}, [savedSha]);
|
|
120
137
|
const [changedSectionIds, setChangedSectionIds] = useState<Set<string>>(new Set());
|
|
121
138
|
const [mainIndex, setMainIndex] = useState<SiteIndex | null>(null);
|
|
122
139
|
const [viewSections, setViewSections] = useState<LoadedSection[] | null>(null);
|
|
@@ -134,6 +151,8 @@ export default function EditorShell({
|
|
|
134
151
|
// doesn't enqueue a GitHub file deletion for a path that was never committed.
|
|
135
152
|
const remoteSectionIdsRef = useRef<Set<string>>(new Set());
|
|
136
153
|
const fontLinkRef = useRef<HTMLLinkElement | null>(null);
|
|
154
|
+
const sectionsRef = useRef<LoadedSection[]>([]);
|
|
155
|
+
useEffect(() => { sectionsRef.current = sections; }, [sections]);
|
|
137
156
|
useEffect(() => { siteIndexRef.current = siteIndex; }, [siteIndex]);
|
|
138
157
|
useEffect(() => { void ensureSanitizer(); }, []);
|
|
139
158
|
|
|
@@ -253,6 +272,7 @@ export default function EditorShell({
|
|
|
253
272
|
setMainSha((prev) => newMainSha ?? prev);
|
|
254
273
|
},
|
|
255
274
|
onPublishComplete: buildStatus.startTracking,
|
|
275
|
+
getBaseVersion: () => savedShaRef.current,
|
|
256
276
|
});
|
|
257
277
|
|
|
258
278
|
const { buttonState } = useContentLifecycle({
|
|
@@ -350,14 +370,32 @@ export default function EditorShell({
|
|
|
350
370
|
let loadedConfig: SiteConfig;
|
|
351
371
|
let loadedManifest: MediaManifest = { images: {} };
|
|
352
372
|
|
|
373
|
+
// Key the content cache on the head of the branch we actually load (the
|
|
374
|
+
// draft `saved` branch if it exists, else main), provided by the SSR edit
|
|
375
|
+
// page. Previously this compared against main's head while the cache
|
|
376
|
+
// stored the saved-branch SHA, so any load with a draft missed and
|
|
377
|
+
// refetched ~2N+ files from GitHub every time.
|
|
378
|
+
//
|
|
379
|
+
// draftHeadSha is the server's view at page-load time (fresher than the
|
|
380
|
+
// client could get without a blocking round-trip). It can only be stale for
|
|
381
|
+
// the SSR->hydration window, and only if another writer moves the draft in
|
|
382
|
+
// that window AND this browser already cached the old SHA; the result is at
|
|
383
|
+
// worst momentarily-stale read content, self-corrected on reload and guarded
|
|
384
|
+
// against lost writes by the save-time 409 (optimistic concurrency). We
|
|
385
|
+
// deliberately don't block every load on a freshness fetch to validate this.
|
|
386
|
+
const cacheKeySha = draftHeadSha ?? headSha;
|
|
387
|
+
|
|
353
388
|
const cached = await getCachedContent();
|
|
354
|
-
if (cached && cached.sha ===
|
|
389
|
+
if (cached && cached.sha === cacheKeySha) {
|
|
355
390
|
loadedSections = cached.sections;
|
|
356
391
|
loadedIndex = cached.index;
|
|
357
392
|
loadedConfig = SiteConfigSchema.parse(cached.siteConfig);
|
|
358
393
|
const savedManifest = await getMediaManifest();
|
|
359
394
|
if (savedManifest) loadedManifest = savedManifest;
|
|
360
|
-
|
|
395
|
+
setSavedSha(cached.savedBranchSha ?? null);
|
|
396
|
+
setMainSha(headSha);
|
|
397
|
+
setChangedSectionIds(new Set(cached.changedSectionIds ?? []));
|
|
398
|
+
setMainIndex(cached.mainIndex ?? loadedIndex);
|
|
361
399
|
} else {
|
|
362
400
|
const response = await fetch("/api/content?branch=saved");
|
|
363
401
|
if (!response.ok) throw new Error(`Failed to load content: ${response.status}`);
|
|
@@ -365,7 +403,11 @@ export default function EditorShell({
|
|
|
365
403
|
loadedSections = data.sections;
|
|
366
404
|
loadedIndex = data.index;
|
|
367
405
|
loadedConfig = SiteConfigSchema.parse(data.siteConfig);
|
|
368
|
-
await cacheContent(data.sha, data.sections, data.index, data.siteConfig
|
|
406
|
+
await cacheContent(data.sha, data.sections, data.index, data.siteConfig, {
|
|
407
|
+
savedBranchSha: data.savedBranchSha ?? null,
|
|
408
|
+
changedSectionIds: data.changedSectionIds ?? [],
|
|
409
|
+
mainIndex: data.mainIndex ?? data.index,
|
|
410
|
+
});
|
|
369
411
|
if (data.mediaManifest) {
|
|
370
412
|
loadedManifest = data.mediaManifest as MediaManifest;
|
|
371
413
|
await persistMediaManifest(loadedManifest);
|
|
@@ -428,7 +470,7 @@ export default function EditorShell({
|
|
|
428
470
|
setShellState({ phase: "error", message: err instanceof Error ? err.message : "Failed to load content" });
|
|
429
471
|
});
|
|
430
472
|
return () => { cancelled = true; };
|
|
431
|
-
}, [headSha, siteId, applySiteConfigPreview]);
|
|
473
|
+
}, [headSha, draftHeadSha, siteId, applySiteConfigPreview]);
|
|
432
474
|
|
|
433
475
|
// --- Recovery handlers ---
|
|
434
476
|
|
|
@@ -601,6 +643,24 @@ export default function EditorShell({
|
|
|
601
643
|
[persistence, resolvedActivePage],
|
|
602
644
|
);
|
|
603
645
|
|
|
646
|
+
const onBlockMove = useMemo(
|
|
647
|
+
() => makeBlockMoveDispatch({
|
|
648
|
+
getState: () => ({ sections: sectionsRef.current, index: siteIndexRef.current, rootPageId: resolvedActivePage?.id ?? homePage(siteIndexRef.current).id }),
|
|
649
|
+
setSections,
|
|
650
|
+
setSiteIndex,
|
|
651
|
+
persistence: { markSectionDirty: persistence.markSectionDirty, markIndexDirty: persistence.markIndexDirty, removeSection: persistence.removeSection },
|
|
652
|
+
isRemote: (id) => remoteSectionIdsRef.current.has(id),
|
|
653
|
+
scheduleDelete: (id) => setDeletedSections((prev) => new Set(prev).add(id)),
|
|
654
|
+
markLocalChanges: () => setLocalChangesExist(true),
|
|
655
|
+
newSectionContent: (id, secs) => {
|
|
656
|
+
const s = secs.find((x) => x.section.id === id)?.section;
|
|
657
|
+
const { id: _omit, ...content } = (s ?? { id }) as { id: string } & Record<string, unknown>;
|
|
658
|
+
return content as unknown as SectionContent;
|
|
659
|
+
},
|
|
660
|
+
}),
|
|
661
|
+
[persistence, resolvedActivePage],
|
|
662
|
+
);
|
|
663
|
+
|
|
604
664
|
const handleMoveSection = useCallback((sectionId: string, destPageId: string, position: "top" | "bottom") => {
|
|
605
665
|
const next = moveSectionReducer(siteIndexRef.current, sectionId, destPageId, position);
|
|
606
666
|
setSiteIndex(next);
|
|
@@ -819,7 +879,10 @@ export default function EditorShell({
|
|
|
819
879
|
onPagesClick={() => setShowPagesModal(true)}
|
|
820
880
|
/>
|
|
821
881
|
|
|
822
|
-
|
|
882
|
+
{/* Bug reports persist via Supabase. On the password-only (zero-Supabase)
|
|
883
|
+
path there's no backend and the editor session has userId: null, so
|
|
884
|
+
the button would always 401 — hide it there. */}
|
|
885
|
+
{!capabilities.passwordOnly && <BugReportFAB />}
|
|
823
886
|
|
|
824
887
|
{rejectedUploads.length > 0 && (
|
|
825
888
|
<div className="sticky top-2 z-30 mx-auto w-full max-w-3xl px-4">
|
|
@@ -845,6 +908,7 @@ export default function EditorShell({
|
|
|
845
908
|
onAddSection={onAddSection}
|
|
846
909
|
onDeleteSection={setPendingDeleteSectionId}
|
|
847
910
|
onReorderSections={onReorderSections}
|
|
911
|
+
onBlockMove={onBlockMove}
|
|
848
912
|
onMoveSection={siteIndex.pages.length > 1 ? setMovingSectionId : undefined}
|
|
849
913
|
onAccessChange={onAccessChange}
|
|
850
914
|
onStatusChange={onStatusChange}
|
|
@@ -1103,6 +1167,7 @@ function EditorContent({
|
|
|
1103
1167
|
onAddSection,
|
|
1104
1168
|
onDeleteSection,
|
|
1105
1169
|
onReorderSections,
|
|
1170
|
+
onBlockMove,
|
|
1106
1171
|
onMoveSection,
|
|
1107
1172
|
onAccessChange,
|
|
1108
1173
|
onStatusChange,
|
|
@@ -1119,6 +1184,7 @@ function EditorContent({
|
|
|
1119
1184
|
onAddSection: (insertIndex: number, type: string) => void;
|
|
1120
1185
|
onDeleteSection: (sectionId: string) => void;
|
|
1121
1186
|
onReorderSections: (fromIndex: number, toIndex: number) => void;
|
|
1187
|
+
onBlockMove: (move: BlockMove) => void;
|
|
1122
1188
|
onMoveSection?: (sectionId: string) => void;
|
|
1123
1189
|
onAccessChange: (sectionId: string, access: string[]) => void;
|
|
1124
1190
|
onStatusChange: (sectionId: string, status: "draft" | "live" | "archived") => void;
|
|
@@ -1127,17 +1193,19 @@ function EditorContent({
|
|
|
1127
1193
|
viewSections: LoadedSection[] | null;
|
|
1128
1194
|
}) {
|
|
1129
1195
|
const { isEditMode, viewBranch } = useEditorContext();
|
|
1130
|
-
const { openModal } = useEditorModal();
|
|
1196
|
+
const { openModal, closeModal } = useEditorModal();
|
|
1131
1197
|
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
|
|
1132
1198
|
const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
|
|
1133
1199
|
|
|
1134
1200
|
const typeOptions = useMemo(
|
|
1135
1201
|
() =>
|
|
1136
|
-
getAllSections()
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1202
|
+
getAllSections()
|
|
1203
|
+
.filter((def) => def.type !== "spacer")
|
|
1204
|
+
.map((def) => ({
|
|
1205
|
+
type: def.type,
|
|
1206
|
+
label: def.label,
|
|
1207
|
+
icon: def.icon,
|
|
1208
|
+
})),
|
|
1141
1209
|
[],
|
|
1142
1210
|
);
|
|
1143
1211
|
|
|
@@ -1155,19 +1223,42 @@ function EditorContent({
|
|
|
1155
1223
|
useEffect(() => {
|
|
1156
1224
|
return combine(
|
|
1157
1225
|
monitorForElements({
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1226
|
+
canMonitor: ({ source }) => source.data.dragType === "block",
|
|
1227
|
+
onDragStart: () => setPendingInsertIndex(null),
|
|
1228
|
+
onDrop: ({ source, location }) => {
|
|
1229
|
+
const target = location.current.dropTargets[0];
|
|
1230
|
+
if (!target) return;
|
|
1231
|
+
const s = source.data as { blockId: string; containerId: string; index: number };
|
|
1232
|
+
const d = target.data as { dropContainerId: string; index: number; toColumn?: number };
|
|
1233
|
+
if (d.toColumn != null) {
|
|
1234
|
+
onBlockMove({
|
|
1235
|
+
blockId: s.blockId,
|
|
1236
|
+
fromContainerId: s.containerId,
|
|
1237
|
+
fromIndex: s.index,
|
|
1238
|
+
toContainerId: d.dropContainerId,
|
|
1239
|
+
toIndex: 0,
|
|
1240
|
+
toColumn: d.toColumn,
|
|
1241
|
+
});
|
|
1242
|
+
return;
|
|
1161
1243
|
}
|
|
1244
|
+
const edge = extractClosestEdge(target.data);
|
|
1245
|
+
const toIndex = resolveDropIndex(d.index, edge);
|
|
1246
|
+
onBlockMove({
|
|
1247
|
+
blockId: s.blockId,
|
|
1248
|
+
fromContainerId: s.containerId,
|
|
1249
|
+
fromIndex: s.index,
|
|
1250
|
+
toContainerId: d.dropContainerId,
|
|
1251
|
+
toIndex,
|
|
1252
|
+
});
|
|
1162
1253
|
},
|
|
1163
1254
|
}),
|
|
1164
|
-
// Gradually scroll the window when a
|
|
1255
|
+
// Gradually scroll the window when a block drag nears the viewport
|
|
1165
1256
|
// edge, so long reorders don't require repeated drag-and-release
|
|
1166
1257
|
autoScrollWindowForElements({
|
|
1167
|
-
canScroll: ({ source }) => source.data.dragType === "
|
|
1258
|
+
canScroll: ({ source }) => source.data.dragType === "block",
|
|
1168
1259
|
}),
|
|
1169
1260
|
);
|
|
1170
|
-
}, []);
|
|
1261
|
+
}, [onBlockMove]);
|
|
1171
1262
|
|
|
1172
1263
|
return (
|
|
1173
1264
|
<div>
|
|
@@ -1208,6 +1299,8 @@ function EditorContent({
|
|
|
1208
1299
|
dirty={dirtySectionIds.has(section.id)}
|
|
1209
1300
|
index={index}
|
|
1210
1301
|
isLast={index === displaySections.length - 1}
|
|
1302
|
+
containerId={ROOT_CONTAINER_ID}
|
|
1303
|
+
isContainerBlock={section.type === "container"}
|
|
1211
1304
|
definition={definition}
|
|
1212
1305
|
mainStatus={mainIndex?.sections[section.id]?.status ?? null}
|
|
1213
1306
|
contentDiffersFromMain={changedSectionIds.has(section.id) || dirtySectionIds.has(section.id)}
|
|
@@ -1241,6 +1334,42 @@ function EditorContent({
|
|
|
1241
1334
|
onMoveSection={editingEnabled && onMoveSection ? () => onMoveSection(section.id) : undefined}
|
|
1242
1335
|
onRequestInsert={editingEnabled ? (i) => setPendingInsertIndex(i) : undefined}
|
|
1243
1336
|
onDelete={editingEnabled ? () => onDeleteSection(section.id) : undefined}
|
|
1337
|
+
onAddChild={
|
|
1338
|
+
editingEnabled && section.type === "container"
|
|
1339
|
+
? () => {
|
|
1340
|
+
openModal(
|
|
1341
|
+
"Add block",
|
|
1342
|
+
<SectionTypePicker
|
|
1343
|
+
types={childInsertableTypes()}
|
|
1344
|
+
onClose={closeModal}
|
|
1345
|
+
onSelect={(type) => {
|
|
1346
|
+
const def = getSection(type);
|
|
1347
|
+
if (def) {
|
|
1348
|
+
const id =
|
|
1349
|
+
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
1350
|
+
? crypto.randomUUID()
|
|
1351
|
+
: `child-${Date.now()}`;
|
|
1352
|
+
const child = { id, ...(def.defaults() as object) } as Section;
|
|
1353
|
+
const containerSection: Section = {
|
|
1354
|
+
id: section.id,
|
|
1355
|
+
type: "container",
|
|
1356
|
+
content: section.content as Record<string, unknown>,
|
|
1357
|
+
};
|
|
1358
|
+
const childCount =
|
|
1359
|
+
(section.content as { children?: unknown[] }).children?.length ?? 0;
|
|
1360
|
+
const next = insertChildAt(containerSection, child, childCount);
|
|
1361
|
+
onSectionChange(section.id, {
|
|
1362
|
+
...section,
|
|
1363
|
+
content: next.content,
|
|
1364
|
+
} as SectionContent);
|
|
1365
|
+
}
|
|
1366
|
+
closeModal();
|
|
1367
|
+
}}
|
|
1368
|
+
/>,
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
: undefined
|
|
1372
|
+
}
|
|
1244
1373
|
>
|
|
1245
1374
|
<Component
|
|
1246
1375
|
content={section}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { EditorModal } from "./EditorModal";
|
|
3
3
|
import { Button } from "../shared/Button";
|
|
4
|
+
import { Tabs } from "../shared/Tabs";
|
|
4
5
|
import { SiteSettingsViewerAccess } from "./SiteSettingsViewerAccess";
|
|
5
6
|
import { SiteSettingsDisplay } from "./SiteSettingsDisplay";
|
|
6
7
|
import { SiteSettingsUsers } from "./SiteSettingsUsers";
|
|
7
|
-
import { cn } from "../../lib/cn";
|
|
8
8
|
import type { SiteConfig } from "../../schemas/site-config";
|
|
9
9
|
import type { Audience } from "../../auth/types";
|
|
10
10
|
|
|
@@ -25,17 +25,7 @@ interface Props {
|
|
|
25
25
|
currentUser: { email: string; role: "owner" | "editor" } | null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
type TabId = "users" | "viewer-access" | "display";
|
|
29
|
-
|
|
30
28
|
export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigChange, onAudiencesChange, capabilities, currentUser }: Props) {
|
|
31
|
-
const tabs: { id: TabId; label: string; show: boolean }[] = [
|
|
32
|
-
{ id: "users", label: "Users", show: capabilities.userManagement && currentUser?.role === "owner" },
|
|
33
|
-
{ id: "viewer-access", label: "Viewer Access", show: true },
|
|
34
|
-
{ id: "display", label: "Display", show: true },
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
const visibleTabs = tabs.filter((t) => t.show);
|
|
38
|
-
const [activeTab, setActiveTab] = useState<TabId>(visibleTabs[0]?.id ?? "display");
|
|
39
29
|
const [signOutError, setSignOutError] = useState<string | null>(null);
|
|
40
30
|
|
|
41
31
|
async function handleSignOut() {
|
|
@@ -58,49 +48,49 @@ export function SiteSettingsModal({ isOpen, onClose, siteConfig, onSiteConfigCha
|
|
|
58
48
|
? (currentUser.role === "owner" ? "Owner" : "Editor")
|
|
59
49
|
: null;
|
|
60
50
|
|
|
51
|
+
const allTabs = [
|
|
52
|
+
{
|
|
53
|
+
id: "users",
|
|
54
|
+
label: "Users",
|
|
55
|
+
show: capabilities.userManagement && currentUser?.role === "owner",
|
|
56
|
+
content: (
|
|
57
|
+
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
58
|
+
<SiteSettingsUsers currentUser={currentUser} />
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "viewer-access",
|
|
64
|
+
label: "Viewer Access",
|
|
65
|
+
show: true,
|
|
66
|
+
content: (
|
|
67
|
+
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
68
|
+
<SiteSettingsViewerAccess
|
|
69
|
+
audienceManagement={capabilities.audienceManagement}
|
|
70
|
+
passwordToggle={capabilities.passwordToggle}
|
|
71
|
+
onAudiencesChange={onAudiencesChange}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "display",
|
|
78
|
+
label: "Display",
|
|
79
|
+
show: true,
|
|
80
|
+
content: (
|
|
81
|
+
<div data-testid="site-settings-tab-panel" className="flex flex-1 flex-col overflow-y-auto px-6 py-4">
|
|
82
|
+
<SiteSettingsDisplay siteConfig={siteConfig} onChange={onSiteConfigChange} />
|
|
83
|
+
</div>
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const tabItems = allTabs.filter((t) => t.show).map(({ id, label, content }) => ({ id, label, content }));
|
|
89
|
+
|
|
61
90
|
return (
|
|
62
91
|
<EditorModal isOpen={isOpen} onClose={onClose} title="Site Settings" size="settings" noPadding>
|
|
63
92
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
64
|
-
<
|
|
65
|
-
<div className="flex px-6" role="tablist">
|
|
66
|
-
{visibleTabs.map((tab) => (
|
|
67
|
-
<button
|
|
68
|
-
key={tab.id}
|
|
69
|
-
role="tab"
|
|
70
|
-
aria-selected={activeTab === tab.id}
|
|
71
|
-
onClick={() => setActiveTab(tab.id)}
|
|
72
|
-
className={cn(
|
|
73
|
-
"cursor-pointer px-4 py-2 text-sm font-medium border-b-2 -mb-px",
|
|
74
|
-
activeTab === tab.id
|
|
75
|
-
? "border-brand text-brand"
|
|
76
|
-
: "border-transparent text-base-contrast-light hover:text-base-contrast",
|
|
77
|
-
)}
|
|
78
|
-
>
|
|
79
|
-
{tab.label}
|
|
80
|
-
</button>
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
</div>
|
|
84
|
-
|
|
85
|
-
<div
|
|
86
|
-
data-testid="site-settings-tab-panel"
|
|
87
|
-
className="flex flex-1 flex-col overflow-y-auto px-6 py-4"
|
|
88
|
-
>
|
|
89
|
-
{activeTab === "users" && <SiteSettingsUsers currentUser={currentUser} />}
|
|
90
|
-
{activeTab === "viewer-access" && (
|
|
91
|
-
<SiteSettingsViewerAccess
|
|
92
|
-
audienceManagement={capabilities.audienceManagement}
|
|
93
|
-
passwordToggle={capabilities.passwordToggle}
|
|
94
|
-
onAudiencesChange={onAudiencesChange}
|
|
95
|
-
/>
|
|
96
|
-
)}
|
|
97
|
-
{activeTab === "display" && (
|
|
98
|
-
<SiteSettingsDisplay
|
|
99
|
-
siteConfig={siteConfig}
|
|
100
|
-
onChange={onSiteConfigChange}
|
|
101
|
-
/>
|
|
102
|
-
)}
|
|
103
|
-
</div>
|
|
93
|
+
<Tabs tabs={tabItems} />
|
|
104
94
|
|
|
105
95
|
{currentUser && (
|
|
106
96
|
<div className="flex items-center justify-between gap-3 border-t border-base-200 px-6 py-3">
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { applyBlockMove, type BlockMove, type BlockMoveState } from "../../lib/block-move";
|
|
2
|
+
import type { LoadedSection } from "../../lib/loader";
|
|
3
|
+
import type { SiteIndex } from "../../schemas/site-config";
|
|
4
|
+
import type { SectionContent } from "../../schemas/sections";
|
|
5
|
+
|
|
6
|
+
export interface BlockMoveDispatchDeps {
|
|
7
|
+
getState: () => BlockMoveState;
|
|
8
|
+
setSections: (updater: (prev: LoadedSection[]) => LoadedSection[]) => void;
|
|
9
|
+
setSiteIndex: (updater: (prev: SiteIndex) => SiteIndex) => void;
|
|
10
|
+
persistence: {
|
|
11
|
+
markSectionDirty: (id: string, content: SectionContent) => void;
|
|
12
|
+
markIndexDirty: () => void;
|
|
13
|
+
removeSection: (id: string) => void;
|
|
14
|
+
};
|
|
15
|
+
isRemote: (id: string) => boolean;
|
|
16
|
+
scheduleDelete: (id: string) => void;
|
|
17
|
+
markLocalChanges: () => void;
|
|
18
|
+
/** Latest content for a section id, for markSectionDirty after the move. */
|
|
19
|
+
newSectionContent: (id: string, sections: LoadedSection[]) => SectionContent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function makeBlockMoveDispatch(deps: BlockMoveDispatchDeps) {
|
|
23
|
+
return function onBlockMove(move: BlockMove) {
|
|
24
|
+
const result = applyBlockMove(deps.getState(), move);
|
|
25
|
+
if (!result.changed) return;
|
|
26
|
+
|
|
27
|
+
deps.setSections(() => result.sections);
|
|
28
|
+
deps.setSiteIndex(() => result.index);
|
|
29
|
+
|
|
30
|
+
for (const id of result.effects.deleteSectionIds) {
|
|
31
|
+
if (deps.isRemote(id)) deps.scheduleDelete(id);
|
|
32
|
+
deps.persistence.removeSection(id);
|
|
33
|
+
}
|
|
34
|
+
for (const id of result.effects.markDirtySectionIds) {
|
|
35
|
+
deps.persistence.markSectionDirty(id, deps.newSectionContent(id, result.sections));
|
|
36
|
+
}
|
|
37
|
+
if (result.effects.indexDirty) deps.persistence.markIndexDirty();
|
|
38
|
+
deps.markLocalChanges();
|
|
39
|
+
};
|
|
40
|
+
}
|