@morphika/andami 0.5.4 → 0.5.6

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 (46) hide show
  1. package/app/admin/assets/page.tsx +3 -2
  2. package/app/admin/layout.tsx +4 -0
  3. package/components/admin/nav-builder/NavBuilder.tsx +2 -1
  4. package/components/admin/styles/FontsEditor.tsx +2 -1
  5. package/components/builder/ColumnDragOverlay.tsx +4 -4
  6. package/components/builder/CoverSectionCanvas.tsx +10 -9
  7. package/components/builder/InsertionLines.tsx +3 -3
  8. package/components/builder/SectionV2Canvas.tsx +3 -3
  9. package/components/builder/SectionV2Column.tsx +20 -20
  10. package/components/builder/SettingsPanel.tsx +14 -8
  11. package/components/builder/SortableBlock.tsx +4 -0
  12. package/components/builder/SortableRow.tsx +2 -0
  13. package/components/builder/asset-browser/useR2Operations.ts +5 -4
  14. package/components/builder/editors/AudioBlockEditor.tsx +10 -8
  15. package/components/builder/editors/BeforeAfterBlockEditor.tsx +10 -8
  16. package/components/builder/editors/ButtonBlockEditor.tsx +9 -7
  17. package/components/builder/editors/ImageBlockEditor.tsx +10 -8
  18. package/components/builder/editors/ImageGridBlockEditor.tsx +10 -8
  19. package/components/builder/editors/SpacerBlockEditor.tsx +4 -4
  20. package/components/builder/editors/TextBlockEditor.tsx +471 -468
  21. package/components/builder/editors/VideoBlockEditor.tsx +10 -8
  22. package/components/builder/live-preview/drag-utils.tsx +5 -3
  23. package/components/builder/settings-panel/AnimationTab.tsx +11 -8
  24. package/components/builder/settings-panel/BlockLayoutTab.tsx +514 -511
  25. package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +2 -2
  26. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +11 -8
  27. package/components/builder/settings-panel/ColumnV2Settings.tsx +6 -5
  28. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +4 -3
  29. package/components/builder/settings-panel/CoverSectionSettings.tsx +14 -9
  30. package/components/builder/settings-panel/CustomSectionSettings.tsx +9 -7
  31. package/components/builder/settings-panel/PageSettings.tsx +39 -32
  32. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +2 -2
  33. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  34. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +7 -5
  35. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +13 -9
  36. package/components/builder/settings-panel/SectionV2Settings.tsx +10 -9
  37. package/components/builder/settings-panel/TRBLInputs.tsx +2 -2
  38. package/components/builder/settings-panel/useSettingsPanelSelection.ts +16 -13
  39. package/components/ui/ToastStack.tsx +142 -0
  40. package/lib/auth-token.ts +5 -1
  41. package/lib/bot-guard.ts +6 -0
  42. package/lib/builder/constants.ts +5 -10
  43. package/lib/toast/index.ts +56 -0
  44. package/lib/toast/store.ts +56 -0
  45. package/lib/version.ts +1 -1
  46. package/package.json +3 -1
