@morphika/andami 0.1.3 → 0.1.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 (105) hide show
  1. package/app/(site)/[slug]/page.tsx +7 -4
  2. package/app/(site)/layout.tsx +5 -2
  3. package/app/(site)/page.tsx +2 -2
  4. package/app/(site)/preview/page.tsx +4 -4
  5. package/app/(site)/work/[slug]/page.tsx +7 -4
  6. package/app/admin/layout.tsx +3 -2
  7. package/app/admin/login/page.tsx +5 -5
  8. package/app/admin/navigation/page.tsx +255 -157
  9. package/app/api/admin/assets/health/route.ts +1 -1
  10. package/app/api/admin/assets/register/route.ts +1 -1
  11. package/app/api/admin/assets/registry/route.ts +1 -1
  12. package/app/api/admin/assets/relink/confirm/route.ts +2 -2
  13. package/app/api/admin/assets/relink/route.ts +1 -1
  14. package/app/api/admin/assets/scan/route.ts +1 -1
  15. package/app/api/admin/custom-sections/[slug]/route.ts +1 -1
  16. package/app/api/admin/custom-sections/route.ts +1 -1
  17. package/app/api/admin/database/route.ts +1 -1
  18. package/app/api/admin/pages/[slug]/duplicate/route.ts +1 -1
  19. package/app/api/admin/pages/[slug]/route.ts +2 -2
  20. package/app/api/admin/pages/[slug]/set-home/route.ts +1 -1
  21. package/app/api/admin/pages/route.ts +1 -1
  22. package/app/api/admin/preview/route.ts +1 -1
  23. package/app/api/admin/r2/delete/route.ts +1 -1
  24. package/app/api/admin/r2/rename/route.ts +1 -1
  25. package/app/api/admin/r2/status/route.ts +1 -1
  26. package/app/api/admin/r2/upload-url/route.ts +1 -1
  27. package/app/api/admin/settings/route.ts +41 -16
  28. package/app/api/admin/setup/complete/route.ts +2 -2
  29. package/app/api/admin/setup/route.ts +7 -4
  30. package/app/api/admin/storage/switch/route.ts +1 -1
  31. package/app/api/admin/styles/route.ts +1 -1
  32. package/components/admin/index.ts +7 -0
  33. package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
  34. package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
  35. package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
  36. package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
  37. package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
  38. package/components/admin/nav-builder/index.ts +2 -0
  39. package/components/blocks/BlockRenderer.tsx +65 -13
  40. package/components/blocks/ButtonBlockRenderer.tsx +29 -6
  41. package/components/blocks/CoverBlockRenderer.tsx +36 -14
  42. package/components/blocks/ImageBlockRenderer.tsx +5 -3
  43. package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
  44. package/components/blocks/PageRenderer.tsx +4 -2
  45. package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
  46. package/components/blocks/SectionRenderer.tsx +9 -8
  47. package/components/blocks/SectionV2Renderer.tsx +8 -8
  48. package/components/blocks/SpacerBlockRenderer.tsx +4 -2
  49. package/components/blocks/TextBlockRenderer.tsx +9 -4
  50. package/components/builder/BuilderCanvas.tsx +10 -4
  51. package/components/builder/ColorPicker.tsx +51 -243
  52. package/components/builder/ColorSwatchPicker.tsx +214 -274
  53. package/components/builder/DndWrapper.tsx +5 -2
  54. package/components/builder/SectionV2Canvas.tsx +15 -4
  55. package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
  56. package/components/builder/color-picker/AlphaSlider.tsx +141 -0
  57. package/components/builder/color-picker/AngleControl.tsx +138 -0
  58. package/components/builder/color-picker/ColorInputs.tsx +105 -0
  59. package/components/builder/color-picker/EyedropperButton.tsx +74 -0
  60. package/components/builder/color-picker/GradientBar.tsx +222 -0
  61. package/components/builder/color-picker/GradientPreview.tsx +53 -0
  62. package/components/builder/color-picker/HueSlider.tsx +124 -0
  63. package/components/builder/color-picker/MeshCanvas.tsx +172 -0
  64. package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
  65. package/components/builder/color-picker/MeshPointList.tsx +200 -0
  66. package/components/builder/color-picker/PositionControl.tsx +158 -0
  67. package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
  68. package/components/builder/color-picker/StopEditor.tsx +178 -0
  69. package/components/builder/color-picker/SwatchBar.tsx +93 -0
  70. package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
  71. package/components/builder/color-picker/index.ts +62 -0
  72. package/components/builder/color-picker/types.ts +115 -0
  73. package/components/builder/color-picker/utils.ts +138 -0
  74. package/components/builder/editors/CoverBlockEditor.tsx +86 -32
  75. package/components/builder/editors/ProjectGridEditor.tsx +51 -4
  76. package/components/builder/hooks/useColumnDrag.ts +25 -27
  77. package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
  78. package/components/builder/settings-panel/LayoutTab.tsx +382 -310
  79. package/components/builder/settings-panel/PageSettings.tsx +6 -4
  80. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  81. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
  82. package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
  83. package/components/ui/Navbar.tsx +97 -25
  84. package/components/ui/PortfolioTracker.tsx +3 -3
  85. package/lib/assets.ts +1 -1
  86. package/lib/auth.ts +1 -1
  87. package/lib/builder/gradient-presets.ts +128 -0
  88. package/lib/builder/layout-styles.ts +16 -10
  89. package/lib/builder/serializer.ts +1 -0
  90. package/lib/builder/store-blocks.ts +48 -61
  91. package/lib/builder/store-helpers.ts +31 -14
  92. package/lib/builder/store.ts +59 -41
  93. package/lib/builder/types.ts +14 -0
  94. package/lib/color-utils.ts +200 -0
  95. package/lib/revalidate.ts +2 -2
  96. package/lib/sanity/client.ts +16 -0
  97. package/lib/sanity/queries.ts +4 -3
  98. package/lib/sanity/types.ts +76 -1
  99. package/lib/setup/detect.ts +1 -1
  100. package/lib/storage/index.ts +22 -4
  101. package/lib/version.ts +6 -0
  102. package/package.json +8 -2
  103. package/sanity/schemas/siteSettings.ts +34 -0
  104. package/styles/base.css +3 -3
  105. package/app/globals.css +0 -7
