@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.
- package/app/(site)/[slug]/page.tsx +7 -4
- package/app/(site)/layout.tsx +5 -2
- package/app/(site)/page.tsx +2 -2
- package/app/(site)/preview/page.tsx +4 -4
- package/app/(site)/work/[slug]/page.tsx +7 -4
- package/app/admin/layout.tsx +3 -2
- package/app/admin/login/page.tsx +5 -5
- package/app/admin/navigation/page.tsx +255 -157
- package/app/api/admin/assets/health/route.ts +1 -1
- package/app/api/admin/assets/register/route.ts +1 -1
- package/app/api/admin/assets/registry/route.ts +1 -1
- package/app/api/admin/assets/relink/confirm/route.ts +2 -2
- package/app/api/admin/assets/relink/route.ts +1 -1
- package/app/api/admin/assets/scan/route.ts +1 -1
- package/app/api/admin/custom-sections/[slug]/route.ts +1 -1
- package/app/api/admin/custom-sections/route.ts +1 -1
- package/app/api/admin/database/route.ts +1 -1
- package/app/api/admin/pages/[slug]/duplicate/route.ts +1 -1
- package/app/api/admin/pages/[slug]/route.ts +2 -2
- package/app/api/admin/pages/[slug]/set-home/route.ts +1 -1
- package/app/api/admin/pages/route.ts +1 -1
- package/app/api/admin/preview/route.ts +1 -1
- package/app/api/admin/r2/delete/route.ts +1 -1
- package/app/api/admin/r2/rename/route.ts +1 -1
- package/app/api/admin/r2/status/route.ts +1 -1
- package/app/api/admin/r2/upload-url/route.ts +1 -1
- package/app/api/admin/settings/route.ts +41 -16
- package/app/api/admin/setup/complete/route.ts +2 -2
- package/app/api/admin/setup/route.ts +7 -4
- package/app/api/admin/storage/switch/route.ts +1 -1
- package/app/api/admin/styles/route.ts +1 -1
- package/components/admin/index.ts +7 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +11 -15
- package/components/admin/nav-builder/NavItemSettings.tsx +29 -5
- package/components/admin/nav-builder/NavLivePreview.tsx +4 -1
- package/components/admin/nav-builder/NavMobileLivePreview.tsx +226 -0
- package/components/admin/nav-builder/NavMobileSettings.tsx +223 -0
- package/components/admin/nav-builder/index.ts +2 -0
- package/components/blocks/BlockRenderer.tsx +65 -13
- package/components/blocks/ButtonBlockRenderer.tsx +29 -6
- package/components/blocks/CoverBlockRenderer.tsx +36 -14
- package/components/blocks/ImageBlockRenderer.tsx +5 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +13 -6
- package/components/blocks/PageRenderer.tsx +4 -2
- package/components/blocks/ProjectGridBlockRenderer.tsx +18 -3
- package/components/blocks/SectionRenderer.tsx +9 -8
- package/components/blocks/SectionV2Renderer.tsx +8 -8
- package/components/blocks/SpacerBlockRenderer.tsx +4 -2
- package/components/blocks/TextBlockRenderer.tsx +9 -4
- package/components/builder/BuilderCanvas.tsx +10 -4
- package/components/builder/ColorPicker.tsx +51 -243
- package/components/builder/ColorSwatchPicker.tsx +214 -274
- package/components/builder/DndWrapper.tsx +5 -2
- package/components/builder/SectionV2Canvas.tsx +15 -4
- package/components/builder/asset-browser/useAssetBrowser.ts +9 -1
- package/components/builder/color-picker/AlphaSlider.tsx +141 -0
- package/components/builder/color-picker/AngleControl.tsx +138 -0
- package/components/builder/color-picker/ColorInputs.tsx +105 -0
- package/components/builder/color-picker/EyedropperButton.tsx +74 -0
- package/components/builder/color-picker/GradientBar.tsx +222 -0
- package/components/builder/color-picker/GradientPreview.tsx +53 -0
- package/components/builder/color-picker/HueSlider.tsx +124 -0
- package/components/builder/color-picker/MeshCanvas.tsx +172 -0
- package/components/builder/color-picker/MeshPointEditor.tsx +133 -0
- package/components/builder/color-picker/MeshPointList.tsx +200 -0
- package/components/builder/color-picker/PositionControl.tsx +158 -0
- package/components/builder/color-picker/SaturationCanvas.tsx +142 -0
- package/components/builder/color-picker/StopEditor.tsx +178 -0
- package/components/builder/color-picker/SwatchBar.tsx +93 -0
- package/components/builder/color-picker/UnifiedColorPicker.tsx +713 -0
- package/components/builder/color-picker/index.ts +62 -0
- package/components/builder/color-picker/types.ts +115 -0
- package/components/builder/color-picker/utils.ts +138 -0
- package/components/builder/editors/CoverBlockEditor.tsx +86 -32
- package/components/builder/editors/ProjectGridEditor.tsx +51 -4
- package/components/builder/hooks/useColumnDrag.ts +25 -27
- package/components/builder/settings-panel/BlockLayoutTab.tsx +29 -7
- package/components/builder/settings-panel/LayoutTab.tsx +382 -310
- package/components/builder/settings-panel/PageSettings.tsx +6 -4
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +392 -312
- package/components/builder/settings-panel/SectionV2Settings.tsx +65 -35
- package/components/ui/Navbar.tsx +97 -25
- package/components/ui/PortfolioTracker.tsx +3 -3
- package/lib/assets.ts +1 -1
- package/lib/auth.ts +1 -1
- package/lib/builder/gradient-presets.ts +128 -0
- package/lib/builder/layout-styles.ts +16 -10
- package/lib/builder/serializer.ts +1 -0
- package/lib/builder/store-blocks.ts +48 -61
- package/lib/builder/store-helpers.ts +31 -14
- package/lib/builder/store.ts +59 -41
- package/lib/builder/types.ts +14 -0
- package/lib/color-utils.ts +200 -0
- package/lib/revalidate.ts +2 -2
- package/lib/sanity/client.ts +16 -0
- package/lib/sanity/queries.ts +4 -3
- package/lib/sanity/types.ts +76 -1
- package/lib/setup/detect.ts +1 -1
- package/lib/storage/index.ts +22 -4
- package/lib/version.ts +6 -0
- package/package.json +8 -2
- package/sanity/schemas/siteSettings.ts +34 -0
- package/styles/base.css +3 -3
- 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 (
|
|
160
|
+
// Border color (supports solid + gradients via ColorField bridge)
|
|
115
161
|
if (overrides.border_color) {
|
|
116
|
-
rules.push(
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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: {
|
|
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
|
|
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
|
-
<
|
|
36
|
-
|
|
37
|
-
target={block.target ? "_blank" : undefined}
|
|
38
|
-
rel={block.target ? "noopener noreferrer" : undefined}
|
|
59
|
+
<Tag
|
|
60
|
+
{...linkProps}
|
|
39
61
|
className={[
|
|
40
|
-
"inline-block font-
|
|
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
|
-
</
|
|
72
|
+
</Tag>
|
|
50
73
|
</div>
|
|
51
74
|
);
|
|
52
75
|
}
|