@morphika/andami 0.1.2 → 0.1.5
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 +2 -2
- package/app/(site)/layout.tsx +1 -0
- package/app/(site)/page.tsx +2 -2
- package/app/(site)/preview/page.tsx +4 -4
- package/app/(site)/work/[slug]/page.tsx +2 -2
- package/app/admin/layout.tsx +2 -2
- package/app/admin/login/page.tsx +5 -5
- package/app/admin/navigation/page.tsx +255 -157
- package/app/api/admin/assets/relink/confirm/route.ts +1 -1
- package/app/api/admin/pages/[slug]/route.ts +1 -1
- package/app/api/admin/settings/route.ts +40 -15
- package/app/api/admin/setup/complete/route.ts +1 -1
- package/app/api/admin/setup/route.ts +6 -3
- 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 +95 -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/config/index.ts +14 -43
- package/lib/revalidate.ts +2 -2
- package/lib/sanity/queries.ts +4 -3
- package/lib/sanity/types.ts +76 -1
- package/lib/setup/detect.ts +1 -1
- package/package.json +8 -12
- package/sanity/schemas/siteSettings.ts +34 -0
- package/styles/base.css +7 -51
- package/app/globals.css +0 -7
|
@@ -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
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import type { CoverBlock } from "../../lib/sanity/types";
|
|
4
4
|
import { useAssetUrl } from "../../lib/contexts/AssetContext";
|
|
5
5
|
import { handleImageRetry, handleVideoRetry } from "../../lib/asset-retry";
|
|
6
|
+
import { parseColorField, colorToCSS, isGradient } from "../../lib/color-utils";
|
|
6
7
|
|
|
7
8
|
function getOverlayStyle(
|
|
8
9
|
overlay: CoverBlock["overlay"],
|
|
@@ -70,10 +71,18 @@ export default function CoverBlockRenderer({
|
|
|
70
71
|
? block.mobile_height
|
|
71
72
|
: null;
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
block.
|
|
76
|
-
|
|
74
|
+
// Phase 4: custom overlay_gradient takes precedence over hardcoded presets
|
|
75
|
+
const overlayStyle: React.CSSProperties | null = (() => {
|
|
76
|
+
if (block.overlay_gradient) {
|
|
77
|
+
const parsed = parseColorField(block.overlay_gradient);
|
|
78
|
+
if (isGradient(parsed)) {
|
|
79
|
+
return { backgroundImage: colorToCSS(parsed) };
|
|
80
|
+
}
|
|
81
|
+
// Solid color overlay from gradient field
|
|
82
|
+
return { backgroundColor: colorToCSS(parsed) };
|
|
83
|
+
}
|
|
84
|
+
return getOverlayStyle(block.overlay ?? "none", block.overlay_opacity ?? 50);
|
|
85
|
+
})();
|
|
77
86
|
|
|
78
87
|
const resolveAsset = useAssetUrl();
|
|
79
88
|
const mediaSrc = block.media_path ? resolveAsset(block.media_path) : undefined;
|
|
@@ -82,10 +91,14 @@ export default function CoverBlockRenderer({
|
|
|
82
91
|
: undefined;
|
|
83
92
|
const isVideo = block.media_type === "video";
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
// BLK-010: Validate objectFit against allowed CSS values
|
|
95
|
+
const allowedObjectFit = new Set(["cover", "contain", "none", "fill", "scale-down"]);
|
|
96
|
+
const objectFit = allowedObjectFit.has(block.background_size || "") ? block.background_size! : "cover";
|
|
86
97
|
const objectPosition = block.background_position || "center center";
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
// Guard: text color must be a solid hex string. Fallback if gradient slips through.
|
|
100
|
+
const rawTextColor = block.text_color || "#ffffff";
|
|
101
|
+
const textColor = typeof rawTextColor === "string" ? rawTextColor : "#ffffff";
|
|
89
102
|
|
|
90
103
|
const ctaStyleClasses: Record<string, string> = {
|
|
91
104
|
primary:
|
|
@@ -97,19 +110,28 @@ export default function CoverBlockRenderer({
|
|
|
97
110
|
text: "underline underline-offset-4 hover:opacity-70",
|
|
98
111
|
};
|
|
99
112
|
|
|
113
|
+
// BLK-002: Sanitize _key and mobileHeight before CSS interpolation
|
|
114
|
+
// _key: allow only alphanumeric, hyphens, underscores (strip anything else)
|
|
115
|
+
const safeKey = block._key?.replace(/[^a-zA-Z0-9_-]/g, "") || "";
|
|
116
|
+
// mobileHeight: must match valid CSS height (number+unit or CSS keyword)
|
|
117
|
+
const safeMobileHeight =
|
|
118
|
+
mobileHeight && /^(\d+(\.\d+)?(px|vh|vw|em|rem|%|svh|dvh)|auto|inherit)$/i.test(mobileHeight)
|
|
119
|
+
? mobileHeight
|
|
120
|
+
: null;
|
|
121
|
+
|
|
100
122
|
return (
|
|
101
123
|
<>
|
|
102
124
|
{/* Mobile height override via inline style tag */}
|
|
103
|
-
{
|
|
125
|
+
{safeMobileHeight && safeKey && (
|
|
104
126
|
<style
|
|
105
127
|
dangerouslySetInnerHTML={{
|
|
106
|
-
__html: `@media(max-width:767px){.cover-block-${
|
|
128
|
+
__html: `@media(max-width:767px){.cover-block-${safeKey}{height:${safeMobileHeight}!important;min-height:${safeMobileHeight}!important;}}`,
|
|
107
129
|
}}
|
|
108
130
|
/>
|
|
109
131
|
)}
|
|
110
132
|
|
|
111
133
|
<section
|
|
112
|
-
className={`cover-block-${
|
|
134
|
+
className={`cover-block-${safeKey} relative flex overflow-hidden`}
|
|
113
135
|
style={{
|
|
114
136
|
height,
|
|
115
137
|
minHeight: height,
|
|
@@ -127,7 +149,7 @@ export default function CoverBlockRenderer({
|
|
|
127
149
|
onError={handleImageRetry}
|
|
128
150
|
className="absolute inset-0 h-full w-full"
|
|
129
151
|
style={{
|
|
130
|
-
objectFit: objectFit as "
|
|
152
|
+
objectFit: objectFit as React.CSSProperties["objectFit"],
|
|
131
153
|
objectPosition,
|
|
132
154
|
}}
|
|
133
155
|
/>
|
|
@@ -144,7 +166,7 @@ export default function CoverBlockRenderer({
|
|
|
144
166
|
onError={handleVideoRetry}
|
|
145
167
|
className="absolute inset-0 h-full w-full"
|
|
146
168
|
style={{
|
|
147
|
-
objectFit: objectFit as "
|
|
169
|
+
objectFit: objectFit as React.CSSProperties["objectFit"],
|
|
148
170
|
objectPosition,
|
|
149
171
|
}}
|
|
150
172
|
/>
|
|
@@ -182,13 +204,13 @@ export default function CoverBlockRenderer({
|
|
|
182
204
|
}}
|
|
183
205
|
>
|
|
184
206
|
{block.headline && (
|
|
185
|
-
<h1 className="font-
|
|
207
|
+
<h1 className="font-sans text-4xl uppercase tracking-widest md:text-6xl lg:text-7xl">
|
|
186
208
|
{block.headline}
|
|
187
209
|
</h1>
|
|
188
210
|
)}
|
|
189
211
|
|
|
190
212
|
{block.subheadline && (
|
|
191
|
-
<p className="font-
|
|
213
|
+
<p className="font-sans text-sm uppercase tracking-wider opacity-80 md:text-base">
|
|
192
214
|
{block.subheadline}
|
|
193
215
|
</p>
|
|
194
216
|
)}
|
|
@@ -205,7 +227,7 @@ export default function CoverBlockRenderer({
|
|
|
205
227
|
? "noopener noreferrer"
|
|
206
228
|
: undefined
|
|
207
229
|
}
|
|
208
|
-
className={`inline-block px-6 py-3 font-
|
|
230
|
+
className={`inline-block px-6 py-3 font-sans text-sm uppercase tracking-wider transition ${
|
|
209
231
|
ctaStyleClasses[block.cta_button.style || "primary"]
|
|
210
232
|
}`}
|
|
211
233
|
>
|
|
@@ -28,7 +28,9 @@ export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
|
|
|
28
28
|
const widthStyle = widthStyleMap[block.width ?? "full"] || widthStyleMap.full;
|
|
29
29
|
const aspect = aspectMap[block.aspect_ratio ?? "auto"];
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// BLK-014: Strip any existing unit suffix, then validate as a number before appending px
|
|
32
|
+
const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
|
|
33
|
+
const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : undefined;
|
|
32
34
|
|
|
33
35
|
const imgStyle: React.CSSProperties = {
|
|
34
36
|
width: "100%",
|
|
@@ -44,7 +46,7 @@ export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
|
|
|
44
46
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
45
47
|
<img
|
|
46
48
|
src={src}
|
|
47
|
-
alt={block.alt
|
|
49
|
+
alt={block.alt || block.caption || ""}
|
|
48
50
|
loading={block.lazy !== false ? "lazy" : "eager"}
|
|
49
51
|
decoding="async"
|
|
50
52
|
onError={handleImageRetry}
|
|
@@ -52,7 +54,7 @@ export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
|
|
|
52
54
|
className={imgClassName}
|
|
53
55
|
/>
|
|
54
56
|
{block.caption && (
|
|
55
|
-
<figcaption className="mt-2 font-
|
|
57
|
+
<figcaption className="mt-2 font-sans text-xs uppercase tracking-wider text-brand-muted">
|
|
56
58
|
{block.caption}
|
|
57
59
|
</figcaption>
|
|
58
60
|
)}
|
|
@@ -420,12 +420,19 @@ function GridLightbox({
|
|
|
420
420
|
}
|
|
421
421
|
>
|
|
422
422
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
423
|
+
{/* BLK-009: Guard against missing asset_path from Sanity null fields */}
|
|
424
|
+
{img?.asset_path ? (
|
|
425
|
+
<img
|
|
426
|
+
src={resolveAsset(img.asset_path)}
|
|
427
|
+
alt={img.alt ?? ""}
|
|
428
|
+
className="max-w-full max-h-[85vh] object-contain"
|
|
429
|
+
style={{ borderRadius: "2px", pointerEvents: "none" }}
|
|
430
|
+
/>
|
|
431
|
+
) : (
|
|
432
|
+
<div className="flex h-40 w-60 items-center justify-center bg-neutral-900 text-sm text-neutral-500">
|
|
433
|
+
Image unavailable
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
429
436
|
</div>
|
|
430
437
|
</div>
|
|
431
438
|
</>,
|
|
@@ -8,6 +8,7 @@ import { PageNavColor } from "./PageNavColor";
|
|
|
8
8
|
import { PageNavAnimation } from "./PageNavAnimation";
|
|
9
9
|
import { PageBackground } from "./PageBackground";
|
|
10
10
|
import { assetUrl } from "../../lib/assets";
|
|
11
|
+
import { parseColorField, colorToCSSProperty } from "../../lib/color-utils";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Find the first image-bearing block (CoverBlock or ImageBlock) in the page.
|
|
@@ -81,7 +82,7 @@ export default function PageRenderer({ page }: { page: Page }) {
|
|
|
81
82
|
if (!page.content_rows?.length) {
|
|
82
83
|
return (
|
|
83
84
|
<div className="flex min-h-[50vh] items-center justify-center">
|
|
84
|
-
<p className="font-
|
|
85
|
+
<p className="font-sans text-sm text-brand-muted">
|
|
85
86
|
This page has no content yet.
|
|
86
87
|
</p>
|
|
87
88
|
</div>
|
|
@@ -95,7 +96,8 @@ export default function PageRenderer({ page }: { page: Page }) {
|
|
|
95
96
|
const ps = page.page_settings;
|
|
96
97
|
const pageStyle: React.CSSProperties = {};
|
|
97
98
|
if (ps?.background_color && ps.background_color !== "transparent") {
|
|
98
|
-
|
|
99
|
+
const colorField = parseColorField(ps.background_color);
|
|
100
|
+
Object.assign(pageStyle, colorToCSSProperty(colorField));
|
|
99
101
|
}
|
|
100
102
|
if (ps?.text_color) {
|
|
101
103
|
pageStyle.color = ps.text_color;
|