@jhits/botanics_and_you 0.0.1
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 +39 -0
- package/src/index.ts +10 -0
- package/src/plugin-blog/api-config.ts +10 -0
- package/src/plugin-blog/blocks/CategoryDropdown.tsx +167 -0
- package/src/plugin-blog/blocks/Heading.tsx +138 -0
- package/src/plugin-blog/blocks/Hero.tsx +228 -0
- package/src/plugin-blog/blocks/Image.tsx +474 -0
- package/src/plugin-blog/blocks/List.tsx +305 -0
- package/src/plugin-blog/blocks/Paragraph.tsx +85 -0
- package/src/plugin-blog/blocks/Recipe.tsx +227 -0
- package/src/plugin-blog/blocks/Table.tsx +259 -0
- package/src/plugin-blog/blocks.ts +32 -0
- package/src/plugin-blog/index.ts +25 -0
- package/src/plugin-blog/theme.css +17 -0
- package/src/plugin-blog/theme.ts +30 -0
- package/tsconfig.json +23 -0
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jhits/botanics_and_you",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "BotanicsAndYou client configuration for JHITS plugins",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./plugin-blog": {
|
|
16
|
+
"types": "./src/plugin-blog/index.ts",
|
|
17
|
+
"default": "./src/plugin-blog/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./plugin-blog/theme": {
|
|
20
|
+
"types": "./src/plugin-blog/theme.ts",
|
|
21
|
+
"default": "./src/plugin-blog/theme.css"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=18.0.0",
|
|
26
|
+
"react-dom": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"lucide-react": "^0.562.0",
|
|
30
|
+
"react": "^19.2.3",
|
|
31
|
+
"react-dom": "^19.2.3",
|
|
32
|
+
"@jhits/plugin-blog": "0.0.7",
|
|
33
|
+
"@jhits/plugin-images": "0.0.5"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/react": "^19.2.9",
|
|
37
|
+
"@types/react-dom": "^19.2.3"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BotanicsAndYou Client Configuration for JHITS
|
|
3
|
+
*
|
|
4
|
+
* This package contains all client-specific plugin configurations.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { blogPluginConfig, botanyBlocks } from '@jhits/BotanicsAndYou/plugin-blog';
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from './plugin-blog';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// API Configuration for BotanicsAndYou package
|
|
2
|
+
|
|
3
|
+
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
|
|
4
|
+
|| (process.env.NODE_ENV === 'production' ? 'https://your-domain.com' : 'http://localhost:3001');
|
|
5
|
+
|
|
6
|
+
export function createApiUrl(path: string): string {
|
|
7
|
+
return `${API_BASE_URL}/api${path}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export { API_BASE_URL };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import { ChevronDown, Plus } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
export interface CategoryDropdownProps {
|
|
7
|
+
value: string;
|
|
8
|
+
onChange: (category: string) => void;
|
|
9
|
+
existingCategories: string[];
|
|
10
|
+
className?: string;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
showLabel?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CategoryDropdown({
|
|
16
|
+
value,
|
|
17
|
+
onChange,
|
|
18
|
+
existingCategories = [],
|
|
19
|
+
className = '',
|
|
20
|
+
placeholder = 'Select or add category...',
|
|
21
|
+
showLabel = false,
|
|
22
|
+
}: CategoryDropdownProps) {
|
|
23
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
24
|
+
const [inputValue, setInputValue] = useState('');
|
|
25
|
+
const [isAddingNew, setIsAddingNew] = useState(false);
|
|
26
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
28
|
+
|
|
29
|
+
// This ref tracks if we have successfully set an initial value
|
|
30
|
+
// to avoid snapping back to the first item if the user manually clears the field
|
|
31
|
+
const hasAutoSelected = useRef(false);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* AUTO-SELECT LOGIC
|
|
35
|
+
* We watch 'existingCategories'. As soon as it populates and we have no value, we pick the first.
|
|
36
|
+
*/
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const isValueEmpty = !value || value.trim() === '';
|
|
39
|
+
|
|
40
|
+
if (isValueEmpty && existingCategories.length > 0 && !hasAutoSelected.current) {
|
|
41
|
+
const firstCategory = existingCategories.find(cat => cat && cat.trim() !== '');
|
|
42
|
+
if (firstCategory) {
|
|
43
|
+
// Set the flag FIRST to prevent race conditions
|
|
44
|
+
hasAutoSelected.current = true;
|
|
45
|
+
|
|
46
|
+
// Use requestAnimationFrame to ensure we are outside the
|
|
47
|
+
// render phase of the category fetch completion
|
|
48
|
+
requestAnimationFrame(() => {
|
|
49
|
+
onChange(firstCategory);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// If a value is manually set (by user or saved data),
|
|
55
|
+
// we mark auto-select as "done" so it doesn't fight the user
|
|
56
|
+
if (!isValueEmpty) {
|
|
57
|
+
hasAutoSelected.current = true;
|
|
58
|
+
}
|
|
59
|
+
}, [existingCategories, value, onChange]);
|
|
60
|
+
|
|
61
|
+
// UI filtering
|
|
62
|
+
const otherCategories = existingCategories.filter(
|
|
63
|
+
cat => cat && cat.trim() !== '' && cat !== value
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Outside Click Handler
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
69
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
70
|
+
setIsOpen(false);
|
|
71
|
+
setIsAddingNew(false);
|
|
72
|
+
setInputValue('');
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
if (isOpen) {
|
|
76
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
77
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
78
|
+
}
|
|
79
|
+
}, [isOpen]);
|
|
80
|
+
|
|
81
|
+
// Focus on new input
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (isAddingNew && inputRef.current) {
|
|
84
|
+
inputRef.current.focus();
|
|
85
|
+
}
|
|
86
|
+
}, [isAddingNew]);
|
|
87
|
+
|
|
88
|
+
const handleSelect = (category: string) => {
|
|
89
|
+
onChange(category);
|
|
90
|
+
setIsOpen(false);
|
|
91
|
+
setIsAddingNew(false);
|
|
92
|
+
setInputValue('');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className={`relative ${className}`} ref={dropdownRef}>
|
|
97
|
+
{showLabel && (
|
|
98
|
+
<label className="text-[10px] text-neutral-500 uppercase font-bold block mb-2 tracking-widest">
|
|
99
|
+
Category
|
|
100
|
+
</label>
|
|
101
|
+
)}
|
|
102
|
+
<div className="relative">
|
|
103
|
+
<div
|
|
104
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
105
|
+
className="w-full bg-transparent border border-sage/20 rounded-full px-4 py-1.5 text-xs font-bold uppercase tracking-[0.2em] text-sage flex items-center justify-between cursor-pointer hover:bg-sage/5 transition-all"
|
|
106
|
+
>
|
|
107
|
+
<span className="truncate">{value || placeholder}</span>
|
|
108
|
+
<ChevronDown size={14} className={`transition-transform text-sage/50 ${isOpen ? 'rotate-180' : ''}`} />
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{isOpen && (
|
|
112
|
+
<div className="absolute z-50 w-full mt-2 bg-white dark:bg-neutral-900 rounded-xl shadow-xl border border-neutral-100 dark:border-neutral-800 overflow-hidden animate-in fade-in zoom-in-95 duration-150">
|
|
113
|
+
{otherCategories.length > 0 && (
|
|
114
|
+
<div className="py-1 max-h-48 overflow-y-auto">
|
|
115
|
+
{otherCategories.map((category) => (
|
|
116
|
+
<button
|
|
117
|
+
key={category}
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => handleSelect(category)}
|
|
120
|
+
className="w-full text-left px-4 py-2.5 text-xs font-bold text-neutral-700 dark:text-neutral-300 hover:bg-sage/5 hover:text-sage transition-colors"
|
|
121
|
+
>
|
|
122
|
+
{category}
|
|
123
|
+
</button>
|
|
124
|
+
))}
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{!isAddingNew ? (
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setIsAddingNew(true)}
|
|
132
|
+
className="w-full text-left px-4 py-3 text-xs font-bold text-sage hover:bg-primary/5 transition-colors border-t border-neutral-50 dark:border-neutral-800 flex items-center gap-2"
|
|
133
|
+
>
|
|
134
|
+
<Plus size={14} /> Add new category
|
|
135
|
+
</button>
|
|
136
|
+
) : (
|
|
137
|
+
<div className="p-3 border-t border-neutral-50 dark:border-neutral-800 bg-neutral-50/50 dark:bg-neutral-800/50">
|
|
138
|
+
<input
|
|
139
|
+
ref={inputRef}
|
|
140
|
+
value={inputValue}
|
|
141
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
142
|
+
onKeyDown={(e) => e.key === 'Enter' && inputValue.trim() && handleSelect(inputValue.trim())}
|
|
143
|
+
placeholder="Category name..."
|
|
144
|
+
className="w-full border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-2 rounded-lg text-xs font-bold outline-none focus:border-sage mb-2"
|
|
145
|
+
/>
|
|
146
|
+
<div className="flex gap-2">
|
|
147
|
+
<button
|
|
148
|
+
onClick={() => inputValue.trim() && handleSelect(inputValue.trim())}
|
|
149
|
+
className="flex-1 bg-sage text-white px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider"
|
|
150
|
+
>
|
|
151
|
+
Add
|
|
152
|
+
</button>
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => { setIsAddingNew(false); setInputValue(''); }}
|
|
155
|
+
className="px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase border border-neutral-200 dark:border-neutral-700 text-neutral-500"
|
|
156
|
+
>
|
|
157
|
+
Cancel
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { BlockEditProps, BlockPreviewProps, ClientBlockDefinition } from "@jhits/plugin-blog";
|
|
4
|
+
import { Heading, ChevronDown } from "lucide-react";
|
|
5
|
+
import { useState, useRef, useEffect } from "react";
|
|
6
|
+
|
|
7
|
+
const getClassName = (level: number) => `
|
|
8
|
+
font-serif text-forest
|
|
9
|
+
${level === 1 ? "text-5xl lg:text-6xl mt-12 mb-8 font-bold" : ""}
|
|
10
|
+
${level === 2 ? "text-4xl lg:text-5xl mt-10 mb-2" : ""}
|
|
11
|
+
${level === 3 ? "text-3xl mt-8 mb-4 text-forest/90" : ""}
|
|
12
|
+
${level === 4 ? "text-xl mt-6 mb-3 font-medium text-sage italic" : ""}
|
|
13
|
+
${level === 5 ? "text-lg mt-4 mb-2 font-medium text-sage" : ""}
|
|
14
|
+
${level === 6 ? "text-base mt-3 mb-2 font-medium text-sage/80" : ""}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Heading Block
|
|
19
|
+
* Simple heading block for botanical articles with SEO-optimized heading levels
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const HeadingEdit: React.FC<BlockEditProps> = ({ block, onUpdate, isSelected }) => {
|
|
23
|
+
const level = (block.data.level as number) || 2;
|
|
24
|
+
const text = (block.data.text as string) || '';
|
|
25
|
+
const [showLevelDropdown, setShowLevelDropdown] = useState(false);
|
|
26
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
|
|
28
|
+
const defaultClassName = getClassName(level);
|
|
29
|
+
|
|
30
|
+
// Close dropdown when clicking outside
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
33
|
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
34
|
+
setShowLevelDropdown(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
if (showLevelDropdown) {
|
|
39
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
40
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
41
|
+
}
|
|
42
|
+
}, [showLevelDropdown]);
|
|
43
|
+
|
|
44
|
+
const handleLevelChange = (newLevel: number) => {
|
|
45
|
+
onUpdate({ level: newLevel });
|
|
46
|
+
setShowLevelDropdown(false);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="relative group">
|
|
51
|
+
<div className="flex items-center gap-3">
|
|
52
|
+
{/* Heading Level Selector */}
|
|
53
|
+
<div className="relative" ref={dropdownRef}>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => setShowLevelDropdown(!showLevelDropdown)}
|
|
57
|
+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-200 bg-white hover:bg-gray-50 transition-colors text-xs font-bold text-gray-600"
|
|
58
|
+
title="Select heading level for SEO"
|
|
59
|
+
>
|
|
60
|
+
<span>H{level}</span>
|
|
61
|
+
<ChevronDown size={12} className={`transition-transform ${showLevelDropdown ? 'rotate-180' : ''}`} />
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
{showLevelDropdown && (
|
|
65
|
+
<div className="absolute z-50 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden min-w-[120px]">
|
|
66
|
+
{[1, 2, 3, 4, 5, 6].map((lvl) => (
|
|
67
|
+
<button
|
|
68
|
+
key={lvl}
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => handleLevelChange(lvl)}
|
|
71
|
+
className={`w-full text-left px-3 py-2 text-xs font-bold transition-colors ${level === lvl
|
|
72
|
+
? 'bg-primary/10 text-primary'
|
|
73
|
+
: 'text-gray-700 hover:bg-gray-50'
|
|
74
|
+
}`}
|
|
75
|
+
>
|
|
76
|
+
H{lvl} {lvl === 1 ? '(Page Title)' : lvl === 2 ? '(Section)' : lvl === 3 ? '(Subsection)' : ''}
|
|
77
|
+
</button>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Heading Text Input */}
|
|
84
|
+
<input
|
|
85
|
+
type="text"
|
|
86
|
+
value={text}
|
|
87
|
+
onChange={(e) => onUpdate({ text: e.target.value })}
|
|
88
|
+
placeholder="Enter heading text..."
|
|
89
|
+
className={`flex-1 ${defaultClassName}`}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const HeadingPreview: React.FC<BlockPreviewProps> = ({ block }) => {
|
|
97
|
+
const level = Math.min(Math.max((block.data.level as number) || 2, 1), 6);
|
|
98
|
+
const text = (block.data.text as string) || '';
|
|
99
|
+
|
|
100
|
+
const defaultClassName = getClassName(level);
|
|
101
|
+
|
|
102
|
+
switch (level) {
|
|
103
|
+
case 1:
|
|
104
|
+
return <h1 className={defaultClassName}>{text}</h1>;
|
|
105
|
+
case 3:
|
|
106
|
+
return <h3 className={defaultClassName}>{text}</h3>;
|
|
107
|
+
case 4:
|
|
108
|
+
return <h4 className={defaultClassName}>{text}</h4>;
|
|
109
|
+
case 5:
|
|
110
|
+
return <h5 className={defaultClassName}>{text}</h5>;
|
|
111
|
+
case 6:
|
|
112
|
+
return <h6 className={defaultClassName}>{text}</h6>;
|
|
113
|
+
default:
|
|
114
|
+
return <h2 className={defaultClassName}>{text}</h2>;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const HeadingBlock: ClientBlockDefinition = {
|
|
119
|
+
type: 'heading',
|
|
120
|
+
name: 'Heading',
|
|
121
|
+
description: 'Add a heading (H1-H6)',
|
|
122
|
+
icon: Heading,
|
|
123
|
+
defaultData: {
|
|
124
|
+
text: '',
|
|
125
|
+
level: 2,
|
|
126
|
+
},
|
|
127
|
+
category: 'text',
|
|
128
|
+
validate: (data: Record<string, unknown>) => {
|
|
129
|
+
return typeof data.text === 'string' &&
|
|
130
|
+
typeof data.level === 'number' &&
|
|
131
|
+
data.level >= 1 && data.level <= 6;
|
|
132
|
+
},
|
|
133
|
+
components: {
|
|
134
|
+
Edit: HeadingEdit,
|
|
135
|
+
Preview: HeadingPreview,
|
|
136
|
+
Icon: Heading,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { Image as ImageIcon } from 'lucide-react';
|
|
6
|
+
import { BlockEditProps, BlockPreviewProps, ClientBlockDefinition } from '@jhits/plugin-blog';
|
|
7
|
+
import { useCategories } from '@jhits/plugin-blog';
|
|
8
|
+
import { ImagePicker, Image as PluginImage } from '@jhits/plugin-images';
|
|
9
|
+
import { CategoryDropdown } from './CategoryDropdown';
|
|
10
|
+
import { createApiUrl } from '../api-config';
|
|
11
|
+
|
|
12
|
+
interface HeroData {
|
|
13
|
+
title: string;
|
|
14
|
+
category: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
imageId?: string;
|
|
17
|
+
brightness?: number;
|
|
18
|
+
blur?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 2. Hero Edit Component
|
|
23
|
+
*/
|
|
24
|
+
export const HeroEdit: React.FC<BlockEditProps> = ({ block, onUpdate }) => {
|
|
25
|
+
const [showImagePicker, setShowImagePicker] = useState(false);
|
|
26
|
+
const [currentBrightness, setCurrentBrightness] = useState<number>(100);
|
|
27
|
+
const [currentBlur, setCurrentBlur] = useState<number>(0);
|
|
28
|
+
const [mounted, setMounted] = useState(false);
|
|
29
|
+
const { categories } = useCategories();
|
|
30
|
+
|
|
31
|
+
// Handle SSR - ensure we only render portal on client
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setMounted(true);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const data = (block.data || {}) as unknown as HeroData;
|
|
37
|
+
const title = data.title || '';
|
|
38
|
+
const category = data.category || '';
|
|
39
|
+
const summary = data.summary || '';
|
|
40
|
+
const imageId = data.imageId;
|
|
41
|
+
|
|
42
|
+
// Sync effects
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (imageId) {
|
|
45
|
+
fetch(createApiUrl(`/plugin-images/resolve?id=${encodeURIComponent(imageId)}`))
|
|
46
|
+
.then(res => res.ok ? res.json() : null)
|
|
47
|
+
.then(resolved => {
|
|
48
|
+
if (resolved) {
|
|
49
|
+
setCurrentBrightness(data.brightness ?? resolved.brightness ?? 100);
|
|
50
|
+
setCurrentBlur(data.blur ?? resolved.blur ?? 0);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {
|
|
54
|
+
setCurrentBrightness(100);
|
|
55
|
+
setCurrentBlur(0);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}, [imageId, data.brightness, data.blur]);
|
|
59
|
+
|
|
60
|
+
const updateField = (updates: Partial<HeroData>) => {
|
|
61
|
+
onUpdate({ ...data, ...updates });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<section className="relative pt-32 pb-20 overflow-hidden group/hero">
|
|
66
|
+
<div className="max-w-7xl mx-auto px-6 lg:px-12 grid lg:grid-cols-2 gap-16 items-center">
|
|
67
|
+
<div className="order-2 lg:order-1 text-left flex flex-col items-start">
|
|
68
|
+
<CategoryDropdown
|
|
69
|
+
value={category}
|
|
70
|
+
onChange={(newCategory: string) => updateField({ category: newCategory })}
|
|
71
|
+
existingCategories={categories}
|
|
72
|
+
placeholder="CATEGORIE"
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<textarea
|
|
76
|
+
value={title}
|
|
77
|
+
onChange={(e) => updateField({ title: e.target.value })}
|
|
78
|
+
placeholder="Artikel Titel..."
|
|
79
|
+
rows={2}
|
|
80
|
+
className="w-full text-5xl lg:text-7xl font-serif text-forest mb-8 leading-[1.1] tracking-tight bg-transparent border-none outline-none focus:ring-0 p-0 resize-none placeholder:text-gray-200 overflow-hidden"
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
<textarea
|
|
84
|
+
value={summary}
|
|
85
|
+
onChange={(e) => updateField({ summary: e.target.value })}
|
|
86
|
+
placeholder="Klik om een samenvatting te typen..."
|
|
87
|
+
rows={3}
|
|
88
|
+
className="w-full text-lg lg:text-xl text-gray-600 leading-relaxed font-light bg-transparent border-none outline-none focus:ring-0 p-0 resize-none placeholder:text-gray-300"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Image Side - Fixed Container */}
|
|
93
|
+
<div className="order-1 lg:order-2 w-full">
|
|
94
|
+
<div
|
|
95
|
+
className="relative rounded-3xl overflow-hidden shadow-2xl aspect-[4/5] lg:aspect-[3/4] bg-gray-50 border border-gray-100 cursor-pointer group/imgcontainer"
|
|
96
|
+
onClick={() => setShowImagePicker(true)}
|
|
97
|
+
>
|
|
98
|
+
{imageId ? (
|
|
99
|
+
<PluginImage
|
|
100
|
+
id={imageId}
|
|
101
|
+
alt={title || 'Hero'}
|
|
102
|
+
fill
|
|
103
|
+
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
104
|
+
className="object-cover w-full h-full transition-transform duration-700 group-hover/imgcontainer:scale-105"
|
|
105
|
+
editable={false}
|
|
106
|
+
/>
|
|
107
|
+
) : (
|
|
108
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
|
|
109
|
+
<ImageIcon size={48} className="mb-2" />
|
|
110
|
+
<p className="text-sm font-bold">Add Image</p>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover/imgcontainer:opacity-100 transition-opacity flex items-center justify-center z-10">
|
|
115
|
+
<div className="bg-white px-4 py-2 rounded-full flex items-center gap-2">
|
|
116
|
+
<ImageIcon size={16} className="text-primary" />
|
|
117
|
+
<span className="text-sm font-bold text-neutral-900">Edit Image</span>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{showImagePicker && mounted && createPortal(
|
|
125
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
126
|
+
<div className="bg-white dark:bg-neutral-900 rounded-2xl w-full max-w-2xl p-6 shadow-2xl max-h-[90vh] overflow-y-auto">
|
|
127
|
+
<div className="flex justify-between items-center mb-4">
|
|
128
|
+
<h3 className="font-bold text-neutral-900 dark:text-white">Hero Image Settings</h3>
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => setShowImagePicker(false)}
|
|
131
|
+
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors text-neutral-700 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white"
|
|
132
|
+
aria-label="Close"
|
|
133
|
+
>
|
|
134
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
135
|
+
<line x1="5" y1="5" x2="15" y2="15"></line>
|
|
136
|
+
<line x1="15" y1="5" x2="5" y2="15"></line>
|
|
137
|
+
</svg>
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
<ImagePicker
|
|
141
|
+
value={imageId}
|
|
142
|
+
onChange={(img) => {
|
|
143
|
+
updateField({ imageId: img?.id });
|
|
144
|
+
setTimeout(() => setShowImagePicker(false), 100);
|
|
145
|
+
}}
|
|
146
|
+
showEffects={true}
|
|
147
|
+
brightness={currentBrightness}
|
|
148
|
+
blur={currentBlur}
|
|
149
|
+
onBrightnessChange={(b) => { setCurrentBrightness(b); updateField({ brightness: b }); }}
|
|
150
|
+
onBlurChange={(bl) => { setCurrentBlur(bl); updateField({ blur: bl }); }}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
</div>,
|
|
154
|
+
document.body
|
|
155
|
+
)}
|
|
156
|
+
</section>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 3. Hero Preview Component
|
|
162
|
+
*/
|
|
163
|
+
export const HeroPreview: React.FC<BlockPreviewProps & { fallbackImage?: { id?: string; src?: string } }> = ({ block, fallbackImage, context }) => {
|
|
164
|
+
const data = (block.data || {}) as unknown as HeroData;
|
|
165
|
+
const contextFallback = (context as any)?.fallbackImage;
|
|
166
|
+
const effectiveFallback = fallbackImage || contextFallback;
|
|
167
|
+
|
|
168
|
+
// Logic: Use data.imageId, then fallback.id, then fallback.src
|
|
169
|
+
const activeImageId = data.imageId || effectiveFallback?.id || effectiveFallback?.src;
|
|
170
|
+
|
|
171
|
+
const paragraphs = (data.summary || '').split('\n\n').filter(p => p.trim());
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<section className="relative pt-32 pb-20">
|
|
175
|
+
<div className="max-w-7xl mx-auto px-6 lg:px-12 grid lg:grid-cols-2 gap-16 items-center">
|
|
176
|
+
<div className="order-2 lg:order-1">
|
|
177
|
+
<span className="inline-flex px-4 py-1.5 rounded-full border border-sage/20 bg-sage/5 text-sage text-xs font-bold uppercase tracking-widest mb-8">
|
|
178
|
+
{data.category || 'Plant Care'}
|
|
179
|
+
</span>
|
|
180
|
+
<h1 className="text-5xl lg:text-7xl font-serif text-forest mb-8 leading-[1.1]">
|
|
181
|
+
{data.title || 'Zonder Titel'}
|
|
182
|
+
</h1>
|
|
183
|
+
{paragraphs.map((p, i) => (
|
|
184
|
+
<p key={i} className="text-lg lg:text-xl text-gray-600 font-light mt-4">
|
|
185
|
+
{p}
|
|
186
|
+
</p>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div className="order-1 lg:order-2 w-full">
|
|
191
|
+
{/* The aspect ratio div MUST be relative and have a background to prevent collapse */}
|
|
192
|
+
<div className="relative w-full aspect-[4/5] lg:aspect-[3/4] rounded-3xl overflow-hidden shadow-2xl bg-neutral-100">
|
|
193
|
+
{activeImageId && (
|
|
194
|
+
<PluginImage
|
|
195
|
+
id={activeImageId}
|
|
196
|
+
alt={data.title || 'Hero'}
|
|
197
|
+
fill
|
|
198
|
+
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
199
|
+
className="object-cover w-full h-full"
|
|
200
|
+
editable={false}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</section>
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export const heroBlock: ClientBlockDefinition = {
|
|
211
|
+
type: 'hero',
|
|
212
|
+
name: 'Hero Sectie',
|
|
213
|
+
description: 'De Botanics split-view introductie',
|
|
214
|
+
icon: ImageIcon,
|
|
215
|
+
category: 'layout',
|
|
216
|
+
defaultData: {
|
|
217
|
+
title: '',
|
|
218
|
+
category: '',
|
|
219
|
+
summary: '',
|
|
220
|
+
imageId: undefined,
|
|
221
|
+
},
|
|
222
|
+
validate: (data: Record<string, unknown>) => typeof data.title === 'string',
|
|
223
|
+
components: {
|
|
224
|
+
Edit: HeroEdit,
|
|
225
|
+
Preview: HeroPreview,
|
|
226
|
+
Icon: ImageIcon,
|
|
227
|
+
},
|
|
228
|
+
};
|