@drawnagency/primitives 0.1.42 → 0.1.44

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.
Files changed (80) hide show
  1. package/dist/{chunk-62OWSJ7V.js → chunk-5XYUO4HP.js} +1 -1
  2. package/dist/{chunk-A4RARGF2.js → chunk-BJ6FYGYP.js} +2 -0
  3. package/dist/{chunk-VY67DS3O.js → chunk-L2JJFOXD.js} +6 -0
  4. package/dist/{chunk-BU52OBPW.js → chunk-P3HO76OS.js} +1 -1
  5. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  6. package/dist/components/primitives/EditableRichText.d.ts.map +1 -1
  7. package/dist/components/primitives/ImageDropZone.d.ts +15 -0
  8. package/dist/components/primitives/ImageDropZone.d.ts.map +1 -0
  9. package/dist/components/primitives/RichTextToolbar.d.ts.map +1 -1
  10. package/dist/components/sections/Button/index.d.ts.map +1 -1
  11. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  12. package/dist/components/sections/DoDontList/index.d.ts.map +1 -1
  13. package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +1 -1
  14. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  15. package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
  16. package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +1 -1
  17. package/dist/components/sections/MediaGrid/index.d.ts.map +1 -1
  18. package/dist/components/sections/Prose/index.d.ts.map +1 -1
  19. package/dist/components/sections/SplitContent/SplitContent.d.ts.map +1 -1
  20. package/dist/components/sections/SplitContent/index.d.ts.map +1 -1
  21. package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
  22. package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
  23. package/dist/components/shared/UploadRejectionAlert.d.ts +15 -0
  24. package/dist/components/shared/UploadRejectionAlert.d.ts.map +1 -0
  25. package/dist/components/shell/BugReportFAB.d.ts +1 -5
  26. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/components/shell/MediaLibraryContext.d.ts +4 -0
  29. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  30. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  31. package/dist/components/shell/SectionSkeleton.d.ts.map +1 -1
  32. package/dist/components/shell/SectionTypePicker.d.ts +4 -3
  33. package/dist/components/shell/SectionTypePicker.d.ts.map +1 -1
  34. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  35. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  36. package/dist/index.js +6 -4
  37. package/dist/lib/index.js +2 -2
  38. package/dist/lib/registry.d.ts +2 -2
  39. package/dist/lib/registry.d.ts.map +1 -1
  40. package/dist/media/index.js +3 -1
  41. package/dist/media/utils.d.ts +1 -0
  42. package/dist/media/utils.d.ts.map +1 -1
  43. package/dist/schemas/index.js +2 -2
  44. package/dist/schemas/site-config.d.ts +2 -0
  45. package/dist/schemas/site-config.d.ts.map +1 -1
  46. package/dist/types/database.d.ts +512 -0
  47. package/dist/types/database.d.ts.map +1 -0
  48. package/dist/types/database.js +12 -0
  49. package/package.json +5 -1
  50. package/src/components/editor/SectionWrapper.tsx +9 -4
  51. package/src/components/primitives/EditableRichText.tsx +1 -0
  52. package/src/components/primitives/ImageDropZone.tsx +74 -0
  53. package/src/components/primitives/RichTextToolbar.tsx +42 -31
  54. package/src/components/primitives/tiptap-presets.ts +1 -1
  55. package/src/components/sections/Button/index.tsx +2 -0
  56. package/src/components/sections/Colors/index.tsx +2 -0
  57. package/src/components/sections/DoDontList/index.tsx +2 -0
  58. package/src/components/sections/DoDontMediaGrid/index.tsx +2 -0
  59. package/src/components/sections/IconList/index.tsx +2 -0
  60. package/src/components/sections/LinkHeading/index.tsx +2 -1
  61. package/src/components/sections/MediaGrid/MediaGrid.tsx +18 -5
  62. package/src/components/sections/MediaGrid/index.tsx +2 -0
  63. package/src/components/sections/Prose/index.tsx +2 -0
  64. package/src/components/sections/SplitContent/SplitContent.tsx +8 -7
  65. package/src/components/sections/SplitContent/index.tsx +2 -0
  66. package/src/components/sections/SubHeading/index.tsx +2 -1
  67. package/src/components/sections/SubSubHeading/index.tsx +2 -1
  68. package/src/components/shared/UploadRejectionAlert.tsx +52 -0
  69. package/src/components/shell/BugReportFAB.tsx +21 -26
  70. package/src/components/shell/EditorShell.tsx +16 -4
  71. package/src/components/shell/MediaLibraryContext.tsx +1 -0
  72. package/src/components/shell/MediaLibraryModal.tsx +10 -19
  73. package/src/components/shell/SectionSkeleton.tsx +15 -14
  74. package/src/components/shell/SectionTypePicker.tsx +15 -11
  75. package/src/components/shell/SiteSettingsDisplay.tsx +12 -0
  76. package/src/hooks/useMediaPipeline.ts +3 -0
  77. package/src/lib/registry.ts +2 -2
  78. package/src/media/utils.ts +6 -0
  79. package/src/schemas/site-config.ts +2 -0
  80. package/src/types/database.ts +568 -0
