@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
|
@@ -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;
|
|
@@ -66,6 +66,8 @@ export default function ProjectGridBlockRenderer({
|
|
|
66
66
|
const containerTopRef = useRef<number | null>(null);
|
|
67
67
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
68
68
|
const [resolvedProjects, setResolvedProjects] = useState<ResolvedProject[]>([]);
|
|
69
|
+
// BLK-005: Track fetch errors so we can provide user feedback
|
|
70
|
+
const [fetchError, setFetchError] = useState(false);
|
|
69
71
|
|
|
70
72
|
// ─── Measure container width via ResizeObserver ───
|
|
71
73
|
// Uses callback ref so the observer is set up the instant the DOM node
|
|
@@ -116,8 +118,9 @@ export default function ProjectGridBlockRenderer({
|
|
|
116
118
|
|
|
117
119
|
const slugs = block.projects.map((p) => p.project_slug);
|
|
118
120
|
|
|
121
|
+
setFetchError(false);
|
|
119
122
|
fetch("/api/projects")
|
|
120
|
-
.then((r) => (r.ok ? r.json() :
|
|
123
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`API ${r.status}`))))
|
|
121
124
|
.then((data) => {
|
|
122
125
|
const projectMap = new Map<string, { title: string; subtitle: string; thumbnail_path?: string; cover_video?: string }>();
|
|
123
126
|
for (const proj of data.projects || []) {
|
|
@@ -151,8 +154,10 @@ export default function ProjectGridBlockRenderer({
|
|
|
151
154
|
|
|
152
155
|
setResolvedProjects(resolved);
|
|
153
156
|
})
|
|
154
|
-
.catch(() => {
|
|
157
|
+
.catch((err) => {
|
|
158
|
+
console.error("[ProjectGridBlock] Failed to fetch projects:", err);
|
|
155
159
|
setResolvedProjects([]);
|
|
160
|
+
setFetchError(true);
|
|
156
161
|
});
|
|
157
162
|
}, [block.projects]);
|
|
158
163
|
|
|
@@ -270,7 +275,17 @@ export default function ProjectGridBlockRenderer({
|
|
|
270
275
|
return indices;
|
|
271
276
|
}, [entranceEnabled, masonry.items, aboveFoldKeys, gapV]);
|
|
272
277
|
|
|
273
|
-
if (resolvedProjects.length === 0)
|
|
278
|
+
if (resolvedProjects.length === 0) {
|
|
279
|
+
// BLK-005: Show subtle error message if fetch failed (not just empty data)
|
|
280
|
+
if (fetchError) {
|
|
281
|
+
return (
|
|
282
|
+
<div className="py-8 text-center font-sans text-sm text-brand-muted opacity-60">
|
|
283
|
+
Unable to load projects. Please try refreshing the page.
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
274
289
|
|
|
275
290
|
return (
|
|
276
291
|
<div
|
|
@@ -16,6 +16,8 @@ import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
|
|
|
16
16
|
import BlockRenderer from "./BlockRenderer";
|
|
17
17
|
import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
18
18
|
import { getRowLayoutStyles, hexToRgba } from "../../lib/builder/layout-styles";
|
|
19
|
+
import { colorToOverrideRule, borderColorToOverrideRule, parseColorField } from "../../lib/color-utils";
|
|
20
|
+
import type { ColorField } from "../../lib/sanity/types";
|
|
19
21
|
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -81,9 +83,9 @@ function buildSectionResponsiveCss(section: PageSection): string | null {
|
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
// Border color
|
|
86
|
+
// Border color (supports solid + gradients via ColorField bridge)
|
|
85
87
|
if (overrides.border_color) {
|
|
86
|
-
rules.push(
|
|
88
|
+
rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
// Border style
|
|
@@ -91,14 +93,13 @@ function buildSectionResponsiveCss(section: PageSection): string | null {
|
|
|
91
93
|
rules.push(`border-style:${overrides.border_style}!important`);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
// Background color + opacity
|
|
96
|
+
// Background color + opacity (gradient-safe via ColorField bridge)
|
|
95
97
|
if (overrides.background_color) {
|
|
96
98
|
const opacity = overrides.background_opacity as number | undefined;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
99
|
+
rules.push(colorToOverrideRule(
|
|
100
|
+
parseColorField(overrides.background_color),
|
|
101
|
+
opacity
|
|
102
|
+
));
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
// Background image + sub-properties
|
|
@@ -25,6 +25,7 @@ import { resolveEnterAnimation } from "../../lib/animation/enter-resolve";
|
|
|
25
25
|
import BlockRenderer from "./BlockRenderer";
|
|
26
26
|
import EnterAnimationWrapper from "./EnterAnimationWrapper";
|
|
27
27
|
import { getRowLayoutStyles, getBlockAlignmentStyles, hasBlockAlignment, getColumnVerticalAlign, hexToRgba } from "../../lib/builder/layout-styles";
|
|
28
|
+
import { parseColorField, colorToOverrideRule, borderColorToOverrideRule } from "../../lib/color-utils";
|
|
28
29
|
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
29
30
|
|
|
30
31
|
// ── Responsive CSS generation ──
|
|
@@ -99,9 +100,9 @@ function buildSettingsOverrideRules(
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
// Border color
|
|
103
|
+
// Border color (supports solid + gradients via ColorField bridge)
|
|
103
104
|
if (overrides.border_color) {
|
|
104
|
-
rules.push(
|
|
105
|
+
rules.push(borderColorToOverrideRule(parseColorField(overrides.border_color)));
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
// Border style
|
|
@@ -109,14 +110,13 @@ function buildSettingsOverrideRules(
|
|
|
109
110
|
rules.push(`border-style:${overrides.border_style}!important`);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
// Background color + opacity
|
|
113
|
+
// Background color + opacity (supports solid + gradients via ColorField bridge)
|
|
113
114
|
if (overrides.background_color) {
|
|
114
115
|
const opacity = overrides.background_opacity;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
116
|
+
rules.push(colorToOverrideRule(
|
|
117
|
+
parseColorField(overrides.background_color),
|
|
118
|
+
opacity
|
|
119
|
+
));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
return rules;
|
|
@@ -8,8 +8,10 @@ const heightMap: Record<string, string> = {
|
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export default function SpacerBlockRenderer({ block }: { block: SpacerBlock }) {
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// BLK-007: Use != null instead of truthiness to allow custom_height of 0
|
|
12
|
+
if (block.height === "custom" && block.custom_height != null) {
|
|
13
|
+
const h = Math.max(0, block.custom_height);
|
|
14
|
+
return <div style={{ height: `${h}px` }} aria-hidden />;
|
|
13
15
|
}
|
|
14
16
|
return (
|
|
15
17
|
<div className={heightMap[block.height ?? "medium"]} aria-hidden />
|
|
@@ -3,7 +3,8 @@ import { PortableText } from "next-sanity";
|
|
|
3
3
|
|
|
4
4
|
/** Resolve fontSize: supports numeric px and legacy string enum */
|
|
5
5
|
function resolvePublicFontSize(fontSize?: number | string): string | undefined {
|
|
6
|
-
|
|
6
|
+
// BLK-015: Guard against negative or zero font sizes
|
|
7
|
+
if (typeof fontSize === "number") return fontSize > 0 ? `${fontSize}px` : undefined;
|
|
7
8
|
// Legacy Tailwind class mapping
|
|
8
9
|
const tailwindMap: Record<string, string> = {
|
|
9
10
|
small: "text-sm",
|
|
@@ -49,7 +50,7 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
|
|
|
49
50
|
const isNumericWeight = s?.fontWeight && !isNaN(parseInt(s.fontWeight, 10));
|
|
50
51
|
|
|
51
52
|
const classes = [
|
|
52
|
-
"font-
|
|
53
|
+
"font-sans",
|
|
53
54
|
!isNumericFontSize ? resolvePublicFontSize(s?.fontSize) : undefined,
|
|
54
55
|
alignmentMap[s?.alignment ?? "left"],
|
|
55
56
|
!isNumericWeight ? resolvePublicFontWeight(s?.fontWeight) : undefined,
|
|
@@ -58,9 +59,13 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
|
|
|
58
59
|
.join(" ");
|
|
59
60
|
|
|
60
61
|
const inlineStyle: React.CSSProperties = {};
|
|
61
|
-
if (isNumericFontSize) inlineStyle.fontSize = `${s!.fontSize}px`;
|
|
62
|
+
if (isNumericFontSize && (s!.fontSize as number) > 0) inlineStyle.fontSize = `${s!.fontSize}px`;
|
|
62
63
|
if (isNumericWeight) inlineStyle.fontWeight = parseInt(s!.fontWeight!, 10);
|
|
63
|
-
|
|
64
|
+
// Guard: text color must be a solid hex string, not a gradient.
|
|
65
|
+
// resolveColorHex extracts a representative hex if a gradient slips through.
|
|
66
|
+
if (s?.color) {
|
|
67
|
+
inlineStyle.color = typeof s.color === "string" ? s.color : "#000000";
|
|
68
|
+
}
|
|
64
69
|
if (s?.lineHeight) inlineStyle.lineHeight = s.lineHeight;
|
|
65
70
|
if (s?.letterSpacing) inlineStyle.letterSpacing = s.letterSpacing;
|
|
66
71
|
if (s?.maxWidth) inlineStyle.maxWidth = s.maxWidth;
|
|
@@ -70,9 +70,15 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
|
|
|
70
70
|
const zoomRef = useRef(zoom);
|
|
71
71
|
const panXRef = useRef(panX);
|
|
72
72
|
const panYRef = useRef(panY);
|
|
73
|
+
// LEAK-002 fix: Also ref action functions so flushWheel/handleWheel
|
|
74
|
+
// callbacks are truly stable and never cause wheel listener resubscription.
|
|
75
|
+
const zoomToPointRef = useRef(zoomToPoint);
|
|
76
|
+
const setCanvasPanRef = useRef(setCanvasPan);
|
|
73
77
|
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
|
|
74
78
|
useEffect(() => { panXRef.current = panX; }, [panX]);
|
|
75
79
|
useEffect(() => { panYRef.current = panY; }, [panY]);
|
|
80
|
+
useEffect(() => { zoomToPointRef.current = zoomToPoint; }, [zoomToPoint]);
|
|
81
|
+
useEffect(() => { setCanvasPanRef.current = setCanvasPan; }, [setCanvasPan]);
|
|
76
82
|
|
|
77
83
|
// ---- Trigger smooth animation for programmatic zoom/pan ----
|
|
78
84
|
const triggerAnimation = useCallback(() => {
|
|
@@ -128,7 +134,7 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
|
|
|
128
134
|
// Throttled via requestAnimationFrame to prevent jank on rapid scroll.
|
|
129
135
|
// Deltas accumulate between frames so no input is lost.
|
|
130
136
|
|
|
131
|
-
// Stable flush — reads current values from refs, never re-created
|
|
137
|
+
// Stable flush — reads current values from refs, never re-created (LEAK-002)
|
|
132
138
|
const flushWheel = useCallback(() => {
|
|
133
139
|
wheelRafRef.current = null;
|
|
134
140
|
const accum = wheelAccumRef.current;
|
|
@@ -138,11 +144,11 @@ export default function BuilderCanvas({ children }: BuilderCanvasProps) {
|
|
|
138
144
|
if (accum.isZoom) {
|
|
139
145
|
const zoomDelta = -accum.deltaY * 0.005;
|
|
140
146
|
const newZoom = zoomRef.current * (1 + zoomDelta);
|
|
141
|
-
|
|
147
|
+
zoomToPointRef.current(newZoom, accum.cursorX, accum.cursorY);
|
|
142
148
|
} else {
|
|
143
|
-
|
|
149
|
+
setCanvasPanRef.current(panXRef.current - accum.deltaX, panYRef.current - accum.deltaY);
|
|
144
150
|
}
|
|
145
|
-
}, [
|
|
151
|
+
}, []); // No deps — all values read from refs
|
|
146
152
|
|
|
147
153
|
// Stable wheel handler — never re-subscribed during zoom/pan
|
|
148
154
|
const handleWheel = useCallback(
|