@@ -0,0 +1,226 @@
1
+ "use client";
2
+
3
+ import type { NavItem, NavDesign, MobileNavDesign, NavColorVariant } from "../../../lib/sanity/types";
4
+ import { hexToRgba } from "./nav-builder-utils";
5
+ import { getSiteConfig } from "../../../lib/config";
6
+
7
+ const _cfg = getSiteConfig();
8
+
9
+ const NAV_COLOR_HEX: Record<NavColorVariant, string> = {
10
+ "yellow-lime": _cfg.palette.accent,
11
+ yellow: _cfg.palette.accent,
12
+ "red-coral": _cfg.palette.secondary,
13
+ blue: "#076bff",
14
+ green: _cfg.palette.accent,
15
+ white: _cfg.palette.text,
16
+ };
17
+
18
+ // ============================================
19
+ // NavMobileLivePreview — phone-sized preview of the mobile menu
20
+ // Session 158: Mirrors NavLivePreview visual language (dark bg, footer label)
21
+ // but renders the mobile hamburger overlay layout inside a phone frame.
22
+ // Designed to sit on the right side of NavMobileSettings.
23
+ // ============================================
24
+
25
+ interface NavMobileLivePreviewProps {
26
+ items: NavItem[];
27
+ design: NavDesign;
28
+ mobileDesign: MobileNavDesign;
29
+ }
30
+
31
+ export default function NavMobileLivePreview({
32
+ items,
33
+ design,
34
+ mobileDesign,
35
+ }: NavMobileLivePreviewProps) {
36
+ // Desktop fallback values
37
+ const desktopColorKey = design.color || "yellow-lime";
38
+ const desktopColor = /^#[0-9a-fA-F]{6}$/.test(desktopColorKey)
39
+ ? desktopColorKey
40
+ : NAV_COLOR_HEX[desktopColorKey as keyof typeof NAV_COLOR_HEX] || NAV_COLOR_HEX["yellow-lime"];
41
+
42
+ // ── Navbar bar (logo + hamburger) ──
43
+ const barPaddingH = mobileDesign.padding_h ?? design.padding_h ?? 24;
44
+ const barPaddingV = mobileDesign.padding_v ?? design.padding_v ?? 27;
45
+ const barBg = mobileDesign.navbar_bg || "";
46
+ const barBgOpacity = mobileDesign.navbar_bg_opacity ?? 0;
47
+ const barBgColor =
48
+ barBg && barBgOpacity > 0 ? hexToRgba(barBg, barBgOpacity / 100) : "transparent";
49
+ const hamburgerColor = mobileDesign.hamburger_color || desktopColor;
50
+
51
+ // ── Overlay (expanded menu) ──
52
+ const overlayBg = mobileDesign.overlay_bg || "#0a0a0a";
53
+ const textColor = mobileDesign.text_color || desktopColor;
54
+ const fontSize = mobileDesign.font_size ?? 24;
55
+ const textTransform = (mobileDesign.text_transform || design.text_transform || "uppercase") as React.CSSProperties["textTransform"];
56
+ const itemsGap = mobileDesign.items_gap ?? 32;
57
+ const itemsAlign = mobileDesign.items_align || "center";
58
+ const alignItems =
59
+ itemsAlign === "right" ? "flex-end" : itemsAlign === "left" ? "flex-start" : "center";
60
+
61
+ const fontFamily =
62
+ design.font_family || `var(--font-family, '${_cfg.typography.defaultFont}', ${_cfg.typography.monoFallback})`;
63
+
64
+ const logoLabel =
65
+ items.find((i) => i.type === "logo")?.label ||
66
+ design.logo_text ||
67
+ _cfg.defaults.logoText;
68
+ const menuItems = items
69
+ .filter((i) => i.type !== "logo" && i.visible !== false)
70
+ .sort((a, b) => (a.grid_column || 0) - (b.grid_column || 0));
71
+
72
+ return (
73
+ <div
74
+ style={{
75
+ background: "#020202",
76
+ height: "100%",
77
+ display: "flex",
78
+ flexDirection: "column",
79
+ }}
80
+ >
81
+ {/* Phone frame — centered */}
82
+ <div
83
+ style={{
84
+ flex: 1,
85
+ display: "flex",
86
+ alignItems: "center",
87
+ justifyContent: "center",
88
+ padding: "20px 24px 12px",
89
+ }}
90
+ >
91
+ <div
92
+ style={{
93
+ width: "100%",
94
+ maxWidth: 280,
95
+ borderRadius: 20,
96
+ overflow: "hidden",
97
+ border: "1px solid rgba(255,255,255,0.08)",
98
+ background: overlayBg,
99
+ }}
100
+ >
101
+ {/* Navbar bar — logo + hamburger icon */}
102
+ <div
103
+ style={{
104
+ display: "flex",
105
+ alignItems: "center",
106
+ justifyContent: "space-between",
107
+ paddingLeft: `${Math.min(barPaddingH, 24)}px`,
108
+ paddingRight: `${Math.min(barPaddingH, 24)}px`,
109
+ paddingTop: `${Math.min(barPaddingV, 20)}px`,
110
+ paddingBottom: `${Math.min(barPaddingV, 20)}px`,
111
+ background: barBgColor,
112
+ }}
113
+ >
114
+ {/* Logo label */}
115
+ <span
116
+ style={{
117
+ color: hamburgerColor,
118
+ fontSize: Math.min(design.font_size ?? 14, 14),
119
+ fontWeight: design.font_weight || "400",
120
+ fontFamily,
121
+ textTransform,
122
+ letterSpacing: "0.05em",
123
+ overflow: "hidden",
124
+ textOverflow: "ellipsis",
125
+ whiteSpace: "nowrap",
126
+ }}
127
+ >
128
+ {logoLabel}
129
+ </span>
130
+
131
+ {/* Hamburger icon */}
132
+ <div
133
+ style={{
134
+ display: "flex",
135
+ flexDirection: "column",
136
+ gap: 3.5,
137
+ flexShrink: 0,
138
+ }}
139
+ >
140
+ <span
141
+ style={{
142
+ display: "block",
143
+ width: 16,
144
+ height: 1.5,
145
+ borderRadius: 1,
146
+ background: hamburgerColor,
147
+ }}
148
+ />
149
+ <span
150
+ style={{
151
+ display: "block",
152
+ width: 16,
153
+ height: 1.5,
154
+ borderRadius: 1,
155
+ background: hamburgerColor,
156
+ }}
157
+ />
158
+ <span
159
+ style={{
160
+ display: "block",
161
+ width: 16,
162
+ height: 1.5,
163
+ borderRadius: 1,
164
+ background: hamburgerColor,
165
+ }}
166
+ />
167
+ </div>
168
+ </div>
169
+
170
+ {/* Overlay — menu items */}
171
+ <div
172
+ style={{
173
+ display: "flex",
174
+ flexDirection: "column",
175
+ alignItems,
176
+ justifyContent: "center",
177
+ gap: `${Math.min(itemsGap, 40)}px`,
178
+ paddingLeft: `${Math.min(barPaddingH, 24)}px`,
179
+ paddingRight: `${Math.min(barPaddingH, 24)}px`,
180
+ paddingTop: 24,
181
+ paddingBottom: 32,
182
+ minHeight: 180,
183
+ }}
184
+ >
185
+ {menuItems.map((item) => (
186
+ <span
187
+ key={item._key}
188
+ style={{
189
+ color: textColor,
190
+ fontSize: Math.min(fontSize, 28),
191
+ fontWeight: design.font_weight || "400",
192
+ fontFamily,
193
+ textTransform,
194
+ letterSpacing: "0.05em",
195
+ whiteSpace: "nowrap",
196
+ overflow: "hidden",
197
+ textOverflow: "ellipsis",
198
+ maxWidth: "100%",
199
+ }}
200
+ >
201
+ {item.label || "Untitled"}
202
+ </span>
203
+ ))}
204
+ {menuItems.length === 0 && (
205
+ <span
206
+ style={{
207
+ color: "rgba(255,255,255,0.2)",
208
+ fontSize: 12,
209
+ fontStyle: "italic",
210
+ }}
211
+ >
212
+ No menu items
213
+ </span>
214
+ )}
215
+ </div>
216
+ </div>
217
+ </div>
218
+
219
+ {/* Footer — matches NavLivePreview style */}
220
+ <div className="flex items-center justify-between px-3 py-1.5 text-[9px] text-neutral-600">
221
+ <span>Mobile Preview</span>
222
+ <span>{menuItems.length} menu items</span>
223
+ </div>
224
+ </div>
225
+ );
226
+ }
@@ -0,0 +1,223 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import type { NavItem, NavDesign, MobileNavDesign } from "../../../lib/sanity/types";
5
+ import NavMobileLivePreview from "./NavMobileLivePreview";
6
+ import ColorSwatchPicker, { usePaletteSwatches } from "../../builder/ColorSwatchPicker";
7
+ import {
8
+ Field,
9
+ TextInput,
10
+ SelectInput,
11
+ SegmentedControl,
12
+ RangeSlider,
13
+ Section,
14
+ } from "./NavSettingsFields";
15
+
16
+ // ============================================
17
+ // NavMobileSettings — standalone panel for mobile menu customization
18
+ // Session 158: Independent mobile menu styles that are NOT affected
19
+ // by page-level nav_color or parallax slide color overrides.
20
+ // ============================================
21
+
22
+ interface NavMobileSettingsProps {
23
+ design: MobileNavDesign;
24
+ /** Desktop nav design — needed for fallback values and preview */
25
+ desktopDesign: NavDesign;
26
+ /** Nav items — needed for preview */
27
+ items: NavItem[];
28
+ onChange: (design: MobileNavDesign) => void;
29
+ onSave: () => void;
30
+ saving: boolean;
31
+ hasChanges: boolean;
32
+ }
33
+
34
+ export default function NavMobileSettings({
35
+ design,
36
+ desktopDesign,
37
+ items,
38
+ onChange,
39
+ onSave,
40
+ saving,
41
+ hasChanges,
42
+ }: NavMobileSettingsProps) {
43
+ const swatches = usePaletteSwatches();
44
+ const update = useCallback(
45
+ (partial: Partial<MobileNavDesign>) => onChange({ ...design, ...partial }),
46
+ [design, onChange]
47
+ );
48
+
49
+ return (
50
+ <div className="bg-white rounded-2xl overflow-hidden border border-neutral-200">
51
+ {/* Header */}
52
+ <div className="px-5 py-3 border-b border-neutral-200 flex items-center justify-between">
53
+ <div>
54
+ <div className="text-sm font-semibold text-neutral-900">
55
+ Mobile Menu
56
+ </div>
57
+ <div className="text-[11px] text-neutral-400 mt-0.5">
58
+ Customize the hamburger menu independently from the desktop navbar
59
+ </div>
60
+ </div>
61
+ <div className="flex items-center gap-3">
62
+ {hasChanges && (
63
+ <span className="text-[11px] text-amber-500 font-medium">
64
+ Unsaved changes
65
+ </span>
66
+ )}
67
+ <button
68
+ onClick={onSave}
69
+ disabled={saving || !hasChanges}
70
+ className={`px-5 py-1.5 text-sm font-medium rounded-lg transition-all ${
71
+ saving || !hasChanges
72
+ ? "bg-neutral-100 text-neutral-400 cursor-not-allowed"
73
+ : "bg-[#076bff] text-white hover:bg-[#0559d4]"
74
+ }`}
75
+ >
76
+ {saving ? "Saving..." : "Save"}
77
+ </button>
78
+ </div>
79
+ </div>
80
+
81
+ {/* Side-by-side: settings left, preview right */}
82
+ <div className="flex">
83
+ {/* Settings content */}
84
+ <div className="flex-1 min-w-0 px-5 py-4 border-r border-neutral-200">
85
+ <div className="max-w-md">
86
+ {/* ── Menu Overlay (expanded fullscreen menu) ── */}
87
+ <Section title="MENU OVERLAY">
88
+ <Field label="Background">
89
+ <ColorSwatchPicker
90
+ value={design.overlay_bg || ""}
91
+ onChange={(v) => update({ overlay_bg: typeof v === "string" ? v : "" })}
92
+ swatches={swatches}
93
+ />
94
+ </Field>
95
+ <Field label="Text color">
96
+ <ColorSwatchPicker
97
+ value={design.text_color || ""}
98
+ onChange={(v) => update({ text_color: typeof v === "string" ? v : "" })}
99
+ swatches={swatches}
100
+ />
101
+ </Field>
102
+ <Field label="Font size">
103
+ <TextInput
104
+ value={design.font_size ?? 24}
105
+ onChange={(v) =>
106
+ update({
107
+ font_size: Math.max(12, Math.min(72, parseInt(v) || 24)),
108
+ })
109
+ }
110
+ type="number"
111
+ />
112
+ </Field>
113
+ <Field label="Transform">
114
+ <SelectInput
115
+ value={design.text_transform || "uppercase"}
116
+ onChange={(v) =>
117
+ update({
118
+ text_transform: v as MobileNavDesign["text_transform"],
119
+ })
120
+ }
121
+ options={[
122
+ { value: "uppercase", label: "UPPERCASE" },
123
+ { value: "none", label: "None" },
124
+ { value: "lowercase", label: "lowercase" },
125
+ { value: "capitalize", label: "Capitalize" },
126
+ ]}
127
+ />
128
+ </Field>
129
+ <Field label="Align">
130
+ <SegmentedControl
131
+ value={design.items_align || "center"}
132
+ onChange={(v) =>
133
+ update({ items_align: v as MobileNavDesign["items_align"] })
134
+ }
135
+ options={[
136
+ { value: "left", label: "Left" },
137
+ { value: "center", label: "Center" },
138
+ { value: "right", label: "Right" },
139
+ ]}
140
+ />
141
+ </Field>
142
+ <Field label="Items gap">
143
+ <RangeSlider
144
+ value={design.items_gap ?? 32}
145
+ onChange={(v) => update({ items_gap: v })}
146
+ min={8}
147
+ max={80}
148
+ suffix="px"
149
+ />
150
+ </Field>
151
+ </Section>
152
+
153
+ {/* ── Navbar Bar (logo + hamburger row) ── */}
154
+ <Section title="NAVBAR BAR">
155
+ <Field label="BG color">
156
+ <ColorSwatchPicker
157
+ value={design.navbar_bg || ""}
158
+ onChange={(v) => update({ navbar_bg: typeof v === "string" ? v : "" })}
159
+ swatches={swatches}
160
+ />
161
+ </Field>
162
+ {design.navbar_bg && (
163
+ <Field label="BG opacity">
164
+ <RangeSlider
165
+ value={design.navbar_bg_opacity ?? 100}
166
+ onChange={(v) => update({ navbar_bg_opacity: v })}
167
+ min={0}
168
+ max={100}
169
+ suffix="%"
170
+ />
171
+ </Field>
172
+ )}
173
+ <Field label="Icon color">
174
+ <ColorSwatchPicker
175
+ value={design.hamburger_color || ""}
176
+ onChange={(v) => update({ hamburger_color: typeof v === "string" ? v : "" })}
177
+ swatches={swatches}
178
+ />
179
+ </Field>
180
+ <Field label="Pad H">
181
+ <RangeSlider
182
+ value={design.padding_h ?? 24}
183
+ onChange={(v) => update({ padding_h: v })}
184
+ min={0}
185
+ max={60}
186
+ suffix="px"
187
+ />
188
+ </Field>
189
+ <Field label="Pad V">
190
+ <RangeSlider
191
+ value={design.padding_v ?? 27}
192
+ onChange={(v) => update({ padding_v: v })}
193
+ min={0}
194
+ max={60}
195
+ suffix="px"
196
+ />
197
+ </Field>
198
+ </Section>
199
+
200
+ {/* Info notice */}
201
+ <div className="mt-4 p-3 bg-blue-50 rounded-xl border border-blue-200">
202
+ <p className="text-[11px] text-blue-600 leading-relaxed">
203
+ <strong>Independent from page overrides:</strong> Page-level{" "}
204
+ <code className="bg-blue-100 px-1 rounded text-[10px]">nav_color</code>{" "}
205
+ and parallax slide color changes only affect the desktop navbar.
206
+ The mobile menu always uses these dedicated styles.
207
+ </p>
208
+ </div>
209
+ </div>
210
+ </div>
211
+
212
+ {/* Live preview — sticky on the right */}
213
+ <div className="w-[340px] shrink-0 self-start sticky top-0">
214
+ <NavMobileLivePreview
215
+ items={items}
216
+ design={desktopDesign}
217
+ mobileDesign={design}
218
+ />
219
+ </div>
220
+ </div>
221
+ </div>
222
+ );
223
+ }
@@ -7,4 +7,6 @@ export { default as NavLivePreview } from "./NavLivePreview";
7
7
  export { default as NavSettingsPanel } from "./NavSettingsPanel";
