create-nextblock 0.2.47 → 0.2.48
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/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +73 -34
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +309 -53
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +175 -25
- package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +44 -26
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +74 -16
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +2 -0
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +41 -49
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -2
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
package/package.json
CHANGED
|
@@ -5,10 +5,8 @@ import { cn } from "@nextblock-cms/utils";
|
|
|
5
5
|
import {
|
|
6
6
|
Dialog,
|
|
7
7
|
DialogContent,
|
|
8
|
-
DialogHeader,
|
|
9
8
|
DialogTitle,
|
|
10
|
-
|
|
11
|
-
DialogDescription,
|
|
9
|
+
DialogClose,
|
|
12
10
|
} from "@nextblock-cms/ui";
|
|
13
11
|
import { Button } from "@nextblock-cms/ui";
|
|
14
12
|
import { blockRegistry, type BlockType } from "@/lib/blocks/blockRegistry";
|
|
@@ -26,6 +24,8 @@ export type BlockEditorProps<T = unknown> = {
|
|
|
26
24
|
block: Block<T>;
|
|
27
25
|
content: T;
|
|
28
26
|
onChange: (newContent: T) => void;
|
|
27
|
+
className?: string; // Added for editor component styling
|
|
28
|
+
sectionBackground?: import("@/lib/blocks/blockRegistry").SectionBlockContent['background'];
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
type BlockEditorModalProps = {
|
|
@@ -34,6 +34,7 @@ type BlockEditorModalProps = {
|
|
|
34
34
|
onClose: () => void;
|
|
35
35
|
onSave: (updatedContent: unknown) => void;
|
|
36
36
|
EditorComponent: LazyExoticComponent<ComponentType<BlockEditorProps<unknown>>> | ComponentType<BlockEditorProps<unknown>>;
|
|
37
|
+
sectionBackground?: import("@/lib/blocks/blockRegistry").SectionBlockContent['background'];
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
export function BlockEditorModal({
|
|
@@ -42,8 +43,10 @@ export function BlockEditorModal({
|
|
|
42
43
|
onClose,
|
|
43
44
|
onSave,
|
|
44
45
|
EditorComponent,
|
|
46
|
+
sectionBackground,
|
|
45
47
|
}: BlockEditorModalProps) {
|
|
46
48
|
const [tempContent, setTempContent] = useState(block.content);
|
|
49
|
+
const isValid = true; // Placeholder for future validation logic
|
|
47
50
|
|
|
48
51
|
useEffect(() => {
|
|
49
52
|
// When the modal is opened with a new block, reset the temp content
|
|
@@ -56,42 +59,78 @@ export function BlockEditorModal({
|
|
|
56
59
|
onSave(tempContent);
|
|
57
60
|
};
|
|
58
61
|
|
|
62
|
+
const handleContentChange = (newContent: unknown) => {
|
|
63
|
+
setTempContent(newContent);
|
|
64
|
+
// Potentially add validation here and set isValid
|
|
65
|
+
};
|
|
66
|
+
|
|
59
67
|
const blockInfo = blockRegistry[block.type];
|
|
68
|
+
const displayText = blockInfo?.label || "Block";
|
|
60
69
|
|
|
61
70
|
return (
|
|
62
71
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
|
63
|
-
<DialogContent
|
|
64
|
-
className=
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"h-[90vh] flex flex-col": block.type === "text",
|
|
69
|
-
// For other blocks, allow the modal itself to scroll if content exceeds viewport
|
|
70
|
-
"overflow-y-auto": block.type !== "text",
|
|
71
|
-
}
|
|
72
|
-
)}
|
|
72
|
+
<DialogContent
|
|
73
|
+
className="max-w-6xl h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
|
|
74
|
+
onInteractOutside={(e) => {
|
|
75
|
+
// Prevent closing when interacting with Tiptap bubbles outside the dialog portal (rare but possible)
|
|
76
|
+
}}
|
|
73
77
|
>
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
</
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
78
|
+
{/* Header */}
|
|
79
|
+
<div className="flex items-center justify-between p-4 border-b bg-background/95 backdrop-blur z-10">
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
<DialogTitle className="text-lg font-semibold">Edit {displayText}</DialogTitle>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
<DialogClose asChild>
|
|
85
|
+
<Button variant="ghost" size="sm">Cancel</Button>
|
|
86
|
+
</DialogClose>
|
|
87
|
+
<Button onClick={handleSave} disabled={!isValid} size="sm">
|
|
88
|
+
Save (CMD+S)
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Editor Area with Contextual Background */}
|
|
94
|
+
<div
|
|
95
|
+
className={cn(
|
|
96
|
+
"flex-1 overflow-y-auto p-6",
|
|
97
|
+
// Conditional Background Logic:
|
|
98
|
+
// Only apply specific section background to 'text' and 'heading' blocks to allow "Live Preview" of copy.
|
|
99
|
+
// For complex blocks like Forms, Buttons, etc., keep a neutral background to ensure input field contrast.
|
|
100
|
+
(block.type === 'text' || block.type === 'heading') ? (
|
|
101
|
+
// If no specific background, use white/dark default
|
|
102
|
+
(!sectionBackground || sectionBackground.type === 'none') && "bg-muted/10"
|
|
103
|
+
) : "bg-muted/10", // Default for non-text blocks
|
|
104
|
+
|
|
105
|
+
// Apply theme classes if present (ONLY for text/heading)
|
|
106
|
+
(block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'theme' && sectionBackground.theme === 'primary' && 'bg-primary text-primary-foreground',
|
|
107
|
+
(block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'theme' && sectionBackground.theme === 'secondary' && 'bg-secondary text-secondary-foreground',
|
|
108
|
+
(block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'theme' && sectionBackground.theme === 'muted' && 'bg-muted text-muted-foreground',
|
|
109
|
+
|
|
110
|
+
// Dark mode prose invert if dark background (approximate check for solid color)
|
|
111
|
+
(block.type === 'text' || block.type === 'heading') && (sectionBackground?.type === 'solid' && sectionBackground.solid_color && ['#000', '#111', '#0f172a', 'black'].some(c => sectionBackground.solid_color?.includes(c))) && "[&_.prose]:prose-invert"
|
|
112
|
+
)}
|
|
113
|
+
style={{
|
|
114
|
+
// Only apply custom color/gradient styles for text/heading
|
|
115
|
+
backgroundColor: (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'solid' ? sectionBackground.solid_color : undefined,
|
|
116
|
+
backgroundImage: (block.type === 'text' || block.type === 'heading') && sectionBackground?.type === 'gradient' && sectionBackground.gradient ?
|
|
117
|
+
`${sectionBackground.gradient.type}-gradient(${sectionBackground.gradient.direction}, ${sectionBackground.gradient.stops.map(s => `${s.color} ${s.position}%`).join(', ')})`
|
|
118
|
+
: undefined
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<div className="max-w-6xl mx-auto">
|
|
122
|
+
<Suspense fallback={<div className="flex justify-center items-center h-32">Loading editor...</div>}>
|
|
123
|
+
<EditorComponent
|
|
124
|
+
block={block}
|
|
125
|
+
content={tempContent}
|
|
126
|
+
onChange={handleContentChange}
|
|
127
|
+
className="bg-transparent border-none shadow-none focus-within:ring-0 min-h-[60vh]" // Make editor transparent
|
|
128
|
+
sectionBackground={sectionBackground} // Pass down if editor supports it
|
|
129
|
+
/>
|
|
130
|
+
</Suspense>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
95
134
|
</DialogContent>
|
|
96
135
|
</Dialog>
|
|
97
136
|
);
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// app/cms/blocks/components/ColumnEditor.tsx
|
|
2
1
|
"use client";
|
|
3
2
|
|
|
4
3
|
import React, { useState, lazy } from 'react';
|
|
5
4
|
import { cn } from '@nextblock-cms/utils';
|
|
6
5
|
import { Button } from '@nextblock-cms/ui';
|
|
7
|
-
import { PlusCircle, Trash2, Edit2, GripVertical } from "lucide-react";
|
|
6
|
+
import { PlusCircle, Trash2, Edit2, GripVertical, Image as ImageIcon } from "lucide-react";
|
|
8
7
|
import type { SectionBlockContent } from '@/lib/blocks/blockRegistry';
|
|
9
8
|
import { availableBlockTypes, getBlockDefinition, getInitialContent, BlockType } from '@/lib/blocks/blockRegistry';
|
|
10
9
|
import { useDroppable } from "@dnd-kit/core";
|
|
@@ -14,7 +13,6 @@ import { BlockEditorModal } from './BlockEditorModal';
|
|
|
14
13
|
import { ConfirmationDialog } from '@nextblock-cms/ui';
|
|
15
14
|
import BlockTypeSelector from './BlockTypeSelector';
|
|
16
15
|
|
|
17
|
-
|
|
18
16
|
type ColumnBlock = SectionBlockContent['column_blocks'][0][0];
|
|
19
17
|
|
|
20
18
|
// Sortable block item component for column blocks
|
|
@@ -26,9 +24,10 @@ interface SortableColumnBlockProps {
|
|
|
26
24
|
onDelete: () => void;
|
|
27
25
|
blockType: 'section' | 'hero';
|
|
28
26
|
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
|
|
27
|
+
sectionBackground?: SectionBlockContent['background'];
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, blockType, onClick }: SortableColumnBlockProps) {
|
|
30
|
+
function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, blockType, onClick, sectionBackground }: SortableColumnBlockProps) {
|
|
32
31
|
const {
|
|
33
32
|
attributes,
|
|
34
33
|
listeners,
|
|
@@ -53,8 +52,215 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
|
|
|
53
52
|
opacity: isDragging ? 0.5 : 1,
|
|
54
53
|
};
|
|
55
54
|
|
|
56
|
-
const
|
|
57
|
-
|
|
55
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
56
|
+
|
|
57
|
+
// Helper to check for dark background
|
|
58
|
+
const isDarkBackground = React.useMemo(() => {
|
|
59
|
+
if (!sectionBackground) return false;
|
|
60
|
+
|
|
61
|
+
// Theme checks
|
|
62
|
+
if (sectionBackground.type === 'theme') {
|
|
63
|
+
return ['primary', 'secondary', 'destructive', 'accent', 'dark'].includes(sectionBackground.theme || '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Image & Gradient - Assume Dark (safe default for overlays)
|
|
67
|
+
if (sectionBackground.type === 'image' || sectionBackground.type === 'gradient') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Solid Color checks
|
|
72
|
+
if (sectionBackground.type === 'solid' && sectionBackground.solid_color) {
|
|
73
|
+
const str = sectionBackground.solid_color.trim();
|
|
74
|
+
let r = 0, g = 0, b = 0;
|
|
75
|
+
|
|
76
|
+
if (str.startsWith('#')) {
|
|
77
|
+
const hex = str.replace('#', '');
|
|
78
|
+
if (hex.length === 3) {
|
|
79
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
80
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
81
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
82
|
+
} else if (hex.length === 6) {
|
|
83
|
+
r = parseInt(hex.substring(0, 2), 16);
|
|
84
|
+
g = parseInt(hex.substring(2, 4), 16);
|
|
85
|
+
b = parseInt(hex.substring(4, 6), 16);
|
|
86
|
+
} else {
|
|
87
|
+
return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
|
|
88
|
+
}
|
|
89
|
+
} else if (str.startsWith('rgb')) {
|
|
90
|
+
const matches = str.match(/\d+/g);
|
|
91
|
+
if (matches && matches.length >= 3) {
|
|
92
|
+
r = parseInt(matches[0]);
|
|
93
|
+
g = parseInt(matches[1]);
|
|
94
|
+
b = parseInt(matches[2]);
|
|
95
|
+
} else {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Calculate luminance (YIQ)
|
|
103
|
+
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
|
104
|
+
return yiq < 128; // Strict threshold for white text
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}, [sectionBackground]);
|
|
108
|
+
|
|
109
|
+
// Helper to render content preview
|
|
110
|
+
const renderContentPreview = () => {
|
|
111
|
+
switch (block.block_type) {
|
|
112
|
+
case 'text': {
|
|
113
|
+
// Basic detection of alignment from HTML content to mirror frontend
|
|
114
|
+
const isCentered = block.content.html_content?.includes('text-align: center') || block.content.html_content?.includes('class="text-center"');
|
|
115
|
+
const isRight = block.content.html_content?.includes('text-align: right') || block.content.html_content?.includes('class="text-right"');
|
|
116
|
+
const alignmentClass = isCentered ? 'text-center' : isRight ? 'text-right' : 'text-left';
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="flex gap-3">
|
|
120
|
+
<div className={cn("text-xs w-full", alignmentClass)}>
|
|
121
|
+
{block.content.html_content ? (
|
|
122
|
+
<div
|
|
123
|
+
dangerouslySetInnerHTML={{ __html: block.content.html_content }}
|
|
124
|
+
className={cn(
|
|
125
|
+
"prose prose-xs max-w-none [&>p]:my-0 [&>h1]:my-0 [&>h2]:my-0 [&>h3]:my-0",
|
|
126
|
+
isDarkBackground ? "prose-invert text-white/90 drop-shadow-sm" : "dark:prose-invert",
|
|
127
|
+
// Ensure the inner prose content also respects the alignment if not explicitly set on children
|
|
128
|
+
isCentered && "[&_*]:text-center",
|
|
129
|
+
isRight && "[&_*]:text-right"
|
|
130
|
+
)}
|
|
131
|
+
/>
|
|
132
|
+
) : <span className={cn("text-muted-foreground", isDarkBackground && "text-white/50")}>Empty text block</span>}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
case 'heading': {
|
|
138
|
+
const content = block.content as any;
|
|
139
|
+
const level = content.level || 1;
|
|
140
|
+
const headingAlign = content.textAlign || 'left';
|
|
141
|
+
const textColor = content.textColor || 'foreground';
|
|
142
|
+
|
|
143
|
+
const sizeClasses: Record<number, string> = {
|
|
144
|
+
1: "text-4xl font-extrabold",
|
|
145
|
+
2: "text-3xl font-bold",
|
|
146
|
+
3: "text-2xl font-semibold",
|
|
147
|
+
4: "text-xl font-semibold",
|
|
148
|
+
5: "text-lg font-semibold",
|
|
149
|
+
6: "text-base font-semibold",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const colorClasses: Record<string, string> = {
|
|
153
|
+
primary: "text-primary",
|
|
154
|
+
secondary: "text-secondary",
|
|
155
|
+
accent: "text-accent",
|
|
156
|
+
destructive: "text-destructive",
|
|
157
|
+
muted: "text-muted-foreground",
|
|
158
|
+
background: "text-background",
|
|
159
|
+
foreground: "text-foreground"
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Override for dark background if color is basic
|
|
163
|
+
let appliedColorClass = colorClasses[textColor] || "text-foreground";
|
|
164
|
+
if (isDarkBackground) {
|
|
165
|
+
if (textColor === 'foreground') appliedColorClass = 'text-white/90';
|
|
166
|
+
if (textColor === 'muted') appliedColorClass = 'text-white/70';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex gap-3">
|
|
171
|
+
<div className={cn(
|
|
172
|
+
"w-full leading-tight",
|
|
173
|
+
sizeClasses[level] || sizeClasses[1],
|
|
174
|
+
appliedColorClass,
|
|
175
|
+
headingAlign === 'center' && 'text-center',
|
|
176
|
+
headingAlign === 'right' && 'text-right'
|
|
177
|
+
)}>
|
|
178
|
+
{content.text_content || <span className={cn("text-muted-foreground font-normal text-sm italic", isDarkBackground && "text-white/50")}>Empty heading</span>}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
case 'image': {
|
|
184
|
+
const imageUrl = block.content.object_key ? `${R2_BASE_URL}/${block.content.object_key}` : block.content.src;
|
|
185
|
+
return (
|
|
186
|
+
<div className="flex gap-3">
|
|
187
|
+
<div className="flex-shrink-0 h-10 w-10 bg-muted/20 rounded overflow-hidden flex items-center justify-center border border-white/10">
|
|
188
|
+
{imageUrl ? (
|
|
189
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
190
|
+
<img src={imageUrl} alt={block.content.alt_text || 'Block image'} className="h-full w-full object-cover" />
|
|
191
|
+
) : (
|
|
192
|
+
<ImageIcon className={cn("h-5 w-5", isDarkBackground ? "text-white/50" : "text-muted-foreground")} />
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
<div className="flex flex-col justify-center">
|
|
196
|
+
<span className="text-xs font-medium truncate max-w-[150px]">{block.content.alt_text || 'No description'}</span>
|
|
197
|
+
<span className={cn("text-[10px] truncate max-w-[150px]", isDarkBackground ? "text-white/50" : "text-muted-foreground")}>{imageUrl ? 'Image set' : 'No image selected'}</span>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
case 'button': {
|
|
203
|
+
const content = block.content as any;
|
|
204
|
+
return (
|
|
205
|
+
<div className={cn("flex gap-3",
|
|
206
|
+
content.position === 'center' ? 'justify-center' :
|
|
207
|
+
content.position === 'right' ? 'justify-end' : 'justify-start'
|
|
208
|
+
)}>
|
|
209
|
+
<Button
|
|
210
|
+
variant={content.variant || 'default'}
|
|
211
|
+
size={content.size || 'default'}
|
|
212
|
+
className={cn("pointer-events-none", block.content.variant === 'outline' && "text-foreground")}
|
|
213
|
+
tabIndex={-1}
|
|
214
|
+
>
|
|
215
|
+
{content.text || 'Button'}
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
case 'video_embed':
|
|
221
|
+
return (
|
|
222
|
+
<div className="flex gap-3 items-center">
|
|
223
|
+
<div className="text-xs truncate max-w-[200px]">
|
|
224
|
+
{block.content.title || block.content.url || 'No Video URL'}
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
case 'posts_grid':
|
|
229
|
+
return (
|
|
230
|
+
<div className="flex gap-3 items-center">
|
|
231
|
+
<div className={cn("text-xs", isDarkBackground ? "text-white/70" : "text-muted-foreground")}>
|
|
232
|
+
Posts Grid: {block.content.columns || 3} cols, {block.content.postsPerPage || 12} items
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
case 'testimonial':
|
|
237
|
+
return (
|
|
238
|
+
<div className="flex flex-col gap-2">
|
|
239
|
+
<div className={cn("italic text-xs line-clamp-2", isDarkBackground ? "text-white/80" : "text-muted-foreground")}>
|
|
240
|
+
"{block.content.quote || 'No quote'}"
|
|
241
|
+
</div>
|
|
242
|
+
<div className="flex items-center gap-2">
|
|
243
|
+
{block.content.image_url ? (
|
|
244
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
245
|
+
<img src={block.content.image_url} alt={block.content.author_name} className="w-5 h-5 rounded-full object-cover border border-white/10" />
|
|
246
|
+
) : (
|
|
247
|
+
<div className={cn("w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold border border-white/10", isDarkBackground ? "bg-white/20 text-white" : "bg-muted text-foreground")}>
|
|
248
|
+
{(block.content.author_name || 'A').charAt(0)}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
<div className="flex flex-col leading-none">
|
|
252
|
+
<span className={cn("text-[10px] font-semibold", isDarkBackground ? "text-white" : "text-foreground")}>{block.content.author_name || 'Author Name'}</span>
|
|
253
|
+
{block.content.author_title && <span className={cn("text-[9px]", isDarkBackground ? "text-white/60" : "text-muted-foreground")}>{block.content.author_title}</span>}
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
default:
|
|
259
|
+
// For fallback blocks, we might still want the label if we can't render a preview
|
|
260
|
+
// But user asked to remove it. Let's show a generic "configured" message or similar.
|
|
261
|
+
return <div className={cn("text-xs", isDarkBackground ? "text-white/50" : "text-muted-foreground")}>Unknown Block Type</div>;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
58
264
|
|
|
59
265
|
return (
|
|
60
266
|
<div
|
|
@@ -64,45 +270,27 @@ function SortableColumnBlock({ block, index, columnIndex, onEdit, onDelete, bloc
|
|
|
64
270
|
{...attributes}
|
|
65
271
|
{...listeners}
|
|
66
272
|
className={cn(
|
|
67
|
-
"group relative p-
|
|
68
|
-
"cursor-pointer
|
|
273
|
+
"group relative p-3 border border-transparent hover:border-dashed hover:border-primary/50 rounded-lg bg-transparent transition-all",
|
|
274
|
+
"cursor-pointer",
|
|
275
|
+
isDarkBackground ? "text-white border-white/20 hover:border-white/50" : "text-foreground border-transparent"
|
|
69
276
|
)}
|
|
70
277
|
>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
79
|
-
<Button variant="ghost" size="sm" onClick={onEdit} className="h-6 w-6 p-0" title="Edit block">
|
|
80
|
-
<Edit2 className="h-3 w-3" />
|
|
278
|
+
{/* Absolute positioning for actions */}
|
|
279
|
+
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10 p-1 py-2 hover:bg-gray-500 rounded-lg">
|
|
280
|
+
<div className="cursor-grab active:cursor-grabbing p-1">
|
|
281
|
+
<GripVertical className={cn("h-3 w-3", isDarkBackground ? "text-white drop-shadow-md" : "text-foreground/70")} />
|
|
282
|
+
</div>
|
|
283
|
+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onEdit(); }} className="h-6 w-6 p-0 hover:bg-transparent" title="Edit block">
|
|
284
|
+
<Edit2 className={cn("h-3 w-3", isDarkBackground ? "text-white drop-shadow-md" : "text-foreground/70")} />
|
|
81
285
|
</Button>
|
|
82
|
-
<Button variant="ghost" size="sm" onClick={onDelete} className="h-6 w-6 p-0 text-red-600 hover:text-red-700" title="Delete block">
|
|
286
|
+
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); onDelete(); }} className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-transparent" title="Delete block">
|
|
83
287
|
<Trash2 className="h-3 w-3" />
|
|
84
288
|
</Button>
|
|
85
|
-
</div>
|
|
86
289
|
</div>
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
{block.block_type === 'heading' && (
|
|
92
|
-
<div>H{block.content.level || 1}: {(block.content.text_content || 'Empty heading').substring(0, 30) + (block.content.text_content && block.content.text_content.length > 30 ? '...' : '')}</div>
|
|
93
|
-
)}
|
|
94
|
-
{block.block_type === 'image' && (
|
|
95
|
-
<div>Image: {block.content.alt_text || block.content.media_id ? 'Image selected' : 'No image selected'}</div>
|
|
96
|
-
)}
|
|
97
|
-
{block.block_type === 'button' && (
|
|
98
|
-
<div>Button: {block.content.text || 'No text'} → {block.content.url || '#'}</div>
|
|
99
|
-
)}
|
|
100
|
-
{block.block_type === 'video_embed' && (
|
|
101
|
-
<div>Video: {block.content.title || block.content.url || 'No URL set'}</div>
|
|
102
|
-
)}
|
|
103
|
-
{block.block_type === 'posts_grid' && (
|
|
104
|
-
<div>Posts Grid: {block.content.columns || 3} cols, {block.content.postsPerPage || 12} posts</div>
|
|
105
|
-
)}
|
|
290
|
+
|
|
291
|
+
{/* Live Preview Area - No headers, just content */}
|
|
292
|
+
<div className={cn("min-h-[20px]", isDarkBackground ? "text-white drop-shadow-sm" : "text-foreground")}>
|
|
293
|
+
{renderContentPreview()}
|
|
106
294
|
</div>
|
|
107
295
|
</div>
|
|
108
296
|
);
|
|
@@ -114,11 +302,12 @@ export interface ColumnEditorProps {
|
|
|
114
302
|
blocks: ColumnBlock[];
|
|
115
303
|
onBlocksChange: (newBlocks: ColumnBlock[]) => void;
|
|
116
304
|
blockType: 'section' | 'hero';
|
|
305
|
+
sectionBackground?: SectionBlockContent['background'];
|
|
117
306
|
}
|
|
118
307
|
|
|
119
308
|
type EditingBlock = ColumnBlock & { index: number };
|
|
120
309
|
|
|
121
|
-
export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, blockType }: ColumnEditorProps) {
|
|
310
|
+
export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, blockType, sectionBackground }: ColumnEditorProps) {
|
|
122
311
|
const [editingBlock, setEditingBlock] = useState<EditingBlock | null>(null);
|
|
123
312
|
const [isBlockSelectorOpen, setIsBlockSelectorOpen] = useState(false);
|
|
124
313
|
const [LazyEditor, setLazyEditor] = useState<React.LazyExoticComponent<React.ComponentType<any>> | React.ComponentType<any> | null>(null);
|
|
@@ -129,6 +318,57 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
129
318
|
id: `${blockType}-column-droppable-${columnIndex}`,
|
|
130
319
|
});
|
|
131
320
|
|
|
321
|
+
// Duplicate isDark logic (should ideally be shared utils but inline to avoid import cycles)
|
|
322
|
+
const isDarkBackground = React.useMemo(() => {
|
|
323
|
+
if (!sectionBackground) return false;
|
|
324
|
+
|
|
325
|
+
// Theme checks
|
|
326
|
+
if (sectionBackground.type === 'theme') {
|
|
327
|
+
return ['primary', 'secondary', 'destructive', 'accent', 'dark'].includes(sectionBackground.theme || '');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Image & Gradient - Assume Dark (safe default for overlays)
|
|
331
|
+
if (sectionBackground.type === 'image' || sectionBackground.type === 'gradient') {
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Solid Color checks
|
|
336
|
+
if (sectionBackground.type === 'solid' && sectionBackground.solid_color) {
|
|
337
|
+
const str = sectionBackground.solid_color.trim();
|
|
338
|
+
let r = 0, g = 0, b = 0;
|
|
339
|
+
|
|
340
|
+
if (str.startsWith('#')) {
|
|
341
|
+
const hex = str.replace('#', '');
|
|
342
|
+
if (hex.length === 3) {
|
|
343
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
344
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
345
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
346
|
+
} else if (hex.length === 6) {
|
|
347
|
+
r = parseInt(hex.substring(0, 2), 16);
|
|
348
|
+
g = parseInt(hex.substring(2, 4), 16);
|
|
349
|
+
b = parseInt(hex.substring(4, 6), 16);
|
|
350
|
+
} else {
|
|
351
|
+
return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
|
|
352
|
+
}
|
|
353
|
+
} else if (str.startsWith('rgb')) {
|
|
354
|
+
const matches = str.match(/\d+/g);
|
|
355
|
+
if (matches && matches.length >= 3) {
|
|
356
|
+
r = parseInt(matches[0]);
|
|
357
|
+
g = parseInt(matches[1]);
|
|
358
|
+
b = parseInt(matches[2]);
|
|
359
|
+
} else {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
return ['black', 'navy', 'darkblue', 'darkgray'].includes(str.toLowerCase());
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
|
367
|
+
return yiq < 128;
|
|
368
|
+
}
|
|
369
|
+
return false;
|
|
370
|
+
}, [sectionBackground]);
|
|
371
|
+
|
|
132
372
|
const handleAddBlock = (selectedBlockType: BlockType) => {
|
|
133
373
|
if (!selectedBlockType) return;
|
|
134
374
|
const initialContent = getInitialContent(selectedBlockType);
|
|
@@ -146,11 +386,7 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
146
386
|
};
|
|
147
387
|
|
|
148
388
|
const handleCardClick = (e: React.MouseEvent<HTMLDivElement>, block: ColumnBlock, index: number) => {
|
|
149
|
-
|
|
150
|
-
if ((e.target as HTMLElement).closest('button')) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
// Call the existing function to open the modal for this block.
|
|
389
|
+
if ((e.target as HTMLElement).closest('button')) return;
|
|
154
390
|
handleStartEdit(block, index);
|
|
155
391
|
};
|
|
156
392
|
|
|
@@ -202,20 +438,37 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
202
438
|
};
|
|
203
439
|
|
|
204
440
|
return (
|
|
205
|
-
<div className=
|
|
206
|
-
|
|
441
|
+
<div className={cn(
|
|
442
|
+
"border border-dashed rounded-lg bg-transparent flex flex-col transition-colors",
|
|
443
|
+
isDarkBackground ? "border-white/30" : "border-gray-300 dark:border-gray-700/50"
|
|
444
|
+
)}>
|
|
445
|
+
<div className={cn(
|
|
446
|
+
"p-3 border-b",
|
|
447
|
+
isDarkBackground ? "border-white/20" : "border-gray-200 dark:border-gray-700"
|
|
448
|
+
)}>
|
|
207
449
|
<div className="flex items-center justify-between">
|
|
208
450
|
<div className="flex items-center gap-2">
|
|
209
|
-
<h4 className=
|
|
451
|
+
<h4 className={cn(
|
|
452
|
+
"text-sm font-medium",
|
|
453
|
+
isDarkBackground ? "text-white" : "text-gray-700 dark:text-gray-300"
|
|
454
|
+
)}>
|
|
210
455
|
Column {columnIndex + 1}
|
|
211
456
|
</h4>
|
|
212
|
-
<span className=
|
|
213
|
-
|
|
457
|
+
<span className={cn(
|
|
458
|
+
"text-xs px-2 py-1 rounded",
|
|
459
|
+
isDarkBackground ? "bg-white/20 text-white" : "text-gray-500 bg-gray-200 dark:bg-gray-700"
|
|
460
|
+
)}>
|
|
461
|
+
{blocks.length}
|
|
214
462
|
</span>
|
|
215
463
|
</div>
|
|
216
464
|
</div>
|
|
217
465
|
<div className="mt-2">
|
|
218
|
-
<Button
|
|
466
|
+
<Button
|
|
467
|
+
onClick={() => setIsBlockSelectorOpen(true)}
|
|
468
|
+
size="sm"
|
|
469
|
+
variant={isDarkBackground ? "secondary" : "default"}
|
|
470
|
+
className="w-full h-8"
|
|
471
|
+
>
|
|
219
472
|
<PlusCircle className="h-3 w-3 mr-2" />
|
|
220
473
|
Add Block
|
|
221
474
|
</Button>
|
|
@@ -225,8 +478,8 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
225
478
|
{blocks.length === 0 ? (
|
|
226
479
|
<div
|
|
227
480
|
ref={setDroppableNodeRef}
|
|
228
|
-
className={`h-full flex items-center justify-center text-xs text-
|
|
229
|
-
isOver ? 'border-
|
|
481
|
+
className={`h-full flex items-center justify-center text-xs text-muted-foreground border-2 border-dashed rounded-lg transition-colors p-4 ${
|
|
482
|
+
isOver ? 'border-primary bg-primary/5' : 'border-gray-300 dark:border-gray-600'
|
|
230
483
|
}`}
|
|
231
484
|
>
|
|
232
485
|
Drag block here
|
|
@@ -243,6 +496,7 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
243
496
|
onEdit={() => handleStartEdit(block, index)}
|
|
244
497
|
onDelete={() => handleDeleteBlock(index)}
|
|
245
498
|
onClick={(e) => handleCardClick(e, block, index)}
|
|
499
|
+
sectionBackground={sectionBackground}
|
|
246
500
|
/>
|
|
247
501
|
</div>
|
|
248
502
|
))}
|
|
@@ -262,6 +516,8 @@ export default function ColumnEditor({ columnIndex, blocks, onBlocksChange, bloc
|
|
|
262
516
|
content: editingBlock.content,
|
|
263
517
|
}}
|
|
264
518
|
EditorComponent={LazyEditor}
|
|
519
|
+
// Pass the section background to the modal
|
|
520
|
+
sectionBackground={sectionBackground}
|
|
265
521
|
/>
|
|
266
522
|
)}
|
|
267
523
|
<ConfirmationDialog
|
|
@@ -6,13 +6,15 @@ import type { Database } from "@nextblock-cms/db";
|
|
|
6
6
|
import PostsGridBlockEditor from '../editors/PostsGridBlockEditor';
|
|
7
7
|
|
|
8
8
|
type Block = Database['public']['Tables']['blocks']['Row'];
|
|
9
|
-
import { Button } from "@nextblock-cms/ui";
|
|
10
|
-
import { GripVertical, Edit2 } from "lucide-react";
|
|
9
|
+
import { Button, Card, CardContent, Avatar, AvatarImage, AvatarFallback } from "@nextblock-cms/ui";
|
|
10
|
+
import { GripVertical, Edit2, Image as ImageIcon, MessageSquareQuote } from "lucide-react";
|
|
11
11
|
import { getBlockDefinition, blockRegistry, BlockType } from "@/lib/blocks/blockRegistry";
|
|
12
12
|
import { BlockEditorModal } from './BlockEditorModal';
|
|
13
13
|
import { DeleteBlockButtonClient } from './DeleteBlockButtonClient';
|
|
14
14
|
import { cn } from '@nextblock-cms/utils';
|
|
15
15
|
|
|
16
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
17
|
+
|
|
16
18
|
export interface EditableBlockProps {
|
|
17
19
|
block: Block;
|
|
18
20
|
onDelete: (blockId: number) => void;
|
|
@@ -100,28 +102,174 @@ export default function EditableBlock({
|
|
|
100
102
|
return <div className="text-red-500">Error: Block type missing for preview.</div>;
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
105
|
+
switch (currentBlockType) {
|
|
106
|
+
case 'text': {
|
|
107
|
+
const content = (block.content || {}) as any;
|
|
108
|
+
const htmlContent = String(content.html_content || '');
|
|
109
|
+
const isCentered = htmlContent.includes('text-align: center') || htmlContent.includes('class="text-center"');
|
|
110
|
+
const isRight = htmlContent.includes('text-align: right') || htmlContent.includes('class="text-right"');
|
|
111
|
+
const alignmentClass = isCentered ? 'text-center' : isRight ? 'text-right' : 'text-left';
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="py-2">
|
|
115
|
+
<div className={cn("text-xs w-full", alignmentClass)}>
|
|
116
|
+
{htmlContent ? (
|
|
117
|
+
<div
|
|
118
|
+
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
|
119
|
+
className={cn(
|
|
120
|
+
"prose prose-sm max-w-none [&>p]:my-0 [&>h1]:my-0 [&>h2]:my-0 [&>h3]:my-0 dark:prose-invert",
|
|
121
|
+
isCentered && "[&_*]:text-center",
|
|
122
|
+
isRight && "[&_*]:text-right"
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
125
|
+
) : <span className="text-muted-foreground italic">Empty text block</span>}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
case 'heading': {
|
|
131
|
+
const content = (block.content || {}) as any;
|
|
132
|
+
const level = content.level || 1;
|
|
133
|
+
const headingAlign = content.textAlign || 'left';
|
|
134
|
+
const textColor = content.textColor || 'foreground';
|
|
135
|
+
|
|
136
|
+
const sizeClasses: Record<number, string> = {
|
|
137
|
+
1: "text-4xl font-extrabold",
|
|
138
|
+
2: "text-3xl font-bold",
|
|
139
|
+
3: "text-2xl font-semibold",
|
|
140
|
+
4: "text-xl font-semibold",
|
|
141
|
+
5: "text-lg font-semibold",
|
|
142
|
+
6: "text-base font-semibold",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const colorClasses: Record<string, string> = {
|
|
146
|
+
primary: "text-primary",
|
|
147
|
+
secondary: "text-secondary",
|
|
148
|
+
accent: "text-accent",
|
|
149
|
+
destructive: "text-destructive",
|
|
150
|
+
muted: "text-muted-foreground",
|
|
151
|
+
background: "text-background",
|
|
152
|
+
foreground: "text-foreground"
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className="py-2">
|
|
157
|
+
<div className={cn(
|
|
158
|
+
"w-full leading-tight",
|
|
159
|
+
sizeClasses[level] || sizeClasses[1],
|
|
160
|
+
colorClasses[textColor] || "text-foreground",
|
|
161
|
+
headingAlign === 'center' && 'text-center',
|
|
162
|
+
headingAlign === 'right' && 'text-right'
|
|
163
|
+
)}>
|
|
164
|
+
{content.text_content || <span className="text-muted-foreground italic text-sm font-normal">Empty heading</span>}
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
case 'image': {
|
|
170
|
+
const content = (block.content || {}) as any;
|
|
171
|
+
const imageUrl = content.object_key ? `${R2_BASE_URL}/${content.object_key}` : content.src;
|
|
172
|
+
return (
|
|
173
|
+
<div className="flex gap-4 py-2">
|
|
174
|
+
<div className="flex-shrink-0 h-16 w-16 bg-muted rounded overflow-hidden flex items-center justify-center border">
|
|
175
|
+
{imageUrl ? (
|
|
176
|
+
/* eslint-disable-next-line @next/next/no-img-element */
|
|
177
|
+
<img src={imageUrl} alt={content.alt_text || 'Block image'} className="h-full w-full object-cover" />
|
|
178
|
+
) : (
|
|
179
|
+
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
<div className="flex flex-col justify-center">
|
|
183
|
+
<span className="text-sm font-medium truncate max-w-[200px]">{content.alt_text || 'No description'}</span>
|
|
184
|
+
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{imageUrl ? 'Image set' : 'No image selected'}</span>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
case 'button': {
|
|
190
|
+
const content = (block.content || {}) as any;
|
|
191
|
+
return (
|
|
192
|
+
<div className={cn("py-2 flex",
|
|
193
|
+
content.position === 'center' ? 'justify-center' :
|
|
194
|
+
content.position === 'right' ? 'justify-end' : 'justify-start'
|
|
195
|
+
)}>
|
|
196
|
+
<Button
|
|
197
|
+
variant={content.variant || 'default'}
|
|
198
|
+
size={content.size || 'default'}
|
|
199
|
+
className={cn("pointer-events-none", content.variant === 'outline' && "text-foreground")}
|
|
200
|
+
tabIndex={-1}
|
|
201
|
+
>
|
|
202
|
+
{content.text || 'Button'}
|
|
203
|
+
</Button>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
case 'video_embed': {
|
|
208
|
+
const content = (block.content || {}) as any;
|
|
209
|
+
return (
|
|
210
|
+
<div className="py-2">
|
|
211
|
+
<div className="text-sm text-muted-foreground truncate">
|
|
212
|
+
📹 {content.title || content.url || 'No Video configured'}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
case 'posts_grid': {
|
|
218
|
+
const content = (block.content || {}) as any;
|
|
219
|
+
return (
|
|
220
|
+
<div className="py-2">
|
|
221
|
+
<div className="text-sm text-muted-foreground">
|
|
222
|
+
Posts Grid: {content.columns || 3} cols, {content.postsPerPage || 12} items
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
case 'testimonial': {
|
|
228
|
+
const content = (block.content || {}) as any;
|
|
229
|
+
return (
|
|
230
|
+
<div className="py-2">
|
|
231
|
+
<Card className="h-full border-none shadow-none bg-transparent">
|
|
232
|
+
<CardContent className="pt-2 flex flex-col gap-4 h-full p-4">
|
|
233
|
+
<MessageSquareQuote className="w-8 h-8 text-primary/40" />
|
|
234
|
+
|
|
235
|
+
<blockquote className="flex-grow text-lg italic text-muted-foreground leading-relaxed">
|
|
236
|
+
"{content.quote || 'No quote provided'}"
|
|
237
|
+
</blockquote>
|
|
238
|
+
|
|
239
|
+
<div className="flex items-center gap-3 mt-2">
|
|
240
|
+
<Avatar>
|
|
241
|
+
{content.image_url && <AvatarImage src={content.image_url} alt={content.author_name} />}
|
|
242
|
+
<AvatarFallback>{(content.author_name || 'A').slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
243
|
+
</Avatar>
|
|
244
|
+
|
|
245
|
+
<div>
|
|
246
|
+
<div className="font-semibold">{content.author_name || 'Author Name'}</div>
|
|
247
|
+
{content.author_title && (
|
|
248
|
+
<div className="text-sm text-muted-foreground">{content.author_title}</div>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</CardContent>
|
|
253
|
+
</Card>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
default: {
|
|
258
|
+
const blockDefinition = getBlockDefinition(currentBlockType as BlockType);
|
|
259
|
+
const blockLabel = blockDefinition?.label || currentBlockType;
|
|
260
|
+
return (
|
|
261
|
+
<div
|
|
262
|
+
className="py-4 flex flex-col items-center justify-center space-y-2 min-h-[80px] border border-dashed rounded-md bg-muted/20 cursor-pointer hover:border-primary"
|
|
263
|
+
onClick={handleCardClick}
|
|
264
|
+
>
|
|
265
|
+
<div className="text-center">
|
|
266
|
+
<p className="text-sm font-medium text-muted-foreground">{blockLabel}</p>
|
|
267
|
+
<p className="text-xs text-muted-foreground">Click edit to modify content</p>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
125
273
|
};
|
|
126
274
|
|
|
127
275
|
const isSection = block?.block_type === 'section' || block?.block_type === 'hero';
|
|
@@ -129,8 +277,10 @@ export default function EditableBlock({
|
|
|
129
277
|
|
|
130
278
|
return (
|
|
131
279
|
<div
|
|
280
|
+
onClick={handleCardClick}
|
|
132
281
|
className={cn(
|
|
133
282
|
"p-4 border rounded-lg bg-card shadow",
|
|
283
|
+
!isSection && "cursor-pointer hover:border-primary transition-colors",
|
|
134
284
|
className
|
|
135
285
|
)}
|
|
136
286
|
>
|
|
@@ -139,7 +289,7 @@ export default function EditableBlock({
|
|
|
139
289
|
<button {...dragHandleProps} className="p-1 rounded-md hover:bg-muted cursor-grab" aria-label="Drag to reorder">
|
|
140
290
|
<GripVertical className="h-5 w-5" />
|
|
141
291
|
</button>
|
|
142
|
-
<
|
|
292
|
+
<h4 className="font-semibold p-0 m-0 mb-1">{blockDefinition?.label || block.block_type}</h4>
|
|
143
293
|
</div>
|
|
144
294
|
<div className="flex items-center gap-1">
|
|
145
295
|
<Button
|
|
@@ -11,18 +11,18 @@ export type ButtonBlockContent = {
|
|
|
11
11
|
text?: string;
|
|
12
12
|
url?: string;
|
|
13
13
|
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
14
|
-
size?: 'default' | 'sm' | 'lg';
|
|
14
|
+
size?: 'default' | 'sm' | 'lg' | 'full';
|
|
15
|
+
position?: 'left' | 'center' | 'right';
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
const buttonVariants: ButtonBlockContent['variant'][] = ['default', 'outline', 'secondary', 'ghost', 'link'];
|
|
18
|
-
const buttonSizes: ButtonBlockContent['size'][] = ['default', 'sm', 'lg'];
|
|
19
|
+
const buttonSizes: ButtonBlockContent['size'][] = ['default', 'sm', 'lg', 'full'];
|
|
20
|
+
const buttonPositions: ButtonBlockContent['position'][] = ['left', 'center', 'right'];
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
export default function ButtonBlockEditor({ content, onChange }: BlockEditorProps<Partial<ButtonBlockContent>>) {
|
|
22
24
|
|
|
23
25
|
const handleChange = (field: keyof ButtonBlockContent, value: string) => {
|
|
24
|
-
// Ensure that when variant or size is cleared, it's set to undefined or a valid default, not an empty string if your type doesn't allow it.
|
|
25
|
-
// However, the Select component's onValueChange will provide valid values from the list or an empty string if placeholder is re-selected (which shouldn't happen here).
|
|
26
26
|
onChange({ ...content, [field]: value });
|
|
27
27
|
};
|
|
28
28
|
|
|
@@ -48,34 +48,52 @@ export default function ButtonBlockEditor({ content, onChange }: BlockEditorProp
|
|
|
48
48
|
className="mt-1"
|
|
49
49
|
/>
|
|
50
50
|
</div>
|
|
51
|
-
<div>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
51
|
+
<div className="grid grid-cols-2 gap-4">
|
|
52
|
+
<div>
|
|
53
|
+
<Label htmlFor="btn-variant">Variant</Label>
|
|
54
|
+
<Select
|
|
55
|
+
value={content.variant || "default"}
|
|
56
|
+
onValueChange={(val: string) => handleChange('variant', val)}
|
|
57
|
+
>
|
|
58
|
+
<SelectTrigger id="btn-variant" className="mt-1">
|
|
59
|
+
<SelectValue placeholder="Select variant" />
|
|
60
|
+
</SelectTrigger>
|
|
61
|
+
<SelectContent>
|
|
62
|
+
{buttonVariants.filter((v): v is Exclude<ButtonBlockContent['variant'], undefined> => v !== undefined).map(v => (
|
|
63
|
+
<SelectItem key={v} value={v}>{v.charAt(0).toUpperCase() + v.slice(1)}</SelectItem>
|
|
64
|
+
))}
|
|
65
|
+
</SelectContent>
|
|
66
|
+
</Select>
|
|
67
|
+
</div>
|
|
68
|
+
<div>
|
|
69
|
+
<Label htmlFor="btn-size">Size</Label>
|
|
70
|
+
<Select
|
|
71
|
+
value={content.size || "default"}
|
|
72
|
+
onValueChange={(val: string) => handleChange('size', val)}
|
|
73
|
+
>
|
|
74
|
+
<SelectTrigger id="btn-size" className="mt-1">
|
|
75
|
+
<SelectValue placeholder="Select size" />
|
|
76
|
+
</SelectTrigger>
|
|
77
|
+
<SelectContent>
|
|
78
|
+
{buttonSizes.filter((s): s is Exclude<ButtonBlockContent['size'], undefined> => s !== undefined).map(s => (
|
|
79
|
+
<SelectItem key={s} value={s}>{s.toUpperCase()}</SelectItem>
|
|
80
|
+
))}
|
|
81
|
+
</SelectContent>
|
|
82
|
+
</Select>
|
|
83
|
+
</div>
|
|
66
84
|
</div>
|
|
67
85
|
<div>
|
|
68
|
-
<Label htmlFor="btn-
|
|
86
|
+
<Label htmlFor="btn-position">Alignment</Label>
|
|
69
87
|
<Select
|
|
70
|
-
value={content.
|
|
71
|
-
onValueChange={(val: string) => handleChange('
|
|
88
|
+
value={content.position || "left"}
|
|
89
|
+
onValueChange={(val: string) => handleChange('position', val)}
|
|
72
90
|
>
|
|
73
|
-
<SelectTrigger id="btn-
|
|
74
|
-
<SelectValue placeholder="Select
|
|
91
|
+
<SelectTrigger id="btn-position" className="mt-1">
|
|
92
|
+
<SelectValue placeholder="Select alignment" />
|
|
75
93
|
</SelectTrigger>
|
|
76
94
|
<SelectContent>
|
|
77
|
-
{
|
|
78
|
-
<SelectItem key={
|
|
95
|
+
{buttonPositions.filter((p): p is Exclude<ButtonBlockContent['position'], undefined> => p !== undefined).map(p => (
|
|
96
|
+
<SelectItem key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</SelectItem>
|
|
79
97
|
))}
|
|
80
98
|
</SelectContent>
|
|
81
99
|
</Select>
|
|
@@ -38,6 +38,67 @@ interface SectionBlockEditorProps {
|
|
|
38
38
|
blockType: 'section' | 'hero';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Background style generator (Mirrors SectionBlockRenderer logic)
|
|
42
|
+
function generateBackgroundStyles(background: SectionBlockContent['background']) {
|
|
43
|
+
const styles: React.CSSProperties = {};
|
|
44
|
+
let className = '';
|
|
45
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
|
|
46
|
+
|
|
47
|
+
switch (background?.type) {
|
|
48
|
+
case 'theme': {
|
|
49
|
+
// Theme-based backgrounds using CSS classes
|
|
50
|
+
const themeClasses: Record<string, string> = {
|
|
51
|
+
primary: 'bg-primary text-primary-foreground',
|
|
52
|
+
secondary: 'bg-secondary text-secondary-foreground',
|
|
53
|
+
muted: 'bg-muted text-muted-foreground',
|
|
54
|
+
accent: 'bg-accent text-accent-foreground',
|
|
55
|
+
destructive: 'bg-destructive text-destructive-foreground'
|
|
56
|
+
};
|
|
57
|
+
className = background.theme ? themeClasses[background.theme] || '' : '';
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
case 'solid':
|
|
62
|
+
if (background.solid_color) {
|
|
63
|
+
styles.backgroundColor = background.solid_color;
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
case 'gradient':
|
|
68
|
+
if (background.gradient) {
|
|
69
|
+
const { type, direction, stops } = background.gradient;
|
|
70
|
+
const gradientStops = stops.map(stop => `${stop.color} ${stop.position}%`).join(', ');
|
|
71
|
+
styles.background = `${type}-gradient(${direction || 'to right'}, ${gradientStops})`;
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
|
|
75
|
+
case 'image':
|
|
76
|
+
if (background.image) {
|
|
77
|
+
const imageUrl = `${R2_BASE_URL}/${background.image.object_key}`;
|
|
78
|
+
styles.backgroundSize = background.image.size || 'cover';
|
|
79
|
+
styles.backgroundPosition = background.image.position || 'center';
|
|
80
|
+
|
|
81
|
+
let finalBackgroundImage = `url(${imageUrl})`;
|
|
82
|
+
|
|
83
|
+
if (background.image.overlay && background.image.overlay.gradient) {
|
|
84
|
+
const { type, direction, stops } = background.image.overlay.gradient;
|
|
85
|
+
const gradientStops = stops.map(stop => `${stop.color} ${stop.position}%`).join(', ');
|
|
86
|
+
const gradient = `${type}-gradient(${direction || 'to right'}, ${gradientStops})`;
|
|
87
|
+
finalBackgroundImage = `${gradient}, ${finalBackgroundImage}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
styles.backgroundImage = finalBackgroundImage;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
default:
|
|
95
|
+
// No background
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { styles, className };
|
|
100
|
+
}
|
|
101
|
+
|
|
41
102
|
export default function SectionBlockEditor({
|
|
42
103
|
content,
|
|
43
104
|
onChange,
|
|
@@ -72,6 +133,9 @@ export default function SectionBlockEditor({
|
|
|
72
133
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
73
134
|
const [draggedBlock, setDraggedBlock] = useState<any>(null);
|
|
74
135
|
|
|
136
|
+
// Generate background styles
|
|
137
|
+
const { styles: backgroundStyles, className: backgroundClassName } = generateBackgroundStyles(processedContent.background);
|
|
138
|
+
|
|
75
139
|
// DND sensors for cross-column dragging
|
|
76
140
|
const sensors = useSensors(
|
|
77
141
|
useSensor(PointerSensor, {
|
|
@@ -236,13 +300,6 @@ return (
|
|
|
236
300
|
)}
|
|
237
301
|
|
|
238
302
|
{/* Column Content Management */}
|
|
239
|
-
<div className="space-y-4">
|
|
240
|
-
<div className="flex items-center justify-between">
|
|
241
|
-
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
242
|
-
Column Content
|
|
243
|
-
</h3>
|
|
244
|
-
</div>
|
|
245
|
-
|
|
246
303
|
<SortableContext
|
|
247
304
|
items={(processedContent.column_blocks || [])
|
|
248
305
|
.flatMap((columnBlocks, columnIndex) =>
|
|
@@ -260,14 +317,15 @@ return (
|
|
|
260
317
|
strategy={verticalListSortingStrategy}
|
|
261
318
|
>
|
|
262
319
|
<div
|
|
263
|
-
className={
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
320
|
+
className={`grid gap-4 rounded-lg border transition-colors ${backgroundClassName} ${
|
|
321
|
+
(processedContent.column_blocks || []).length === 1
|
|
322
|
+
? "grid-cols-1"
|
|
323
|
+
: `grid-cols-${processedContent.responsive_columns.mobile} md:grid-cols-${processedContent.responsive_columns.tablet} lg:grid-cols-${processedContent.responsive_columns.desktop}`
|
|
324
|
+
}`}
|
|
325
|
+
style={{
|
|
326
|
+
...backgroundStyles,
|
|
327
|
+
minHeight: '200px'
|
|
328
|
+
}}
|
|
271
329
|
>
|
|
272
330
|
{Array.from({ length: (processedContent.column_blocks || []).length }, (_, columnIndex) => (
|
|
273
331
|
<ColumnEditor
|
|
@@ -278,6 +336,7 @@ return (
|
|
|
278
336
|
handleColumnBlocksChange(columnIndex, newBlocks)
|
|
279
337
|
}
|
|
280
338
|
blockType={blockType}
|
|
339
|
+
sectionBackground={processedContent.background}
|
|
281
340
|
/>
|
|
282
341
|
))}
|
|
283
342
|
</div>
|
|
@@ -332,7 +391,6 @@ return (
|
|
|
332
391
|
</div>
|
|
333
392
|
) : null}
|
|
334
393
|
</DragOverlay>
|
|
335
|
-
</div>
|
|
336
394
|
</div>
|
|
337
395
|
</DndContext>
|
|
338
396
|
);
|
|
@@ -27,6 +27,7 @@ export type TextBlockContent = {
|
|
|
27
27
|
export default function TextBlockEditor({
|
|
28
28
|
content,
|
|
29
29
|
onChange,
|
|
30
|
+
className,
|
|
30
31
|
}: BlockEditorProps<Partial<TextBlockContent>>) {
|
|
31
32
|
const labelId = useId();
|
|
32
33
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
@@ -50,6 +51,7 @@ export default function TextBlockEditor({
|
|
|
50
51
|
content={content?.html_content ?? ''}
|
|
51
52
|
onChange={(html) => onChange({ html_content: html })}
|
|
52
53
|
openImagePicker={openImagePicker}
|
|
54
|
+
className={className}
|
|
53
55
|
/>
|
|
54
56
|
|
|
55
57
|
{/* Hidden controlled MediaPickerDialog for image selection */}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import Link from "next/link";
|
|
3
|
+
import { Button } from "@nextblock-cms/ui";
|
|
4
|
+
import { cn } from "@nextblock-cms/utils";
|
|
5
|
+
|
|
3
6
|
export type ButtonBlockContent = {
|
|
4
7
|
text?: string;
|
|
5
8
|
url?: string;
|
|
6
9
|
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link';
|
|
7
|
-
size?: 'default' | 'sm' | 'lg';
|
|
10
|
+
size?: 'default' | 'sm' | 'lg' | 'full';
|
|
11
|
+
position?: 'left' | 'center' | 'right';
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
interface ButtonBlockRendererProps {
|
|
@@ -14,25 +18,7 @@ interface ButtonBlockRendererProps {
|
|
|
14
18
|
|
|
15
19
|
const ButtonBlockRenderer: React.FC<ButtonBlockRendererProps> = ({
|
|
16
20
|
content,
|
|
17
|
-
// languageId, // Unused
|
|
18
21
|
}) => {
|
|
19
|
-
const baseClasses =
|
|
20
|
-
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50";
|
|
21
|
-
const variantClasses: Record<string, string> = {
|
|
22
|
-
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
23
|
-
outline:
|
|
24
|
-
"border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
25
|
-
secondary:
|
|
26
|
-
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
27
|
-
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
28
|
-
link: "text-primary underline-offset-4 hover:underline",
|
|
29
|
-
};
|
|
30
|
-
const sizeClasses: Record<string, string> = {
|
|
31
|
-
default: "h-10 px-4 py-2",
|
|
32
|
-
sm: "h-9 rounded-md px-3",
|
|
33
|
-
lg: "h-11 rounded-md px-8",
|
|
34
|
-
};
|
|
35
|
-
|
|
36
22
|
const isExternal =
|
|
37
23
|
content.url?.startsWith("http") ||
|
|
38
24
|
content.url?.startsWith("mailto:") ||
|
|
@@ -42,48 +28,54 @@ const ButtonBlockRenderer: React.FC<ButtonBlockRendererProps> = ({
|
|
|
42
28
|
const buttonText = content.text || "Button";
|
|
43
29
|
const buttonVariant = content.variant || "default";
|
|
44
30
|
const buttonSize = content.size || "default";
|
|
31
|
+
const buttonPosition = content.position || "left";
|
|
32
|
+
|
|
33
|
+
const alignmentClasses = {
|
|
34
|
+
left: "justify-start text-left",
|
|
35
|
+
center: "justify-center text-center",
|
|
36
|
+
right: "justify-end text-right",
|
|
37
|
+
};
|
|
45
38
|
|
|
46
39
|
return (
|
|
47
|
-
<div className="my-6
|
|
40
|
+
<div className={cn("my-6 flex w-full", alignmentClasses[buttonPosition])}>
|
|
48
41
|
{/* Case 1: Internal link (not external, not anchor, has URL) */}
|
|
49
42
|
{!isExternal && !isAnchor && !!content.url ? (
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
sizeClasses[buttonSize],
|
|
56
|
-
].join(" ")}
|
|
43
|
+
<Button
|
|
44
|
+
asChild
|
|
45
|
+
variant={buttonVariant}
|
|
46
|
+
size={buttonSize}
|
|
47
|
+
className={cn(content.variant === 'outline' && "text-foreground")}
|
|
57
48
|
>
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
<Link href={content.url}>
|
|
50
|
+
{buttonText}
|
|
51
|
+
</Link>
|
|
52
|
+
</Button>
|
|
60
53
|
) : /* Case 2: External or Anchor link (has URL) */
|
|
61
54
|
(isExternal || isAnchor) && !!content.url ? (
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
baseClasses,
|
|
68
|
-
variantClasses[buttonVariant],
|
|
69
|
-
sizeClasses[buttonSize],
|
|
70
|
-
].join(" ")}
|
|
55
|
+
<Button
|
|
56
|
+
asChild
|
|
57
|
+
variant={buttonVariant}
|
|
58
|
+
size={buttonSize}
|
|
59
|
+
className={cn(content.variant === 'outline' && "text-foreground")}
|
|
71
60
|
>
|
|
72
|
-
|
|
73
|
-
|
|
61
|
+
<a
|
|
62
|
+
href={content.url}
|
|
63
|
+
target={isExternal ? "_blank" : undefined}
|
|
64
|
+
rel={isExternal ? "noopener noreferrer" : undefined}
|
|
65
|
+
>
|
|
66
|
+
{buttonText}
|
|
67
|
+
</a>
|
|
68
|
+
</Button>
|
|
74
69
|
) : (
|
|
75
70
|
/* Case 3: No URL or other edge cases - render a plain or disabled button */
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
sizeClasses[buttonSize],
|
|
82
|
-
].join(" ")}
|
|
83
|
-
disabled={!content.url}
|
|
71
|
+
<Button
|
|
72
|
+
variant={buttonVariant}
|
|
73
|
+
size={buttonSize}
|
|
74
|
+
disabled={!content.url}
|
|
75
|
+
className={cn(content.variant === 'outline' && "text-foreground")}
|
|
84
76
|
>
|
|
85
77
|
{buttonText}
|
|
86
|
-
</
|
|
78
|
+
</Button>
|
|
87
79
|
)}
|
|
88
80
|
</div>
|
|
89
81
|
);
|
|
@@ -44,7 +44,8 @@ export const ButtonBlockSchema = z.object({
|
|
|
44
44
|
text: z.string().describe('The text displayed on the button'),
|
|
45
45
|
url: z.string().describe('The URL the button links to'),
|
|
46
46
|
variant: z.enum(['default', 'outline', 'secondary', 'ghost', 'link']).optional().describe('Visual style variant'),
|
|
47
|
-
size: z.enum(['default', 'sm', 'lg']).optional().describe('Size of the button'),
|
|
47
|
+
size: z.enum(['default', 'sm', 'lg', 'full']).optional().describe('Size of the button'),
|
|
48
|
+
position: z.enum(['left', 'center', 'right']).optional().describe('Button alignment'),
|
|
48
49
|
});
|
|
49
50
|
export type ButtonBlockContent = z.infer<typeof ButtonBlockSchema>;
|
|
50
51
|
|
|
@@ -281,7 +282,7 @@ export const blockRegistry: Record<BlockType, BlockDefinition> = {
|
|
|
281
282
|
type: "button",
|
|
282
283
|
label: "Button",
|
|
283
284
|
icon: "SquareMousePointer",
|
|
284
|
-
initialContent: { text: "Click Me", url: "#", variant: "default", size: "default" } as ButtonBlockContent,
|
|
285
|
+
initialContent: { text: "Click Me", url: "#", variant: "default", size: "default", position: "left" } as ButtonBlockContent,
|
|
285
286
|
editorComponentFilename: "ButtonBlockEditor.tsx",
|
|
286
287
|
rendererComponentFilename: "ButtonBlockRenderer.tsx",
|
|
287
288
|
schema: ButtonBlockSchema,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|