@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,468 +1,471 @@
1
- "use client";
2
-
3
- import { useState, useEffect, type ReactNode } from "react";
4
- import { useBuilderStore } from "../../../lib/builder/store";
5
- import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
6
- import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
7
- import type { DeviceViewport } from "../../../lib/builder/types";
8
- import {
9
- TypographyIcon,
10
- ColumnsIcon,
11
- } from "./section-icons";
12
- import {
13
- SettingsSection,
14
- SettingsField,
15
- ViewportBadge,
16
- ResponsiveField,
17
- useActiveViewport,
18
- INPUT_CLASS,
19
- SELECT_CLASS,
20
- } from "./shared";
21
- import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
22
- import TextStylePicker, {
23
- FALLBACK_PRESETS,
24
- buildPresetsFromStyles,
25
- type TextStylePreset,
26
- } from "./TextStylePicker";
27
- import {
28
- AlignLeftIcon,
29
- AlignCenterIcon,
30
- AlignRightIcon,
31
- AlignJustifyIcon,
32
- } from "./TextAlignmentIcons";
33
- import { BubbleTooltip } from "../BubbleIcons";
34
-
35
- // ============================================
36
- // Responsive style field — MUST be defined outside the editor component
37
- // to avoid React treating it as a new component on every re-render,
38
- // which causes input elements to lose focus.
39
- // ============================================
40
-
41
- function ResponsiveStyleField({
42
- label,
43
- subProp,
44
- viewport,
45
- isOverridden,
46
- onReset,
47
- children,
48
- hint,
49
- }: {
50
- label: string;
51
- subProp: string;
52
- viewport: DeviceViewport;
53
- isOverridden: boolean;
54
- onReset: (subProp: string) => void;
55
- children: ReactNode;
56
- hint?: string;
57
- }) {
58
- return (
59
- <div className="flex items-start gap-3 mb-2 last:mb-0">
60
- <label className="text-[11px] text-neutral-400 w-[68px] min-w-[68px] shrink-0 pt-[7px] leading-tight">
61
- {label}
62
- {viewport !== "desktop" && !isOverridden && (
63
- <span className="block text-[9px] text-neutral-300 italic mt-0.5">inherited</span>
64
- )}
65
- {isOverridden && (
66
- <span className="block text-[9px] text-[#3580f9] mt-0.5">overridden</span>
67
- )}
68
- </label>
69
- <div className="flex-1 min-w-0">
70
- {children}
71
- {hint && <p className="text-[10px] text-neutral-400 mt-1">{hint}</p>}
72
- {isOverridden && (
73
- <button
74
- onClick={() => onReset(subProp)}
75
- className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors mt-0.5"
76
- >
77
- Reset
78
- </button>
79
- )}
80
- </div>
81
- </div>
82
- );
83
- }
84
-
85
- // ============================================
86
- // Main Editor
87
- // ============================================
88
-
89
- export default function TextBlockEditor({ block }: { block: TextBlock }) {
90
- const store = useBuilderStore();
91
- const viewport = useActiveViewport();
92
- const paletteSwatches = usePaletteSwatches();
93
- const pageTextColor = store.pageSettings.text_color || "#0a0a0a";
94
- const [presets, setPresets] = useState<TextStylePreset[]>(FALLBACK_PRESETS);
95
-
96
- useEffect(() => {
97
- fetch("/api/admin/styles", { credentials: "include" })
98
- .then((r) => r.json())
99
- .then((data) => {
100
- if (data.styles) {
101
- const built = buildPresetsFromStyles(data.styles);
102
- if (built.length > 0) setPresets(built);
103
- }
104
- })
105
- .catch(() => { /* Style presets unavailable — fallback presets used */ });
106
- }, []);
107
-
108
- const style = block.style || {};
109
- // Undo snapshot strategy:
110
- // - Continuous inputs (text fields, sliders): snapshot on focus (one snapshot per edit session)
111
- // - Discrete actions (buttons, preset picks): snapshot immediately before mutation
112
- const snapshotOnFocus = () => store._pushSnapshot();
113
-
114
- // === Responsive helpers for nested style sub-properties ===
115
- // The responsive system deep-merges 1-level objects, so we store
116
- // partial style overrides at responsive[viewport].style = { fontSize: 24 }
117
- // and resolveBlock merges { ...block.style, ...responsive[viewport].style }.
118
-
119
- /** Get the current responsive style overrides for the active viewport */
120
- const getViewportStyleOverrides = (): Record<string, unknown> => {
121
- const responsive = (block as unknown as Record<string, unknown>).responsive as
122
- | Record<string, Record<string, unknown>>
123
- | undefined;
124
- if (!responsive?.[viewport]?.style) return {};
125
- return responsive[viewport].style as Record<string, unknown>;
126
- };
127
-
128
- /** Check if a style sub-property has a responsive override */
129
- const hasStyleOverride = (subProp: string): boolean => {
130
- if (viewport === "desktop") return true;
131
- const overrides = getViewportStyleOverrides();
132
- return subProp in overrides;
133
- };
134
-
135
- /** Get effective value of a style sub-property for the active viewport */
136
- const getEffectiveStyleValue = <T,>(subProp: string, baseValue: T): T => {
137
- if (viewport === "desktop") return baseValue;
138
- const overrides = getViewportStyleOverrides();
139
- return subProp in overrides ? (overrides[subProp] as T) : baseValue;
140
- };
141
-
142
- /** Update a style sub-property, responsive-aware */
143
- const updateStyleResponsive = (subProp: string, value: unknown) => {
144
- if (viewport === "desktop") {
145
- store.updateBlock(block._key, {
146
- style: { ...style, [subProp]: value },
147
- } as Partial<ContentBlock>);
148
- } else {
149
- // Merge into responsive[viewport].style
150
- const existing = (block as unknown as Record<string, unknown>).responsive as
151
- | Record<string, Record<string, unknown>>
152
- | undefined || {};
153
- const vpOverrides = { ...(existing[viewport] || {}) };
154
- const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
155
- vpOverrides.style = styleOverrides;
156
- store.updateBlock(block._key, {
157
- responsive: { ...existing, [viewport]: vpOverrides },
158
- } as Partial<ContentBlock>);
159
- }
160
- };
161
-
162
- /** Update a style sub-property with debounce, responsive-aware */
163
- const updateStyleDebouncedResponsive = (subProp: string, value: unknown) => {
164
- if (viewport === "desktop") {
165
- store.updateBlockDebounced(block._key, {
166
- style: { ...style, [subProp]: value },
167
- } as Partial<ContentBlock>);
168
- } else {
169
- const existing = (block as unknown as Record<string, unknown>).responsive as
170
- | Record<string, Record<string, unknown>>
171
- | undefined || {};
172
- const vpOverrides = { ...(existing[viewport] || {}) };
173
- const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
174
- vpOverrides.style = styleOverrides;
175
- store.updateBlockDebounced(block._key, {
176
- responsive: { ...existing, [viewport]: vpOverrides },
177
- } as Partial<ContentBlock>);
178
- }
179
- };
180
-
181
- /** Reset a style sub-property override */
182
- const resetStyleOverride = (subProp: string) => {
183
- const existing = (block as unknown as Record<string, unknown>).responsive as
184
- | Record<string, Record<string, unknown>>
185
- | undefined || {};
186
- const vpOverrides = { ...(existing[viewport] || {}) };
187
- const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}) };
188
- delete styleOverrides[subProp];
189
- if (Object.keys(styleOverrides).length === 0) {
190
- delete vpOverrides.style;
191
- } else {
192
- vpOverrides.style = styleOverrides;
193
- }
194
- const responsive = { ...existing };
195
- if (Object.keys(vpOverrides).length === 0) {
196
- delete responsive[viewport];
197
- } else {
198
- responsive[viewport] = vpOverrides;
199
- }
200
- store.updateBlock(block._key, { responsive } as Partial<ContentBlock>);
201
- };
202
-
203
- // === Responsive helpers for top-level properties (e.g. columns) ===
204
- const updateResponsive = (property: string, value: unknown) => {
205
- if (viewport === "desktop") {
206
- store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
207
- } else {
208
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
209
- store.updateBlock(block._key, overrides as Partial<ContentBlock>);
210
- }
211
- };
212
-
213
- const resetOverride = (property: string) => {
214
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
215
- store.updateBlock(block._key, overrides as Partial<ContentBlock>);
216
- };
217
-
218
- const baseFontSizePx = (() => {
219
- const fs = style.fontSize;
220
- if (typeof fs === "number") return fs;
221
- const legacyMap: Record<string, number> = { small: 12, base: 14, large: 20, xl: 24, "2xl": 32, "3xl": 48 };
222
- return legacyMap[fs || "base"] || 14;
223
- })();
224
-
225
- // Responsive-aware effective values
226
- const currentFontSizePx = getEffectiveStyleValue<number>("fontSize", baseFontSizePx);
227
- const currentAlignment = getEffectiveStyleValue<string>("alignment", style.alignment || "left");
228
- const currentMaxWidth = getEffectiveStyleValue<string>("maxWidth", style.maxWidth || "");
229
- const effectiveColumns = getEffectiveValue<number>(block as ContentBlock, viewport, "columns", block.columns || 1);
230
-
231
- const baseFontWeight = (() => {
232
- const fw = style.fontWeight;
233
- if (!fw) return "400";
234
- if (!isNaN(parseInt(fw, 10))) return fw;
235
- if (fw === "bold") return "700";
236
- if (fw === "medium") return "500";
237
- return "400";
238
- })();
239
- const currentFontWeight = getEffectiveStyleValue<string>("fontWeight", baseFontWeight);
240
-
241
- const handleStyleSelect = (preset: TextStylePreset) => {
242
- store._pushSnapshot();
243
- store.updateBlock(block._key, {
244
- textStyle: preset.key,
245
- style: {
246
- ...style,
247
- fontSize: preset.fontSize,
248
- fontWeight: preset.fontWeight,
249
- lineHeight: preset.lineHeight,
250
- letterSpacing: preset.letterSpacing,
251
- textTransform: preset.textTransform as TextBlock["style"] extends undefined ? never : NonNullable<TextBlock["style"]>["textTransform"],
252
- },
253
- } as Partial<ContentBlock>);
254
- };
255
-
256
- const handleStyleClear = () => {
257
- store._pushSnapshot();
258
- store.updateBlock(block._key, {
259
- textStyle: undefined,
260
- } as Partial<ContentBlock>);
261
- };
262
-
263
- const alignments: { value: "left" | "center" | "right" | "justify"; icon: React.ReactNode }[] = [
264
- { value: "left", icon: <AlignLeftIcon /> },
265
- { value: "center", icon: <AlignCenterIcon /> },
266
- { value: "right", icon: <AlignRightIcon /> },
267
- { value: "justify", icon: <AlignJustifyIcon /> },
268
- ];
269
-
270
- return (
271
- <>
272
- <ViewportBadge />
273
-
274
- {/* Typography section: Style, Color, Align, Size, Weight, Line height, Letter spacing, Transform */}
275
- <SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
276
- <SettingsField label="Style">
277
- <TextStylePicker
278
- presets={presets}
279
- activeKey={block.textStyle}
280
- onSelect={handleStyleSelect}
281
- onClear={handleStyleClear}
282
- />
283
- </SettingsField>
284
-
285
- <ResponsiveStyleField label="Color" subProp="color" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("color")} onReset={resetStyleOverride}>
286
- <ColorSwatchPicker
287
- value={getEffectiveStyleValue<string>("color", style.color || "")}
288
- onChange={(hex) => updateStyleResponsive("color", hex)}
289
- swatches={paletteSwatches}
290
- allowClear
291
- />
292
- </ResponsiveStyleField>
293
-
294
- <ResponsiveStyleField label="Align" subProp="alignment" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("alignment")} onReset={resetStyleOverride}>
295
- <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
296
- {alignments.map(({ value, icon }) => {
297
- const label = value.charAt(0).toUpperCase() + value.slice(1);
298
- return (
299
- <button
300
- key={value}
301
- onClick={() => updateStyleResponsive("alignment", value)}
302
- className={`group/bb relative flex-1 flex items-center justify-center py-[5px] rounded-md transition-all ${
303
- currentAlignment === value
304
- ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
305
- : "text-neutral-300 hover:text-neutral-500"
306
- }`}
307
- aria-label={label}
308
- >
309
- {icon}
310
- <BubbleTooltip>{label}</BubbleTooltip>
311
- </button>
312
- );
313
- })}
314
- </div>
315
- </ResponsiveStyleField>
316
-
317
- <ResponsiveStyleField label="Size" subProp="fontSize" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontSize")} onReset={resetStyleOverride}>
318
- <div className="flex items-center gap-0 bg-[#f5f5f5] rounded-lg overflow-hidden transition-all border border-transparent focus-within:bg-white focus-within:border-[#3580f9] focus-within:shadow-[0_0_0_3px_rgba(53, 128, 249,0.06)]">
319
- <input
320
- type="number"
321
- min={1}
322
- max={999}
323
- value={currentFontSizePx}
324
- onFocus={snapshotOnFocus}
325
- onChange={(e) => {
326
- const val = parseInt(e.target.value, 10);
327
- if (!isNaN(val) && val > 0) {
328
- updateStyleDebouncedResponsive("fontSize", val);
329
- if (viewport === "desktop" && block.textStyle) {
330
- store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
331
- }
332
- }
333
- }}
334
- className="flex-1 min-w-0 bg-transparent border-none px-2.5 py-[7px] text-xs text-neutral-900 outline-none"
335
- />
336
- <span className="text-[10px] text-neutral-400 pr-2.5 shrink-0 select-none">px</span>
337
- </div>
338
- </ResponsiveStyleField>
339
-
340
- <ResponsiveStyleField label="Weight" subProp="fontWeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontWeight")} onReset={resetStyleOverride}>
341
- <select
342
- value={currentFontWeight}
343
- onChange={(e) => {
344
- updateStyleResponsive("fontWeight", e.target.value);
345
- if (viewport === "desktop" && block.textStyle) {
346
- store.updateBlock(block._key, { textStyle: undefined } as Partial<ContentBlock>);
347
- }
348
- }}
349
- className={SELECT_CLASS}
350
- >
351
- <option value="100">Thin (100)</option>
352
- <option value="200">ExtraLight (200)</option>
353
- <option value="300">Light (300)</option>
354
- <option value="400">Regular (400)</option>
355
- <option value="500">Medium (500)</option>
356
- <option value="600">SemiBold (600)</option>
357
- <option value="700">Bold (700)</option>
358
- <option value="800">ExtraBold (800)</option>
359
- <option value="900">Black (900)</option>
360
- </select>
361
- </ResponsiveStyleField>
362
-
363
- <ResponsiveStyleField label="Line height" subProp="lineHeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("lineHeight")} onReset={resetStyleOverride}>
364
- <input
365
- type="text"
366
- value={getEffectiveStyleValue<string>("lineHeight", style.lineHeight || "")}
367
- onFocus={snapshotOnFocus}
368
- onChange={(e) => {
369
- updateStyleDebouncedResponsive("lineHeight", e.target.value);
370
- if (viewport === "desktop" && block.textStyle) {
371
- store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
372
- }
373
- }}
374
- placeholder="1.5"
375
- className={INPUT_CLASS}
376
- />
377
- </ResponsiveStyleField>
378
-
379
- <ResponsiveStyleField label="Spacing" subProp="letterSpacing" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("letterSpacing")} onReset={resetStyleOverride}>
380
- <input
381
- type="text"
382
- value={getEffectiveStyleValue<string>("letterSpacing", style.letterSpacing || "")}
383
- onFocus={snapshotOnFocus}
384
- onChange={(e) => {
385
- updateStyleDebouncedResponsive("letterSpacing", e.target.value);
386
- if (viewport === "desktop" && block.textStyle) {
387
- store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
388
- }
389
- }}
390
- placeholder="0, -0.02em, 2px"
391
- className={INPUT_CLASS}
392
- />
393
- </ResponsiveStyleField>
394
-
395
- <ResponsiveStyleField label="Transform" subProp="textTransform" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("textTransform")} onReset={resetStyleOverride}>
396
- <select
397
- value={getEffectiveStyleValue<string>("textTransform", style.textTransform || "none")}
398
- onChange={(e) => updateStyleResponsive("textTransform", e.target.value)}
399
- className={SELECT_CLASS}
400
- >
401
- <option value="none">None</option>
402
- <option value="uppercase">UPPERCASE</option>
403
- <option value="lowercase">lowercase</option>
404
- <option value="capitalize">Capitalize</option>
405
- </select>
406
- </ResponsiveStyleField>
407
- </SettingsSection>
408
-
409
- {/* Columns section */}
410
- <SettingsSection title="Columns" icon={<ColumnsIcon />}>
411
- <ResponsiveField
412
- label="Columns"
413
- block={block as ContentBlock}
414
- property="columns"
415
- onReset={() => resetOverride("columns")}
416
- >
417
- <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
418
- {[1, 2, 3, 4].map((n) => (
419
- <button
420
- key={n}
421
- onClick={() => {
422
- store._pushSnapshot();
423
- updateResponsive("columns", n);
424
- }}
425
- className={`flex-1 flex items-center justify-center py-[5px] rounded-md text-xs transition-all ${
426
- effectiveColumns === n
427
- ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)] font-medium"
428
- : "text-neutral-400 hover:text-neutral-600"
429
- }`}
430
- >
431
- {n}
432
- </button>
433
- ))}
434
- </div>
435
- </ResponsiveField>
436
-
437
- <ResponsiveStyleField label="Max Width" subProp="maxWidth" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("maxWidth")} onReset={resetStyleOverride}>
438
- <input
439
- type="text"
440
- value={currentMaxWidth}
441
- onFocus={snapshotOnFocus}
442
- onChange={(e) => updateStyleDebouncedResponsive("maxWidth", e.target.value)}
443
- placeholder="none, 600px, 80%"
444
- className={INPUT_CLASS}
445
- />
446
- </ResponsiveStyleField>
447
-
448
- <ResponsiveStyleField label="Opacity" subProp="opacity" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("opacity")} onReset={resetStyleOverride}>
449
- <div className="flex items-center gap-2">
450
- <input
451
- type="range"
452
- min={0}
453
- max={100}
454
- value={Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}
455
- onChange={(e) =>
456
- updateStyleResponsive("opacity", parseInt(e.target.value) / 100)
457
- }
458
- className="flex-1 accent-[#3580f9]"
459
- />
460
- <span className="text-xs text-neutral-900 w-10 text-right tabular-nums">
461
- {Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}%
462
- </span>
463
- </div>
464
- </ResponsiveStyleField>
465
- </SettingsSection>
466
- </>
467
- );
468
- }
1
+ "use client";
2
+
3
+ import { useState, useEffect, type ReactNode } from "react";
4
+ import { useBuilderStore } from "../../../lib/builder/store";
5
+ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
6
+ import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
7
+ import type { DeviceViewport } from "../../../lib/builder/types";
8
+ import {
9
+ TypographyIcon,
10
+ ColumnsIcon,
11
+ } from "./section-icons";
12
+ import {
13
+ SettingsSection,
14
+ SettingsField,
15
+ ViewportBadge,
16
+ ResponsiveField,
17
+ useActiveViewport,
18
+ INPUT_CLASS,
19
+ SELECT_CLASS,
20
+ } from "./shared";
21
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
22
+ import TextStylePicker, {
23
+ FALLBACK_PRESETS,
24
+ buildPresetsFromStyles,
25
+ type TextStylePreset,
26
+ } from "./TextStylePicker";
27
+ import {
28
+ AlignLeftIcon,
29
+ AlignCenterIcon,
30
+ AlignRightIcon,
31
+ AlignJustifyIcon,
32
+ } from "./TextAlignmentIcons";
33
+ import { BubbleTooltip } from "../BubbleIcons";
34
+
35
+ // ============================================
36
+ // Responsive style field — MUST be defined outside the editor component
37
+ // to avoid React treating it as a new component on every re-render,
38
+ // which causes input elements to lose focus.
39
+ // ============================================
40
+
41
+ function ResponsiveStyleField({
42
+ label,
43
+ subProp,
44
+ viewport,
45
+ isOverridden,
46
+ onReset,
47
+ children,
48
+ hint,
49
+ }: {
50
+ label: string;
51
+ subProp: string;
52
+ viewport: DeviceViewport;
53
+ isOverridden: boolean;
54
+ onReset: (subProp: string) => void;
55
+ children: ReactNode;
56
+ hint?: string;
57
+ }) {
58
+ return (
59
+ <div className="flex items-start gap-3 mb-2 last:mb-0">
60
+ <label className="text-[11px] text-neutral-400 w-[68px] min-w-[68px] shrink-0 pt-[7px] leading-tight">
61
+ {label}
62
+ {viewport !== "desktop" && !isOverridden && (
63
+ <span className="block text-[9px] text-neutral-300 italic mt-0.5">inherited</span>
64
+ )}
65
+ {isOverridden && (
66
+ <span className="block text-[9px] text-[#3580f9] mt-0.5">overridden</span>
67
+ )}
68
+ </label>
69
+ <div className="flex-1 min-w-0">
70
+ {children}
71
+ {hint && <p className="text-[10px] text-neutral-400 mt-1">{hint}</p>}
72
+ {isOverridden && (
73
+ <button
74
+ onClick={() => onReset(subProp)}
75
+ className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors mt-0.5"
76
+ >
77
+ Reset
78
+ </button>
79
+ )}
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ // ============================================
86
+ // Main Editor
87
+ // ============================================
88
+
89
+ export default function TextBlockEditor({ block }: { block: TextBlock }) {
90
+ const updateBlock = useBuilderStore((s) => s.updateBlock);
91
+ const updateBlockDebounced = useBuilderStore((s) => s.updateBlockDebounced);
92
+ const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
93
+ const pageSettings = useBuilderStore((s) => s.pageSettings);
94
+ const viewport = useActiveViewport();
95
+ const paletteSwatches = usePaletteSwatches();
96
+ const pageTextColor = pageSettings.text_color || "#0a0a0a";
97
+ const [presets, setPresets] = useState<TextStylePreset[]>(FALLBACK_PRESETS);
98
+
99
+ useEffect(() => {
100
+ fetch("/api/admin/styles", { credentials: "include" })
101
+ .then((r) => r.json())
102
+ .then((data) => {
103
+ if (data.styles) {
104
+ const built = buildPresetsFromStyles(data.styles);
105
+ if (built.length > 0) setPresets(built);
106
+ }
107
+ })
108
+ .catch(() => { /* Style presets unavailable — fallback presets used */ });
109
+ }, []);
110
+
111
+ const style = block.style || {};
112
+ // Undo snapshot strategy:
113
+ // - Continuous inputs (text fields, sliders): snapshot on focus (one snapshot per edit session)
114
+ // - Discrete actions (buttons, preset picks): snapshot immediately before mutation
115
+ const snapshotOnFocus = () => _pushSnapshot();
116
+
117
+ // === Responsive helpers for nested style sub-properties ===
118
+ // The responsive system deep-merges 1-level objects, so we store
119
+ // partial style overrides at responsive[viewport].style = { fontSize: 24 }
120
+ // and resolveBlock merges { ...block.style, ...responsive[viewport].style }.
121
+
122
+ /** Get the current responsive style overrides for the active viewport */
123
+ const getViewportStyleOverrides = (): Record<string, unknown> => {
124
+ const responsive = (block as unknown as Record<string, unknown>).responsive as
125
+ | Record<string, Record<string, unknown>>
126
+ | undefined;
127
+ if (!responsive?.[viewport]?.style) return {};
128
+ return responsive[viewport].style as Record<string, unknown>;
129
+ };
130
+
131
+ /** Check if a style sub-property has a responsive override */
132
+ const hasStyleOverride = (subProp: string): boolean => {
133
+ if (viewport === "desktop") return true;
134
+ const overrides = getViewportStyleOverrides();
135
+ return subProp in overrides;
136
+ };
137
+
138
+ /** Get effective value of a style sub-property for the active viewport */
139
+ const getEffectiveStyleValue = <T,>(subProp: string, baseValue: T): T => {
140
+ if (viewport === "desktop") return baseValue;
141
+ const overrides = getViewportStyleOverrides();
142
+ return subProp in overrides ? (overrides[subProp] as T) : baseValue;
143
+ };
144
+
145
+ /** Update a style sub-property, responsive-aware */
146
+ const updateStyleResponsive = (subProp: string, value: unknown) => {
147
+ if (viewport === "desktop") {
148
+ updateBlock(block._key, {
149
+ style: { ...style, [subProp]: value },
150
+ } as Partial<ContentBlock>);
151
+ } else {
152
+ // Merge into responsive[viewport].style
153
+ const existing = (block as unknown as Record<string, unknown>).responsive as
154
+ | Record<string, Record<string, unknown>>
155
+ | undefined || {};
156
+ const vpOverrides = { ...(existing[viewport] || {}) };
157
+ const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
158
+ vpOverrides.style = styleOverrides;
159
+ updateBlock(block._key, {
160
+ responsive: { ...existing, [viewport]: vpOverrides },
161
+ } as Partial<ContentBlock>);
162
+ }
163
+ };
164
+
165
+ /** Update a style sub-property with debounce, responsive-aware */
166
+ const updateStyleDebouncedResponsive = (subProp: string, value: unknown) => {
167
+ if (viewport === "desktop") {
168
+ updateBlockDebounced(block._key, {
169
+ style: { ...style, [subProp]: value },
170
+ } as Partial<ContentBlock>);
171
+ } else {
172
+ const existing = (block as unknown as Record<string, unknown>).responsive as
173
+ | Record<string, Record<string, unknown>>
174
+ | undefined || {};
175
+ const vpOverrides = { ...(existing[viewport] || {}) };
176
+ const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
177
+ vpOverrides.style = styleOverrides;
178
+ updateBlockDebounced(block._key, {
179
+ responsive: { ...existing, [viewport]: vpOverrides },
180
+ } as Partial<ContentBlock>);
181
+ }
182
+ };
183
+
184
+ /** Reset a style sub-property override */
185
+ const resetStyleOverride = (subProp: string) => {
186
+ const existing = (block as unknown as Record<string, unknown>).responsive as
187
+ | Record<string, Record<string, unknown>>
188
+ | undefined || {};
189
+ const vpOverrides = { ...(existing[viewport] || {}) };
190
+ const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}) };
191
+ delete styleOverrides[subProp];
192
+ if (Object.keys(styleOverrides).length === 0) {
193
+ delete vpOverrides.style;
194
+ } else {
195
+ vpOverrides.style = styleOverrides;
196
+ }
197
+ const responsive = { ...existing };
198
+ if (Object.keys(vpOverrides).length === 0) {
199
+ delete responsive[viewport];
200
+ } else {
201
+ responsive[viewport] = vpOverrides;
202
+ }
203
+ updateBlock(block._key, { responsive } as Partial<ContentBlock>);
204
+ };
205
+
206
+ // === Responsive helpers for top-level properties (e.g. columns) ===
207
+ const updateResponsive = (property: string, value: unknown) => {
208
+ if (viewport === "desktop") {
209
+ updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
210
+ } else {
211
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
212
+ updateBlock(block._key, overrides as Partial<ContentBlock>);
213
+ }
214
+ };
215
+
216
+ const resetOverride = (property: string) => {
217
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
218
+ updateBlock(block._key, overrides as Partial<ContentBlock>);
219
+ };
220
+
221
+ const baseFontSizePx = (() => {
222
+ const fs = style.fontSize;
223
+ if (typeof fs === "number") return fs;
224
+ const legacyMap: Record<string, number> = { small: 12, base: 14, large: 20, xl: 24, "2xl": 32, "3xl": 48 };
225
+ return legacyMap[fs || "base"] || 14;
226
+ })();
227
+
228
+ // Responsive-aware effective values
229
+ const currentFontSizePx = getEffectiveStyleValue<number>("fontSize", baseFontSizePx);
230
+ const currentAlignment = getEffectiveStyleValue<string>("alignment", style.alignment || "left");
231
+ const currentMaxWidth = getEffectiveStyleValue<string>("maxWidth", style.maxWidth || "");
232
+ const effectiveColumns = getEffectiveValue<number>(block as ContentBlock, viewport, "columns", block.columns || 1);
233
+
234
+ const baseFontWeight = (() => {
235
+ const fw = style.fontWeight;
236
+ if (!fw) return "400";
237
+ if (!isNaN(parseInt(fw, 10))) return fw;
238
+ if (fw === "bold") return "700";
239
+ if (fw === "medium") return "500";
240
+ return "400";
241
+ })();
242
+ const currentFontWeight = getEffectiveStyleValue<string>("fontWeight", baseFontWeight);
243
+
244
+ const handleStyleSelect = (preset: TextStylePreset) => {
245
+ _pushSnapshot();
246
+ updateBlock(block._key, {
247
+ textStyle: preset.key,
248
+ style: {
249
+ ...style,
250
+ fontSize: preset.fontSize,
251
+ fontWeight: preset.fontWeight,
252
+ lineHeight: preset.lineHeight,
253
+ letterSpacing: preset.letterSpacing,
254
+ textTransform: preset.textTransform as TextBlock["style"] extends undefined ? never : NonNullable<TextBlock["style"]>["textTransform"],
255
+ },
256
+ } as Partial<ContentBlock>);
257
+ };
258
+
259
+ const handleStyleClear = () => {
260
+ _pushSnapshot();
261
+ updateBlock(block._key, {
262
+ textStyle: undefined,
263
+ } as Partial<ContentBlock>);
264
+ };
265
+
266
+ const alignments: { value: "left" | "center" | "right" | "justify"; icon: React.ReactNode }[] = [
267
+ { value: "left", icon: <AlignLeftIcon /> },
268
+ { value: "center", icon: <AlignCenterIcon /> },
269
+ { value: "right", icon: <AlignRightIcon /> },
270
+ { value: "justify", icon: <AlignJustifyIcon /> },
271
+ ];
272
+
273
+ return (
274
+ <>
275
+ <ViewportBadge />
276
+
277
+ {/* Typography section: Style, Color, Align, Size, Weight, Line height, Letter spacing, Transform */}
278
+ <SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
279
+ <SettingsField label="Style">
280
+ <TextStylePicker
281
+ presets={presets}
282
+ activeKey={block.textStyle}
283
+ onSelect={handleStyleSelect}
284
+ onClear={handleStyleClear}
285
+ />
286
+ </SettingsField>
287
+
288
+ <ResponsiveStyleField label="Color" subProp="color" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("color")} onReset={resetStyleOverride}>
289
+ <ColorSwatchPicker
290
+ value={getEffectiveStyleValue<string>("color", style.color || "")}
291
+ onChange={(hex) => updateStyleResponsive("color", hex)}
292
+ swatches={paletteSwatches}
293
+ allowClear
294
+ />
295
+ </ResponsiveStyleField>
296
+
297
+ <ResponsiveStyleField label="Align" subProp="alignment" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("alignment")} onReset={resetStyleOverride}>
298
+ <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
299
+ {alignments.map(({ value, icon }) => {
300
+ const label = value.charAt(0).toUpperCase() + value.slice(1);
301
+ return (
302
+ <button
303
+ key={value}
304
+ onClick={() => updateStyleResponsive("alignment", value)}
305
+ className={`group/bb relative flex-1 flex items-center justify-center py-[5px] rounded-md transition-all ${
306
+ currentAlignment === value
307
+ ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
308
+ : "text-neutral-300 hover:text-neutral-500"
309
+ }`}
310
+ aria-label={label}
311
+ >
312
+ {icon}
313
+ <BubbleTooltip>{label}</BubbleTooltip>
314
+ </button>
315
+ );
316
+ })}
317
+ </div>
318
+ </ResponsiveStyleField>
319
+
320
+ <ResponsiveStyleField label="Size" subProp="fontSize" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontSize")} onReset={resetStyleOverride}>
321
+ <div className="flex items-center gap-0 bg-[#f5f5f5] rounded-lg overflow-hidden transition-all border border-transparent focus-within:bg-white focus-within:border-[#3580f9] focus-within:shadow-[0_0_0_3px_rgba(53, 128, 249,0.06)]">
322
+ <input
323
+ type="number"
324
+ min={1}
325
+ max={999}
326
+ value={currentFontSizePx}
327
+ onFocus={snapshotOnFocus}
328
+ onChange={(e) => {
329
+ const val = parseInt(e.target.value, 10);
330
+ if (!isNaN(val) && val > 0) {
331
+ updateStyleDebouncedResponsive("fontSize", val);
332
+ if (viewport === "desktop" && block.textStyle) {
333
+ updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
334
+ }
335
+ }
336
+ }}
337
+ className="flex-1 min-w-0 bg-transparent border-none px-2.5 py-[7px] text-xs text-neutral-900 outline-none"
338
+ />
339
+ <span className="text-[10px] text-neutral-400 pr-2.5 shrink-0 select-none">px</span>
340
+ </div>
341
+ </ResponsiveStyleField>
342
+
343
+ <ResponsiveStyleField label="Weight" subProp="fontWeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontWeight")} onReset={resetStyleOverride}>
344
+ <select
345
+ value={currentFontWeight}
346
+ onChange={(e) => {
347
+ updateStyleResponsive("fontWeight", e.target.value);
348
+ if (viewport === "desktop" && block.textStyle) {
349
+ updateBlock(block._key, { textStyle: undefined } as Partial<ContentBlock>);
350
+ }
351
+ }}
352
+ className={SELECT_CLASS}
353
+ >
354
+ <option value="100">Thin (100)</option>
355
+ <option value="200">ExtraLight (200)</option>
356
+ <option value="300">Light (300)</option>
357
+ <option value="400">Regular (400)</option>
358
+ <option value="500">Medium (500)</option>
359
+ <option value="600">SemiBold (600)</option>
360
+ <option value="700">Bold (700)</option>
361
+ <option value="800">ExtraBold (800)</option>
362
+ <option value="900">Black (900)</option>
363
+ </select>
364
+ </ResponsiveStyleField>
365
+
366
+ <ResponsiveStyleField label="Line height" subProp="lineHeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("lineHeight")} onReset={resetStyleOverride}>
367
+ <input
368
+ type="text"
369
+ value={getEffectiveStyleValue<string>("lineHeight", style.lineHeight || "")}
370
+ onFocus={snapshotOnFocus}
371
+ onChange={(e) => {
372
+ updateStyleDebouncedResponsive("lineHeight", e.target.value);
373
+ if (viewport === "desktop" && block.textStyle) {
374
+ updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
375
+ }
376
+ }}
377
+ placeholder="1.5"
378
+ className={INPUT_CLASS}
379
+ />
380
+ </ResponsiveStyleField>
381
+
382
+ <ResponsiveStyleField label="Spacing" subProp="letterSpacing" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("letterSpacing")} onReset={resetStyleOverride}>
383
+ <input
384
+ type="text"
385
+ value={getEffectiveStyleValue<string>("letterSpacing", style.letterSpacing || "")}
386
+ onFocus={snapshotOnFocus}
387
+ onChange={(e) => {
388
+ updateStyleDebouncedResponsive("letterSpacing", e.target.value);
389
+ if (viewport === "desktop" && block.textStyle) {
390
+ updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
391
+ }
392
+ }}
393
+ placeholder="0, -0.02em, 2px"
394
+ className={INPUT_CLASS}
395
+ />
396
+ </ResponsiveStyleField>
397
+
398
+ <ResponsiveStyleField label="Transform" subProp="textTransform" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("textTransform")} onReset={resetStyleOverride}>
399
+ <select
400
+ value={getEffectiveStyleValue<string>("textTransform", style.textTransform || "none")}
401
+ onChange={(e) => updateStyleResponsive("textTransform", e.target.value)}
402
+ className={SELECT_CLASS}
403
+ >
404
+ <option value="none">None</option>
405
+ <option value="uppercase">UPPERCASE</option>
406
+ <option value="lowercase">lowercase</option>
407
+ <option value="capitalize">Capitalize</option>
408
+ </select>
409
+ </ResponsiveStyleField>
410
+ </SettingsSection>
411
+
412
+ {/* Columns section */}
413
+ <SettingsSection title="Columns" icon={<ColumnsIcon />}>
414
+ <ResponsiveField
415
+ label="Columns"
416
+ block={block as ContentBlock}
417
+ property="columns"
418
+ onReset={() => resetOverride("columns")}
419
+ >
420
+ <div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
421
+ {[1, 2, 3, 4].map((n) => (
422
+ <button
423
+ key={n}
424
+ onClick={() => {
425
+ _pushSnapshot();
426
+ updateResponsive("columns", n);
427
+ }}
428
+ className={`flex-1 flex items-center justify-center py-[5px] rounded-md text-xs transition-all ${
429
+ effectiveColumns === n
430
+ ? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)] font-medium"
431
+ : "text-neutral-400 hover:text-neutral-600"
432
+ }`}
433
+ >
434
+ {n}
435
+ </button>
436
+ ))}
437
+ </div>
438
+ </ResponsiveField>
439
+
440
+ <ResponsiveStyleField label="Max Width" subProp="maxWidth" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("maxWidth")} onReset={resetStyleOverride}>
441
+ <input
442
+ type="text"
443
+ value={currentMaxWidth}
444
+ onFocus={snapshotOnFocus}
445
+ onChange={(e) => updateStyleDebouncedResponsive("maxWidth", e.target.value)}
446
+ placeholder="none, 600px, 80%"
447
+ className={INPUT_CLASS}
448
+ />
449
+ </ResponsiveStyleField>
450
+
451
+ <ResponsiveStyleField label="Opacity" subProp="opacity" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("opacity")} onReset={resetStyleOverride}>
452
+ <div className="flex items-center gap-2">
453
+ <input
454
+ type="range"
455
+ min={0}
456
+ max={100}
457
+ value={Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}
458
+ onChange={(e) =>
459
+ updateStyleResponsive("opacity", parseInt(e.target.value) / 100)
460
+ }
461
+ className="flex-1 accent-[#3580f9]"
462
+ />
463
+ <span className="text-xs text-neutral-900 w-10 text-right tabular-nums">
464
+ {Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}%
465
+ </span>
466
+ </div>
467
+ </ResponsiveStyleField>
468
+ </SettingsSection>
469
+ </>
470
+ );
471
+ }