8
8
  export { default as NavGeneralSettings } from "./NavGeneralSettings";
9
9
  export { default as NavItemSettings } from "./NavItemSettings";
10
+ export { default as NavMobileSettings } from "./NavMobileSettings";
11
+ export { default as NavMobileLivePreview } from "./NavMobileLivePreview";
10
12
  export * from "./nav-builder-utils";
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { Component } from "react";
3
4
  import type { ContentBlock, BlockLayout } from "../../lib/sanity/types";
4
5
  import type { EnterAnimationConfig, TypewriterConfig } from "../../lib/animation/enter-types";
5
6
  import type { HoverEffectConfig } from "../../lib/animation/hover-effect-types";
@@ -9,7 +10,8 @@ import { useAssetUrl } from "../../lib/contexts/AssetContext";
9
10
  import { useViewport } from "../../lib/hooks/useViewport";
10
11
  import { resolveBlock } from "../../lib/builder/responsive";
11
12
  import { getBlockLayoutStyles, hasBlockLayout } from "../../lib/builder/layout-styles";
12
- import { hexToRgba } from "../../lib/color-utils";
13
+ import { hexToRgba, colorToOverrideRule, borderColorToOverrideRule, parseColorField } from "../../lib/color-utils";
14
+ import type { ColorField } from "../../lib/sanity/types";
13
15
  import { BREAKPOINTS } from "../../lib/builder/constants";
