create-nextblock 0.2.31 → 0.2.34

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 (25) hide show
  1. package/package.json +1 -1
  2. package/scripts/sync-template.js +70 -52
  3. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +67 -67
  4. package/templates/nextblock-template/app/[slug]/page.tsx +4 -4
  5. package/templates/nextblock-template/app/cms/blocks/actions.ts +10 -10
  6. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -348
  7. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +8 -8
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +10 -10
  9. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -81
  10. package/templates/nextblock-template/app/cms/media/actions.ts +35 -35
  11. package/templates/nextblock-template/app/cms/media/page.tsx +120 -120
  12. package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -86
  13. package/templates/nextblock-template/app/cms/revisions/service.ts +344 -344
  14. package/templates/nextblock-template/app/providers.tsx +2 -2
  15. package/templates/nextblock-template/components/BlockRenderer.tsx +9 -9
  16. package/templates/nextblock-template/components/ResponsiveNav.tsx +22 -22
  17. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  18. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +26 -26
  19. package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +41 -41
  20. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +7 -7
  21. package/templates/nextblock-template/components/theme-switcher.tsx +78 -78
  22. package/templates/nextblock-template/eslint.config.mjs +35 -37
  23. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +19 -19
  24. package/templates/nextblock-template/next-env.d.ts +6 -6
  25. package/templates/nextblock-template/package.json +1 -1
