@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.
Files changed (117) hide show
  1. package/app/admin/assets/page.tsx +6 -6
  2. package/app/admin/database/page.tsx +302 -302
  3. package/app/admin/error.tsx +53 -53
  4. package/app/admin/layout.tsx +320 -320
  5. package/app/admin/navigation/page.tsx +255 -255
  6. package/app/admin/pages/[slug]/page.tsx +6 -6
  7. package/app/admin/pages/page.tsx +11 -11
  8. package/app/admin/projects/page.tsx +14 -14
  9. package/app/admin/setup/page.tsx +1 -1
  10. package/app/admin/styles/page.tsx +1 -1
  11. package/components/admin/MetadataEditor.tsx +6 -6
  12. package/components/admin/nav-builder/NavBuilder.tsx +1 -1
  13. package/components/admin/nav-builder/NavBuilderGrid.tsx +3 -3
  14. package/components/admin/nav-builder/NavGridCell.tsx +48 -48
  15. package/components/admin/nav-builder/NavGridItem.tsx +4 -4
  16. package/components/admin/nav-builder/NavItemSettings.tsx +331 -331
  17. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -102
  18. package/components/admin/nav-builder/NavLivePreview.tsx +1 -1
  19. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -226
  20. package/components/admin/nav-builder/NavMobileSettings.tsx +242 -242
  21. package/components/admin/nav-builder/NavSettingsFields.tsx +514 -514
  22. package/components/admin/setup-wizard/BrandingStep.tsx +3 -3
  23. package/components/admin/setup-wizard/DatabaseStep.tsx +2 -2
  24. package/components/admin/setup-wizard/DoneStep.tsx +1 -1
  25. package/components/admin/setup-wizard/SetupWizard.tsx +4 -4
  26. package/components/admin/setup-wizard/StorageStep.tsx +2 -2
  27. package/components/admin/setup-wizard/WelcomeStep.tsx +2 -2
  28. package/components/admin/styles/ColorsEditor.tsx +2 -2
  29. package/components/admin/styles/FontsEditor.tsx +6 -6
  30. package/components/admin/styles/GridLayoutEditor.tsx +9 -9
  31. package/components/admin/styles/LinksButtonsEditor.tsx +5 -5
  32. package/components/admin/styles/TypographyEditor.tsx +6 -6
  33. package/components/admin/styles/shared.tsx +68 -68
  34. package/components/blocks/AudioBlockRenderer.tsx +286 -286
  35. package/components/blocks/MarqueeBlockRenderer.tsx +316 -0
  36. package/components/blocks/ProjectCarouselBlockRenderer.tsx +1 -1
  37. package/components/builder/BlockCardIcons.tsx +316 -316
  38. package/components/builder/BlockTypePicker.tsx +1 -1
  39. package/components/builder/BubbleIcons.tsx +90 -0
  40. package/components/builder/BuilderCanvas.tsx +2 -0
  41. package/components/builder/CanvasMinimap.tsx +2 -2
  42. package/components/builder/CoverSectionCanvas.tsx +363 -363
  43. package/components/builder/DeviceFrame.tsx +1 -1
  44. package/components/builder/DndWrapper.tsx +3 -3
  45. package/components/builder/InsertionLines.tsx +1 -1
  46. package/components/builder/SectionCardIcons.tsx +421 -320
  47. package/components/builder/SectionEditorBar.tsx +1 -1
  48. package/components/builder/SectionTypePicker.tsx +4 -4
  49. package/components/builder/SectionV2Canvas.tsx +1 -1
  50. package/components/builder/SectionV2Column.tsx +69 -67
  51. package/components/builder/SortableBlock.tsx +93 -73
  52. package/components/builder/SortableRow.tsx +27 -26
  53. package/components/builder/VirtualAssetGrid.tsx +2 -2
  54. package/components/builder/asset-browser/R2BrowserContent.tsx +11 -11
  55. package/components/builder/blockStyles.tsx +192 -185
  56. package/components/builder/color-picker/AlphaSlider.tsx +141 -141
  57. package/components/builder/color-picker/ColorInputs.tsx +105 -105
  58. package/components/builder/color-picker/EyedropperButton.tsx +74 -74
  59. package/components/builder/color-picker/HueSlider.tsx +124 -124
  60. package/components/builder/color-picker/SaturationCanvas.tsx +142 -142
  61. package/components/builder/color-picker/SwatchBar.tsx +93 -93
  62. package/components/builder/editors/AudioBlockEditor.tsx +242 -242
  63. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -360
  64. package/components/builder/editors/ButtonBlockEditor.tsx +4 -4
  65. package/components/builder/editors/EnterAnimationPicker.tsx +2 -2
  66. package/components/builder/editors/HoverEffectPicker.tsx +2 -2
  67. package/components/builder/editors/ImageBlockEditor.tsx +2 -2
  68. package/components/builder/editors/ImageGridBlockEditor.tsx +4 -4
  69. package/components/builder/editors/MarqueeBlockEditor.tsx +621 -0
  70. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -443
  71. package/components/builder/editors/ProjectGridEditor.tsx +9 -9
  72. package/components/builder/editors/SpacerBlockEditor.tsx +5 -5
  73. package/components/builder/editors/StaggerSettings.tsx +109 -109
  74. package/components/builder/editors/TextBlockEditor.tsx +3 -3
  75. package/components/builder/editors/TextStylePicker.tsx +1 -1
  76. package/components/builder/editors/VideoBlockEditor.tsx +2 -2
  77. package/components/builder/editors/index.ts +11 -10
  78. package/components/builder/editors/shared.tsx +6 -6
  79. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -120
  80. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +1 -1
  81. package/components/builder/live-preview/LiveImageGridPreview.tsx +10 -2
  82. package/components/builder/live-preview/LiveImagePreview.tsx +1 -1
  83. package/components/builder/live-preview/LiveMarqueePreview.tsx +39 -0
  84. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +1 -1
  85. package/components/builder/live-preview/LiveVideoPreview.tsx +1 -1
  86. package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -291
  87. package/components/builder/settings-panel/AnimationTab.tsx +138 -138
  88. package/components/builder/settings-panel/BlockLayoutTab.tsx +7 -7
  89. package/components/builder/settings-panel/CardEntranceSection.tsx +114 -114
  90. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  91. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +71 -71
  92. package/components/builder/settings-panel/CoverSectionSettings.tsx +335 -335
  93. package/components/builder/settings-panel/PageSettings.tsx +3 -3
  94. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  95. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +4 -4
  96. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +356 -356
  97. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  98. package/components/builder/settings-panel/TRBLInputs.tsx +1 -1
  99. package/lib/animation/enter-types.ts +1 -0
  100. package/lib/animation/hover-effect-presets.ts +210 -210
  101. package/lib/animation/hover-effect-types.ts +1 -0
  102. package/lib/builder/block-registrations.ts +468 -417
  103. package/lib/builder/constants.ts +111 -111
  104. package/lib/builder/store-sections.ts +2 -2
  105. package/lib/builder/types-slices.ts +414 -414
  106. package/lib/builder/types.ts +4 -1
  107. package/lib/config/index.ts +27 -27
  108. package/lib/sanity/types.ts +98 -1
  109. package/lib/version.ts +1 -1
  110. package/package.json +1 -1
  111. package/sanity/schemas/blocks/audioBlock.ts +69 -69
  112. package/sanity/schemas/blocks/index.ts +12 -11
  113. package/sanity/schemas/blocks/marqueeBlock.ts +292 -0
  114. package/sanity/schemas/index.ts +120 -117
  115. package/styles/admin.css +85 -85
  116. package/styles/animations.css +237 -237
  117. 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
+ }