14
16
  import EnterAnimationWrapper from "./EnterAnimationWrapper";
15
17
  import HoverAnimationWrapper from "./HoverAnimationWrapper";
@@ -24,6 +26,50 @@ import ButtonBlockRenderer from "./ButtonBlockRenderer";
24
26
  import CoverBlockRenderer from "./CoverBlockRenderer";
25
27
  import ProjectGridBlockRenderer from "./ProjectGridBlockRenderer";
26
28
 
29
+ // ── BLK-003: Error Boundary for block renderers ──
30
+ // Prevents a single broken block from crashing the entire page.
31
+ // Class component required — React error boundaries don't support hooks.
32
+ interface BlockErrorBoundaryProps {
33
+ blockType: string;
34
+ blockKey: string;
35
+ children: React.ReactNode;
36
+ }
37
+ interface BlockErrorBoundaryState {
38
+ hasError: boolean;
39
+ }
40
+ class BlockErrorBoundary extends Component<BlockErrorBoundaryProps, BlockErrorBoundaryState> {
41
+ constructor(props: BlockErrorBoundaryProps) {
42
+ super(props);
43
+ this.state = { hasError: false };
44
+ }
45
+
46
+ static getDerivedStateFromError(): BlockErrorBoundaryState {
47
+ return { hasError: true };
48
+ }
49
+
50
+ componentDidCatch(error: Error) {
51
+ console.error(
52
+ `[BlockRenderer] Error in ${this.props.blockType} (key: ${this.props.blockKey}):`,
53
+ error
54
+ );
55
+ }
56
+
57
+ render() {
58
+ if (this.state.hasError) {
59
+ if (process.env.NODE_ENV === "development") {
60
+ return (
61
+ <div className="border border-dashed border-red-400 bg-red-50 p-4 font-mono text-xs text-red-600">
62
+ Block crashed: {this.props.blockType} ({this.props.blockKey})
63
+ </div>
64
+ );
65
+ }
66
+ // Production: render nothing — block silently disappears instead of page crash
67
+ return null;
68
+ }
69
+ return this.props.children;
70
+ }
71
+ }
72
+
27
73
  /**
28
74
  * Central block dispatcher for the public site.
29
75
  *
@@ -111,9 +157,9 @@ function buildBlockLayoutOverrideRules(
111
157
  }
112
158
  }
113
159
 
114
- // Border color (no px)
160
+ // Border color (supports solid + gradients via ColorField bridge)
115
161
  if (overrides.border_color) {
116
- rules.push(`border-color:${overrides.border_color}!important`);
162
+ rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
117
163
  }
118
164
 
119
165
  // Border style (no px)
@@ -121,15 +167,13 @@ function buildBlockLayoutOverrideRules(
121
167
  rules.push(`border-style:${overrides.border_style}!important`);
122
168
  }
123
169
 
124
- // Background color (with opacity support)
170
+ // Background color (with opacity support, gradient-safe via ColorField bridge)
125
171
  if (overrides.background_color) {
126
172
  const opacity = overrides.background_opacity;
127
- if (opacity !== undefined && opacity < 100) {
128
- const rgba = hexToRgba(overrides.background_color, opacity / 100);
129
- rules.push(`background-color:${rgba}!important`);
130
- } else {
131
- rules.push(`background-color:${overrides.background_color}!important`);
132
- }
173
+ rules.push(colorToOverrideRule(
174
+ parseColorField(overrides.background_color),
175
+ opacity !== undefined ? opacity : undefined
176
+ ));
133
177
  } else if (overrides.background_opacity !== undefined) {
134
178
  // Opacity-only override (color inherited from desktop) — handled at render via resolveBlock
135
179
  }
@@ -269,16 +313,20 @@ export default function BlockRenderer({
269
313
  case "projectGridBlock":
270
314
  content = <ProjectGridBlockRenderer block={resolved as import("../../lib/sanity/types").ProjectGridBlock} />;
271
315
  break;
272
- default:
316
+ default: {
317
+ const unknownBlock = resolved as ContentBlock;
273
318
  if (process.env.NODE_ENV === "development") {
274
319
  content = (
275
320
  <div className="border border-dashed border-brand-secondary/50 p-4 font-mono text-xs text-brand-secondary">
276
- Unknown block type: {(resolved as ContentBlock)._type}
321
+ Unknown block type: {unknownBlock._type}
277
322
  </div>
278
323
  );
279
324
  } else {
325
+ // BLK-004: Log unknown block types in production for debugging
326
+ console.warn(`[BlockRenderer] Unknown block type "${unknownBlock._type}" (key: ${unknownBlock._key}) — skipped`);
280
327
  return null;
281
328
  }
329
+ }
282
330
  }
283
331
 
284
332
  // ── Resolve enter animation once (used both for typewriter early-wrap and normal path) ──
@@ -400,5 +448,9 @@ export default function BlockRenderer({
400
448
  }
401
449
  }
402
450
 
403
- return <>{content}</>;
451
+ return (
452
+ <BlockErrorBoundary blockType={resolved._type} blockKey={block._key}>
453
+ {content}
454
+ </BlockErrorBoundary>
455
+ );
404
456
  }
@@ -21,6 +21,18 @@ const alignmentMap: Record<string, string> = {
21
21
  right: "justify-end",
22
22
  };
23
23
 
24
+ // BLK-001: Validate URL — only allow safe protocols and non-empty values
25
+ function isValidUrl(url: unknown): url is string {
26
+ if (!url || typeof url !== "string") return false;
27
+ const trimmed = url.trim();
28
+ if (!trimmed) return false;
29
+ // Allow relative paths, http(s), mailto, tel — block javascript: and data:
30
+ if (/^(https?:\/\/|mailto:|tel:|\/)/i.test(trimmed)) return true;
31
+ // Allow fragment-only links (#section)
32
+ if (trimmed.startsWith("#")) return true;
33
+ return false;
34
+ }
35
+
24
36
  export default function ButtonBlockRenderer({
25
37
  block,
26
38
  }: {
@@ -30,23 +42,34 @@ export default function ButtonBlockRenderer({
30
42
  const size = sizeMap[block.size ?? "medium"];
31
43
  const alignment = alignmentMap[block.alignment ?? "left"];
32
44
 
45
+ const href = isValidUrl(block.url) ? block.url : undefined;
46
+
47
+ // Don't render a broken link — render as inert span if URL is missing/invalid
48
+ const Tag = href ? "a" : "span";
49
+ const linkProps = href
50
+ ? {
51
+ href,
52
+ target: block.target ? ("_blank" as const) : undefined,
53
+ rel: block.target ? "noopener noreferrer" : undefined,
54
+ }
55
+ : {};
56
+
33
57
  return (
34
58
  <div className={`flex ${alignment}`}>
35
- <a
36
- href={block.url}
37
- target={block.target ? "_blank" : undefined}
38
- rel={block.target ? "noopener noreferrer" : undefined}
59
+ <Tag
60
+ {...linkProps}
39
61
  className={[
40
- "inline-block font-mono uppercase tracking-wider transition-all",
62
+ "inline-block font-sans uppercase tracking-wider transition-all",
41
63
  variant,
42
64
  size,
43
65
  block.full_width ? "w-full text-center" : "",
66
+ !href ? "cursor-default opacity-60" : "",
44
67
  ]
45
68
  .filter(Boolean)
46
69
  .join(" ")}
47
70
  >
48
71
  {block.text}
49
- </a>
72
+ </Tag>
50
73
  </div>
51
74
  );
52
75
  }