@@ -1,348 +1,348 @@
1
- // app/cms/blocks/components/BackgroundSelector.tsx
2
- "use client";
3
-
4
- import React, { useState, useEffect } from "react";
5
- import Image from "next/image";
6
- import { Label, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, Button, Input, Checkbox } from "@nextblock-cms/ui";
7
- import { CustomSelectWithInput, ColorPicker } from "@nextblock-cms/ui";
8
- import { TooltipProvider } from "@radix-ui/react-tooltip";
9
- import { ImageIcon, X as XIcon, Save } from "lucide-react";
10
- import { cn } from "@nextblock-cms/utils";
11
- import type { Database } from "@nextblock-cms/db";
12
- import type { SectionBlockContent } from "@/lib/blocks/blockRegistry";
13
- import MediaPickerDialog from "@/app/cms/media/components/MediaPickerDialog";
14
-
15
- type Media = Database["public"]["Tables"]["media"]["Row"];
16
-
17
- const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
18
-
19
- interface BackgroundSelectorProps {
20
- background: SectionBlockContent["background"];
21
- onChange: (newBackground: SectionBlockContent["background"]) => void;
22
- }
23
-
24
- export default function BackgroundSelector({ background, onChange }: BackgroundSelectorProps) {
25
-
26
- const backgroundType = background?.type || "none";
27
- const selectedImage = background?.type === "image" ? background.image : undefined;
28
- const [minHeight, setMinHeight] = useState(background?.min_height || "");
29
- const [imagePosition, setImagePosition] = useState<string>(selectedImage?.position || "center");
30
- const [overlayDirection, setOverlayDirection] = useState(selectedImage?.overlay?.gradient?.direction || "to bottom");
31
-
32
- useEffect(() => {
33
- setMinHeight(background?.min_height || "");
34
- }, [background?.min_height]);
35
-
36
- useEffect(() => {
37
- setImagePosition(selectedImage?.position || "center");
38
- setOverlayDirection(selectedImage?.overlay?.gradient?.direction || "to bottom");
39
- }, [selectedImage?.position, selectedImage?.overlay?.gradient?.direction]);
40
-
41
- const generateGradientCss = (gradient: { direction?: string; stops?: Array<{ color: string; position: number }> }) => {
42
- if (!gradient || !gradient.stops || gradient.stops.length === 0) return "none";
43
- const direction = gradient.direction || "to bottom";
44
- const stops = gradient.stops.map((s) => `${s.color} ${s.position}%`).join(", ");
45
- return `linear-gradient(${direction}, ${stops})`;
46
- };
47
-
48
- const handleTypeChange = (type: SectionBlockContent["background"]["type"]) => {
49
- if (type === "image") {
50
- onChange({
51
- type: "image",
52
- image: {
53
- media_id: "",
54
- object_key: "",
55
- size: "cover",
56
- position: "center",
57
- overlay: undefined,
58
- },
59
- });
60
- } else if (type === "gradient") {
61
- onChange({
62
- type: "gradient",
63
- gradient: {
64
- type: "linear",
65
- direction: "to right",
66
- stops: [
67
- { color: "#3b82f6", position: 0 },
68
- { color: "#8b5cf6", position: 100 },
69
- ],
70
- },
71
- });
72
- } else {
73
- onChange({ type });
74
- }
75
- };
76
-
77
- const handleSelectMediaFromLibrary = (mediaItem: Media) => {
78
- onChange({
79
- type: "image",
80
- image: {
81
- ...selectedImage,
82
- media_id: mediaItem.id,
83
- object_key: mediaItem.object_key,
84
- width: mediaItem.width ?? undefined,
85
- height: mediaItem.height ?? undefined,
86
- size: selectedImage?.size || "cover",
87
- position: selectedImage?.position || "center",
88
- },
89
- });
90
- };
91
-
92
- const handleRemoveImage = () => {
93
- onChange({
94
- type: "image",
95
- image: {
96
- media_id: "",
97
- object_key: "",
98
- size: "cover",
99
- position: "center",
100
- overlay: undefined,
101
- },
102
- });
103
- };
104
-
105
- const handleImagePropertyChange = (prop: "size" | "position", value: string) => {
106
- if (background?.type === "image" && background.image) {
107
- onChange({ ...background, image: { ...background.image, [prop]: value } });
108
- }
109
- };
110
-
111
- const handleOverlayToggle = (checked: boolean) => {
112
- if (background?.type === "image" && background.image) {
113
- const newOverlay = checked
114
- ? {
115
- type: "gradient" as const,
116
- gradient: {
117
- type: "linear" as const,
118
- direction: "to bottom",
119
- stops: [
120
- { color: "rgba(0,0,0,0.5)", position: 0 },
121
- { color: "rgba(0,0,0,0)", position: 100 },
122
- ],
123
- },
124
- }
125
- : undefined;
126
- onChange({ ...background, image: { ...background.image, overlay: newOverlay } });
127
- }
128
- };
129
-
130
- const handleBackgroundPropertyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
131
- const { name, value } = e.target;
132
- onChange({ ...background, [name]: value });
133
- };
134
-
135
- const handleOverlayGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
136
- const { name, value } = e.target;
137
- if (background?.type === "image" && background.image) {
138
- const { image } = background;
139
- const overlay = image.overlay;
140
- const currentGradient = overlay?.gradient || {
141
- type: "linear" as const,
142
- direction: "to bottom",
143
- stops: [
144
- { color: "rgba(0,0,0,0.5)", position: 0 },
145
- { color: "rgba(0,0,0,0)", position: 100 },
146
- ],
147
- };
148
-
149
- const updatedStops = currentGradient.stops.map((stop) => {
150
- if (name === "startColor" && stop.position === 0) return { ...stop, color: value };
151
- if (name === "endColor" && stop.position === 100) return { ...stop, color: value };
152
- return stop;
153
- });
154
-
155
- const updatedGradient =
156
- name === "direction"
157
- ? { ...currentGradient, direction: value }
158
- : { ...currentGradient, stops: updatedStops };
159
-
160
- onChange({ ...background, image: { ...image, overlay: { type: "gradient", gradient: updatedGradient } } });
161
- }
162
- };
163
-
164
- const hasMinHeightChanged = (background?.min_height || "") !== minHeight;
165
- const imageSizeClass = selectedImage?.size === "contain" ? "object-contain" : "object-cover";
166
- const hasOverlayDirectionChanged = (selectedImage?.overlay?.gradient?.direction || "to bottom") !== overlayDirection;
167
-
168
- return (
169
- <TooltipProvider>
170
- <div className="space-y-4">
171
- <div className="grid gap-2">
172
- <Label>Background Type</Label>
173
- <Select value={backgroundType} onValueChange={(v) => handleTypeChange(v as any)}>
174
- <SelectTrigger className="w-full max-w-[250px]"><SelectValue placeholder="Select type" /></SelectTrigger>
175
- <SelectContent>
176
- <SelectItem value="none">None</SelectItem>
177
- <SelectItem value="gradient">Gradient</SelectItem>
178
- <SelectItem value="image">Image</SelectItem>
179
- </SelectContent>
180
- </Select>
181
- </div>
182
-
183
- <div className="grid gap-2">
184
- <Label htmlFor="min_height">Minimum Height (e.g., 250px)</Label>
185
- <div className="flex items-center gap-2">
186
- <Input id="min_height" name="min_height" value={minHeight} onChange={(e) => setMinHeight(e.target.value)} placeholder="e.g., 250px" className="max-w-[200px]" />
187
- <Button type="button" variant="ghost" size="icon" onClick={() => handleBackgroundPropertyChange({ target: { name: "min_height", value: minHeight } } as any)} disabled={!hasMinHeightChanged} title="Save Minimum Height">
188
- <Save className={cn("h-5 w-5", hasMinHeightChanged && "text-green-600")} />
189
- </Button>
190
- </div>
191
- </div>
192
-
193
- {backgroundType === "image" && (
194
- <>
195
- <div className="mt-3 p-3 border rounded-md bg-muted/30 min-h-[120px] flex flex-col items-center justify-center">
196
- {selectedImage?.object_key ? (
197
- <div className="relative group w-full" style={{ height: background?.min_height || "250px", overflow: "hidden" }}>
198
- <Image src={`${R2_BASE_URL}/${selectedImage.object_key}`} alt="Selected background image" width={selectedImage.width || 500} height={selectedImage.height || 300} sizes="100vw" className={`w-full h-full ${imageSizeClass}`} style={{ objectPosition: selectedImage.position }} />
199
- {selectedImage.overlay && (
200
- <div className="absolute inset-0" style={{ background: generateGradientCss(selectedImage.overlay.gradient) }} />
201
- )}
202
- <Button type="button" variant="destructive" size="icon" className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6" onClick={handleRemoveImage} title="Remove Image">
203
- <XIcon className="h-3 w-3" />
204
- </Button>
205
- </div>
206
- ) : (
207
- <ImageIcon className="h-16 w-16 text-muted-foreground" />
208
- )}
209
-
210
- <div className="mt-3">
211
- <MediaPickerDialog
212
- triggerLabel={selectedImage?.object_key ? "Change Image" : "Select from Library"}
213
- onSelect={handleSelectMediaFromLibrary}
214
- accept={(m) => !!m.file_type?.startsWith("image/")}
215
- title="Select or Upload Background Image"
216
- />
217
- </div>
218
- </div>
219
-
220
- <div className="grid gap-2">
221
- <Label>Image Size</Label>
222
- <Select value={selectedImage?.size || "cover"} onValueChange={(v) => handleImagePropertyChange("size", v)}>
223
- <SelectTrigger className="w-full max-w-[250px]"><SelectValue /></SelectTrigger>
224
- <SelectContent>
225
- <SelectItem value="cover">Cover</SelectItem>
226
- <SelectItem value="contain">Contain</SelectItem>
227
- </SelectContent>
228
- </Select>
229
- </div>
230
-
231
- <div className="grid gap-2">
232
- <Label>Image Position</Label>
233
- <Select value={imagePosition} onValueChange={(v) => { setImagePosition(v); handleImagePropertyChange("position", v); }}>
234
- <SelectTrigger className="w-full max-w-[250px]"><SelectValue /></SelectTrigger>
235
- <SelectContent>
236
- <SelectItem value="center">Center</SelectItem>
237
- <SelectItem value="top">Top</SelectItem>
238
- <SelectItem value="bottom">Bottom</SelectItem>
239
- <SelectItem value="left">Left</SelectItem>
240
- <SelectItem value="right">Right</SelectItem>
241
- <SelectItem value="top left">Top Left</SelectItem>
242
- <SelectItem value="top right">Top Right</SelectItem>
243
- <SelectItem value="bottom left">Bottom Left</SelectItem>
244
- <SelectItem value="bottom right">Bottom Right</SelectItem>
245
- </SelectContent>
246
- </Select>
247
- </div>
248
-
249
- <div className="flex items-center space-x-2 mt-2">
250
- <Checkbox id="gradientOverlay" checked={!!selectedImage?.overlay} onCheckedChange={(c) => handleOverlayToggle(!!c)} />
251
- <div className="grid gap-1.5 leading-none">
252
- <label htmlFor="gradientOverlay" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Add Gradient Overlay</label>
253
- </div>
254
- </div>
255
-
256
- {selectedImage?.overlay && (
257
- <div className="mt-3 p-3 border rounded-md bg-muted/30 space-y-4">
258
- <div className="flex items-center gap-2">
259
- <div className="flex-grow">
260
- <CustomSelectWithInput
261
- label="Direction"
262
- tooltipContent="Select a preset or enter a custom angle like '45deg' or 'to top left'. See MDN's linear-gradient docs for more options."
263
- value={overlayDirection}
264
- onChange={setOverlayDirection}
265
- options={[
266
- { value: "to bottom", label: "To Bottom" },
267
- { value: "to top", label: "To Top" },
268
- { value: "to left", label: "To Left" },
269
- { value: "to right", label: "To Right" },
270
- { value: "to bottom right", label: "To Bottom Right" },
271
- { value: "to top left", label: "To Top Left" },
272
- ]}
273
- />
274
- </div>
275
- <Button size="icon" variant="ghost" onClick={() => handleOverlayGradientChange({ target: { name: "direction", value: overlayDirection } } as any)} disabled={!hasOverlayDirectionChanged} title="Save Overlay Direction">
276
- <Save className={cn("h-5 w-5 mt-[1.3rem]", hasOverlayDirectionChanged && "text-green-600")} />
277
- </Button>
278
- </div>
279
- <div className="flex items-center gap-4">
280
- <ColorPicker
281
- label="Start Color"
282
- color={selectedImage.overlay.gradient?.stops?.[0]?.color || "rgba(0,0,0,0.5)"}
283
- onChange={(color) => handleOverlayGradientChange({ target: { name: "startColor", value: color } } as any)}
284
- />
285
- <ColorPicker
286
- label="End Color"
287
- color={selectedImage.overlay.gradient?.stops?.[1]?.color || "rgba(0,0,0,0)"}
288
- onChange={(color) => handleOverlayGradientChange({ target: { name: "endColor", value: color } } as any)}
289
- />
290
- </div>
291
- </div>
292
- )}
293
- </>
294
- )}
295
-
296
- {backgroundType === "gradient" && (
297
- <div className="mt-3 p-3 border rounded-md bg-muted/30 space-y-4">
298
- <div>
299
- <CustomSelectWithInput
300
- label="Direction"
301
- tooltipContent="Select a preset or enter a custom angle like '45deg' or 'to top left'. See MDN's linear-gradient docs for more options."
302
- value={background.gradient?.direction || "to right"}
303
- onChange={(value: string) => handleBackgroundGradientChange({ target: { name: "direction", value } } as any)}
304
- options={[
305
- { value: "to right", label: "To Right" },
306
- { value: "to left", label: "To Left" },
307
- { value: "to top", label: "To Top" },
308
- { value: "to bottom", label: "To Bottom" },
309
- { value: "to bottom right", label: "To Bottom Right" },
310
- { value: "to top left", label: "To Top Left" },
311
- ]}
312
- />
313
- </div>
314
- <div className="flex items-center gap-4">
315
- <ColorPicker
316
- label="Start Color"
317
- color={background.gradient?.stops?.[0]?.color || "#3b82f6"}
318
- onChange={(color) => handleBackgroundGradientChange({ target: { name: "startColor", value: color } } as any)}
319
- />
320
- <ColorPicker
321
- label="End Color"
322
- color={background.gradient?.stops?.[1]?.color || "#8b5cf6"}
323
- onChange={(color) => handleBackgroundGradientChange({ target: { name: "endColor", value: color } } as any)}
324
- />
325
- </div>
326
- </div>
327
- )}
328
- </div>
329
- </TooltipProvider>
330
- );
331
- const handleBackgroundGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
332
- const { name, value } = e.target as any;
333
- if (backgroundType !== 'gradient') return;
334
- const current = background.gradient || { type: 'linear' as const, direction: 'to right', stops: [ { color: '#3b82f6', position: 0 }, { color: '#8b5cf6', position: 100 } ] };
335
- if (name === 'direction') {
336
- onChange({ type: 'gradient', gradient: { ...current, direction: value } });
337
- return;
338
- }
339
- if (name === 'startColor' || name === 'endColor') {
340
- const updatedStops = (current.stops || [ { color: '#3b82f6', position: 0 }, { color: '#8b5cf6', position: 100 } ]).map((s) => {
341
- if (name === 'startColor' && s.position === 0) return { ...s, color: value };
342
- if (name === 'endColor' && s.position === 100) return { ...s, color: value };
343
- return s;
344
- });
345
- onChange({ type: 'gradient', gradient: { ...current, stops: updatedStops } });
346
- }
347
- };
348
- }
1
+ // app/cms/blocks/components/BackgroundSelector.tsx
2
+ "use client";
3
+
4
+ import React, { useState, useEffect } from "react";
5
+ import Image from "next/image";
6
+ import { Label, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, Button, Input, Checkbox } from "@nextblock-cms/ui";
7
+ import { CustomSelectWithInput, ColorPicker } from "@nextblock-cms/ui";
8
+ import { TooltipProvider } from "@radix-ui/react-tooltip";
9
+ import { ImageIcon, X as XIcon, Save } from "lucide-react";
10
+ import { cn } from "@nextblock-cms/utils";
11
+ import type { Database } from "@nextblock-cms/db";
12
+ import type { SectionBlockContent } from "@/lib/blocks/blockRegistry";
13
+ import MediaPickerDialog from "@/app/cms/media/components/MediaPickerDialog";
14
+
15
+ type Media = Database["public"]["Tables"]["media"]["Row"];
16
+
17
+ const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
18
+
19
+ interface BackgroundSelectorProps {
20
+ background: SectionBlockContent["background"];
21
+ onChange: (newBackground: SectionBlockContent["background"]) => void;
22
+ }
23
+
24
+ export default function BackgroundSelector({ background, onChange }: BackgroundSelectorProps) {
25
+
26
+ const backgroundType = background?.type || "none";
27
+ const selectedImage = background?.type === "image" ? background.image : undefined;
28
+ const [minHeight, setMinHeight] = useState(background?.min_height || "");
29
+ const [imagePosition, setImagePosition] = useState<string>(selectedImage?.position || "center");
30
+ const [overlayDirection, setOverlayDirection] = useState(selectedImage?.overlay?.gradient?.direction || "to bottom");
31
+
32
+ useEffect(() => {
33
+ setMinHeight(background?.min_height || "");
34
+ }, [background?.min_height]);
35
+
36
+ useEffect(() => {
37
+ setImagePosition(selectedImage?.position || "center");
38
+ setOverlayDirection(selectedImage?.overlay?.gradient?.direction || "to bottom");
39
+ }, [selectedImage?.position, selectedImage?.overlay?.gradient?.direction]);
40
+
41
+ const generateGradientCss = (gradient: { direction?: string; stops?: Array<{ color: string; position: number }> }) => {
42
+ if (!gradient || !gradient.stops || gradient.stops.length === 0) return "none";
43
+ const direction = gradient.direction || "to bottom";
44
+ const stops = gradient.stops.map((s) => `${s.color} ${s.position}%`).join(", ");
45
+ return `linear-gradient(${direction}, ${stops})`;
46
+ };
47
+
48
+ const handleTypeChange = (type: SectionBlockContent["background"]["type"]) => {
49
+ if (type === "image") {
50
+ onChange({
51
+ type: "image",
52
+ image: {
53
+ media_id: "",
54
+ object_key: "",
55
+ size: "cover",
56
+ position: "center",
57
+ overlay: undefined,
58
+ },
59
+ });
60
+ } else if (type === "gradient") {
61
+ onChange({
62
+ type: "gradient",
63
+ gradient: {
64
+ type: "linear",
65
+ direction: "to right",
66
+ stops: [
67
+ { color: "#3b82f6", position: 0 },
68
+ { color: "#8b5cf6", position: 100 },
69
+ ],
70
+ },
71
+ });
72
+ } else {
73
+ onChange({ type });
74
+ }
75
+ };
76
+
77
+ const handleSelectMediaFromLibrary = (mediaItem: Media) => {
78
+ onChange({
79
+ type: "image",
80
+ image: {
81
+ ...selectedImage,
82
+ media_id: mediaItem.id,
83
+ object_key: mediaItem.object_key,
84
+ width: mediaItem.width ?? undefined,
85
+ height: mediaItem.height ?? undefined,
86
+ size: selectedImage?.size || "cover",
87
+ position: selectedImage?.position || "center",
88
+ },
89
+ });
90
+ };
91
+
92
+ const handleRemoveImage = () => {
93
+ onChange({
94
+ type: "image",
95
+ image: {
96
+ media_id: "",
97
+ object_key: "",
98
+ size: "cover",
99
+ position: "center",
100
+ overlay: undefined,
101
+ },
102
+ });
103
+ };
104
+
105
+ const handleImagePropertyChange = (prop: "size" | "position", value: string) => {
106
+ if (background?.type === "image" && background.image) {
107
+ onChange({ ...background, image: { ...background.image, [prop]: value } });
108
+ }
109
+ };
110
+
111
+ const handleOverlayToggle = (checked: boolean) => {
112
+ if (background?.type === "image" && background.image) {
113
+ const newOverlay = checked
114
+ ? {
115
+ type: "gradient" as const,
116
+ gradient: {
117
+ type: "linear" as const,
118
+ direction: "to bottom",
119
+ stops: [
120
+ { color: "rgba(0,0,0,0.5)", position: 0 },
121
+ { color: "rgba(0,0,0,0)", position: 100 },
122
+ ],
123
+ },
124
+ }
125
+ : undefined;
126
+ onChange({ ...background, image: { ...background.image, overlay: newOverlay } });
127
+ }
128
+ };
129
+
130
+ const handleBackgroundPropertyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
131
+ const { name, value } = e.target;
132
+ onChange({ ...background, [name]: value });
133
+ };
134
+
135
+ const handleOverlayGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
136
+ const { name, value } = e.target;
137
+ if (background?.type === "image" && background.image) {
138
+ const { image } = background;
139
+ const overlay = image.overlay;
140
+ const currentGradient = overlay?.gradient || {
141
+ type: "linear" as const,
142
+ direction: "to bottom",
143
+ stops: [
144
+ { color: "rgba(0,0,0,0.5)", position: 0 },
145
+ { color: "rgba(0,0,0,0)", position: 100 },
146
+ ],
147
+ };
148
+
149
+ const updatedStops = currentGradient.stops.map((stop) => {
150
+ if (name === "startColor" && stop.position === 0) return { ...stop, color: value };
151
+ if (name === "endColor" && stop.position === 100) return { ...stop, color: value };
152
+ return stop;
153
+ });
154
+
155
+ const updatedGradient =
156
+ name === "direction"
157
+ ? { ...currentGradient, direction: value }
158
+ : { ...currentGradient, stops: updatedStops };
159
+
160
+ onChange({ ...background, image: { ...image, overlay: { type: "gradient", gradient: updatedGradient } } });
161
+ }
162
+ };
163
+
164
+ const hasMinHeightChanged = (background?.min_height || "") !== minHeight;
165
+ const imageSizeClass = selectedImage?.size === "contain" ? "object-contain" : "object-cover";
166
+ const hasOverlayDirectionChanged = (selectedImage?.overlay?.gradient?.direction || "to bottom") !== overlayDirection;
167
+
168
+ return (
169
+ <TooltipProvider>
170
+ <div className="space-y-4">
171
+ <div className="grid gap-2">
172
+ <Label>Background Type</Label>
173
+ <Select value={backgroundType} onValueChange={(v) => handleTypeChange(v as any)}>
174
+ <SelectTrigger className="w-full max-w-[250px]"><SelectValue placeholder="Select type" /></SelectTrigger>
175
+ <SelectContent>
176
+ <SelectItem value="none">None</SelectItem>
177
+ <SelectItem value="gradient">Gradient</SelectItem>
178
+ <SelectItem value="image">Image</SelectItem>
179
+ </SelectContent>
180
+ </Select>
181
+ </div>
182
+
183
+ <div className="grid gap-2">
184
+ <Label htmlFor="min_height">Minimum Height (e.g., 250px)</Label>
185
+ <div className="flex items-center gap-2">
186
+ <Input id="min_height" name="min_height" value={minHeight} onChange={(e) => setMinHeight(e.target.value)} placeholder="e.g., 250px" className="max-w-[200px]" />
187
+ <Button type="button" variant="ghost" size="icon" onClick={() => handleBackgroundPropertyChange({ target: { name: "min_height", value: minHeight } } as any)} disabled={!hasMinHeightChanged} title="Save Minimum Height">
188
+ <Save className={cn("h-5 w-5", hasMinHeightChanged && "text-green-600")} />
189
+ </Button>
190
+ </div>
191
+ </div>
192
+
193
+ {backgroundType === "image" && (
194
+ <>
195
+ <div className="mt-3 p-3 border rounded-md bg-muted/30 min-h-[120px] flex flex-col items-center justify-center">
196
+ {selectedImage?.object_key ? (
197
+ <div className="relative group w-full" style={{ height: background?.min_height || "250px", overflow: "hidden" }}>
198
+ <Image src={`${R2_BASE_URL}/${selectedImage.object_key}`} alt="Selected background image" width={selectedImage.width || 500} height={selectedImage.height || 300} sizes="100vw" className={`w-full h-full ${imageSizeClass}`} style={{ objectPosition: selectedImage.position }} />
199
+ {selectedImage.overlay && (
200
+ <div className="absolute inset-0" style={{ background: generateGradientCss(selectedImage.overlay.gradient) }} />
201
+ )}
202
+ <Button type="button" variant="destructive" size="icon" className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6" onClick={handleRemoveImage} title="Remove Image">
203
+ <XIcon className="h-3 w-3" />
204
+ </Button>
205
+ </div>
206
+ ) : (
207
+ <ImageIcon className="h-16 w-16 text-muted-foreground" />
208
+ )}
209
+
210
+ <div className="mt-3">
211
+ <MediaPickerDialog
212
+ triggerLabel={selectedImage?.object_key ? "Change Image" : "Select from Library"}
213
+ onSelect={handleSelectMediaFromLibrary}
214
+ accept={(m) => !!m.file_type?.startsWith("image/")}
215
+ title="Select or Upload Background Image"
216
+ />
217
+ </div>
218
+ </div>
219
+
220
+ <div className="grid gap-2">
221
+ <Label>Image Size</Label>
222
+ <Select value={selectedImage?.size || "cover"} onValueChange={(v) => handleImagePropertyChange("size", v)}>
223
+ <SelectTrigger className="w-full max-w-[250px]"><SelectValue /></SelectTrigger>
224
+ <SelectContent>
225
+ <SelectItem value="cover">Cover</SelectItem>
226
+ <SelectItem value="contain">Contain</SelectItem>
227
+ </SelectContent>
228
+ </Select>
229
+ </div>
230
+
231
+ <div className="grid gap-2">
232
+ <Label>Image Position</Label>
233
+ <Select value={imagePosition} onValueChange={(v) => { setImagePosition(v); handleImagePropertyChange("position", v); }}>
234
+ <SelectTrigger className="w-full max-w-[250px]"><SelectValue /></SelectTrigger>
235
+ <SelectContent>
236
+ <SelectItem value="center">Center</SelectItem>
237
+ <SelectItem value="top">Top</SelectItem>
238
+ <SelectItem value="bottom">Bottom</SelectItem>
239
+ <SelectItem value="left">Left</SelectItem>
240
+ <SelectItem value="right">Right</SelectItem>
241
+ <SelectItem value="top left">Top Left</SelectItem>
242
+ <SelectItem value="top right">Top Right</SelectItem>
243
+ <SelectItem value="bottom left">Bottom Left</SelectItem>
244
+ <SelectItem value="bottom right">Bottom Right</SelectItem>
245
+ </SelectContent>
246
+ </Select>
247
+ </div>
248
+
249
+ <div className="flex items-center space-x-2 mt-2">
250
+ <Checkbox id="gradientOverlay" checked={!!selectedImage?.overlay} onCheckedChange={(c) => handleOverlayToggle(!!c)} />
251
+ <div className="grid gap-1.5 leading-none">
252
+ <label htmlFor="gradientOverlay" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">Add Gradient Overlay</label>
253
+ </div>
254
+ </div>
255
+
256
+ {selectedImage?.overlay && (
257
+ <div className="mt-3 p-3 border rounded-md bg-muted/30 space-y-4">
258
+ <div className="flex items-center gap-2">
259
+ <div className="flex-grow">
260
+ <CustomSelectWithInput
261
+ label="Direction"
262
+ tooltipContent="Select a preset or enter a custom angle like '45deg' or 'to top left'. See MDN's linear-gradient docs for more options."
263
+ value={overlayDirection}
264
+ onChange={setOverlayDirection}
265
+ options={[
266
+ { value: "to bottom", label: "To Bottom" },
267
+ { value: "to top", label: "To Top" },
268
+ { value: "to left", label: "To Left" },
269
+ { value: "to right", label: "To Right" },
270
+ { value: "to bottom right", label: "To Bottom Right" },
271
+ { value: "to top left", label: "To Top Left" },
272
+ ]}
273
+ />
274
+ </div>
275
+ <Button size="icon" variant="ghost" onClick={() => handleOverlayGradientChange({ target: { name: "direction", value: overlayDirection } } as any)} disabled={!hasOverlayDirectionChanged} title="Save Overlay Direction">
276
+ <Save className={cn("h-5 w-5 mt-[1.3rem]", hasOverlayDirectionChanged && "text-green-600")} />
277
+ </Button>
278
+ </div>
279
+ <div className="flex items-center gap-4">
280
+ <ColorPicker
281
+ label="Start Color"
282
+ color={selectedImage.overlay.gradient?.stops?.[0]?.color || "rgba(0,0,0,0.5)"}
283
+ onChange={(color) => handleOverlayGradientChange({ target: { name: "startColor", value: color } } as any)}
284
+ />
285
+ <ColorPicker
286
+ label="End Color"
287
+ color={selectedImage.overlay.gradient?.stops?.[1]?.color || "rgba(0,0,0,0)"}
288
+ onChange={(color) => handleOverlayGradientChange({ target: { name: "endColor", value: color } } as any)}
289
+ />
290
+ </div>
291
+ </div>
292
+ )}
293
+ </>
294
+ )}
295
+
296
+ {backgroundType === "gradient" && (
297
+ <div className="mt-3 p-3 border rounded-md bg-muted/30 space-y-4">
298
+ <div>
299
+ <CustomSelectWithInput
300
+ label="Direction"
301
+ tooltipContent="Select a preset or enter a custom angle like '45deg' or 'to top left'. See MDN's linear-gradient docs for more options."
302
+ value={background.gradient?.direction || "to right"}
303
+ onChange={(value: string) => handleBackgroundGradientChange({ target: { name: "direction", value } } as any)}
304
+ options={[
305
+ { value: "to right", label: "To Right" },
306
+ { value: "to left", label: "To Left" },
307
+ { value: "to top", label: "To Top" },
308
+ { value: "to bottom", label: "To Bottom" },
309
+ { value: "to bottom right", label: "To Bottom Right" },
310
+ { value: "to top left", label: "To Top Left" },
311
+ ]}
312
+ />
313
+ </div>
314
+ <div className="flex items-center gap-4">
315
+ <ColorPicker
316
+ label="Start Color"
317
+ color={background.gradient?.stops?.[0]?.color || "#3b82f6"}
318
+ onChange={(color) => handleBackgroundGradientChange({ target: { name: "startColor", value: color } } as any)}
319
+ />
320
+ <ColorPicker
321
+ label="End Color"
322
+ color={background.gradient?.stops?.[1]?.color || "#8b5cf6"}
323
+ onChange={(color) => handleBackgroundGradientChange({ target: { name: "endColor", value: color } } as any)}
324
+ />
325
+ </div>
326
+ </div>
327
+ )}
328
+ </div>
329
+ </TooltipProvider>
330
+ );
331
+ const handleBackgroundGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
332
+ const { name, value } = e.target as any;
333
+ if (backgroundType !== 'gradient') return;
334
+ const current = background.gradient || { type: 'linear' as const, direction: 'to right', stops: [ { color: '#3b82f6', position: 0 }, { color: '#8b5cf6', position: 100 } ] };
335
+ if (name === 'direction') {
336
+ onChange({ type: 'gradient', gradient: { ...current, direction: value } });
337
+ return;
338
+ }
339
+ if (name === 'startColor' || name === 'endColor') {
340
+ const updatedStops = (current.stops || [ { color: '#3b82f6', position: 0 }, { color: '#8b5cf6', position: 100 } ]).map((s) => {
341
+ if (name === 'startColor' && s.position === 0) return { ...s, color: value };
342
+ if (name === 'endColor' && s.position === 100) return { ...s, color: value };
343
+ return s;
344
+ });
345
+ onChange({ type: 'gradient', gradient: { ...current, stops: updatedStops } });
346
+ }
347
+ };
348
+ }