@@ -1,511 +1,514 @@
1
- "use client";
2
-
3
- /**
4
- * BlockLayoutTab — Per-block layout styling.
5
- *
6
- * Section order: Alignment → Spacing → Offset → Background → Border.
7
- * Each section has a small visual icon next to its title.
8
- *
9
- * Session 78: Added Alignment section with visual icon buttons.
10
- * Reordered sections. Added section title icons.
11
- * Session 82: Made viewport-aware with responsive override support.
12
- * Every property is now editable per viewport (desktop/tablet/phone).
13
- * Uses getBlockLayoutValue/setBlockLayoutOverride from responsive-helpers.
14
- */
15
-
16
- import { useBuilderStore } from "../../../lib/builder/store";
17
- import type { ContentBlock, BlockLayout } from "../../../lib/sanity/types";
18
- import {
19
- SettingsField,
20
- SettingsSection,
21
- SELECT_CLASS,
22
- AssetPathInput,
23
- } from "../editors/shared";
24
- import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
25
- import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
26
- import { TRBLInputs } from "./TRBLInputs";
27
- import { BubbleTooltip } from "../BubbleIcons";
28
- import {
29
- getBlockLayoutValue,
30
- hasBlockLayoutOverride,
31
- setBlockLayoutOverride,
32
- } from "./responsive-helpers";
33
-
34
- // ── Alignment visual icon buttons ──
35
-
36
- type AlignH = "left" | "center" | "right";
37
- type AlignV = "top" | "center" | "bottom";
38
-
39
- function HAlignIcon({ value, size = 18 }: { value: AlignH; size?: number }) {
40
- // Horizontal lines aligned left/center/right inside a box
41
- const w = size;
42
- const h = size;
43
- const pad = 3;
44
- const barH = 2.5;
45
- const gap = 1.5;
46
- const maxW = w - pad * 2;
47
-
48
- const bars = [
49
- { w: maxW * 0.9, y: pad + 1 },
50
- { w: maxW * 0.55, y: pad + 1 + barH + gap },
51
- { w: maxW * 0.75, y: pad + 1 + (barH + gap) * 2 },
52
- ];
53
-
54
- return (
55
- <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
56
- {bars.map((bar, i) => {
57
- let x = pad;
58
- if (value === "center") x = (w - bar.w) / 2;
59
- else if (value === "right") x = w - pad - bar.w;
60
- return (
61
- <rect
62
- key={i}
63
- x={x}
64
- y={bar.y}
65
- width={bar.w}
66
- height={barH}
67
- rx={1}
68
- fill="currentColor"
69
- />
70
- );
71
- })}
72
- </svg>
73
- );
74
- }
75
-
76
- function VAlignIcon({ value, size = 18 }: { value: AlignV; size?: number }) {
77
- const w = size;
78
- const h = size;
79
- const pad = 3;
80
- const barW = w - pad * 2;
81
- const barH = 3;
82
-
83
- // Position of a single bar within the vertical space
84
- let y = pad;
85
- if (value === "center") y = (h - barH) / 2;
86
- else if (value === "bottom") y = h - pad - barH;
87
-
88
- return (
89
- <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
90
- {/* Container outline */}
91
- <rect
92
- x={pad - 0.5}
93
- y={pad - 0.5}
94
- width={barW + 1}
95
- height={h - pad * 2 + 1}
96
- rx={1.5}
97
- stroke="currentColor"
98
- strokeWidth={0.8}
99
- opacity={0.3}
100
- fill="none"
101
- />
102
- {/* Bar at position */}
103
- <rect
104
- x={pad + 1}
105
- y={y}
106
- width={barW - 2}
107
- height={barH}
108
- rx={1}
109
- fill="currentColor"
110
- />
111
- </svg>
112
- );
113
- }
114
-
115
- function AlignmentButtons<T extends string>({
116
- options,
117
- value,
118
- onChange,
119
- renderIcon,
120
- }: {
121
- options: { value: T; label: string }[];
122
- value: T;
123
- onChange: (v: T) => void;
124
- renderIcon: (v: T) => React.ReactNode;
125
- }) {
126
- return (
127
- <div className="flex gap-1">
128
- {options.map((opt) => {
129
- const isActive = value === opt.value;
130
- return (
131
- <button
132
- key={opt.value}
133
- aria-label={opt.label}
134
- onClick={() => onChange(opt.value)}
135
- className={`group/bb relative flex items-center justify-center w-[34px] h-[30px] rounded-md border transition-all ${
136
- isActive
137
- ? "bg-[#3580f9]/10 border-[#3580f9]/30 text-[#3580f9]"
138
- : "bg-[#f5f5f5] border-transparent text-neutral-400 hover:bg-[#efefef] hover:text-neutral-600"
139
- }`}
140
- >
141
- {renderIcon(opt.value)}
142
- <BubbleTooltip>{opt.label}</BubbleTooltip>
143
- </button>
144
- );
145
- })}
146
- </div>
147
- );
148
- }
149
-
150
- // ── Section title icons (centralized colored icons — Session 163) ──
151
- import {
152
- AlignmentIcon,
153
- SpacingIcon,
154
- OffsetIcon,
155
- BackgroundIcon,
156
- BorderIcon,
157
- } from "../editors/section-icons";
158
-
159
- // ── Override indicator badge ──
160
-
161
- function OverrideBadge({
162
- block,
163
- viewport,
164
- properties,
165
- onReset,
166
- }: {
167
- block: ContentBlock;
168
- viewport: string;
169
- properties: (keyof BlockLayout)[];
170
- onReset: () => void;
171
- }) {
172
- if (viewport === "desktop") return null;
173
- const hasAny = properties.some((p) => hasBlockLayoutOverride(block, viewport as "tablet" | "phone", p));
174
- if (hasAny) {
175
- return (
176
- <div className="flex items-center gap-2 mt-1">
177
- <span className="text-[9px] text-[#3580f9]">overridden</span>
178
- <button
179
- onClick={onReset}
180
- className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
181
- >
182
- Reset
183
- </button>
184
- </div>
185
- );
186
- }
187
- return <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>;
188
- }
189
-
190
- // ── Main component ──
191
-
192
- export function BlockLayoutTab({ block }: { block: ContentBlock }) {
193
- const store = useBuilderStore();
194
- const paletteSwatches = usePaletteSwatches();
195
- const activeViewport = store.activeViewport;
196
-
197
- // Live preview callbacks (Phase 4)
198
- const handleBgPreview = (val: import("../../../lib/sanity/types").ColorField) => {
199
- store.setColorPickerPreview({ blockKey: block._key, field: "background_color", value: val });
200
- };
201
- const handleBorderPreview = (val: import("../../../lib/sanity/types").ColorField) => {
202
- store.setColorPickerPreview({ blockKey: block._key, field: "border_color", value: val });
203
- };
204
-
205
- /** Update a block layout property, viewport-aware */
206
- const updateLayout = (property: keyof BlockLayout, value: unknown) => {
207
- const updates = setBlockLayoutOverride(block, activeViewport, property, value);
208
- store.updateBlock(block._key, updates);
209
- };
210
-
211
- /** Reset all layout overrides for a group of properties */
212
- const resetGroup = (properties: (keyof BlockLayout)[]) => {
213
- store._pushSnapshot();
214
- // Accumulate resets by setting each property to undefined
215
- let accumulated = block;
216
- for (const prop of properties) {
217
- const u = setBlockLayoutOverride(accumulated, activeViewport, prop, undefined);
218
- accumulated = { ...accumulated, ...u } as ContentBlock;
219
- }
220
- const responsive = (accumulated as unknown as Record<string, unknown>).responsive;
221
- store.updateBlock(block._key, { responsive } as unknown as Partial<ContentBlock>);
222
- };
223
-
224
- // Effective values per viewport
225
- const bgOpacity = getBlockLayoutValue<number>(block, activeViewport, "background_opacity", 100);
226
- const bgIsGradient = isGradient(parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", "")));
227
- const alignH: AlignH = getBlockLayoutValue<string>(block, activeViewport, "align_h", "left") as AlignH;
228
- const alignV: AlignV = getBlockLayoutValue<string>(block, activeViewport, "align_v", "top") as AlignV;
229
-
230
- const viewportLabel = activeViewport !== "desktop"
231
- ? activeViewport === "tablet" ? "Tablet" : "Phone"
232
- : null;
233
-
234
- return (
235
- <>
236
- {viewportLabel && (
237
- <div className="px-4 pt-3">
238
- <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#3580f9]/8 border border-[#3580f9]/15">
239
- <span className="text-[11px] font-medium text-[#3580f9]">
240
- Editing {viewportLabel} overrides
241
- </span>
242
- </div>
243
- </div>
244
- )}
245
-
246
- {/* Alignment */}
247
- <SettingsSection title="Alignment" defaultOpen icon={<AlignmentIcon />}>
248
- <SettingsField label="Horizontal">
249
- <AlignmentButtons<AlignH>
250
- options={[
251
- { value: "left", label: "Left" },
252
- { value: "center", label: "Center" },
253
- { value: "right", label: "Right" },
254
- ]}
255
- value={alignH}
256
- onChange={(v) => {
257
- store._pushSnapshot();
258
- updateLayout("align_h", v);
259
- }}
260
- renderIcon={(v) => <HAlignIcon value={v} />}
261
- />
262
- </SettingsField>
263
-
264
- <SettingsField label="Vertical">
265
- <AlignmentButtons<AlignV>
266
- options={[
267
- { value: "top", label: "Top" },
268
- { value: "center", label: "Center" },
269
- { value: "bottom", label: "Bottom" },
270
- ]}
271
- value={alignV}
272
- onChange={(v) => {
273
- store._pushSnapshot();
274
- updateLayout("align_v", v);
275
- }}
276
- renderIcon={(v) => <VAlignIcon value={v} />}
277
- />
278
- </SettingsField>
279
-
280
- <OverrideBadge
281
- block={block}
282
- viewport={activeViewport}
283
- properties={["align_h", "align_v"]}
284
- onReset={() => resetGroup(["align_h", "align_v"])}
285
- />
286
- </SettingsSection>
287
-
288
- {/* Spacing (Padding) */}
289
- <SettingsSection title="Spacing" defaultOpen icon={<SpacingIcon />}>
290
- <TRBLInputs
291
- top={getBlockLayoutValue<string>(block, activeViewport, "spacing_top", "0")}
292
- right={getBlockLayoutValue<string>(block, activeViewport, "spacing_right", "0")}
293
- bottom={getBlockLayoutValue<string>(block, activeViewport, "spacing_bottom", "0")}
294
- left={getBlockLayoutValue<string>(block, activeViewport, "spacing_left", "0")}
295
- onChange={(field, value) => {
296
- updateLayout(`spacing_${field}` as keyof BlockLayout, value);
297
- }}
298
- />
299
- <OverrideBadge
300
- block={block}
301
- viewport={activeViewport}
302
- properties={["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"]}
303
- onReset={() => resetGroup(["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"])}
304
- />
305
- </SettingsSection>
306
-
307
- {/* Offset (Margin) */}
308
- <SettingsSection title="Offset" icon={<OffsetIcon />}>
309
- <TRBLInputs
310
- top={getBlockLayoutValue<string>(block, activeViewport, "offset_top", "0")}
311
- right={getBlockLayoutValue<string>(block, activeViewport, "offset_right", "0")}
312
- bottom={getBlockLayoutValue<string>(block, activeViewport, "offset_bottom", "0")}
313
- left={getBlockLayoutValue<string>(block, activeViewport, "offset_left", "0")}
314
- onChange={(field, value) => {
315
- updateLayout(`offset_${field}` as keyof BlockLayout, value);
316
- }}
317
- />
318
- <OverrideBadge
319
- block={block}
320
- viewport={activeViewport}
321
- properties={["offset_top", "offset_right", "offset_bottom", "offset_left"]}
322
- onReset={() => resetGroup(["offset_top", "offset_right", "offset_bottom", "offset_left"])}
323
- />
324
- </SettingsSection>
325
-
326
- {/* Background */}
327
- <SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
328
- <SettingsField label="Color">
329
- <ColorSwatchPicker
330
- value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", ""))}
331
- onChange={(val) => {
332
- store._pushSnapshot();
333
- store.clearColorPickerPreview();
334
- updateLayout("background_color", serializeColorField(val));
335
- }}
336
- swatches={paletteSwatches}
337
- allowGradients
338
- onPreview={handleBgPreview}
339
- />
340
- </SettingsField>
341
-
342
- <SettingsField label="Opacity">
343
- <div className="flex items-center gap-2">
344
- <input
345
- type="range"
346
- min={0}
347
- max={100}
348
- value={bgOpacity}
349
- onChange={(e) => updateLayout("background_opacity", parseInt(e.target.value))}
350
- className={`flex-1 accent-[#3580f9] ${bgIsGradient ? "opacity-40 pointer-events-none" : ""}`}
351
- disabled={bgIsGradient}
352
- />
353
- <span className="text-xs text-neutral-900 w-10 text-right">
354
- {bgOpacity}%
355
- </span>
356
- </div>
357
- {bgIsGradient && (
358
- <p className="text-[9px] text-neutral-400 italic mt-1">
359
- Opacity is controlled per stop in gradient mode
360
- </p>
361
- )}
362
- </SettingsField>
363
-
364
- <SettingsField label="Image">
365
- <AssetPathInput
366
- value={getBlockLayoutValue<string>(block, activeViewport, "background_image", "")}
367
- onFocus={() => store._pushSnapshot()}
368
- onChange={(v) => updateLayout("background_image", v)}
369
- placeholder="path/to/image.jpg"
370
- filterType="image"
371
- />
372
- </SettingsField>
373
-
374
- {getBlockLayoutValue<string>(block, activeViewport, "background_image", "") && (
375
- <>
376
- <SettingsField label="Size">
377
- <select
378
- value={getBlockLayoutValue<string>(block, activeViewport, "background_size", "cover")}
379
- onChange={(e) => updateLayout("background_size", e.target.value)}
380
- className={SELECT_CLASS}
381
- >
382
- <option value="cover">Cover</option>
383
- <option value="contain">Contain</option>
384
- <option value="auto">Auto</option>
385
- </select>
386
- </SettingsField>
387
-
388
- <SettingsField label="Position">
389
- <select
390
- value={getBlockLayoutValue<string>(block, activeViewport, "background_position", "center center")}
391
- onChange={(e) => updateLayout("background_position", e.target.value)}
392
- className={SELECT_CLASS}
393
- >
394
- <option value="center center">Center</option>
395
- <option value="top center">Top</option>
396
- <option value="bottom center">Bottom</option>
397
- <option value="left center">Left</option>
398
- <option value="right center">Right</option>
399
- </select>
400
- </SettingsField>
401
-
402
- <SettingsField label="Repeat">
403
- <select
404
- value={getBlockLayoutValue<string>(block, activeViewport, "background_repeat", "no-repeat")}
405
- onChange={(e) => updateLayout("background_repeat", e.target.value)}
406
- className={SELECT_CLASS}
407
- >
408
- <option value="no-repeat">No Repeat</option>
409
- <option value="repeat">Repeat</option>
410
- <option value="repeat-x">Repeat X</option>
411
- <option value="repeat-y">Repeat Y</option>
412
- </select>
413
- </SettingsField>
414
- </>
415
- )}
416
-
417
- <OverrideBadge
418
- block={block}
419
- viewport={activeViewport}
420
- properties={["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"]}
421
- onReset={() => resetGroup(["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"])}
422
- />
423
- </SettingsSection>
424
-
425
- {/* Border */}
426
- <SettingsSection title="Border" icon={<BorderIcon />}>
427
- <SettingsField label="Color">
428
- <ColorSwatchPicker
429
- value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "border_color", ""))}
430
- onChange={(val) => {
431
- store._pushSnapshot();
432
- store.clearColorPickerPreview();
433
- updateLayout("border_color", serializeColorField(val));
434
- }}
435
- swatches={paletteSwatches}
436
- allowGradients
437
- onPreview={handleBorderPreview}
438
- />
439
- </SettingsField>
440
-
441
- <SettingsField label="Width">
442
- <div className="flex items-center gap-2">
443
- <input
444
- type="range"
445
- min={0}
446
- max={20}
447
- value={parseInt(getBlockLayoutValue<string>(block, activeViewport, "border_width", "0"))}
448
- onChange={(e) => updateLayout("border_width", e.target.value)}
449
- className="flex-1 accent-[#3580f9]"
450
- />
451
- <span className="text-xs text-neutral-900 w-10 text-right">
452
- {getBlockLayoutValue<string>(block, activeViewport, "border_width", "0")}px
453
- </span>
454
- </div>
455
- </SettingsField>
456
-
457
- <SettingsField label="Style">
458
- <select
459
- value={getBlockLayoutValue<string>(block, activeViewport, "border_style", "none")}
460
- onChange={(e) => updateLayout("border_style", e.target.value)}
461
- className={SELECT_CLASS}
462
- >
463
- <option value="none">None</option>
464
- <option value="solid">Solid</option>
465
- <option value="dashed">Dashed</option>
466
- <option value="dotted">Dotted</option>
467
- </select>
468
- </SettingsField>
469
-
470
- <SettingsField label="Sides">
471
- <select
472
- value={getBlockLayoutValue<string>(block, activeViewport, "border_sides", "all")}
473
- onChange={(e) => updateLayout("border_sides", e.target.value)}
474
- className={SELECT_CLASS}
475
- >
476
- <option value="all">All</option>
477
- <option value="top">Top</option>
478
- <option value="right">Right</option>
479
- <option value="bottom">Bottom</option>
480
- <option value="left">Left</option>
481
- <option value="top-bottom">Top & Bottom</option>
482
- <option value="left-right">Left & Right</option>
483
- </select>
484
- </SettingsField>
485
-
486
- <SettingsField label="Radius">
487
- <div className="flex items-center gap-2">
488
- <input
489
- type="range"
490
- min={0}
491
- max={50}
492
- value={parseInt(getBlockLayoutValue<string>(block, activeViewport, "border_radius", "0"))}
493
- onChange={(e) => updateLayout("border_radius", e.target.value)}
494
- className="flex-1 accent-[#3580f9]"
495
- />
496
- <span className="text-xs text-neutral-900 w-10 text-right">
497
- {getBlockLayoutValue<string>(block, activeViewport, "border_radius", "0")}px
498
- </span>
499
- </div>
500
- </SettingsField>
501
-
502
- <OverrideBadge
503
- block={block}
504
- viewport={activeViewport}
505
- properties={["border_color", "border_width", "border_style", "border_sides", "border_radius"]}
506
- onReset={() => resetGroup(["border_color", "border_width", "border_style", "border_sides", "border_radius"])}
507
- />
508
- </SettingsSection>
509
- </>
510
- );
511
- }
1
+ "use client";
2
+
3
+ /**
4
+ * BlockLayoutTab — Per-block layout styling.
5
+ *
6
+ * Section order: Alignment → Spacing → Offset → Background → Border.
7
+ * Each section has a small visual icon next to its title.
8
+ *
9
+ * Session 78: Added Alignment section with visual icon buttons.
10
+ * Reordered sections. Added section title icons.
11
+ * Session 82: Made viewport-aware with responsive override support.
12
+ * Every property is now editable per viewport (desktop/tablet/phone).
13
+ * Uses getBlockLayoutValue/setBlockLayoutOverride from responsive-helpers.
14
+ */
15
+
16
+ import { useBuilderStore } from "../../../lib/builder/store";
17
+ import type { ContentBlock, BlockLayout } from "../../../lib/sanity/types";
18
+ import {
19
+ SettingsField,
20
+ SettingsSection,
21
+ SELECT_CLASS,
22
+ AssetPathInput,
23
+ } from "../editors/shared";
24
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
25
+ import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
26
+ import { TRBLInputs } from "./TRBLInputs";
27
+ import { BubbleTooltip } from "../BubbleIcons";
28
+ import {
29
+ getBlockLayoutValue,
30
+ hasBlockLayoutOverride,
31
+ setBlockLayoutOverride,
32
+ } from "./responsive-helpers";
33
+
34
+ // ── Alignment visual icon buttons ──
35
+
36
+ type AlignH = "left" | "center" | "right";
37
+ type AlignV = "top" | "center" | "bottom";
38
+
39
+ function HAlignIcon({ value, size = 18 }: { value: AlignH; size?: number }) {
40
+ // Horizontal lines aligned left/center/right inside a box
41
+ const w = size;
42
+ const h = size;
43
+ const pad = 3;
44
+ const barH = 2.5;
45
+ const gap = 1.5;
46
+ const maxW = w - pad * 2;
47
+
48
+ const bars = [
49
+ { w: maxW * 0.9, y: pad + 1 },
50
+ { w: maxW * 0.55, y: pad + 1 + barH + gap },
51
+ { w: maxW * 0.75, y: pad + 1 + (barH + gap) * 2 },
52
+ ];
53
+
54
+ return (
55
+ <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
56
+ {bars.map((bar, i) => {
57
+ let x = pad;
58
+ if (value === "center") x = (w - bar.w) / 2;
59
+ else if (value === "right") x = w - pad - bar.w;
60
+ return (
61
+ <rect
62
+ key={i}
63
+ x={x}
64
+ y={bar.y}
65
+ width={bar.w}
66
+ height={barH}
67
+ rx={1}
68
+ fill="currentColor"
69
+ />
70
+ );
71
+ })}
72
+ </svg>
73
+ );
74
+ }
75
+
76
+ function VAlignIcon({ value, size = 18 }: { value: AlignV; size?: number }) {
77
+ const w = size;
78
+ const h = size;
79
+ const pad = 3;
80
+ const barW = w - pad * 2;
81
+ const barH = 3;
82
+
83
+ // Position of a single bar within the vertical space
84
+ let y = pad;
85
+ if (value === "center") y = (h - barH) / 2;
86
+ else if (value === "bottom") y = h - pad - barH;
87
+
88
+ return (
89
+ <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none">
90
+ {/* Container outline */}
91
+ <rect
92
+ x={pad - 0.5}
93
+ y={pad - 0.5}
94
+ width={barW + 1}
95
+ height={h - pad * 2 + 1}
96
+ rx={1.5}
97
+ stroke="currentColor"
98
+ strokeWidth={0.8}
99
+ opacity={0.3}
100
+ fill="none"
101
+ />
102
+ {/* Bar at position */}
103
+ <rect
104
+ x={pad + 1}
105
+ y={y}
106
+ width={barW - 2}
107
+ height={barH}
108
+ rx={1}
109
+ fill="currentColor"
110
+ />
111
+ </svg>
112
+ );
113
+ }
114
+
115
+ function AlignmentButtons<T extends string>({
116
+ options,
117
+ value,
118
+ onChange,
119
+ renderIcon,
120
+ }: {
121
+ options: { value: T; label: string }[];
122
+ value: T;
123
+ onChange: (v: T) => void;
124
+ renderIcon: (v: T) => React.ReactNode;
125
+ }) {
126
+ return (
127
+ <div className="flex gap-1">
128
+ {options.map((opt) => {
129
+ const isActive = value === opt.value;
130
+ return (
131
+ <button
132
+ key={opt.value}
133
+ aria-label={opt.label}
134
+ onClick={() => onChange(opt.value)}
135
+ className={`group/bb relative flex items-center justify-center w-[34px] h-[30px] rounded-md border transition-all ${
136
+ isActive
137
+ ? "bg-[#3580f9]/10 border-[#3580f9]/30 text-[#3580f9]"
138
+ : "bg-[#f5f5f5] border-transparent text-neutral-400 hover:bg-[#efefef] hover:text-neutral-600"
139
+ }`}
140
+ >
141
+ {renderIcon(opt.value)}
142
+ <BubbleTooltip>{opt.label}</BubbleTooltip>
143
+ </button>
144
+ );
145
+ })}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ // ── Section title icons (centralized colored icons — Session 163) ──
151
+ import {
152
+ AlignmentIcon,
153
+ SpacingIcon,
154
+ OffsetIcon,
155
+ BackgroundIcon,
156
+ BorderIcon,
157
+ } from "../editors/section-icons";
158
+
159
+ // ── Override indicator badge ──
160
+
161
+ function OverrideBadge({
162
+ block,
163
+ viewport,
164
+ properties,
165
+ onReset,
166
+ }: {
167
+ block: ContentBlock;
168
+ viewport: string;
169
+ properties: (keyof BlockLayout)[];
170
+ onReset: () => void;
171
+ }) {
172
+ if (viewport === "desktop") return null;
173
+ const hasAny = properties.some((p) => hasBlockLayoutOverride(block, viewport as "tablet" | "phone", p));
174
+ if (hasAny) {
175
+ return (
176
+ <div className="flex items-center gap-2 mt-1">
177
+ <span className="text-[9px] text-[#3580f9]">overridden</span>
178
+ <button
179
+ onClick={onReset}
180
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
181
+ >
182
+ Reset
183
+ </button>
184
+ </div>
185
+ );
186
+ }
187
+ return <p className="text-[9px] text-neutral-300 italic mt-1">inherited</p>;
188
+ }
189
+
190
+ // ── Main component ──
191
+
192
+ export function BlockLayoutTab({ block }: { block: ContentBlock }) {
193
+ const activeViewport = useBuilderStore((s) => s.activeViewport);
194
+ const updateBlock = useBuilderStore((s) => s.updateBlock);
195
+ const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
196
+ const setColorPickerPreview = useBuilderStore((s) => s.setColorPickerPreview);
197
+ const clearColorPickerPreview = useBuilderStore((s) => s.clearColorPickerPreview);
198
+ const paletteSwatches = usePaletteSwatches();
199
+
200
+ // Live preview callbacks (Phase 4)
201
+ const handleBgPreview = (val: import("../../../lib/sanity/types").ColorField) => {
202
+ setColorPickerPreview({ blockKey: block._key, field: "background_color", value: val });
203
+ };
204
+ const handleBorderPreview = (val: import("../../../lib/sanity/types").ColorField) => {
205
+ setColorPickerPreview({ blockKey: block._key, field: "border_color", value: val });
206
+ };
207
+
208
+ /** Update a block layout property, viewport-aware */
209
+ const updateLayout = (property: keyof BlockLayout, value: unknown) => {
210
+ const updates = setBlockLayoutOverride(block, activeViewport, property, value);
211
+ updateBlock(block._key, updates);
212
+ };
213
+
214
+ /** Reset all layout overrides for a group of properties */
215
+ const resetGroup = (properties: (keyof BlockLayout)[]) => {
216
+ _pushSnapshot();
217
+ // Accumulate resets by setting each property to undefined
218
+ let accumulated = block;
219
+ for (const prop of properties) {
220
+ const u = setBlockLayoutOverride(accumulated, activeViewport, prop, undefined);
221
+ accumulated = { ...accumulated, ...u } as ContentBlock;
222
+ }
223
+ const responsive = (accumulated as unknown as Record<string, unknown>).responsive;
224
+ updateBlock(block._key, { responsive } as unknown as Partial<ContentBlock>);
225
+ };
226
+
227
+ // Effective values per viewport
228
+ const bgOpacity = getBlockLayoutValue<number>(block, activeViewport, "background_opacity", 100);
229
+ const bgIsGradient = isGradient(parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", "")));
230
+ const alignH: AlignH = getBlockLayoutValue<string>(block, activeViewport, "align_h", "left") as AlignH;
231
+ const alignV: AlignV = getBlockLayoutValue<string>(block, activeViewport, "align_v", "top") as AlignV;
232
+
233
+ const viewportLabel = activeViewport !== "desktop"
234
+ ? activeViewport === "tablet" ? "Tablet" : "Phone"
235
+ : null;
236
+
237
+ return (
238
+ <>
239
+ {viewportLabel && (
240
+ <div className="px-4 pt-3">
241
+ <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#3580f9]/8 border border-[#3580f9]/15">
242
+ <span className="text-[11px] font-medium text-[#3580f9]">
243
+ Editing {viewportLabel} overrides
244
+ </span>
245
+ </div>
246
+ </div>
247
+ )}
248
+
249
+ {/* Alignment */}
250
+ <SettingsSection title="Alignment" defaultOpen icon={<AlignmentIcon />}>
251
+ <SettingsField label="Horizontal">
252
+ <AlignmentButtons<AlignH>
253
+ options={[
254
+ { value: "left", label: "Left" },
255
+ { value: "center", label: "Center" },
256
+ { value: "right", label: "Right" },
257
+ ]}
258
+ value={alignH}
259
+ onChange={(v) => {
260
+ _pushSnapshot();
261
+ updateLayout("align_h", v);
262
+ }}
263
+ renderIcon={(v) => <HAlignIcon value={v} />}
264
+ />
265
+ </SettingsField>
266
+
267
+ <SettingsField label="Vertical">
268
+ <AlignmentButtons<AlignV>
269
+ options={[
270
+ { value: "top", label: "Top" },
271
+ { value: "center", label: "Center" },
272
+ { value: "bottom", label: "Bottom" },
273
+ ]}
274
+ value={alignV}
275
+ onChange={(v) => {
276
+ _pushSnapshot();
277
+ updateLayout("align_v", v);
278
+ }}
279
+ renderIcon={(v) => <VAlignIcon value={v} />}
280
+ />
281
+ </SettingsField>
282
+
283
+ <OverrideBadge
284
+ block={block}
285
+ viewport={activeViewport}
286
+ properties={["align_h", "align_v"]}
287
+ onReset={() => resetGroup(["align_h", "align_v"])}
288
+ />
289
+ </SettingsSection>
290
+
291
+ {/* Spacing (Padding) */}
292
+ <SettingsSection title="Spacing" defaultOpen icon={<SpacingIcon />}>
293
+ <TRBLInputs
294
+ top={getBlockLayoutValue<string>(block, activeViewport, "spacing_top", "0")}
295
+ right={getBlockLayoutValue<string>(block, activeViewport, "spacing_right", "0")}
296
+ bottom={getBlockLayoutValue<string>(block, activeViewport, "spacing_bottom", "0")}
297
+ left={getBlockLayoutValue<string>(block, activeViewport, "spacing_left", "0")}
298
+ onChange={(field, value) => {
299
+ updateLayout(`spacing_${field}` as keyof BlockLayout, value);
300
+ }}
301
+ />
302
+ <OverrideBadge
303
+ block={block}
304
+ viewport={activeViewport}
305
+ properties={["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"]}
306
+ onReset={() => resetGroup(["spacing_top", "spacing_right", "spacing_bottom", "spacing_left"])}
307
+ />
308
+ </SettingsSection>
309
+
310
+ {/* Offset (Margin) */}
311
+ <SettingsSection title="Offset" icon={<OffsetIcon />}>
312
+ <TRBLInputs
313
+ top={getBlockLayoutValue<string>(block, activeViewport, "offset_top", "0")}
314
+ right={getBlockLayoutValue<string>(block, activeViewport, "offset_right", "0")}
315
+ bottom={getBlockLayoutValue<string>(block, activeViewport, "offset_bottom", "0")}
316
+ left={getBlockLayoutValue<string>(block, activeViewport, "offset_left", "0")}
317
+ onChange={(field, value) => {
318
+ updateLayout(`offset_${field}` as keyof BlockLayout, value);
319
+ }}
320
+ />
321
+ <OverrideBadge
322
+ block={block}
323
+ viewport={activeViewport}
324
+ properties={["offset_top", "offset_right", "offset_bottom", "offset_left"]}
325
+ onReset={() => resetGroup(["offset_top", "offset_right", "offset_bottom", "offset_left"])}
326
+ />
327
+ </SettingsSection>
328
+
329
+ {/* Background */}
330
+ <SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
331
+ <SettingsField label="Color">
332
+ <ColorSwatchPicker
333
+ value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", ""))}
334
+ onChange={(val) => {
335
+ _pushSnapshot();
336
+ clearColorPickerPreview();
337
+ updateLayout("background_color", serializeColorField(val));
338
+ }}
339
+ swatches={paletteSwatches}
340
+ allowGradients
341
+ onPreview={handleBgPreview}
342
+ />
343
+ </SettingsField>
344
+
345
+ <SettingsField label="Opacity">
346
+ <div className="flex items-center gap-2">
347
+ <input
348
+ type="range"
349
+ min={0}
350
+ max={100}
351
+ value={bgOpacity}
352
+ onChange={(e) => updateLayout("background_opacity", parseInt(e.target.value))}
353
+ className={`flex-1 accent-[#3580f9] ${bgIsGradient ? "opacity-40 pointer-events-none" : ""}`}
354
+ disabled={bgIsGradient}
355
+ />
356
+ <span className="text-xs text-neutral-900 w-10 text-right">
357
+ {bgOpacity}%
358
+ </span>
359
+ </div>
360
+ {bgIsGradient && (
361
+ <p className="text-[9px] text-neutral-400 italic mt-1">
362
+ Opacity is controlled per stop in gradient mode
363
+ </p>
364
+ )}
365
+ </SettingsField>
366
+
367
+ <SettingsField label="Image">
368
+ <AssetPathInput
369
+ value={getBlockLayoutValue<string>(block, activeViewport, "background_image", "")}
370
+ onFocus={() => _pushSnapshot()}
371
+ onChange={(v) => updateLayout("background_image", v)}
372
+ placeholder="path/to/image.jpg"
373
+ filterType="image"
374
+ />
375
+ </SettingsField>
376
+
377
+ {getBlockLayoutValue<string>(block, activeViewport, "background_image", "") && (
378
+ <>
379
+ <SettingsField label="Size">
380
+ <select
381
+ value={getBlockLayoutValue<string>(block, activeViewport, "background_size", "cover")}
382
+ onChange={(e) => updateLayout("background_size", e.target.value)}
383
+ className={SELECT_CLASS}
384
+ >
385
+ <option value="cover">Cover</option>
386
+ <option value="contain">Contain</option>
387
+ <option value="auto">Auto</option>
388
+ </select>
389
+ </SettingsField>
390
+
391
+ <SettingsField label="Position">
392
+ <select
393
+ value={getBlockLayoutValue<string>(block, activeViewport, "background_position", "center center")}
394
+ onChange={(e) => updateLayout("background_position", e.target.value)}
395
+ className={SELECT_CLASS}
396
+ >
397
+ <option value="center center">Center</option>
398
+ <option value="top center">Top</option>
399
+ <option value="bottom center">Bottom</option>
400
+ <option value="left center">Left</option>
401
+ <option value="right center">Right</option>
402
+ </select>
403
+ </SettingsField>
404
+
405
+ <SettingsField label="Repeat">
406
+ <select
407
+ value={getBlockLayoutValue<string>(block, activeViewport, "background_repeat", "no-repeat")}
408
+ onChange={(e) => updateLayout("background_repeat", e.target.value)}
409
+ className={SELECT_CLASS}
410
+ >
411
+ <option value="no-repeat">No Repeat</option>
412
+ <option value="repeat">Repeat</option>
413
+ <option value="repeat-x">Repeat X</option>
414
+ <option value="repeat-y">Repeat Y</option>
415
+ </select>
416
+ </SettingsField>
417
+ </>
418
+ )}
419
+
420
+ <OverrideBadge
421
+ block={block}
422
+ viewport={activeViewport}
423
+ properties={["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"]}
424
+ onReset={() => resetGroup(["background_color", "background_opacity", "background_image", "background_size", "background_position", "background_repeat"])}
425
+ />
426
+ </SettingsSection>
427
+
428
+ {/* Border */}
429
+ <SettingsSection title="Border" icon={<BorderIcon />}>
430
+ <SettingsField label="Color">
431
+ <ColorSwatchPicker
432
+ value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "border_color", ""))}
433
+ onChange={(val) => {
434
+ _pushSnapshot();
435
+ clearColorPickerPreview();
436
+ updateLayout("border_color", serializeColorField(val));
437
+ }}
438
+ swatches={paletteSwatches}
439
+ allowGradients
440
+ onPreview={handleBorderPreview}
441
+ />
442
+ </SettingsField>
443
+
444
+ <SettingsField label="Width">
445
+ <div className="flex items-center gap-2">
446
+ <input
447
+ type="range"
448
+ min={0}
449
+ max={20}
450
+ value={parseInt(getBlockLayoutValue<string>(block, activeViewport, "border_width", "0"))}
451
+ onChange={(e) => updateLayout("border_width", e.target.value)}
452
+ className="flex-1 accent-[#3580f9]"
453
+ />
454
+ <span className="text-xs text-neutral-900 w-10 text-right">
455
+ {getBlockLayoutValue<string>(block, activeViewport, "border_width", "0")}px
456
+ </span>
457
+ </div>
458
+ </SettingsField>
459
+
460
+ <SettingsField label="Style">
461
+ <select
462
+ value={getBlockLayoutValue<string>(block, activeViewport, "border_style", "none")}
463
+ onChange={(e) => updateLayout("border_style", e.target.value)}
464
+ className={SELECT_CLASS}
465
+ >
466
+ <option value="none">None</option>
467
+ <option value="solid">Solid</option>
468
+ <option value="dashed">Dashed</option>
469
+ <option value="dotted">Dotted</option>
470
+ </select>
471
+ </SettingsField>
472
+
473
+ <SettingsField label="Sides">
474
+ <select
475
+ value={getBlockLayoutValue<string>(block, activeViewport, "border_sides", "all")}
476
+ onChange={(e) => updateLayout("border_sides", e.target.value)}
477
+ className={SELECT_CLASS}
478
+ >
479
+ <option value="all">All</option>
480
+ <option value="top">Top</option>
481
+ <option value="right">Right</option>
482
+ <option value="bottom">Bottom</option>
483
+ <option value="left">Left</option>
484
+ <option value="top-bottom">Top & Bottom</option>
485
+ <option value="left-right">Left & Right</option>
486
+ </select>
487
+ </SettingsField>
488
+
489
+ <SettingsField label="Radius">
490
+ <div className="flex items-center gap-2">
491
+ <input
492
+ type="range"
493
+ min={0}
494
+ max={50}
495
+ value={parseInt(getBlockLayoutValue<string>(block, activeViewport, "border_radius", "0"))}
496
+ onChange={(e) => updateLayout("border_radius", e.target.value)}
497
+ className="flex-1 accent-[#3580f9]"
498
+ />
499
+ <span className="text-xs text-neutral-900 w-10 text-right">
500
+ {getBlockLayoutValue<string>(block, activeViewport, "border_radius", "0")}px
501
+ </span>
502
+ </div>
503
+ </SettingsField>
504
+
505
+ <OverrideBadge
506
+ block={block}
507
+ viewport={activeViewport}
508
+ properties={["border_color", "border_width", "border_style", "border_sides", "border_radius"]}
509
+ onReset={() => resetGroup(["border_color", "border_width", "border_style", "border_sides", "border_radius"])}
510
+ />
511
+ </SettingsSection>
512
+ </>
513
+ );
514
+ }