@@ -0,0 +1,74 @@
1
+ import { useState, type DragEvent, type ReactNode } from "react";
2
+ import { cn } from "../../lib/cn";
3
+ import { useMediaLibrary } from "../shell/MediaLibraryContext";
4
+
5
+ interface ImageDropZoneProps {
6
+ onImageSelected: (imageId: string) => void;
7
+ className?: string;
8
+ children: ReactNode;
9
+ }
10
+
11
+ // Equivalent to the media library modal's accept="image/*,video/*".
12
+ const ACCEPTED = /^(image|video)\//;
13
+
14
+ /**
15
+ * Wraps an image slot and turns it into a native-HTML file drop target.
16
+ * Only reacts to drags carrying files, so it never intercepts the grid's
17
+ * pragmatic drag-and-drop reorder (which carries no `Files`). No media
18
+ * library context (viewer / not edit mode) → renders children unchanged.
19
+ */
20
+ export function ImageDropZone({ onImageSelected, className, children }: ImageDropZoneProps) {
21
+ const mediaLibrary = useMediaLibrary();
22
+ const [dragging, setDragging] = useState(false);
23
+
24
+ if (!mediaLibrary) return <>{children}</>;
25
+
26
+ const maxFileSize = mediaLibrary.siteConfig?.media.maxFileSize;
27
+ const hasFiles = (e: DragEvent) => e.dataTransfer.types.includes("Files");
28
+
29
+ const handleDragOver = (e: DragEvent) => {
30
+ if (!hasFiles(e)) return;
31
+ e.preventDefault();
32
+ e.dataTransfer.dropEffect = "copy";
33
+ setDragging(true);
34
+ };
35
+
36
+ const handleDragLeave = (e: DragEvent) => {
37
+ if (!hasFiles(e)) return;
38
+ const related = e.relatedTarget as Node | null;
39
+ if (related && e.currentTarget.contains(related)) return;
40
+ setDragging(false);
41
+ };
42
+
43
+ const handleDrop = (e: DragEvent) => {
44
+ if (!hasFiles(e)) return;
45
+ e.preventDefault();
46
+ e.stopPropagation();
47
+ setDragging(false);
48
+ const file = e.dataTransfer.files[0];
49
+ if (!file) return;
50
+ if (!ACCEPTED.test(file.type)) {
51
+ mediaLibrary.reportRejectedUploads([{ name: file.name, reason: "type" }]);
52
+ return;
53
+ }
54
+ if (maxFileSize && file.size > maxFileSize) {
55
+ mediaLibrary.reportRejectedUploads([{ name: file.name, reason: "size" }]);
56
+ return;
57
+ }
58
+ mediaLibrary.uploadFileWithCallback(file, onImageSelected);
59
+ };
60
+
61
+ return (
62
+ <div
63
+ className={cn("relative", className)}
64
+ onDragOver={handleDragOver}
65
+ onDragLeave={handleDragLeave}
66
+ onDrop={handleDrop}
67
+ >
68
+ {children}
69
+ {dragging && (
70
+ <div className="pointer-events-none absolute inset-0 z-10 rounded-md border-2 border-primary bg-primary/5" />
71
+ )}
72
+ </div>
73
+ );
74
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
2
  import type { Editor } from "@tiptap/core";
3
+ import { useEditorState } from "@tiptap/react";
3
4
  import { cn } from "../../lib/cn";
4
5
  import { LinkPopover } from "./LinkPopover";
5
6
  import type { PresetName } from "./tiptap-presets";
@@ -46,14 +47,28 @@ function Separator() {
46
47
  export function RichTextToolbar({ editor, preset }: RichTextToolbarProps) {
47
48
  const [showLinkPopover, setShowLinkPopover] = useState(false);
48
49
 
49
- const paragraphClass = editor.getAttributes("paragraph").class as string | undefined;
50
-
51
- const isLargeActive = paragraphClass === "large";
52
- const isLeadInActive = paragraphClass === "lead-in";
53
-
54
- const toggleParagraphClass = (cls: string) => {
55
- const next = paragraphClass === cls ? null : cls;
56
- editor.chain().focus().updateAttributes("paragraph", { class: next }).run();
50
+ const state = useEditorState({
51
+ editor,
52
+ selector: ({ editor: ed }) => ({
53
+ isBold: ed.isActive("bold"),
54
+ isItalic: ed.isActive("italic"),
55
+ isUnderline: ed.isActive("underline"),
56
+ isLink: ed.isActive("link"),
57
+ isBulletList: ed.isActive("bulletList"),
58
+ isOrderedList: ed.isActive("orderedList"),
59
+ isH3: ed.isActive("heading", { level: 3 }),
60
+ isH4: ed.isActive("heading", { level: 4 }),
61
+ isLarge: ed.isActive("paragraph", { class: "large" }),
62
+ isLeadIn: ed.isActive("paragraph", { class: "lead-in" }),
63
+ }),
64
+ });
65
+
66
+ const toggleParagraphVariant = (cls: string, isCurrentlyActive: boolean) => {
67
+ if (isCurrentlyActive) {
68
+ editor.chain().focus().setNode("paragraph").run();
69
+ } else {
70
+ editor.chain().focus().setNode("paragraph", { class: cls }).run();
71
+ }
57
72
  };
58
73
 
59
74
  return (
@@ -63,21 +78,21 @@ export function RichTextToolbar({ editor, preset }: RichTextToolbarProps) {
63
78
  >
64
79
  {/* Inline formatting group */}
65
80
  <ToolbarButton
66
- isActive={editor.isActive("bold")}
81
+ isActive={state.isBold}
67
82
  onClick={() => editor.chain().focus().toggleBold().run()}
68
83
  title="Bold"
69
84
  >
70
85
  <strong>B</strong>
71
86
  </ToolbarButton>
72
87
  <ToolbarButton
73
- isActive={editor.isActive("italic")}
88
+ isActive={state.isItalic}
74
89
  onClick={() => editor.chain().focus().toggleItalic().run()}
75
90
  title="Italic"
76
91
  >
77
92
  <em>I</em>
78
93
  </ToolbarButton>
79
94
  <ToolbarButton
80
- isActive={editor.isActive("underline")}
95
+ isActive={state.isUnderline}
81
96
  onClick={() => editor.chain().focus().toggleUnderline().run()}
82
97
  title="Underline"
83
98
  >
@@ -91,7 +106,7 @@ export function RichTextToolbar({ editor, preset }: RichTextToolbarProps) {
91
106
  {/* Link */}
92
107
  <div className="relative">
93
108
  <ToolbarButton
94
- isActive={editor.isActive("link") || showLinkPopover}
109
+ isActive={state.isLink || showLinkPopover}
95
110
  onClick={() => setShowLinkPopover((prev) => !prev)}
96
111
  title="Link"
97
112
  >
@@ -111,14 +126,14 @@ export function RichTextToolbar({ editor, preset }: RichTextToolbarProps) {
111
126
 
112
127
  {/* Lists */}
113
128
  <ToolbarButton
114
- isActive={editor.isActive("bulletList")}
129
+ isActive={state.isBulletList}
115
130
  onClick={() => editor.chain().focus().toggleBulletList().run()}
116
131
  title="Bullet List"
117
132
  >
118
133
  •≡
119
134
  </ToolbarButton>
120
135
  <ToolbarButton
121
- isActive={editor.isActive("orderedList")}
136
+ isActive={state.isOrderedList}
122
137
  onClick={() => editor.chain().focus().toggleOrderedList().run()}
123
138
  title="Ordered List"
124
139
  >
@@ -127,35 +142,31 @@ export function RichTextToolbar({ editor, preset }: RichTextToolbarProps) {
127
142
 
128
143
  <Separator />
129
144
 
130
- {/* Headings */}
131
- <ToolbarButton
132
- isActive={editor.isActive("heading", { level: 2 })}
133
- onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
134
- title="Heading 2"
135
- >
136
- H2
137
- </ToolbarButton>
145
+ {/* Block styles — mutually exclusive */}
138
146
  <ToolbarButton
139
- isActive={editor.isActive("heading", { level: 3 })}
147
+ isActive={state.isH3}
140
148
  onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
141
149
  title="Heading 3"
142
150
  >
143
151
  H3
144
152
  </ToolbarButton>
145
-
146
- <Separator />
147
-
148
- {/* Paragraph variants */}
149
153
  <ToolbarButton
150
- isActive={isLargeActive}
151
- onClick={() => toggleParagraphClass("large")}
154
+ isActive={state.isH4}
155
+ onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
156
+ title="Heading 4"
157
+ >
158
+ H4
159
+ </ToolbarButton>
160
+ <ToolbarButton
161
+ isActive={state.isLarge}
162
+ onClick={() => toggleParagraphVariant("large", state.isLarge)}
152
163
  title="Large Paragraph"
153
164
  >
154
165
  Lg
155
166
  </ToolbarButton>
156
167
  <ToolbarButton
157
- isActive={isLeadInActive}
158
- onClick={() => toggleParagraphClass("lead-in")}
168
+ isActive={state.isLeadIn}
169
+ onClick={() => toggleParagraphVariant("lead-in", state.isLeadIn)}
159
170
  title="Lead-In"
160
171
  >
161
172
  Li
@@ -33,7 +33,7 @@ const basic: Extensions = [
33
33
  const rich: Extensions = [
34
34
  StarterKit.configure({
35
35
  paragraph: false,
36
- heading: { levels: [2, 3] },
36
+ heading: { levels: [3, 4] },
37
37
  blockquote: false,
38
38
  codeBlock: false,
39
39
  horizontalRule: false,
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { RectangleHorizontal } from "lucide-react";
3
4
  import CTAButton from "./CTAButton";
4
5
 
5
6
  const schema = z.object({
@@ -15,6 +16,7 @@ const schema = z.object({
15
16
  export default defineSection({
16
17
  type: "button",
17
18
  label: "Button",
19
+ icon: <RectangleHorizontal size={18} />,
18
20
  schema,
19
21
  component: ({ content, onChange }) => (
20
22
  <CTAButton
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { Palette } from "lucide-react";
3
4
  import { ColorItemSchema } from "../../../schemas/shared";
4
5
  import Colors from "../../brandguide/Colors";
5
6
 
@@ -17,6 +18,7 @@ const schema = z.object({
17
18
  export default defineSection({
18
19
  type: "colors",
19
20
  label: "Colors",
21
+ icon: <Palette size={18} />,
20
22
  schema,
21
23
  component: ({ content, options, onChange, openModal }) => (
22
24
  <Colors
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { ListChecks } from "lucide-react";
3
4
  import DoDontList from "../../brandguide/DoDontList";
4
5
 
5
6
  const DoDontItemSchema = z.object({ label: z.string(), text: z.string(), icon: z.string().optional() });
@@ -19,6 +20,7 @@ const schema = z.object({
19
20
  export default defineSection({
20
21
  type: "do_dont",
21
22
  label: "Do / Don't",
23
+ icon: <ListChecks size={18} />,
22
24
  schema,
23
25
  component: ({ content, options, onChange }) => (
24
26
  <DoDontList
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { GalleryVerticalEnd } from "lucide-react";
3
4
  import { MediaReferenceSchema } from "../../../schemas/shared";
4
5
  import { MediaGridOptionsSchema } from "../../../schemas/media-grid-options";
5
6
  import DoDontMediaGrid from "../../brandguide/DoDontMediaGrid";
@@ -16,6 +17,7 @@ const schema = z.object({
16
17
  export default defineSection({
17
18
  type: "do_dont_grid",
18
19
  label: "Do / Don't Grid",
20
+ icon: <GalleryVerticalEnd size={18} />,
19
21
  schema,
20
22
  component: ({ content, options, onChange, openModal }) => (
21
23
  <DoDontMediaGrid
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { List } from "lucide-react";
3
4
  import IconList from "./IconList";
4
5
  import { IconListSettings } from "./IconListSettings";
5
6
 
@@ -18,6 +19,7 @@ const schema = z.object({
18
19
  export default defineSection({
19
20
  type: "icon_list",
20
21
  label: "Icon List",
22
+ icon: <List size={18} />,
21
23
  schema,
22
24
  component: ({ content, options, onChange }) => (
23
25
  <IconList
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { Heading1 } from "lucide-react";
3
4
  import { HeadingSection } from "../../primitives/HeadingSection";
4
5
 
5
6
  const schema = z.object({
@@ -10,6 +11,7 @@ const schema = z.object({
10
11
  export default defineSection({
11
12
  type: "link_heading",
12
13
  label: "Link Heading",
14
+ icon: <Heading1 size={18} />,
13
15
  schema,
14
16
  navRole: "h1",
15
17
  component: ({ content, onChange }) => (
@@ -17,7 +19,6 @@ export default defineSection({
17
19
  heading={content.content.heading}
18
20
  tag="h2"
19
21
  placeholder="Section heading"
20
- className="text-primary"
21
22
  onChange={onChange ? (heading: string) => onChange({ type: "link_heading", content: { heading } }) : undefined}
22
23
  />
23
24
  ),
@@ -9,6 +9,7 @@ import { EditablePlainText } from "../../primitives/EditablePlainText";
9
9
  import type { ReactNode } from "react";
10
10
  import { Check, X } from "lucide-react";
11
11
  import { useMediaLibrary } from "../../shell/MediaLibraryContext";
12
+ import { ImageDropZone } from "../../primitives/ImageDropZone";
12
13
 
13
14
  interface Props {
14
15
  media: MediaReference[];
@@ -101,11 +102,13 @@ function MediaGridEditable({ media, columns, square, border, crop, showCaptions,
101
102
  }
102
103
  };
103
104
 
105
+ const setItemImage = (index: number, imageId: string) => {
106
+ const newMedia = media.map((m, i) => i === index ? { ...m, imageId } : m);
107
+ onChange({ type: sectionType, content: { columns, media: newMedia }, ...opts } as SectionContent);
108
+ };
109
+
104
110
  const handleItemImageClick = (index: number) => {
105
- mediaLibrary?.openSelectModal((imageId) => {
106
- const newMedia = media.map((m, i) => i === index ? { ...m, imageId } : m);
107
- onChange({ type: sectionType, content: { columns, media: newMedia }, ...opts } as SectionContent);
108
- });
111
+ mediaLibrary?.openSelectModal((imageId) => setItemImage(index, imageId));
109
112
  };
110
113
 
111
114
  return (
@@ -128,6 +131,7 @@ function MediaGridEditable({ media, columns, square, border, crop, showCaptions,
128
131
  border={border}
129
132
  crop={crop}
130
133
  showCaptions={showCaptions}
134
+ onImageReplace={mediaLibrary ? (imageId) => setItemImage(index, imageId) : undefined}
131
135
  onCaptionChange={(caption) => {
132
136
  const newMedia = media.map((m, i) => i === index ? { ...m, caption: caption || undefined } : m);
133
137
  onChange({ type: sectionType, content: { columns, media: newMedia }, ...opts } as SectionContent);
@@ -146,6 +150,7 @@ function MediaGridItem({
146
150
  crop,
147
151
  showCaptions,
148
152
  onCaptionChange,
153
+ onImageReplace,
149
154
  }: {
150
155
  item: MediaReference;
151
156
  isEditMode: boolean;
@@ -154,6 +159,7 @@ function MediaGridItem({
154
159
  crop?: boolean;
155
160
  showCaptions?: boolean;
156
161
  onCaptionChange?: (caption: string) => void;
162
+ onImageReplace?: (imageId: string) => void;
157
163
  }) {
158
164
  const isDoDont = item.type === "doDontImage";
159
165
 
@@ -167,7 +173,7 @@ function MediaGridItem({
167
173
  : "";
168
174
 
169
175
  const itemAny = item as Record<string, unknown>;
170
- const media = (
176
+ const resolvedMedia = (
171
177
  <ResolvedMedia
172
178
  imageId={item.imageId || undefined}
173
179
  src={itemAny.src as string | undefined}
@@ -178,6 +184,13 @@ function MediaGridItem({
178
184
  invertFrom={item.invertFrom}
179
185
  />
180
186
  );
187
+ const media = onImageReplace ? (
188
+ <ImageDropZone onImageSelected={onImageReplace} className="h-full w-full">
189
+ {resolvedMedia}
190
+ </ImageDropZone>
191
+ ) : (
192
+ resolvedMedia
193
+ );
181
194
 
182
195
  return (
183
196
  <figure>
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { LayoutGrid } from "lucide-react";
3
4
  import { MediaReferenceSchema } from "../../../schemas/shared";
4
5
  import { MediaGridOptionsSchema } from "../../../schemas/media-grid-options";
5
6
  import MediaGrid from "./MediaGrid";
@@ -16,6 +17,7 @@ const schema = z.object({
16
17
  export default defineSection({
17
18
  type: "media_grid",
18
19
  label: "Media Grid",
20
+ icon: <LayoutGrid size={18} />,
19
21
  schema,
20
22
  component: ({ content, options, onChange, openModal }) => (
21
23
  <MediaGrid
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { AlignLeft } from "lucide-react";
3
4
  import Prose from "./Prose";
4
5
  import { stripHtmlToPlainText, truncate } from "../../../lib/text";
5
6
 
@@ -11,6 +12,7 @@ const schema = z.object({
11
12
  export default defineSection({
12
13
  type: "prose",
13
14
  label: "Prose",
15
+ icon: <AlignLeft size={18} />,
14
16
  schema,
15
17
  component: ({ content, onChange }) => (
16
18
  <Prose body={content.content.body} onChange={onChange ? (c) => onChange(c as typeof content) : undefined} />
@@ -3,6 +3,7 @@ import { sanitizeHtml } from "../../../lib/sanitize";
3
3
  import { ResolvedMedia } from "../../primitives/ResolvedMedia";
4
4
  import { EditableRichText } from "../../primitives/EditableRichText";
5
5
  import { IconButton } from "../../shared/IconButton";
6
+ import { ImageDropZone } from "../../primitives/ImageDropZone";
6
7
  import type { SectionContent } from "../../../schemas/sections";
7
8
  import { useMediaLibrary } from "../../shell/MediaLibraryContext";
8
9
  import { ImageIcon } from "lucide-react";
@@ -47,13 +48,13 @@ export default function SplitContent({ imageId, src, srcset, alt, body, border,
47
48
  imagePosition === "right" && "lg:flex-row-reverse",
48
49
  )}>
49
50
  <div className={cn("group/img relative flex-shrink-0 lg:w-1/2", border && "overflow-hidden rounded-md border border-base-200")}>
50
- <ResolvedMedia
51
- imageId={imageId}
52
- src={src}
53
- srcset={srcset}
54
- alt={alt}
55
- className="w-full"
56
- />
51
+ {onChange && mediaLibrary ? (
52
+ <ImageDropZone onImageSelected={handleImageChange}>
53
+ <ResolvedMedia imageId={imageId} src={src} srcset={srcset} alt={alt} className="w-full" />
54
+ </ImageDropZone>
55
+ ) : (
56
+ <ResolvedMedia imageId={imageId} src={src} srcset={srcset} alt={alt} className="w-full" />
57
+ )}
57
58
  {onChange && mediaLibrary && (
58
59
  <div className="absolute top-1.5 right-1.5 z-10 opacity-0 transition-opacity group-hover/img:opacity-100">
59
60
  <IconButton
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { Columns2 } from "lucide-react";
3
4
  import SplitContent from "./SplitContent";
4
5
  import { stripHtmlToPlainText, truncate } from "../../../lib/text";
5
6
 
@@ -18,6 +19,7 @@ const schema = z.object({
18
19
  export default defineSection({
19
20
  type: "split_content",
20
21
  label: "Split Content",
22
+ icon: <Columns2 size={18} />,
21
23
  schema,
22
24
  component: ({ content, options, onChange }) => (
23
25
  <SplitContent
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { Heading2 } from "lucide-react";
3
4
  import { HeadingSection } from "../../primitives/HeadingSection";
4
5
 
5
6
  const schema = z.object({
@@ -10,6 +11,7 @@ const schema = z.object({
10
11
  export default defineSection({
11
12
  type: "sub_heading",
12
13
  label: "Sub Heading",
14
+ icon: <Heading2 size={18} />,
13
15
  schema,
14
16
  navRole: "h2",
15
17
  component: ({ content, onChange }) => (
@@ -17,7 +19,6 @@ export default defineSection({
17
19
  heading={content.content.heading}
18
20
  tag="h3"
19
21
  placeholder="Sub heading"
20
- className="text-base-contrast"
21
22
  onChange={onChange ? (heading: string) => onChange({ ...content, content: { ...content.content, heading } }) : undefined}
22
23
  />
23
24
  ),
@@ -1,5 +1,6 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
+ import { Heading3 } from "lucide-react";
3
4
  import { HeadingSection } from "../../primitives/HeadingSection";
4
5
 
5
6
  const schema = z.object({
@@ -10,6 +11,7 @@ const schema = z.object({
10
11
  export default defineSection({
11
12
  type: "sub_sub_heading",
12
13
  label: "Sub Sub Heading",
14
+ icon: <Heading3 size={18} />,
13
15
  schema,
14
16
  navRole: "h3",
15
17
  component: ({ content, onChange }) => (
@@ -17,7 +19,6 @@ export default defineSection({
17
19
  heading={content.content.heading}
18
20
  tag="h4"
19
21
  placeholder="Sub sub heading"
20
- className="text-lg font-bold text-base-contrast"
21
22
  onChange={onChange ? (heading: string) => onChange({ ...content, content: { ...content.content, heading } }) : undefined}
22
23
  />
23
24
  ),
@@ -0,0 +1,52 @@
1
+ import { Button } from "./Button";
2
+ import { formatFileSize } from "../../media/utils";
3
+
4
+ export interface UploadRejection {
5
+ name: string;
6
+ reason: "size" | "type";
7
+ }
8
+
9
+ /**
10
+ * Compose a human-readable message from a list of rejected uploads.
11
+ * Inline drops only ever send one file, so the single-file branches are the
12
+ * common path; the multi-file branch keeps the helper reusable by the modal.
13
+ */
14
+ export function formatRejections(files: UploadRejection[], maxFileSize?: number): string {
15
+ if (files.length === 0) return "";
16
+ if (files.length === 1) {
17
+ const f = files[0];
18
+ if (f.reason === "size") {
19
+ return maxFileSize
20
+ ? `"${f.name}" exceeds the ${formatFileSize(maxFileSize)} file size limit.`
21
+ : `"${f.name}" exceeds the file size limit.`;
22
+ }
23
+ return `"${f.name}" — images and videos only.`;
24
+ }
25
+ const sizeCount = files.filter((f) => f.reason === "size").length;
26
+ const typeCount = files.length - sizeCount;
27
+ const parts: string[] = [];
28
+ if (sizeCount > 0) parts.push(`${sizeCount} too large`);
29
+ if (typeCount > 0) parts.push(`${typeCount} not an image or video`);
30
+ return `${files.length} files rejected (${parts.join(", ")}).`;
31
+ }
32
+
33
+ export function UploadRejectionAlert({
34
+ message,
35
+ onDismiss,
36
+ }: {
37
+ message: string;
38
+ onDismiss: () => void;
39
+ }) {
40
+ return (
41
+ <div role="alert" className="flex flex-col items-center rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 lg:flex-row lg:justify-between dark:border-amber-900/50 dark:bg-amber-950/30">
42
+ <p className="mb-2 text-center text-sm text-amber-800 lg:mb-0 lg:text-left dark:text-amber-200">
43
+ {message}
44
+ </p>
45
+ <div className="flex shrink-0 items-center">
46
+ <Button variant="secondary" size="sm" type="button" onClick={onDismiss}>
47
+ Dismiss
48
+ </Button>
49
+ </div>
50
+ </div>
51
+ );
52
+ }
@@ -1,12 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
- import { createClient } from "@supabase/supabase-js";
3
2
  import { EditorModal } from "./EditorModal";
4
3
  import { useEditorContext } from "./EditorContext";
5
- import { env } from "../../lib/env";
6
-
7
- interface Props {
8
- siteId: string;
9
- }
10
4
 
11
5
  type Category = "Visual" | "Unexpected Behavior" | "Media / Media Library" | "Save / Publish" | "Feature Request" | "Other";
12
6
 
@@ -32,7 +26,7 @@ interface ImagePreview {
32
26
  name: string;
33
27
  }
34
28
 
35
- export function BugReportFAB({ siteId }: Props) {
29
+ export function BugReportFAB() {
36
30
  const { isEditMode, historyState } = useEditorContext();
37
31
  const [isOpen, setIsOpen] = useState(false);
38
32
  const [category, setCategory] = useState<Category>("Visual");
@@ -149,26 +143,27 @@ export function BugReportFAB({ siteId }: Props) {
149
143
  setIsSubmitting(true);
150
144
 
151
145
  try {
152
- const supabase = createClient(
153
- env("SUPABASE_URL"),
154
- env("SUPABASE_ANON_KEY"),
155
- );
156
-
157
- const { data: userData } = await supabase.auth.getUser();
158
- const userId = userData?.user?.id ?? null;
159
-
160
- const { error } = await supabase.from("bug_reports").insert({
161
- site_id: siteId,
162
- user_id: userId,
163
- category,
164
- is_critical: isCritical,
165
- description: description.trim(),
166
- images: images.map((img) => img.dataUri),
167
- context: capturedContextRef.current,
146
+ const response = await fetch("/api/bug-report", {
147
+ method: "POST",
148
+ headers: { "Content-Type": "application/json" },
149
+ body: JSON.stringify({
150
+ category,
151
+ is_critical: isCritical,
152
+ description: description.trim(),
153
+ images: images.map((img) => img.dataUri),
154
+ context: capturedContextRef.current,
155
+ }),
168
156
  });
169
157
 
170
- if (error) {
171
- setSubmitError(error.message);
158
+ if (!response.ok) {
159
+ let message = "Submission failed";
160
+ try {
161
+ const data = await response.json();
162
+ if (data?.error) message = data.error;
163
+ } catch {
164
+ // No JSON body on the error response; keep the default message.
165
+ }
166
+ setSubmitError(message);
172
167
  return;
173
168
  }
174
169
 
@@ -180,7 +175,7 @@ export function BugReportFAB({ siteId }: Props) {
180
175
  } finally {
181
176
  setIsSubmitting(false);
182
177
  }
183
- }, [siteId, category, isCritical, description, images, handleClose]);
178
+ }, [category, isCritical, description, images, handleClose]);
184
179
 
185
180
  const visible = isEditMode && historyState === null;
186
181