@morphika/andami 0.5.1 → 0.5.2
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/app/admin/assets/page.tsx +6 -6
- package/app/admin/database/page.tsx +302 -302
- package/app/admin/error.tsx +53 -53
- package/app/admin/layout.tsx +320 -320
- package/app/admin/navigation/page.tsx +255 -255
- package/app/admin/pages/[slug]/page.tsx +6 -6
- package/app/admin/pages/page.tsx +11 -11
- package/app/admin/projects/page.tsx +14 -14
- package/app/admin/setup/page.tsx +1 -1
- package/app/admin/styles/page.tsx +1 -1
- package/components/admin/MetadataEditor.tsx +6 -6
- package/components/admin/nav-builder/NavBuilder.tsx +1 -1
- package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
- package/components/admin/nav-builder/NavGridCell.tsx +48 -48
- package/components/admin/nav-builder/NavGridItem.tsx +4 -4
- package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
- package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
- package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
- package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
- package/components/admin/nav-builder/NavSettingsFields.tsx +514 -514
- package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
- package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
- package/components/admin/setup-wizard/DoneStep.tsx +1 -1
- package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
- package/components/admin/setup-wizard/StorageStep.tsx +2 -2
- package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
- package/components/admin/styles/ColorsEditor.tsx +2 -2
- package/components/admin/styles/FontsEditor.tsx +6 -6
- package/components/admin/styles/GridLayoutEditor.tsx +9 -9
- package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
- package/components/admin/styles/TypographyEditor.tsx +6 -6
- package/components/admin/styles/shared.tsx +68 -68
- package/components/blocks/AudioBlockRenderer.tsx +286 -286
- package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
- package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
- package/components/builder/BlockCardIcons.tsx +316 -316
- package/components/builder/BlockTypePicker.tsx +1 -1
- package/components/builder/BubbleIcons.tsx +90 -0
- package/components/builder/BuilderCanvas.tsx +2 -0
- package/components/builder/CanvasMinimap.tsx +2 -2
- package/components/builder/CoverSectionCanvas.tsx +363 -363
- package/components/builder/DeviceFrame.tsx +1 -1
- package/components/builder/DndWrapper.tsx +3 -3
- package/components/builder/InsertionLines.tsx +1 -1
- package/components/builder/SectionCardIcons.tsx +421 -320
- package/components/builder/SectionEditorBar.tsx +1 -1
- package/components/builder/SectionTypePicker.tsx +4 -4
- package/components/builder/SectionV2Canvas.tsx +1 -1
- package/components/builder/SectionV2Column.tsx +69 -67
- package/components/builder/SortableBlock.tsx +93 -73
- package/components/builder/SortableRow.tsx +27 -26
- package/components/builder/VirtualAssetGrid.tsx +2 -2
- package/components/builder/asset-browser/R2BrowserContent.tsx +11 -11
- package/components/builder/blockStyles.tsx +192 -185
- package/components/builder/color-picker/AlphaSlider.tsx +141 -141
- package/components/builder/color-picker/ColorInputs.tsx +105 -105
- package/components/builder/color-picker/EyedropperButton.tsx +74 -74
- package/components/builder/color-picker/HueSlider.tsx +124 -124
- package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
- package/components/builder/color-picker/SwatchBar.tsx +93 -93
- package/components/builder/editors/AudioBlockEditor.tsx +242 -242
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -360
- package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
- package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
- package/components/builder/editors/HoverEffectPicker.tsx +2 -2
- package/components/builder/editors/ImageBlockEditor.tsx +2 -2
- package/components/builder/editors/ImageGridBlockEditor.tsx +4 -4
- package/components/builder/editors/MarqueeBlockEditor.tsx +621 -0
- package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
- package/components/builder/editors/ProjectGridEditor.tsx +9 -9
- package/components/builder/editors/SpacerBlockEditor.tsx +5 -5
- package/components/builder/editors/StaggerSettings.tsx +109 -109
- package/components/builder/editors/TextBlockEditor.tsx +3 -3
- package/components/builder/editors/TextStylePicker.tsx +1 -1
- package/components/builder/editors/VideoBlockEditor.tsx +2 -2
- package/components/builder/editors/index.ts +11 -10
- package/components/builder/editors/shared.tsx +6 -6
- package/components/builder/live-preview/LiveAudioPreview.tsx +120 -120
- package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +1 -1
- package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
- package/components/builder/live-preview/LiveImagePreview.tsx +1 -1
- package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
- package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
- package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
- package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -291
- package/components/builder/settings-panel/AnimationTab.tsx +138 -138
- package/components/builder/settings-panel/BlockLayoutTab.tsx +7 -7
- package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
- package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
- package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
- package/components/builder/settings-panel/CoverSectionSettings.tsx +335 -335
- package/components/builder/settings-panel/PageSettings.tsx +3 -3
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
- package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
- package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
- package/lib/animation/enter-types.ts +1 -0
- package/lib/animation/hover-effect-presets.ts +210 -210
- package/lib/animation/hover-effect-types.ts +1 -0
- package/lib/builder/block-registrations.ts +468 -417
- package/lib/builder/constants.ts +111 -111
- package/lib/builder/store-sections.ts +2 -2
- package/lib/builder/types-slices.ts +414 -414
- package/lib/builder/types.ts +4 -1
- package/lib/config/index.ts +27 -27
- package/lib/sanity/types.ts +98 -1
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/audioBlock.ts +69 -69
- package/sanity/schemas/blocks/index.ts +12 -11
- package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
- package/sanity/schemas/index.ts +120 -117
- package/styles/admin.css +85 -85
- package/styles/animations.css +237 -237
- package/styles/base.css +114 -114
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MarqueeBlockEditor — Settings editor for the marqueeBlock.
|
|
5
|
+
*
|
|
6
|
+
* Sections:
|
|
7
|
+
* - Content (items list with add / reorder / remove, inline edit)
|
|
8
|
+
* - Motion (direction, speed, pause on hover)
|
|
9
|
+
* - Typography (font size, weight, color, text style, letter spacing, transform)
|
|
10
|
+
* - Layout (gap, row height, vertical padding, background color)
|
|
11
|
+
*
|
|
12
|
+
* Items reorder uses native HTML5 drag & drop — no dnd-kit dependency. Kept
|
|
13
|
+
* intentionally lightweight because the item list rarely exceeds ~20 entries.
|
|
14
|
+
*
|
|
15
|
+
* Typography controls apply to both text and separator items — separators
|
|
16
|
+
* inherit everything except the per-item character.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, { useCallback, useState } from "react";
|
|
20
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
21
|
+
import type {
|
|
22
|
+
MarqueeBlock,
|
|
23
|
+
MarqueeItem,
|
|
24
|
+
MarqueeTextItem,
|
|
25
|
+
MarqueeImageItem,
|
|
26
|
+
MarqueeSeparatorItem,
|
|
27
|
+
MarqueeFontSize,
|
|
28
|
+
} from "../../../lib/sanity/types";
|
|
29
|
+
import {
|
|
30
|
+
SettingsField,
|
|
31
|
+
SettingsSection,
|
|
32
|
+
StyledCheckbox,
|
|
33
|
+
} from "./shared";
|
|
34
|
+
import {
|
|
35
|
+
ContentIcon,
|
|
36
|
+
AnimationIcon,
|
|
37
|
+
TypographyIcon,
|
|
38
|
+
LayoutIcon,
|
|
39
|
+
} from "./section-icons";
|
|
40
|
+
|
|
41
|
+
// ============================================
|
|
42
|
+
// Constants
|
|
43
|
+
// ============================================
|
|
44
|
+
|
|
45
|
+
const DIRECTION_OPTIONS = [
|
|
46
|
+
{ value: "left", label: "← Left" },
|
|
47
|
+
{ value: "right", label: "Right →" },
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
const FONT_SIZE_OPTIONS: { value: MarqueeFontSize; label: string }[] = [
|
|
51
|
+
{ value: "s", label: "S" },
|
|
52
|
+
{ value: "base", label: "Base" },
|
|
53
|
+
{ value: "l", label: "L" },
|
|
54
|
+
{ value: "xl", label: "XL" },
|
|
55
|
+
{ value: "2xl", label: "2XL" },
|
|
56
|
+
{ value: "3xl", label: "3XL" },
|
|
57
|
+
{ value: "4xl", label: "4XL" },
|
|
58
|
+
{ value: "5xl", label: "5XL" },
|
|
59
|
+
{ value: "6xl", label: "6XL" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const FONT_WEIGHT_OPTIONS = [
|
|
63
|
+
{ value: "400", label: "Normal" },
|
|
64
|
+
{ value: "500", label: "Medium" },
|
|
65
|
+
{ value: "700", label: "Bold" },
|
|
66
|
+
{ value: "900", label: "Black" },
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
const TEXT_STYLE_OPTIONS = [
|
|
70
|
+
{ value: "solid", label: "Solid" },
|
|
71
|
+
{ value: "outline", label: "Outline" },
|
|
72
|
+
{ value: "italic-outline", label: "Italic outline" },
|
|
73
|
+
] as const;
|
|
74
|
+
|
|
75
|
+
const TEXT_TRANSFORM_OPTIONS = [
|
|
76
|
+
{ value: "none", label: "Aa" },
|
|
77
|
+
{ value: "uppercase", label: "AA" },
|
|
78
|
+
{ value: "lowercase", label: "aa" },
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
const ITEM_TYPE_LABEL: Record<MarqueeItem["_type"], string> = {
|
|
82
|
+
marqueeText: "Text",
|
|
83
|
+
marqueeImage: "Image",
|
|
84
|
+
marqueeSeparator: "Separator",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ============================================
|
|
88
|
+
// Shared mini-components
|
|
89
|
+
// ============================================
|
|
90
|
+
|
|
91
|
+
function SegmentedControl<T extends string>({
|
|
92
|
+
options,
|
|
93
|
+
value,
|
|
94
|
+
onChange,
|
|
95
|
+
}: {
|
|
96
|
+
options: readonly { value: T; label: string }[];
|
|
97
|
+
value: T;
|
|
98
|
+
onChange: (v: T) => void;
|
|
99
|
+
}) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex gap-1">
|
|
102
|
+
{options.map((opt) => {
|
|
103
|
+
const active = value === opt.value;
|
|
104
|
+
return (
|
|
105
|
+
<button
|
|
106
|
+
key={opt.value}
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => onChange(opt.value)}
|
|
109
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${
|
|
110
|
+
active
|
|
111
|
+
? "bg-[#3580f9] text-white"
|
|
112
|
+
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
|
113
|
+
}`}
|
|
114
|
+
>
|
|
115
|
+
{opt.label}
|
|
116
|
+
</button>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function RangeSlider({
|
|
124
|
+
value,
|
|
125
|
+
onChange,
|
|
126
|
+
min,
|
|
127
|
+
max,
|
|
128
|
+
step = 1,
|
|
129
|
+
suffix = "",
|
|
130
|
+
decimals = 0,
|
|
131
|
+
}: {
|
|
132
|
+
value: number;
|
|
133
|
+
onChange: (v: number) => void;
|
|
134
|
+
min: number;
|
|
135
|
+
max: number;
|
|
136
|
+
step?: number;
|
|
137
|
+
suffix?: string;
|
|
138
|
+
decimals?: number;
|
|
139
|
+
}) {
|
|
140
|
+
return (
|
|
141
|
+
<div className="flex items-center gap-2">
|
|
142
|
+
<input
|
|
143
|
+
type="range"
|
|
144
|
+
min={min}
|
|
145
|
+
max={max}
|
|
146
|
+
step={step}
|
|
147
|
+
value={value}
|
|
148
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
149
|
+
className="flex-1 h-1 accent-[#3580f9] cursor-pointer"
|
|
150
|
+
/>
|
|
151
|
+
<span className="text-[11px] text-neutral-500 w-14 text-right tabular-nums shrink-0">
|
|
152
|
+
{value.toFixed(decimals)}{suffix}
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function Dropdown<T extends string>({
|
|
159
|
+
options,
|
|
160
|
+
value,
|
|
161
|
+
onChange,
|
|
162
|
+
}: {
|
|
163
|
+
options: readonly { value: T; label: string }[];
|
|
164
|
+
value: T;
|
|
165
|
+
onChange: (v: T) => void;
|
|
166
|
+
}) {
|
|
167
|
+
return (
|
|
168
|
+
<select
|
|
169
|
+
value={value}
|
|
170
|
+
onChange={(e) => onChange(e.target.value as T)}
|
|
171
|
+
className="w-full rounded-lg border border-transparent bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 font-normal outline-none transition-all hover:bg-[#efefef] focus:bg-white focus:border-[#3580f9] focus:shadow-[0_0_0_3px_rgba(53, 128, 249,0.06)]"
|
|
172
|
+
>
|
|
173
|
+
{options.map((opt) => (
|
|
174
|
+
<option key={opt.value} value={opt.value}>
|
|
175
|
+
{opt.label}
|
|
176
|
+
</option>
|
|
177
|
+
))}
|
|
178
|
+
</select>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function TextInput({
|
|
183
|
+
value,
|
|
184
|
+
onChange,
|
|
185
|
+
placeholder,
|
|
186
|
+
}: {
|
|
187
|
+
value: string;
|
|
188
|
+
onChange: (v: string) => void;
|
|
189
|
+
placeholder?: string;
|
|
190
|
+
}) {
|
|
191
|
+
return (
|
|
192
|
+
<input
|
|
193
|
+
type="text"
|
|
194
|
+
value={value}
|
|
195
|
+
onChange={(e) => onChange(e.target.value)}
|
|
196
|
+
placeholder={placeholder}
|
|
197
|
+
className="w-full rounded-lg border border-transparent bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 font-normal outline-none transition-all hover:bg-[#efefef] focus:bg-white focus:border-[#3580f9] focus:shadow-[0_0_0_3px_rgba(53, 128, 249,0.06)]"
|
|
198
|
+
/>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ColorInput({
|
|
203
|
+
value,
|
|
204
|
+
onChange,
|
|
205
|
+
}: {
|
|
206
|
+
value: string;
|
|
207
|
+
onChange: (v: string) => void;
|
|
208
|
+
}) {
|
|
209
|
+
return (
|
|
210
|
+
<div className="flex gap-2 items-center">
|
|
211
|
+
<input
|
|
212
|
+
type="color"
|
|
213
|
+
value={value || "#000000"}
|
|
214
|
+
onChange={(e) => onChange(e.target.value)}
|
|
215
|
+
className="w-8 h-8 rounded cursor-pointer border border-neutral-200 bg-transparent p-0 shrink-0"
|
|
216
|
+
/>
|
|
217
|
+
<input
|
|
218
|
+
type="text"
|
|
219
|
+
value={value}
|
|
220
|
+
onChange={(e) => onChange(e.target.value)}
|
|
221
|
+
placeholder="#000000"
|
|
222
|
+
className="flex-1 rounded-lg border border-transparent bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 font-normal outline-none hover:bg-[#efefef] focus:bg-white focus:border-[#3580f9]"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================
|
|
229
|
+
// Item row — inline editor per item
|
|
230
|
+
// ============================================
|
|
231
|
+
|
|
232
|
+
interface ItemRowProps {
|
|
233
|
+
item: MarqueeItem;
|
|
234
|
+
index: number;
|
|
235
|
+
isDragging: boolean;
|
|
236
|
+
isDragOver: boolean;
|
|
237
|
+
onUpdate: (updates: Partial<MarqueeItem>) => void;
|
|
238
|
+
onRemove: () => void;
|
|
239
|
+
onDragStart: () => void;
|
|
240
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
241
|
+
onDrop: () => void;
|
|
242
|
+
onDragEnd: () => void;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function ItemRow({
|
|
246
|
+
item,
|
|
247
|
+
index,
|
|
248
|
+
isDragging,
|
|
249
|
+
isDragOver,
|
|
250
|
+
onUpdate,
|
|
251
|
+
onRemove,
|
|
252
|
+
onDragStart,
|
|
253
|
+
onDragOver,
|
|
254
|
+
onDrop,
|
|
255
|
+
onDragEnd,
|
|
256
|
+
}: ItemRowProps) {
|
|
257
|
+
return (
|
|
258
|
+
<div
|
|
259
|
+
draggable
|
|
260
|
+
onDragStart={onDragStart}
|
|
261
|
+
onDragOver={onDragOver}
|
|
262
|
+
onDrop={onDrop}
|
|
263
|
+
onDragEnd={onDragEnd}
|
|
264
|
+
className={`rounded-lg border p-2 transition-colors ${
|
|
265
|
+
isDragging
|
|
266
|
+
? "opacity-40 border-[#7500d5]"
|
|
267
|
+
: isDragOver
|
|
268
|
+
? "border-[#7500d5] bg-[#7500d5]/5"
|
|
269
|
+
: "border-neutral-200 bg-white hover:border-neutral-300"
|
|
270
|
+
}`}
|
|
271
|
+
>
|
|
272
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
273
|
+
<span
|
|
274
|
+
className="text-neutral-300 cursor-grab active:cursor-grabbing select-none"
|
|
275
|
+
title="Drag to reorder"
|
|
276
|
+
aria-label="Drag handle"
|
|
277
|
+
>
|
|
278
|
+
⋮⋮
|
|
279
|
+
</span>
|
|
280
|
+
<span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
|
|
281
|
+
{ITEM_TYPE_LABEL[item._type]} · #{index + 1}
|
|
282
|
+
</span>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={onRemove}
|
|
286
|
+
className="ml-auto text-[11px] text-neutral-400 hover:text-red-500 transition-colors"
|
|
287
|
+
aria-label="Remove item"
|
|
288
|
+
>
|
|
289
|
+
✕
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
{item._type === "marqueeText" && (
|
|
294
|
+
<TextInput
|
|
295
|
+
value={(item as MarqueeTextItem).text}
|
|
296
|
+
onChange={(v) => onUpdate({ text: v } as Partial<MarqueeTextItem>)}
|
|
297
|
+
placeholder="Enter text…"
|
|
298
|
+
/>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{item._type === "marqueeImage" && (
|
|
302
|
+
<div className="space-y-1.5">
|
|
303
|
+
<TextInput
|
|
304
|
+
value={(item as MarqueeImageItem).asset_path}
|
|
305
|
+
onChange={(v) =>
|
|
306
|
+
onUpdate({ asset_path: v } as Partial<MarqueeImageItem>)
|
|
307
|
+
}
|
|
308
|
+
placeholder="Asset path (e.g. logos/client.svg)"
|
|
309
|
+
/>
|
|
310
|
+
<TextInput
|
|
311
|
+
value={(item as MarqueeImageItem).alt ?? ""}
|
|
312
|
+
onChange={(v) =>
|
|
313
|
+
onUpdate({ alt: v } as Partial<MarqueeImageItem>)
|
|
314
|
+
}
|
|
315
|
+
placeholder="Alt text (accessibility)"
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{item._type === "marqueeSeparator" && (
|
|
321
|
+
<TextInput
|
|
322
|
+
value={(item as MarqueeSeparatorItem).character}
|
|
323
|
+
onChange={(v) =>
|
|
324
|
+
onUpdate({
|
|
325
|
+
character: v.slice(0, 4),
|
|
326
|
+
} as Partial<MarqueeSeparatorItem>)
|
|
327
|
+
}
|
|
328
|
+
placeholder="• · — / ▸ ★"
|
|
329
|
+
/>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ============================================
|
|
336
|
+
// Main editor
|
|
337
|
+
// ============================================
|
|
338
|
+
|
|
339
|
+
interface MarqueeBlockEditorProps {
|
|
340
|
+
block: MarqueeBlock;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Small random _key generator — matches the format used elsewhere in the builder. */
|
|
344
|
+
function randomKey(): string {
|
|
345
|
+
return Math.random().toString(36).slice(2, 12);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export default function MarqueeBlockEditor({ block }: MarqueeBlockEditorProps) {
|
|
349
|
+
const updateBlock = useBuilderStore((s) => s.updateBlock);
|
|
350
|
+
|
|
351
|
+
const update = useCallback(
|
|
352
|
+
(updates: Partial<MarqueeBlock>) => {
|
|
353
|
+
updateBlock(block._key, updates as Partial<MarqueeBlock>);
|
|
354
|
+
},
|
|
355
|
+
[updateBlock, block._key],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const items = block.items ?? [];
|
|
359
|
+
|
|
360
|
+
// ─── Item mutations ──────────────────────────────
|
|
361
|
+
const addItem = useCallback(
|
|
362
|
+
(type: MarqueeItem["_type"]) => {
|
|
363
|
+
const base = { _key: randomKey(), _type: type };
|
|
364
|
+
let newItem: MarqueeItem;
|
|
365
|
+
if (type === "marqueeText") {
|
|
366
|
+
newItem = { ...base, _type: "marqueeText", text: "New text" };
|
|
367
|
+
} else if (type === "marqueeImage") {
|
|
368
|
+
newItem = {
|
|
369
|
+
...base,
|
|
370
|
+
_type: "marqueeImage",
|
|
371
|
+
asset_path: "",
|
|
372
|
+
alt: "",
|
|
373
|
+
border_radius: 0,
|
|
374
|
+
};
|
|
375
|
+
} else {
|
|
376
|
+
newItem = { ...base, _type: "marqueeSeparator", character: "•" };
|
|
377
|
+
}
|
|
378
|
+
update({ items: [...items, newItem] });
|
|
379
|
+
},
|
|
380
|
+
[items, update],
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const updateItem = useCallback(
|
|
384
|
+
(index: number, updates: Partial<MarqueeItem>) => {
|
|
385
|
+
const next = items.map((it, i) =>
|
|
386
|
+
i === index ? ({ ...it, ...updates } as MarqueeItem) : it,
|
|
387
|
+
);
|
|
388
|
+
update({ items: next });
|
|
389
|
+
},
|
|
390
|
+
[items, update],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const removeItem = useCallback(
|
|
394
|
+
(index: number) => {
|
|
395
|
+
update({ items: items.filter((_, i) => i !== index) });
|
|
396
|
+
},
|
|
397
|
+
[items, update],
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
// ─── Drag-reorder state ──────────────────────────
|
|
401
|
+
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
|
402
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
|
403
|
+
|
|
404
|
+
const handleDragStart = (index: number) => {
|
|
405
|
+
setDraggingIndex(index);
|
|
406
|
+
};
|
|
407
|
+
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
408
|
+
e.preventDefault();
|
|
409
|
+
setDragOverIndex(index);
|
|
410
|
+
};
|
|
411
|
+
const handleDrop = (targetIndex: number) => {
|
|
412
|
+
if (draggingIndex === null || draggingIndex === targetIndex) return;
|
|
413
|
+
const next = [...items];
|
|
414
|
+
const [moved] = next.splice(draggingIndex, 1);
|
|
415
|
+
next.splice(targetIndex, 0, moved);
|
|
416
|
+
update({ items: next });
|
|
417
|
+
};
|
|
418
|
+
const handleDragEnd = () => {
|
|
419
|
+
setDraggingIndex(null);
|
|
420
|
+
setDragOverIndex(null);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// ─── Resolved values (with defaults) ────────────
|
|
424
|
+
const direction = block.direction ?? "left";
|
|
425
|
+
const speed = block.speed ?? 60;
|
|
426
|
+
const pauseOnHover = block.pause_on_hover !== false;
|
|
427
|
+
|
|
428
|
+
const fontSize = block.font_size ?? "3xl";
|
|
429
|
+
const fontWeight = block.font_weight ?? "700";
|
|
430
|
+
const color = block.color ?? "#111111";
|
|
431
|
+
const textStyle = block.text_style ?? "solid";
|
|
432
|
+
const letterSpacing = block.letter_spacing ?? 0;
|
|
433
|
+
const textTransform = block.text_transform ?? "uppercase";
|
|
434
|
+
|
|
435
|
+
const gap = block.gap ?? 48;
|
|
436
|
+
const rowHeight = block.row_height ?? 120;
|
|
437
|
+
const paddingY = block.padding_y ?? 16;
|
|
438
|
+
const backgroundColor = block.background_color ?? "";
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<>
|
|
442
|
+
{/* ─── Content ─────────────────────────────────── */}
|
|
443
|
+
<SettingsSection title="Content" defaultOpen icon={<ContentIcon />}>
|
|
444
|
+
<div className="space-y-1.5 mb-2">
|
|
445
|
+
{items.length === 0 && (
|
|
446
|
+
<p className="text-[11px] text-neutral-400 italic py-2 text-center">
|
|
447
|
+
No items yet. Add text, images or separators below.
|
|
448
|
+
</p>
|
|
449
|
+
)}
|
|
450
|
+
{items.map((item, i) => (
|
|
451
|
+
<ItemRow
|
|
452
|
+
key={item._key}
|
|
453
|
+
item={item}
|
|
454
|
+
index={i}
|
|
455
|
+
isDragging={draggingIndex === i}
|
|
456
|
+
isDragOver={dragOverIndex === i && draggingIndex !== i}
|
|
457
|
+
onUpdate={(updates) => updateItem(i, updates)}
|
|
458
|
+
onRemove={() => removeItem(i)}
|
|
459
|
+
onDragStart={() => handleDragStart(i)}
|
|
460
|
+
onDragOver={(e) => handleDragOver(e, i)}
|
|
461
|
+
onDrop={() => handleDrop(i)}
|
|
462
|
+
onDragEnd={handleDragEnd}
|
|
463
|
+
/>
|
|
464
|
+
))}
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
{/* Add-item buttons */}
|
|
468
|
+
<div className="grid grid-cols-3 gap-1.5">
|
|
469
|
+
<button
|
|
470
|
+
type="button"
|
|
471
|
+
onClick={() => addItem("marqueeText")}
|
|
472
|
+
className="px-2 py-1.5 text-[11px] font-medium rounded-lg bg-neutral-100 text-neutral-700 hover:bg-[#3580f9]/10 hover:text-[#3580f9] transition-colors"
|
|
473
|
+
>
|
|
474
|
+
+ Text
|
|
475
|
+
</button>
|
|
476
|
+
<button
|
|
477
|
+
type="button"
|
|
478
|
+
onClick={() => addItem("marqueeImage")}
|
|
479
|
+
className="px-2 py-1.5 text-[11px] font-medium rounded-lg bg-neutral-100 text-neutral-700 hover:bg-[#3580f9]/10 hover:text-[#3580f9] transition-colors"
|
|
480
|
+
>
|
|
481
|
+
+ Image
|
|
482
|
+
</button>
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => addItem("marqueeSeparator")}
|
|
486
|
+
className="px-2 py-1.5 text-[11px] font-medium rounded-lg bg-neutral-100 text-neutral-700 hover:bg-[#3580f9]/10 hover:text-[#3580f9] transition-colors"
|
|
487
|
+
>
|
|
488
|
+
+ Sep.
|
|
489
|
+
</button>
|
|
490
|
+
</div>
|
|
491
|
+
</SettingsSection>
|
|
492
|
+
|
|
493
|
+
{/* ─── Motion ───────────────────────────────────── */}
|
|
494
|
+
<SettingsSection title="Motion" defaultOpen icon={<AnimationIcon />}>
|
|
495
|
+
<SettingsField label="Direction">
|
|
496
|
+
<SegmentedControl
|
|
497
|
+
options={DIRECTION_OPTIONS}
|
|
498
|
+
value={direction}
|
|
499
|
+
onChange={(v) => update({ direction: v })}
|
|
500
|
+
/>
|
|
501
|
+
</SettingsField>
|
|
502
|
+
|
|
503
|
+
<SettingsField label="Speed">
|
|
504
|
+
<RangeSlider
|
|
505
|
+
value={speed}
|
|
506
|
+
onChange={(v) => update({ speed: Math.round(v) })}
|
|
507
|
+
min={5}
|
|
508
|
+
max={600}
|
|
509
|
+
step={5}
|
|
510
|
+
suffix=" px/s"
|
|
511
|
+
/>
|
|
512
|
+
</SettingsField>
|
|
513
|
+
|
|
514
|
+
<SettingsField label="Hover">
|
|
515
|
+
<StyledCheckbox
|
|
516
|
+
checked={pauseOnHover}
|
|
517
|
+
onChange={(checked) => update({ pause_on_hover: checked })}
|
|
518
|
+
label="Pause on hover"
|
|
519
|
+
/>
|
|
520
|
+
</SettingsField>
|
|
521
|
+
</SettingsSection>
|
|
522
|
+
|
|
523
|
+
{/* ─── Typography ──────────────────────────────── */}
|
|
524
|
+
<SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
|
|
525
|
+
<SettingsField label="Size">
|
|
526
|
+
<Dropdown
|
|
527
|
+
options={FONT_SIZE_OPTIONS}
|
|
528
|
+
value={fontSize}
|
|
529
|
+
onChange={(v) => update({ font_size: v })}
|
|
530
|
+
/>
|
|
531
|
+
</SettingsField>
|
|
532
|
+
|
|
533
|
+
<SettingsField label="Weight">
|
|
534
|
+
<SegmentedControl
|
|
535
|
+
options={FONT_WEIGHT_OPTIONS}
|
|
536
|
+
value={fontWeight}
|
|
537
|
+
onChange={(v) => update({ font_weight: v })}
|
|
538
|
+
/>
|
|
539
|
+
</SettingsField>
|
|
540
|
+
|
|
541
|
+
<SettingsField label="Color">
|
|
542
|
+
<ColorInput
|
|
543
|
+
value={color}
|
|
544
|
+
onChange={(v) => update({ color: v })}
|
|
545
|
+
/>
|
|
546
|
+
</SettingsField>
|
|
547
|
+
|
|
548
|
+
<SettingsField label="Style">
|
|
549
|
+
<SegmentedControl
|
|
550
|
+
options={TEXT_STYLE_OPTIONS}
|
|
551
|
+
value={textStyle}
|
|
552
|
+
onChange={(v) => update({ text_style: v })}
|
|
553
|
+
/>
|
|
554
|
+
</SettingsField>
|
|
555
|
+
|
|
556
|
+
<SettingsField label="Tracking">
|
|
557
|
+
<RangeSlider
|
|
558
|
+
value={letterSpacing}
|
|
559
|
+
onChange={(v) => update({ letter_spacing: v })}
|
|
560
|
+
min={-0.1}
|
|
561
|
+
max={0.5}
|
|
562
|
+
step={0.01}
|
|
563
|
+
decimals={2}
|
|
564
|
+
suffix="em"
|
|
565
|
+
/>
|
|
566
|
+
</SettingsField>
|
|
567
|
+
|
|
568
|
+
<SettingsField label="Case">
|
|
569
|
+
<SegmentedControl
|
|
570
|
+
options={TEXT_TRANSFORM_OPTIONS}
|
|
571
|
+
value={textTransform}
|
|
572
|
+
onChange={(v) => update({ text_transform: v })}
|
|
573
|
+
/>
|
|
574
|
+
</SettingsField>
|
|
575
|
+
</SettingsSection>
|
|
576
|
+
|
|
577
|
+
{/* ─── Layout ──────────────────────────────────── */}
|
|
578
|
+
<SettingsSection title="Layout" defaultOpen icon={<LayoutIcon />}>
|
|
579
|
+
<SettingsField label="Gap">
|
|
580
|
+
<RangeSlider
|
|
581
|
+
value={gap}
|
|
582
|
+
onChange={(v) => update({ gap: Math.round(v) })}
|
|
583
|
+
min={0}
|
|
584
|
+
max={200}
|
|
585
|
+
step={2}
|
|
586
|
+
suffix="px"
|
|
587
|
+
/>
|
|
588
|
+
</SettingsField>
|
|
589
|
+
|
|
590
|
+
<SettingsField label="Row height">
|
|
591
|
+
<RangeSlider
|
|
592
|
+
value={rowHeight}
|
|
593
|
+
onChange={(v) => update({ row_height: Math.round(v) })}
|
|
594
|
+
min={24}
|
|
595
|
+
max={600}
|
|
596
|
+
step={4}
|
|
597
|
+
suffix="px"
|
|
598
|
+
/>
|
|
599
|
+
</SettingsField>
|
|
600
|
+
|
|
601
|
+
<SettingsField label="Padding Y">
|
|
602
|
+
<RangeSlider
|
|
603
|
+
value={paddingY}
|
|
604
|
+
onChange={(v) => update({ padding_y: Math.round(v) })}
|
|
605
|
+
min={0}
|
|
606
|
+
max={200}
|
|
607
|
+
step={2}
|
|
608
|
+
suffix="px"
|
|
609
|
+
/>
|
|
610
|
+
</SettingsField>
|
|
611
|
+
|
|
612
|
+
<SettingsField label="Background">
|
|
613
|
+
<ColorInput
|
|
614
|
+
value={backgroundColor}
|
|
615
|
+
onChange={(v) => update({ background_color: v })}
|
|
616
|
+
/>
|
|
617
|
+
</SettingsField>
|
|
618
|
+
</SettingsSection>
|
|
619
|
+
</>
|
|
620
|
+
);
|
|
621
|
+
}
|