bsmnt 0.11.0 → 0.12.0
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/package.json +1 -1
- package/src/modules/features/cms/sanity/files/components/ui/sanity-image/index.tsx +36 -4
- package/src/modules/features/cms/sanity/files/lib/integrations/sanity/components/rich-text.tsx +7 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +7 -1
- package/src/modules/features/cms/sanity-pagebuilder/files/components/ui/sanity-image/index.tsx +36 -4
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +26 -7
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +242 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +62 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +72 -34
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +37 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +3 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +5 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +7 -1
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/ui/sanity-image/index.tsx +36 -4
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +26 -7
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +242 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +62 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +72 -34
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +1 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +37 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +3 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +2 -0
- package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +5 -0
- package/src/templates/next-default/.biome/plugins/no-fill-without-sizes.grit +11 -0
- package/src/templates/next-default/biome.json +2 -1
- package/src/templates/next-default/components/ui/image/index.tsx +20 -12
- package/src/templates/next-default/next.config.ts +10 -4
- package/src/templates/next-experiments/.biome/plugins/no-fill-without-sizes.grit +11 -0
- package/src/templates/next-experiments/biome.json +2 -1
- package/src/templates/next-experiments/components/ui/image/index.tsx +20 -12
- package/src/templates/next-experiments/next.config.ts +10 -4
- package/src/templates/next-pagebuilder/.biome/plugins/no-fill-without-sizes.grit +11 -0
- package/src/templates/next-pagebuilder/biome.json +2 -1
- package/src/templates/next-pagebuilder/components/page-builder/components/article-content/index.tsx +5 -0
- package/src/templates/next-pagebuilder/components/page-builder/components/article-content/related-post-item.tsx +1 -0
- package/src/templates/next-pagebuilder/components/ui/image/index.tsx +20 -12
- package/src/templates/next-pagebuilder/next.config.ts +4 -3
- package/src/templates/next-pagebuilder-cache/.biome/plugins/no-fill-without-sizes.grit +11 -0
- package/src/templates/next-pagebuilder-cache/biome.json +2 -1
- package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/index.tsx +5 -0
- package/src/templates/next-pagebuilder-cache/components/page-builder/components/article-content/related-post-item.tsx +1 -0
- package/src/templates/next-pagebuilder-cache/components/ui/image/index.tsx +21 -12
- package/src/templates/next-pagebuilder-cache/lib/utils/image-sizes.ts +72 -0
- package/src/templates/next-pagebuilder-cache/next.config.ts +4 -3
- package/src/templates/next-webgl/.biome/plugins/no-fill-without-sizes.grit +11 -0
- package/src/templates/next-webgl/biome.json +2 -1
- package/src/templates/next-webgl/components/ui/image/index.tsx +20 -12
- package/src/templates/next-webgl/next.config.ts +10 -4
|
@@ -9,15 +9,35 @@ export type PageBuilderDocumentType =
|
|
|
9
9
|
| "blogContent"
|
|
10
10
|
| (typeof singletonComponents)[number]["schemaType"];
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A block declares two orthogonal axes. `pageTypes` is which page documents may
|
|
14
|
+
* host it and drives validation; `category` is what the block structurally is
|
|
15
|
+
* and drives the insert menu. Never collapse them back into one field — that is
|
|
16
|
+
* what made the menu a single flat list.
|
|
17
|
+
*/
|
|
18
|
+
export type PageBuilderGroupName = "generic" | "blogEntry";
|
|
19
|
+
|
|
20
|
+
export const componentCategories = [
|
|
21
|
+
{ name: "hero", title: "Hero" },
|
|
22
|
+
{ name: "text", title: "Text" },
|
|
23
|
+
{ name: "list", title: "List" },
|
|
24
|
+
{ name: "content", title: "Content" },
|
|
25
|
+
{ name: "misc", title: "Miscellaneous" },
|
|
26
|
+
] as const satisfies ReadonlyArray<{ name: string; title: string }>;
|
|
27
|
+
|
|
28
|
+
export type ComponentCategory = (typeof componentCategories)[number]["name"];
|
|
29
|
+
|
|
12
30
|
type PageBuilderReferenceMember = {
|
|
13
31
|
documentType: PageBuilderDocumentType;
|
|
14
32
|
name: string;
|
|
15
33
|
title: string;
|
|
16
34
|
documentId?: string;
|
|
17
|
-
|
|
35
|
+
pageTypes: readonly PageBuilderGroupName[];
|
|
36
|
+
category: ComponentCategory;
|
|
18
37
|
};
|
|
19
38
|
|
|
20
|
-
export
|
|
39
|
+
export const blockPreviewKey = (schemaTypeName: string) =>
|
|
40
|
+
`block.${schemaTypeName}`;
|
|
21
41
|
|
|
22
42
|
export const pageBuilderPageTypes: Array<{
|
|
23
43
|
title: string;
|
|
@@ -41,31 +61,36 @@ const reusablePageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
|
|
|
41
61
|
documentType: "hero",
|
|
42
62
|
name: "heroReference",
|
|
43
63
|
title: "Heading",
|
|
44
|
-
|
|
64
|
+
pageTypes: ["generic"],
|
|
65
|
+
category: "hero",
|
|
45
66
|
},
|
|
46
67
|
{
|
|
47
68
|
documentType: "description",
|
|
48
69
|
name: "descriptionReference",
|
|
49
70
|
title: "Description",
|
|
50
|
-
|
|
71
|
+
pageTypes: ["generic"],
|
|
72
|
+
category: "text",
|
|
51
73
|
},
|
|
52
74
|
{
|
|
53
75
|
documentType: "richTextBlock",
|
|
54
76
|
name: "richTextBlockReference",
|
|
55
77
|
title: "Rich Text",
|
|
56
|
-
|
|
78
|
+
pageTypes: ["generic"],
|
|
79
|
+
category: "text",
|
|
57
80
|
},
|
|
58
81
|
{
|
|
59
82
|
documentType: "gatedContent",
|
|
60
83
|
name: "gatedContentReference",
|
|
61
84
|
title: "Gated Content",
|
|
62
|
-
|
|
85
|
+
pageTypes: ["generic"],
|
|
86
|
+
category: "misc",
|
|
63
87
|
},
|
|
64
88
|
{
|
|
65
89
|
documentType: "blogContent",
|
|
66
90
|
name: "blogContentReference",
|
|
67
91
|
title: "Blog Content",
|
|
68
|
-
|
|
92
|
+
pageTypes: ["blogEntry"],
|
|
93
|
+
category: "content",
|
|
69
94
|
},
|
|
70
95
|
];
|
|
71
96
|
|
|
@@ -75,7 +100,8 @@ const singletonPageBuilderReferenceMembers: PageBuilderReferenceMember[] =
|
|
|
75
100
|
name: `${component.schemaType}Reference`,
|
|
76
101
|
title: component.title,
|
|
77
102
|
documentId: component.documentId,
|
|
78
|
-
|
|
103
|
+
pageTypes: ["generic"],
|
|
104
|
+
category: component.category,
|
|
79
105
|
}));
|
|
80
106
|
|
|
81
107
|
export const pageBuilderReferenceMembers: PageBuilderReferenceMember[] = [
|
|
@@ -93,25 +119,37 @@ export const getPageBuilderReferenceMemberByDocumentType = (
|
|
|
93
119
|
(member) => member.documentType === documentType,
|
|
94
120
|
);
|
|
95
121
|
|
|
96
|
-
const
|
|
122
|
+
const isMemberVisibleForPageType = (
|
|
123
|
+
member: PageBuilderReferenceMember,
|
|
97
124
|
pageType: PageBuilderGroupName | undefined,
|
|
98
|
-
|
|
99
|
-
) => (pageType ?? "generic") === group;
|
|
125
|
+
) => member.pageTypes.includes(pageType ?? "generic");
|
|
100
126
|
|
|
101
127
|
export const getVisiblePageBuilderReferenceMembers = (
|
|
102
128
|
pageType: PageBuilderGroupName | undefined,
|
|
103
129
|
) =>
|
|
104
130
|
pageBuilderReferenceMembers.filter((member) =>
|
|
105
|
-
member
|
|
131
|
+
isMemberVisibleForPageType(member, pageType),
|
|
106
132
|
);
|
|
107
133
|
|
|
108
|
-
export const
|
|
134
|
+
export const getVisiblePageBuilderMemberNames = (
|
|
109
135
|
pageType: PageBuilderGroupName | undefined,
|
|
110
136
|
) =>
|
|
111
|
-
|
|
112
|
-
(
|
|
137
|
+
new Set(
|
|
138
|
+
getVisiblePageBuilderReferenceMembers(pageType).map((member) => member.name),
|
|
113
139
|
);
|
|
114
140
|
|
|
141
|
+
export const getVisiblePageBuilderInsertMenuGroups = (
|
|
142
|
+
pageType: PageBuilderGroupName | undefined,
|
|
143
|
+
) => {
|
|
144
|
+
const visible = getVisiblePageBuilderMemberNames(pageType);
|
|
145
|
+
return pageBuilderInsertMenuGroups
|
|
146
|
+
.map((group) => ({
|
|
147
|
+
...group,
|
|
148
|
+
of: group.of.filter((name) => visible.has(name)),
|
|
149
|
+
}))
|
|
150
|
+
.filter((group) => group.of.length > 0);
|
|
151
|
+
};
|
|
152
|
+
|
|
115
153
|
export const getInvalidPageBuilderDocumentTypes = (
|
|
116
154
|
pageType: PageBuilderGroupName | undefined,
|
|
117
155
|
documentTypes: PageBuilderDocumentType[],
|
|
@@ -121,24 +159,24 @@ export const getInvalidPageBuilderDocumentTypes = (
|
|
|
121
159
|
|
|
122
160
|
if (!member) return false;
|
|
123
161
|
|
|
124
|
-
return !member
|
|
125
|
-
isPageBuilderGroupVisible(pageType, group),
|
|
126
|
-
);
|
|
162
|
+
return !isMemberVisibleForPageType(member, pageType);
|
|
127
163
|
});
|
|
128
164
|
|
|
129
|
-
export const pageBuilderInsertMenuGroups
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
name
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
165
|
+
export const pageBuilderInsertMenuGroups: Array<{
|
|
166
|
+
name: ComponentCategory;
|
|
167
|
+
title: string;
|
|
168
|
+
of: string[];
|
|
169
|
+
}> = componentCategories.map(({ name, title }) => ({
|
|
170
|
+
name,
|
|
171
|
+
title,
|
|
172
|
+
of: pageBuilderReferenceMembers
|
|
173
|
+
.filter((member) => member.category === name)
|
|
174
|
+
.map((member) => member.name),
|
|
175
|
+
}));
|
|
176
|
+
|
|
177
|
+
export const pageBuilderBlockPreviewOptions = pageBuilderReferenceMembers.map(
|
|
178
|
+
(member) => ({
|
|
179
|
+
title: `Block — ${member.title} · ${member.name}`,
|
|
180
|
+
value: blockPreviewKey(member.name),
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
@@ -4,3 +4,4 @@ export { gatedContent } from "./reusable/gated-content";
|
|
|
4
4
|
export { hero } from "./reusable/hero";
|
|
5
5
|
export { richTextBlock } from "./reusable/rich-text-block";
|
|
6
6
|
export { blogCollection } from "./singleton/content-collection";
|
|
7
|
+
export { studioPreview } from "./studio-preview";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { defineField, defineType } from "sanity";
|
|
2
|
+
import { pageBuilderCardIcon } from "@/lib/integrations/sanity/icons";
|
|
3
|
+
import { pageBuilderBlockPreviewOptions } from "@/lib/integrations/sanity/page-builder-config";
|
|
4
|
+
|
|
5
|
+
export const studioPreview = defineType({
|
|
6
|
+
name: "studioPreview",
|
|
7
|
+
title: "Component Preview",
|
|
8
|
+
type: "document",
|
|
9
|
+
icon: pageBuilderCardIcon,
|
|
10
|
+
fields: [
|
|
11
|
+
defineField({
|
|
12
|
+
name: "key",
|
|
13
|
+
title: "Component",
|
|
14
|
+
type: "string",
|
|
15
|
+
options: { list: pageBuilderBlockPreviewOptions },
|
|
16
|
+
validation: (Rule) => Rule.required(),
|
|
17
|
+
}),
|
|
18
|
+
defineField({
|
|
19
|
+
name: "preview",
|
|
20
|
+
title: "Preview image",
|
|
21
|
+
type: "image",
|
|
22
|
+
options: { hotspot: true },
|
|
23
|
+
validation: (Rule) => Rule.required(),
|
|
24
|
+
}),
|
|
25
|
+
],
|
|
26
|
+
preview: {
|
|
27
|
+
select: { key: "key", media: "preview" },
|
|
28
|
+
prepare({ key, media }) {
|
|
29
|
+
return {
|
|
30
|
+
title:
|
|
31
|
+
pageBuilderBlockPreviewOptions.find((option) => option.value === key)
|
|
32
|
+
?.title ?? "Component Preview",
|
|
33
|
+
media,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
gatedContent,
|
|
16
16
|
hero,
|
|
17
17
|
richTextBlock,
|
|
18
|
+
studioPreview,
|
|
18
19
|
} from "./components";
|
|
19
20
|
import { author } from "./content/author";
|
|
20
21
|
import { blogCategory } from "./content/blog-category";
|
|
@@ -77,6 +78,7 @@ export {
|
|
|
77
78
|
richText,
|
|
78
79
|
softwareApplicationJsonLd,
|
|
79
80
|
structuredData,
|
|
81
|
+
studioPreview,
|
|
80
82
|
videoJsonLd,
|
|
81
83
|
};
|
|
82
84
|
|
|
@@ -113,6 +115,7 @@ export const schema: { types: SchemaTypeDefinition[] } = {
|
|
|
113
115
|
description,
|
|
114
116
|
richTextBlock,
|
|
115
117
|
gatedContent,
|
|
118
|
+
studioPreview,
|
|
116
119
|
|
|
117
120
|
// Layout types (singletons for site-wide layout)
|
|
118
121
|
companyData,
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts
CHANGED
|
@@ -6,12 +6,14 @@ import {
|
|
|
6
6
|
structuredDataIcon,
|
|
7
7
|
} from "./icons";
|
|
8
8
|
|
|
9
|
+
// `category` must be one of `componentCategories` in page-builder-config.ts.
|
|
9
10
|
export const singletonComponents = [
|
|
10
11
|
{
|
|
11
12
|
schemaType: "blogCollection",
|
|
12
13
|
documentId: "blogCollection",
|
|
13
14
|
title: "Blog Collection",
|
|
14
15
|
icon: pageListIcon,
|
|
16
|
+
category: "list",
|
|
15
17
|
},
|
|
16
18
|
] as const;
|
|
17
19
|
|
package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
gatedContentIcon,
|
|
18
18
|
heroIcon,
|
|
19
19
|
layoutFolderIcon,
|
|
20
|
+
pageBuilderCardIcon,
|
|
20
21
|
pageFolderIcon,
|
|
21
22
|
pageIcon,
|
|
22
23
|
pagesFolderIcon,
|
|
@@ -301,6 +302,10 @@ export const structure: StructureResolver = (S, context) =>
|
|
|
301
302
|
S.documentTypeListItem("blogContent")
|
|
302
303
|
.title("Blog Content")
|
|
303
304
|
.icon(contentIcon),
|
|
305
|
+
S.divider(),
|
|
306
|
+
S.documentTypeListItem("studioPreview")
|
|
307
|
+
.title("Component Previews")
|
|
308
|
+
.icon(pageBuilderCardIcon),
|
|
304
309
|
]),
|
|
305
310
|
),
|
|
306
311
|
|
package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx
CHANGED
|
@@ -42,7 +42,13 @@ export const RichText = ({ content }: RichTextProps) => {
|
|
|
42
42
|
value={content}
|
|
43
43
|
components={{
|
|
44
44
|
types: {
|
|
45
|
-
image: ({ value }) =>
|
|
45
|
+
image: ({ value }) => (
|
|
46
|
+
<SanityImage
|
|
47
|
+
image={value}
|
|
48
|
+
maxWidth={1920}
|
|
49
|
+
sizes="(min-width: 768px) 768px, 100vw"
|
|
50
|
+
/>
|
|
51
|
+
),
|
|
46
52
|
table: ({ value }) => {
|
|
47
53
|
const rows = value?.rows as
|
|
48
54
|
| Array<{ _key?: string; cells?: string[] }>
|
package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/ui/sanity-image/index.tsx
CHANGED
|
@@ -2,7 +2,8 @@ import { getImageDimensions } from "@sanity/asset-utils";
|
|
|
2
2
|
import { Image, type ImageProps } from "@/components/ui/image";
|
|
3
3
|
import { urlForImage } from "@/lib/integrations/sanity/utils/image";
|
|
4
4
|
|
|
5
|
-
interface
|
|
5
|
+
interface SanityImageBaseProps
|
|
6
|
+
extends Omit<ImageProps, "src" | "aspectRatio" | "sizes" | "width" | "height"> {
|
|
6
7
|
image: {
|
|
7
8
|
asset?: {
|
|
8
9
|
_ref: string;
|
|
@@ -15,6 +16,38 @@ interface SanityImageProps extends Omit<ImageProps, "src" | "aspectRatio"> {
|
|
|
15
16
|
maxWidth?: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
type SanityImageProps = SanityImageBaseProps &
|
|
20
|
+
(
|
|
21
|
+
| { sizes: string; width?: never; height?: never }
|
|
22
|
+
| { width: number; height: number; sizes?: string }
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const MAX_SOURCE_WIDTH = 3840;
|
|
26
|
+
|
|
27
|
+
const isGifRef = (ref: string) => ref.endsWith("-gif");
|
|
28
|
+
|
|
29
|
+
const getImageSrc = (
|
|
30
|
+
image: SanityImageProps["image"],
|
|
31
|
+
maxWidth: number,
|
|
32
|
+
croppedWidth: number,
|
|
33
|
+
) => {
|
|
34
|
+
const builder = urlForImage(image);
|
|
35
|
+
const ref = image.asset?._ref ?? "";
|
|
36
|
+
|
|
37
|
+
if (ref.endsWith("-svg")) return builder.url();
|
|
38
|
+
|
|
39
|
+
const requestWidth = Math.min(maxWidth, MAX_SOURCE_WIDTH, croppedWidth);
|
|
40
|
+
const { left, right, top, bottom } = image.crop ?? {};
|
|
41
|
+
const hasCrop = Boolean(left || right || top || bottom);
|
|
42
|
+
const isGif = isGifRef(ref);
|
|
43
|
+
|
|
44
|
+
if (!(isGif || hasCrop) && requestWidth >= croppedWidth) {
|
|
45
|
+
return builder.url();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return builder.width(requestWidth).url();
|
|
49
|
+
};
|
|
50
|
+
|
|
18
51
|
export function SanityImage({
|
|
19
52
|
image,
|
|
20
53
|
maxWidth = 1920,
|
|
@@ -39,11 +72,10 @@ export function SanityImage({
|
|
|
39
72
|
|
|
40
73
|
return (
|
|
41
74
|
<Image
|
|
42
|
-
src={
|
|
43
|
-
.width(Math.min(maxWidth, sourceWidth))
|
|
44
|
-
.url()}
|
|
75
|
+
src={getImageSrc(image, maxWidth, sourceWidth)}
|
|
45
76
|
alt={alt || image.alt || ""}
|
|
46
77
|
aspectRatio={sourceWidth / sourceHeight}
|
|
78
|
+
unoptimized={isGifRef(image.asset._ref)}
|
|
47
79
|
{...imageProps}
|
|
48
80
|
{...props}
|
|
49
81
|
/>
|
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
import { type ArrayOfObjectsInputProps, useFormValue } from "sanity";
|
|
2
|
+
import { PageBuilderInsertMenu } from "@/lib/integrations/sanity/components/page-builder-insert-menu";
|
|
3
|
+
import { useStudioPreviewUrls } from "@/lib/integrations/sanity/components/use-studio-preview-urls";
|
|
2
4
|
import {
|
|
5
|
+
blockPreviewKey,
|
|
3
6
|
getVisiblePageBuilderInsertMenuGroups,
|
|
4
|
-
|
|
7
|
+
getVisiblePageBuilderMemberNames,
|
|
5
8
|
type PageBuilderGroupName,
|
|
9
|
+
pageBuilderReferenceMembers,
|
|
6
10
|
} from "@/lib/integrations/sanity/page-builder-config";
|
|
7
11
|
|
|
12
|
+
const BLOCK_PREVIEW_KEYS = pageBuilderReferenceMembers.map((member) =>
|
|
13
|
+
blockPreviewKey(member.name),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
function useInsertMenuViews() {
|
|
17
|
+
const previewUrls = useStudioPreviewUrls(BLOCK_PREVIEW_KEYS);
|
|
18
|
+
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
name: "grid" as const,
|
|
22
|
+
previewImageUrl: (schemaTypeName: string) =>
|
|
23
|
+
previewUrls?.[blockPreviewKey(schemaTypeName)],
|
|
24
|
+
},
|
|
25
|
+
{ name: "list" as const },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
export function PageBuilderInput(props: ArrayOfObjectsInputProps) {
|
|
9
30
|
const pageTypeValue = useFormValue(["type"]);
|
|
10
31
|
const pageType =
|
|
11
32
|
typeof pageTypeValue === "string"
|
|
12
33
|
? (pageTypeValue as PageBuilderGroupName)
|
|
13
34
|
: undefined;
|
|
14
|
-
|
|
15
|
-
const visibleMemberNames =
|
|
16
|
-
getVisiblePageBuilderReferenceMembers(pageType).map(
|
|
17
|
-
(member) => member.name,
|
|
18
|
-
),
|
|
19
|
-
);
|
|
35
|
+
const views = useInsertMenuViews();
|
|
36
|
+
const visibleMemberNames = getVisiblePageBuilderMemberNames(pageType);
|
|
20
37
|
|
|
21
38
|
return props.renderDefault({
|
|
22
39
|
...props,
|
|
40
|
+
arrayFunctions: PageBuilderInsertMenu,
|
|
23
41
|
schemaType: {
|
|
24
42
|
...props.schemaType,
|
|
25
43
|
of: props.schemaType.of.filter(
|
|
@@ -32,6 +50,7 @@ export function PageBuilderInput(props: ArrayOfObjectsInputProps) {
|
|
|
32
50
|
insertMenu: {
|
|
33
51
|
...props.schemaType.options?.insertMenu,
|
|
34
52
|
groups: getVisiblePageBuilderInsertMenuGroups(pageType),
|
|
53
|
+
views,
|
|
35
54
|
},
|
|
36
55
|
},
|
|
37
56
|
},
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { AddIcon, SearchIcon } from "@sanity/icons";
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Button,
|
|
5
|
+
Card,
|
|
6
|
+
Dialog,
|
|
7
|
+
Flex,
|
|
8
|
+
Grid,
|
|
9
|
+
Stack,
|
|
10
|
+
Text,
|
|
11
|
+
TextInput,
|
|
12
|
+
} from "@sanity/ui";
|
|
13
|
+
import { useMemo, useState } from "react";
|
|
14
|
+
import type { ArrayInputFunctionsProps, ArraySchemaType } from "sanity";
|
|
15
|
+
|
|
16
|
+
type ArrayItem = { _key: string };
|
|
17
|
+
type MemberSchemaType = ArraySchemaType["of"][number];
|
|
18
|
+
|
|
19
|
+
type CreateItem = (memberType: MemberSchemaType) => ArrayItem;
|
|
20
|
+
|
|
21
|
+
type InsertMenuSection = {
|
|
22
|
+
name: string;
|
|
23
|
+
title: string;
|
|
24
|
+
members: MemberSchemaType[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SCROLL_HEIGHT = "min(60vh, 34rem)";
|
|
28
|
+
const TILE_ASPECT_RATIO = "3 / 2";
|
|
29
|
+
|
|
30
|
+
const memberTitle = (member: MemberSchemaType) => member.title ?? member.name;
|
|
31
|
+
|
|
32
|
+
function BlockTile({
|
|
33
|
+
member,
|
|
34
|
+
previewUrl,
|
|
35
|
+
onSelect,
|
|
36
|
+
}: {
|
|
37
|
+
member: MemberSchemaType;
|
|
38
|
+
previewUrl: string | undefined;
|
|
39
|
+
onSelect: () => void;
|
|
40
|
+
}) {
|
|
41
|
+
const Icon = member.icon;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Card
|
|
45
|
+
as="button"
|
|
46
|
+
border
|
|
47
|
+
onClick={onSelect}
|
|
48
|
+
overflow="hidden"
|
|
49
|
+
padding={0}
|
|
50
|
+
radius={2}
|
|
51
|
+
style={{ cursor: "pointer", textAlign: "left", width: "100%" }}
|
|
52
|
+
type="button"
|
|
53
|
+
>
|
|
54
|
+
<Stack space={0}>
|
|
55
|
+
<Box
|
|
56
|
+
style={{
|
|
57
|
+
aspectRatio: TILE_ASPECT_RATIO,
|
|
58
|
+
background: "var(--card-muted-bg-color)",
|
|
59
|
+
position: "relative",
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{previewUrl ? (
|
|
63
|
+
// biome-ignore lint/performance/noImgElement: Studio-only CMS asset
|
|
64
|
+
<img
|
|
65
|
+
alt=""
|
|
66
|
+
src={`${previewUrl}?w=640&fit=max&auto=format`}
|
|
67
|
+
style={{
|
|
68
|
+
height: "100%",
|
|
69
|
+
inset: 0,
|
|
70
|
+
objectFit: "contain",
|
|
71
|
+
position: "absolute",
|
|
72
|
+
width: "100%",
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
) : (
|
|
76
|
+
<Flex
|
|
77
|
+
align="center"
|
|
78
|
+
justify="center"
|
|
79
|
+
style={{ inset: 0, position: "absolute" }}
|
|
80
|
+
>
|
|
81
|
+
{Icon ? <Icon /> : null}
|
|
82
|
+
</Flex>
|
|
83
|
+
)}
|
|
84
|
+
</Box>
|
|
85
|
+
<Box padding={2}>
|
|
86
|
+
<Text size={1} textOverflow="ellipsis" weight="medium">
|
|
87
|
+
{memberTitle(member)}
|
|
88
|
+
</Text>
|
|
89
|
+
</Box>
|
|
90
|
+
</Stack>
|
|
91
|
+
</Card>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function InsertMenuDialog({
|
|
96
|
+
sections,
|
|
97
|
+
previewImageUrl,
|
|
98
|
+
onSelect,
|
|
99
|
+
onClose,
|
|
100
|
+
}: {
|
|
101
|
+
sections: InsertMenuSection[];
|
|
102
|
+
previewImageUrl: ((schemaTypeName: string) => string | undefined) | undefined;
|
|
103
|
+
onSelect: (member: MemberSchemaType) => void;
|
|
104
|
+
onClose: () => void;
|
|
105
|
+
}) {
|
|
106
|
+
const [search, setSearch] = useState("");
|
|
107
|
+
|
|
108
|
+
const visibleSections = useMemo(() => {
|
|
109
|
+
const query = search.trim().toLowerCase();
|
|
110
|
+
if (!query) return sections;
|
|
111
|
+
return sections
|
|
112
|
+
.map((section) => ({
|
|
113
|
+
...section,
|
|
114
|
+
members: section.members.filter((member) =>
|
|
115
|
+
memberTitle(member).toLowerCase().includes(query),
|
|
116
|
+
),
|
|
117
|
+
}))
|
|
118
|
+
.filter((section) => section.members.length > 0);
|
|
119
|
+
}, [search, sections]);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Dialog
|
|
123
|
+
header="Components"
|
|
124
|
+
id="page-builder-insert"
|
|
125
|
+
onClose={onClose}
|
|
126
|
+
open
|
|
127
|
+
width={3}
|
|
128
|
+
>
|
|
129
|
+
<Stack padding={4} space={4}>
|
|
130
|
+
<TextInput
|
|
131
|
+
autoFocus
|
|
132
|
+
icon={SearchIcon}
|
|
133
|
+
onChange={(event) => setSearch(event.currentTarget.value)}
|
|
134
|
+
placeholder="Search components…"
|
|
135
|
+
value={search}
|
|
136
|
+
/>
|
|
137
|
+
<Box style={{ height: SCROLL_HEIGHT, overflowY: "auto" }}>
|
|
138
|
+
{visibleSections.length === 0 ? (
|
|
139
|
+
<Text muted size={1}>
|
|
140
|
+
No components match “{search}”.
|
|
141
|
+
</Text>
|
|
142
|
+
) : (
|
|
143
|
+
<Stack space={5}>
|
|
144
|
+
{visibleSections.map((section) => (
|
|
145
|
+
<Stack key={section.name} space={3}>
|
|
146
|
+
<Text muted size={1} weight="semibold">
|
|
147
|
+
{section.title}
|
|
148
|
+
</Text>
|
|
149
|
+
<Grid columns={[2, 3, 4]} gap={3}>
|
|
150
|
+
{section.members.map((member) => (
|
|
151
|
+
<BlockTile
|
|
152
|
+
key={member.name}
|
|
153
|
+
member={member}
|
|
154
|
+
onSelect={() => onSelect(member)}
|
|
155
|
+
previewUrl={previewImageUrl?.(member.name)}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</Grid>
|
|
159
|
+
</Stack>
|
|
160
|
+
))}
|
|
161
|
+
</Stack>
|
|
162
|
+
)}
|
|
163
|
+
</Box>
|
|
164
|
+
</Stack>
|
|
165
|
+
</Dialog>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pass as `arrayFunctions`. Reads `insertMenu.groups` and `insertMenu.views`, so
|
|
171
|
+
* a schema already configuring the built-in menu needs no extra wiring.
|
|
172
|
+
*
|
|
173
|
+
* Replaces the built-in popover, which is anchored to the array grid element and
|
|
174
|
+
* so clips on page scroll and resizes when switching tabs.
|
|
175
|
+
*/
|
|
176
|
+
export function PageBuilderInsertMenu(
|
|
177
|
+
props: ArrayInputFunctionsProps<ArrayItem, ArraySchemaType>,
|
|
178
|
+
) {
|
|
179
|
+
const { onItemAppend, onValueCreate, readOnly, schemaType } = props;
|
|
180
|
+
const [open, setOpen] = useState(false);
|
|
181
|
+
|
|
182
|
+
const insertMenu = schemaType.options?.insertMenu;
|
|
183
|
+
const members = schemaType.of;
|
|
184
|
+
|
|
185
|
+
const sections = useMemo<InsertMenuSection[]>(() => {
|
|
186
|
+
const byName = new Map(members.map((member) => [member.name, member]));
|
|
187
|
+
const groups = insertMenu?.groups;
|
|
188
|
+
if (!groups) {
|
|
189
|
+
return [{ name: "all", title: "Components", members: [...members] }];
|
|
190
|
+
}
|
|
191
|
+
return groups
|
|
192
|
+
.map((group) => ({
|
|
193
|
+
name: group.name,
|
|
194
|
+
title: group.title ?? group.name,
|
|
195
|
+
members: (group.of ?? [])
|
|
196
|
+
.map((name) => byName.get(name))
|
|
197
|
+
.filter((member) => member !== undefined),
|
|
198
|
+
}))
|
|
199
|
+
.filter((section) => section.members.length > 0);
|
|
200
|
+
}, [members, insertMenu]);
|
|
201
|
+
|
|
202
|
+
const previewImageUrl = useMemo(() => {
|
|
203
|
+
const gridView = insertMenu?.views?.find((view) => view.name === "grid");
|
|
204
|
+
return gridView && "previewImageUrl" in gridView
|
|
205
|
+
? gridView.previewImageUrl
|
|
206
|
+
: undefined;
|
|
207
|
+
}, [insertMenu]);
|
|
208
|
+
|
|
209
|
+
const insert = (member: MemberSchemaType) => {
|
|
210
|
+
onItemAppend((onValueCreate as unknown as CreateItem)(member));
|
|
211
|
+
setOpen(false);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const onlyMember = members.length === 1 ? members[0] : undefined;
|
|
215
|
+
|
|
216
|
+
if (schemaType.options?.disableActions?.includes("add")) return null;
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<>
|
|
220
|
+
<Grid>
|
|
221
|
+
<Button
|
|
222
|
+
disabled={readOnly === true}
|
|
223
|
+
fontSize={1}
|
|
224
|
+
icon={AddIcon}
|
|
225
|
+
mode="ghost"
|
|
226
|
+
onClick={() => (onlyMember ? insert(onlyMember) : setOpen(true))}
|
|
227
|
+
padding={3}
|
|
228
|
+
text={onlyMember ? "Add item" : "Add item…"}
|
|
229
|
+
/>
|
|
230
|
+
</Grid>
|
|
231
|
+
|
|
232
|
+
{open && (
|
|
233
|
+
<InsertMenuDialog
|
|
234
|
+
onClose={() => setOpen(false)}
|
|
235
|
+
onSelect={insert}
|
|
236
|
+
previewImageUrl={previewImageUrl}
|
|
237
|
+
sections={sections}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
}
|