@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
@@ -1,360 +1,360 @@
1
- "use client";
2
-
3
- import { useBuilderStore } from "../../../lib/builder/store";
4
- import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
5
- import type { BeforeAfterBlock, ContentBlock } from "../../../lib/sanity/types";
6
- import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
7
- import { resolveColorHex } from "../../../lib/color-utils";
8
- import {
9
- SourceIcon,
10
- LayoutIcon,
11
- AppearanceIcon,
12
- PlaybackIcon,
13
- OptionsIcon,
14
- } from "./section-icons";
15
- import {
16
- SettingsField,
17
- SettingsSection,
18
- StyledCheckbox,
19
- AssetPathInput,
20
- ViewportBadge,
21
- ResponsiveField,
22
- useActiveViewport,
23
- INPUT_CLASS,
24
- SELECT_CLASS,
25
- } from "./shared";
26
-
27
- interface Props {
28
- block: BeforeAfterBlock;
29
- }
30
-
31
- export default function BeforeAfterBlockEditor({ block }: Props) {
32
- const store = useBuilderStore();
33
- const viewport = useActiveViewport();
34
- const paletteSwatches = usePaletteSwatches();
35
-
36
- const snapshotOnFocus = () => store._pushSnapshot();
37
-
38
- const updateResponsive = (property: string, value: unknown) => {
39
- if (viewport === "desktop") {
40
- store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
41
- } else {
42
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
43
- store.updateBlock(block._key, overrides as Partial<ContentBlock>);
44
- }
45
- };
46
-
47
- const resetOverride = (property: string) => {
48
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
49
- store.updateBlock(block._key, overrides as Partial<ContentBlock>);
50
- };
51
-
52
- const update = (updates: Partial<BeforeAfterBlock>) => {
53
- store.updateBlock(block._key, updates as Partial<ContentBlock>);
54
- };
55
-
56
- const updateDebounced = (updates: Partial<BeforeAfterBlock>) => {
57
- store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
58
- };
59
-
60
- const beforeType = block.before_media_type || "image";
61
- const afterType = block.after_media_type || "image";
62
-
63
- const effectiveWidth = getEffectiveValue<string>(
64
- block as ContentBlock, viewport, "width", block.width || "full"
65
- );
66
- const effectiveAspect = getEffectiveValue<string>(
67
- block as ContentBlock, viewport, "aspect_ratio", block.aspect_ratio || "16:9"
68
- );
69
- const effectiveOrientation = getEffectiveValue<string>(
70
- block as ContentBlock, viewport, "orientation", block.orientation || "horizontal"
71
- );
72
-
73
- const eitherIsVideo = beforeType === "video" || afterType === "video";
74
-
75
- return (
76
- <>
77
- <ViewportBadge />
78
-
79
- {/* ── Before source ── */}
80
- <SettingsSection title="Before" defaultOpen icon={<SourceIcon />}>
81
- <SettingsField label="Media Type">
82
- <div className="flex gap-1">
83
- {(
84
- [
85
- { value: "image", label: "Image" },
86
- { value: "video", label: "Video" },
87
- ] as const
88
- ).map((opt) => (
89
- <button
90
- key={opt.value}
91
- onClick={() => update({ before_media_type: opt.value })}
92
- className={`flex-1 rounded border py-1 text-xs transition-colors ${
93
- beforeType === opt.value
94
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
95
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
96
- }`}
97
- >
98
- {opt.label}
99
- </button>
100
- ))}
101
- </div>
102
- </SettingsField>
103
-
104
- <SettingsField label="Asset Path" hint="Relative path from seed URL">
105
- <AssetPathInput
106
- value={block.before_asset_path || ""}
107
- onFocus={snapshotOnFocus}
108
- onChange={(v) => updateDebounced({ before_asset_path: v })}
109
- placeholder={beforeType === "video" ? "projects/slug/before.mp4" : "projects/slug/before.jpg"}
110
- filterType={beforeType}
111
- />
112
- </SettingsField>
113
-
114
- <SettingsField label="Alt Text">
115
- <input
116
- type="text"
117
- value={block.before_alt || ""}
118
- onFocus={snapshotOnFocus}
119
- onChange={(e) => updateDebounced({ before_alt: e.target.value })}
120
- className={INPUT_CLASS}
121
- placeholder="Describe the before media"
122
- />
123
- </SettingsField>
124
- </SettingsSection>
125
-
126
- {/* ── After source ── */}
127
- <SettingsSection title="After" icon={<SourceIcon />}>
128
- <SettingsField label="Media Type">
129
- <div className="flex gap-1">
130
- {(
131
- [
132
- { value: "image", label: "Image" },
133
- { value: "video", label: "Video" },
134
- ] as const
135
- ).map((opt) => (
136
- <button
137
- key={opt.value}
138
- onClick={() => update({ after_media_type: opt.value })}
139
- className={`flex-1 rounded border py-1 text-xs transition-colors ${
140
- afterType === opt.value
141
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
142
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
143
- }`}
144
- >
145
- {opt.label}
146
- </button>
147
- ))}
148
- </div>
149
- </SettingsField>
150
-
151
- <SettingsField label="Asset Path" hint="Relative path from seed URL">
152
- <AssetPathInput
153
- value={block.after_asset_path || ""}
154
- onFocus={snapshotOnFocus}
155
- onChange={(v) => updateDebounced({ after_asset_path: v })}
156
- placeholder={afterType === "video" ? "projects/slug/after.mp4" : "projects/slug/after.jpg"}
157
- filterType={afterType}
158
- />
159
- </SettingsField>
160
-
161
- <SettingsField label="Alt Text">
162
- <input
163
- type="text"
164
- value={block.after_alt || ""}
165
- onFocus={snapshotOnFocus}
166
- onChange={(e) => updateDebounced({ after_alt: e.target.value })}
167
- className={INPUT_CLASS}
168
- placeholder="Describe the after media"
169
- />
170
- </SettingsField>
171
- </SettingsSection>
172
-
173
- {/* ── Slider options ── */}
174
- <SettingsSection title="Slider" icon={<OptionsIcon />}>
175
- <ResponsiveField
176
- label="Orientation"
177
- block={block as ContentBlock}
178
- property="orientation"
179
- onReset={() => resetOverride("orientation")}
180
- >
181
- <div className="flex gap-1">
182
- {(
183
- [
184
- { value: "horizontal", label: "Horizontal" },
185
- { value: "vertical", label: "Vertical" },
186
- ] as const
187
- ).map((opt) => (
188
- <button
189
- key={opt.value}
190
- onClick={() => updateResponsive("orientation", opt.value)}
191
- className={`flex-1 rounded border py-1 text-xs transition-colors ${
192
- effectiveOrientation === opt.value
193
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
194
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
195
- }`}
196
- >
197
- {opt.label}
198
- </button>
199
- ))}
200
- </div>
201
- </ResponsiveField>
202
-
203
- <SettingsField label="Initial Position" hint="0–100%">
204
- <div className="flex items-center gap-1.5">
205
- <input
206
- type="number"
207
- min={0}
208
- max={100}
209
- value={block.initial_position ?? 50}
210
- onFocus={snapshotOnFocus}
211
- onChange={(e) => {
212
- const n = Math.max(0, Math.min(100, Number(e.target.value) || 0));
213
- updateDebounced({ initial_position: n });
214
- }}
215
- className={INPUT_CLASS}
216
- placeholder="50"
217
- />
218
- <span className="text-[10px] text-neutral-400 shrink-0">%</span>
219
- </div>
220
- </SettingsField>
221
-
222
- <SettingsField label="Handle Color">
223
- <ColorSwatchPicker
224
- value={block.handle_color || "#FFFFFF"}
225
- onChange={(value) => {
226
- const hex = resolveColorHex(value) || "#FFFFFF";
227
- update({ handle_color: hex });
228
- }}
229
- swatches={paletteSwatches}
230
- />
231
- </SettingsField>
232
- </SettingsSection>
233
-
234
- {/* ── Layout ── */}
235
- <SettingsSection title="Layout" icon={<LayoutIcon />}>
236
- <ResponsiveField
237
- label="Width"
238
- block={block as ContentBlock}
239
- property="width"
240
- onReset={() => resetOverride("width")}
241
- >
242
- <div className="flex gap-1">
243
- {(
244
- [
245
- { value: "full", label: "Full" },
246
- { value: "contained", label: "Contained" },
247
- { value: "small", label: "Small" },
248
- { value: "fill", label: "Fill" },
249
- ] as const
250
- ).map((opt) => (
251
- <button
252
- key={opt.value}
253
- onClick={() => updateResponsive("width", opt.value)}
254
- className={`flex-1 rounded border py-1 text-xs transition-colors ${
255
- effectiveWidth === opt.value
256
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
257
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
258
- }`}
259
- >
260
- {opt.label}
261
- </button>
262
- ))}
263
- </div>
264
- </ResponsiveField>
265
-
266
- <ResponsiveField
267
- label="Aspect Ratio"
268
- block={block as ContentBlock}
269
- property="aspect_ratio"
270
- onReset={() => resetOverride("aspect_ratio")}
271
- >
272
- <select
273
- value={effectiveAspect}
274
- onChange={(e) => updateResponsive("aspect_ratio", e.target.value)}
275
- className={SELECT_CLASS}
276
- >
277
- <option value="auto">Auto</option>
278
- <option value="16:9">16:9</option>
279
- <option value="4:3">4:3</option>
280
- <option value="1:1">1:1</option>
281
- <option value="21:9">21:9</option>
282
- </select>
283
- </ResponsiveField>
284
- </SettingsSection>
285
-
286
- {/* ── Appearance ── */}
287
- <SettingsSection title="Appearance" icon={<AppearanceIcon />}>
288
- <ResponsiveField
289
- label="Border Radius"
290
- block={block as ContentBlock}
291
- property="border_radius"
292
- onReset={() => resetOverride("border_radius")}
293
- >
294
- <div className="flex items-center gap-1.5">
295
- <input
296
- type="number"
297
- value={String(getEffectiveValue<string>(block as ContentBlock, viewport, "border_radius", block.border_radius || "")).replace(/px$/i, "")}
298
- onFocus={snapshotOnFocus}
299
- onChange={(e) => {
300
- store._pushSnapshot();
301
- updateResponsive("border_radius", e.target.value.replace(/[^0-9]/g, ""));
302
- }}
303
- className={INPUT_CLASS}
304
- placeholder="0"
305
- min={0}
306
- />
307
- <span className="text-[10px] text-neutral-400 shrink-0">px</span>
308
- </div>
309
- </ResponsiveField>
310
-
311
- <ResponsiveField
312
- label="Shadow"
313
- block={block as ContentBlock}
314
- property="shadow"
315
- onReset={() => resetOverride("shadow")}
316
- >
317
- <button
318
- type="button"
319
- onClick={() => {
320
- const effectiveShadow = getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false);
321
- updateResponsive("shadow", !effectiveShadow);
322
- }}
323
- className={`relative w-8 h-[18px] rounded-full transition-colors ${
324
- getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "bg-[#076bff]" : "bg-neutral-200 hover:bg-neutral-300"
325
- }`}
326
- >
327
- <span
328
- className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow-sm transition-transform ${
329
- getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "left-[16px]" : "left-[2px]"
330
- }`}
331
- />
332
- </button>
333
- </ResponsiveField>
334
- </SettingsSection>
335
-
336
- {/* ── Video playback (only shown when at least one side is video) ── */}
337
- {eitherIsVideo && (
338
- <SettingsSection title="Playback" icon={<PlaybackIcon />}>
339
- <div className="space-y-1.5">
340
- <StyledCheckbox
341
- label="Autoplay"
342
- checked={block.video_autoplay !== false}
343
- onChange={(checked) => update({ video_autoplay: checked })}
344
- />
345
- <StyledCheckbox
346
- label="Loop"
347
- checked={block.video_loop !== false}
348
- onChange={(checked) => update({ video_loop: checked })}
349
- />
350
- <StyledCheckbox
351
- label="Muted"
352
- checked={block.video_muted !== false}
353
- onChange={(checked) => update({ video_muted: checked })}
354
- />
355
- </div>
356
- </SettingsSection>
357
- )}
358
- </>
359
- );
360
- }
1
+ "use client";
2
+
3
+ import { useBuilderStore } from "../../../lib/builder/store";
4
+ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
5
+ import type { BeforeAfterBlock, ContentBlock } from "../../../lib/sanity/types";
6
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
7
+ import { resolveColorHex } from "../../../lib/color-utils";
8
+ import {
9
+ SourceIcon,
10
+ LayoutIcon,
11
+ AppearanceIcon,
12
+ PlaybackIcon,
13
+ OptionsIcon,
14
+ } from "./section-icons";
15
+ import {
16
+ SettingsField,
17
+ SettingsSection,
18
+ StyledCheckbox,
19
+ AssetPathInput,
20
+ ViewportBadge,
21
+ ResponsiveField,
22
+ useActiveViewport,
23
+ INPUT_CLASS,
24
+ SELECT_CLASS,
25
+ } from "./shared";
26
+
27
+ interface Props {
28
+ block: BeforeAfterBlock;
29
+ }
30
+
31
+ export default function BeforeAfterBlockEditor({ block }: Props) {
32
+ const store = useBuilderStore();
33
+ const viewport = useActiveViewport();
34
+ const paletteSwatches = usePaletteSwatches();
35
+
36
+ const snapshotOnFocus = () => store._pushSnapshot();
37
+
38
+ const updateResponsive = (property: string, value: unknown) => {
39
+ if (viewport === "desktop") {
40
+ store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
41
+ } else {
42
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
43
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
44
+ }
45
+ };
46
+
47
+ const resetOverride = (property: string) => {
48
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
49
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
50
+ };
51
+
52
+ const update = (updates: Partial<BeforeAfterBlock>) => {
53
+ store.updateBlock(block._key, updates as Partial<ContentBlock>);
54
+ };
55
+
56
+ const updateDebounced = (updates: Partial<BeforeAfterBlock>) => {
57
+ store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
58
+ };
59
+
60
+ const beforeType = block.before_media_type || "image";
61
+ const afterType = block.after_media_type || "image";
62
+
63
+ const effectiveWidth = getEffectiveValue<string>(
64
+ block as ContentBlock, viewport, "width", block.width || "full"
65
+ );
66
+ const effectiveAspect = getEffectiveValue<string>(
67
+ block as ContentBlock, viewport, "aspect_ratio", block.aspect_ratio || "16:9"
68
+ );
69
+ const effectiveOrientation = getEffectiveValue<string>(
70
+ block as ContentBlock, viewport, "orientation", block.orientation || "horizontal"
71
+ );
72
+
73
+ const eitherIsVideo = beforeType === "video" || afterType === "video";
74
+
75
+ return (
76
+ <>
77
+ <ViewportBadge />
78
+
79
+ {/* ── Before source ── */}
80
+ <SettingsSection title="Before" defaultOpen icon={<SourceIcon />}>
81
+ <SettingsField label="Media Type">
82
+ <div className="flex gap-1">
83
+ {(
84
+ [
85
+ { value: "image", label: "Image" },
86
+ { value: "video", label: "Video" },
87
+ ] as const
88
+ ).map((opt) => (
89
+ <button
90
+ key={opt.value}
91
+ onClick={() => update({ before_media_type: opt.value })}
92
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
93
+ beforeType === opt.value
94
+ ? "border-[#3580f9] bg-[#3580f9]/20 text-neutral-900"
95
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
96
+ }`}
97
+ >
98
+ {opt.label}
99
+ </button>
100
+ ))}
101
+ </div>
102
+ </SettingsField>
103
+
104
+ <SettingsField label="Asset Path" hint="Relative path from seed URL">
105
+ <AssetPathInput
106
+ value={block.before_asset_path || ""}
107
+ onFocus={snapshotOnFocus}
108
+ onChange={(v) => updateDebounced({ before_asset_path: v })}
109
+ placeholder={beforeType === "video" ? "projects/slug/before.mp4" : "projects/slug/before.jpg"}
110
+ filterType={beforeType}
111
+ />
112
+ </SettingsField>
113
+
114
+ <SettingsField label="Alt Text">
115
+ <input
116
+ type="text"
117
+ value={block.before_alt || ""}
118
+ onFocus={snapshotOnFocus}
119
+ onChange={(e) => updateDebounced({ before_alt: e.target.value })}
120
+ className={INPUT_CLASS}
121
+ placeholder="Describe the before media"
122
+ />
123
+ </SettingsField>
124
+ </SettingsSection>
125
+
126
+ {/* ── After source ── */}
127
+ <SettingsSection title="After" icon={<SourceIcon />}>
128
+ <SettingsField label="Media Type">
129
+ <div className="flex gap-1">
130
+ {(
131
+ [
132
+ { value: "image", label: "Image" },
133
+ { value: "video", label: "Video" },
134
+ ] as const
135
+ ).map((opt) => (
136
+ <button
137
+ key={opt.value}
138
+ onClick={() => update({ after_media_type: opt.value })}
139
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
140
+ afterType === opt.value
141
+ ? "border-[#3580f9] bg-[#3580f9]/20 text-neutral-900"
142
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
143
+ }`}
144
+ >
145
+ {opt.label}
146
+ </button>
147
+ ))}
148
+ </div>
149
+ </SettingsField>
150
+
151
+ <SettingsField label="Asset Path" hint="Relative path from seed URL">
152
+ <AssetPathInput
153
+ value={block.after_asset_path || ""}
154
+ onFocus={snapshotOnFocus}
155
+ onChange={(v) => updateDebounced({ after_asset_path: v })}
156
+ placeholder={afterType === "video" ? "projects/slug/after.mp4" : "projects/slug/after.jpg"}
157
+ filterType={afterType}
158
+ />
159
+ </SettingsField>
160
+
161
+ <SettingsField label="Alt Text">
162
+ <input
163
+ type="text"
164
+ value={block.after_alt || ""}
165
+ onFocus={snapshotOnFocus}
166
+ onChange={(e) => updateDebounced({ after_alt: e.target.value })}
167
+ className={INPUT_CLASS}
168
+ placeholder="Describe the after media"
169
+ />
170
+ </SettingsField>
171
+ </SettingsSection>
172
+
173
+ {/* ── Slider options ── */}
174
+ <SettingsSection title="Slider" icon={<OptionsIcon />}>
175
+ <ResponsiveField
176
+ label="Orientation"
177
+ block={block as ContentBlock}
178
+ property="orientation"
179
+ onReset={() => resetOverride("orientation")}
180
+ >
181
+ <div className="flex gap-1">
182
+ {(
183
+ [
184
+ { value: "horizontal", label: "Horizontal" },
185
+ { value: "vertical", label: "Vertical" },
186
+ ] as const
187
+ ).map((opt) => (
188
+ <button
189
+ key={opt.value}
190
+ onClick={() => updateResponsive("orientation", opt.value)}
191
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
192
+ effectiveOrientation === opt.value
193
+ ? "border-[#3580f9] bg-[#3580f9]/20 text-neutral-900"
194
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
195
+ }`}
196
+ >
197
+ {opt.label}
198
+ </button>
199
+ ))}
200
+ </div>
201
+ </ResponsiveField>
202
+
203
+ <SettingsField label="Initial Position" hint="0–100%">
204
+ <div className="flex items-center gap-1.5">
205
+ <input
206
+ type="number"
207
+ min={0}
208
+ max={100}
209
+ value={block.initial_position ?? 50}
210
+ onFocus={snapshotOnFocus}
211
+ onChange={(e) => {
212
+ const n = Math.max(0, Math.min(100, Number(e.target.value) || 0));
213
+ updateDebounced({ initial_position: n });
214
+ }}
215
+ className={INPUT_CLASS}
216
+ placeholder="50"
217
+ />
218
+ <span className="text-[10px] text-neutral-400 shrink-0">%</span>
219
+ </div>
220
+ </SettingsField>
221
+
222
+ <SettingsField label="Handle Color">
223
+ <ColorSwatchPicker
224
+ value={block.handle_color || "#FFFFFF"}
225
+ onChange={(value) => {
226
+ const hex = resolveColorHex(value) || "#FFFFFF";
227
+ update({ handle_color: hex });
228
+ }}
229
+ swatches={paletteSwatches}
230
+ />
231
+ </SettingsField>
232
+ </SettingsSection>
233
+
234
+ {/* ── Layout ── */}
235
+ <SettingsSection title="Layout" icon={<LayoutIcon />}>
236
+ <ResponsiveField
237
+ label="Width"
238
+ block={block as ContentBlock}
239
+ property="width"
240
+ onReset={() => resetOverride("width")}
241
+ >
242
+ <div className="flex gap-1">
243
+ {(
244
+ [
245
+ { value: "full", label: "Full" },
246
+ { value: "contained", label: "Contained" },
247
+ { value: "small", label: "Small" },
248
+ { value: "fill", label: "Fill" },
249
+ ] as const
250
+ ).map((opt) => (
251
+ <button
252
+ key={opt.value}
253
+ onClick={() => updateResponsive("width", opt.value)}
254
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
255
+ effectiveWidth === opt.value
256
+ ? "border-[#3580f9] bg-[#3580f9]/20 text-neutral-900"
257
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
258
+ }`}
259
+ >
260
+ {opt.label}
261
+ </button>
262
+ ))}
263
+ </div>
264
+ </ResponsiveField>
265
+
266
+ <ResponsiveField
267
+ label="Aspect Ratio"
268
+ block={block as ContentBlock}
269
+ property="aspect_ratio"
270
+ onReset={() => resetOverride("aspect_ratio")}
271
+ >
272
+ <select
273
+ value={effectiveAspect}
274
+ onChange={(e) => updateResponsive("aspect_ratio", e.target.value)}
275
+ className={SELECT_CLASS}
276
+ >
277
+ <option value="auto">Auto</option>
278
+ <option value="16:9">16:9</option>
279
+ <option value="4:3">4:3</option>
280
+ <option value="1:1">1:1</option>
281
+ <option value="21:9">21:9</option>
282
+ </select>
283
+ </ResponsiveField>
284
+ </SettingsSection>
285
+
286
+ {/* ── Appearance ── */}
287
+ <SettingsSection title="Appearance" icon={<AppearanceIcon />}>
288
+ <ResponsiveField
289
+ label="Border Radius"
290
+ block={block as ContentBlock}
291
+ property="border_radius"
292
+ onReset={() => resetOverride("border_radius")}
293
+ >
294
+ <div className="flex items-center gap-1.5">
295
+ <input
296
+ type="number"
297
+ value={String(getEffectiveValue<string>(block as ContentBlock, viewport, "border_radius", block.border_radius || "")).replace(/px$/i, "")}
298
+ onFocus={snapshotOnFocus}
299
+ onChange={(e) => {
300
+ store._pushSnapshot();
301
+ updateResponsive("border_radius", e.target.value.replace(/[^0-9]/g, ""));
302
+ }}
303
+ className={INPUT_CLASS}
304
+ placeholder="0"
305
+ min={0}
306
+ />
307
+ <span className="text-[10px] text-neutral-400 shrink-0">px</span>
308
+ </div>
309
+ </ResponsiveField>
310
+
311
+ <ResponsiveField
312
+ label="Shadow"
313
+ block={block as ContentBlock}
314
+ property="shadow"
315
+ onReset={() => resetOverride("shadow")}
316
+ >
317
+ <button
318
+ type="button"
319
+ onClick={() => {
320
+ const effectiveShadow = getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false);
321
+ updateResponsive("shadow", !effectiveShadow);
322
+ }}
323
+ className={`relative w-8 h-[18px] rounded-full transition-colors ${
324
+ getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "bg-[#3580f9]" : "bg-neutral-200 hover:bg-neutral-300"
325
+ }`}
326
+ >
327
+ <span
328
+ className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow-sm transition-transform ${
329
+ getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "left-[16px]" : "left-[2px]"
330
+ }`}
331
+ />
332
+ </button>
333
+ </ResponsiveField>
334
+ </SettingsSection>
335
+
336
+ {/* ── Video playback (only shown when at least one side is video) ── */}
337
+ {eitherIsVideo && (
338
+ <SettingsSection title="Playback" icon={<PlaybackIcon />}>
339
+ <div className="space-y-1.5">
340
+ <StyledCheckbox
341
+ label="Autoplay"
342
+ checked={block.video_autoplay !== false}
343
+ onChange={(checked) => update({ video_autoplay: checked })}
344
+ />
345
+ <StyledCheckbox
346
+ label="Loop"
347
+ checked={block.video_loop !== false}
348
+ onChange={(checked) => update({ video_loop: checked })}
349
+ />
350
+ <StyledCheckbox
351
+ label="Muted"
352
+ checked={block.video_muted !== false}
353
+ onChange={(checked) => update({ video_muted: checked })}
354
+ />
355
+ </div>
356
+ </SettingsSection>
357
+ )}
358
+ </>
359
+ );
360
+ }