@morphika/andami 0.5.1 → 0.5.3

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