@morphika/andami 0.2.26 → 0.3.1
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/admin/pages/[slug]/page.tsx +39 -45
- package/app/api/admin/assets/scan/route.ts +40 -13
- package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
- package/app/api/admin/custom-sections/route.ts +4 -1
- package/app/api/admin/pages/[slug]/route.ts +7 -1
- package/app/api/admin/pages/route.ts +4 -1
- package/app/api/admin/r2/connect/route.ts +19 -1
- package/app/api/admin/r2/disconnect/route.ts +3 -0
- package/app/api/admin/r2/rename/route.ts +52 -13
- package/app/api/admin/r2/upload-url/route.ts +8 -1
- package/app/api/admin/settings/route.ts +4 -1
- package/app/api/admin/styles/route.ts +4 -1
- package/components/admin/styles/GridLayoutEditor.tsx +46 -46
- package/components/blocks/BlockRenderer.tsx +11 -2
- package/components/blocks/CoverSectionRenderer.tsx +75 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
- package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
- package/components/blocks/ShaderCanvas.tsx +10 -6
- package/components/builder/BlockCardIcons.tsx +227 -0
- package/components/builder/BlockTypePicker.tsx +36 -63
- package/components/builder/BuilderCanvas.tsx +6 -2
- package/components/builder/ColumnDragOverlay.tsx +3 -3
- package/components/builder/CoverRowResizeHandle.tsx +5 -2
- package/components/builder/CoverSectionCanvas.tsx +45 -52
- package/components/builder/DndWrapper.tsx +1 -1
- package/components/builder/InsertionLines.tsx +1 -1
- package/components/builder/ParallaxGroupCanvas.tsx +12 -71
- package/components/builder/SectionCardIcons.tsx +266 -0
- package/components/builder/SectionEditorBar.tsx +17 -12
- package/components/builder/SectionTypePicker.tsx +33 -137
- package/components/builder/SectionV2Canvas.tsx +1 -1
- package/components/builder/SectionV2Column.tsx +19 -30
- package/components/builder/SettingsPanel.tsx +8 -32
- package/components/builder/SortableBlock.tsx +42 -50
- package/components/builder/SortableRow.tsx +207 -19
- package/components/builder/blockStyles.tsx +53 -180
- package/components/builder/iconPrimitives.tsx +78 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
- package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
- package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
- package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
- package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
- package/lib/assets.ts +17 -2
- package/lib/builder/constants.ts +22 -15
- package/lib/builder/format.ts +25 -0
- package/lib/builder/history.ts +0 -3
- package/lib/builder/layout-styles.ts +1 -1
- package/lib/builder/section-visibility.ts +36 -0
- package/lib/builder/serializer/normalizers.ts +15 -6
- package/lib/builder/serializer/serializers.ts +3 -3
- package/lib/builder/store-blocks.ts +16 -9
- package/lib/builder/store-cover.ts +76 -8
- package/lib/builder/store.ts +0 -2
- package/lib/builder/types.ts +1 -2
- package/lib/csrf.ts +31 -0
- package/lib/sanity/types.ts +4 -1
- package/lib/security.ts +50 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/objects/coverSection.ts +35 -3
- package/components/builder/ParallaxSlideHeader.tsx +0 -113
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared SVG primitives for the block and section card icons.
|
|
5
|
+
*
|
|
6
|
+
* Both `BlockCardIcons.tsx` and `SectionCardIcons.tsx` consume these so the
|
|
7
|
+
* visual language (brick background + fade + shadow + bevel) stays 1:1
|
|
8
|
+
* consistent. Each helper accepts an `idPrefix` / `id` so multiple icons can
|
|
9
|
+
* render side by side without filter/gradient/mask collisions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Brick-rect positions — 24 rects, 4 rows, top half only. */
|
|
13
|
+
export const BRICK: ReadonlyArray<readonly [number, number]> = [
|
|
14
|
+
[18.7, 0.1], [53.5, 0.1], [88.4, 0.1], [123.2, 0.1], [158.1, 0.1], [192.9, 0.1],
|
|
15
|
+
[1.3, 17.6], [36.1, 17.6], [71, 17.6], [105.8, 17.6], [140.7, 17.6], [175.5, 17.6],
|
|
16
|
+
[18.7, 34.8], [53.5, 34.8], [88.4, 34.8], [123.2, 34.8], [158.1, 34.8], [192.9, 34.8],
|
|
17
|
+
[1.3, 52.3], [36.1, 52.3], [71, 52.3], [105.8, 52.3], [140.7, 52.3], [175.5, 52.3],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Grid line pattern + right-side fade gradient defs.
|
|
21
|
+
*
|
|
22
|
+
* The fade reaches full opacity at x=200 (viewBox units) rather than at
|
|
23
|
+
* x=220 (the right edge). This guarantees that the rightmost ~20 viewBox
|
|
24
|
+
* units of the SVG are solid #F4F4F4 — eliminating sub-pixel anti-aliasing
|
|
25
|
+
* artifacts when the SVG is downscaled to fit a container whose width is
|
|
26
|
+
* slightly smaller than the full viewBox (e.g. 176px for a 220-wide viewBox).
|
|
27
|
+
*/
|
|
28
|
+
export function BgDefs({ prefix }: { prefix: string }) {
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<pattern id={`${prefix}-grid`} x="0.8" y="-0.3" width="17.5" height="17.5" patternUnits="userSpaceOnUse">
|
|
32
|
+
<path d="M 17.5 0 L 0 0 0 17.5" fill="none" stroke="#BABABA" strokeWidth="0.5" opacity="0.49" />
|
|
33
|
+
</pattern>
|
|
34
|
+
<linearGradient id={`${prefix}-fade`} x1="140.2" y1="60" x2="200" y2="60" gradientUnits="userSpaceOnUse">
|
|
35
|
+
<stop offset="0" stopColor="#F4F4F4" stopOpacity="0" />
|
|
36
|
+
<stop offset="1" stopColor="#F4F4F4" />
|
|
37
|
+
</linearGradient>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Background body: solid fill → grid pattern → brick overlay → right-side fade. */
|
|
43
|
+
export function Bg({ prefix }: { prefix: string }) {
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
<rect width="220" height="120" fill="#F4F4F4" />
|
|
47
|
+
<rect width="220" height="120" fill={`url(#${prefix}-grid)`} />
|
|
48
|
+
<g fill="none" stroke="#FFFFFF" strokeWidth="0.5">
|
|
49
|
+
{BRICK.map(([x, y]) => (
|
|
50
|
+
<rect key={`${x}-${y}`} x={x} y={y} width="15.5" height="15.3" />
|
|
51
|
+
))}
|
|
52
|
+
</g>
|
|
53
|
+
<rect x="140.2" y="0" width="79.8" height="120" fill={`url(#${prefix}-fade)`} />
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Vector drop-shadow filter. */
|
|
59
|
+
export function ShadowFilter({ id }: { id: string }) {
|
|
60
|
+
return (
|
|
61
|
+
<filter id={id} x="-30%" y="-30%" width="160%" height="160%">
|
|
62
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
|
63
|
+
<feOffset dy="3" />
|
|
64
|
+
<feComponentTransfer><feFuncA type="linear" slope="0.22" /></feComponentTransfer>
|
|
65
|
+
<feMerge><feMergeNode /><feMergeNode in="SourceGraphic" /></feMerge>
|
|
66
|
+
</filter>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Vertical bevel gradient: #FFFFFF → endColor (default #E6ECF6 for blocks, #EBEAEF for sections). */
|
|
71
|
+
export function VertBevel({ id, endColor = "#E6ECF6" }: { id: string; endColor?: string }) {
|
|
72
|
+
return (
|
|
73
|
+
<linearGradient id={id} x1="0.5" y1="0" x2="0.5" y2="1">
|
|
74
|
+
<stop offset="0" stopColor="#FFFFFF" />
|
|
75
|
+
<stop offset="1" stopColor={endColor} />
|
|
76
|
+
</linearGradient>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -11,9 +11,23 @@ export default function LiveImagePreview({ block }: { block: ImageBlock }) {
|
|
|
11
11
|
const [useFallback, setUseFallback] = useState(false);
|
|
12
12
|
|
|
13
13
|
if (!block.asset_path) {
|
|
14
|
+
// Empty state: fills the column (min 240px) with a light-gray backdrop
|
|
15
|
+
// and a landscape placeholder glyph. Once the user picks an image the
|
|
16
|
+
// block sizes itself normally.
|
|
17
|
+
const isFill = block.width === "fill";
|
|
18
|
+
const wrapperStyle: React.CSSProperties = isFill
|
|
19
|
+
? { position: "absolute", inset: 0 }
|
|
20
|
+
: { width: "100%" };
|
|
14
21
|
return (
|
|
15
|
-
<div
|
|
16
|
-
<
|
|
22
|
+
<div style={wrapperStyle}>
|
|
23
|
+
<div className="w-full h-full min-h-[240px] rounded flex flex-col items-center justify-center gap-2.5" style={{ background: "#f4f4f4" }}>
|
|
24
|
+
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
|
25
|
+
<rect x="6" y="10" width="44" height="36" rx="3" stroke="#b0b5bd" strokeWidth="1.5" fill="#FFFFFF" />
|
|
26
|
+
<circle cx="18" cy="21" r="3" fill="#b0b5bd" />
|
|
27
|
+
<path d="M12 42 L22 28 L28 34 L38 22 L46 42 Z" fill="#b0b5bd" />
|
|
28
|
+
</svg>
|
|
29
|
+
<span className="text-[11px] text-neutral-500">No image yet</span>
|
|
30
|
+
</div>
|
|
17
31
|
</div>
|
|
18
32
|
);
|
|
19
33
|
}
|
|
@@ -17,9 +17,22 @@ import type { VideoBlock } from "../../../lib/sanity/types";
|
|
|
17
17
|
*/
|
|
18
18
|
export default function LiveVideoPreview({ block }: { block: VideoBlock }) {
|
|
19
19
|
if (!block.url_or_path) {
|
|
20
|
+
// Empty state: fills the column (min 240px) with a light-gray backdrop
|
|
21
|
+
// and a centered play-button glyph. Once the user picks a video the
|
|
22
|
+
// block sizes itself normally.
|
|
23
|
+
const isFill = block.width === "fill";
|
|
24
|
+
const wrapperStyle: React.CSSProperties = isFill
|
|
25
|
+
? { position: "absolute", inset: 0 }
|
|
26
|
+
: { width: "100%" };
|
|
20
27
|
return (
|
|
21
|
-
<div
|
|
22
|
-
<
|
|
28
|
+
<div style={wrapperStyle}>
|
|
29
|
+
<div className="w-full h-full min-h-[240px] rounded flex flex-col items-center justify-center gap-2.5" style={{ background: "#f4f4f4" }}>
|
|
30
|
+
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
|
31
|
+
<circle cx="28" cy="28" r="22" fill="#FFFFFF" stroke="#b0b5bd" strokeWidth="1.5" />
|
|
32
|
+
<path d="M24 20 L37 28 L24 36 Z" fill="#b0b5bd" />
|
|
33
|
+
</svg>
|
|
34
|
+
<span className="text-[11px] text-neutral-500">No video yet</span>
|
|
35
|
+
</div>
|
|
23
36
|
</div>
|
|
24
37
|
);
|
|
25
38
|
}
|
|
@@ -85,8 +85,8 @@ export default function ColumnV2Settings({
|
|
|
85
85
|
<>
|
|
86
86
|
{isResponsive && (
|
|
87
87
|
<div className="px-4 pt-3">
|
|
88
|
-
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#
|
|
89
|
-
<span className="text-[11px] font-medium text-[#
|
|
88
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#4794e2]/8 border border-[#4794e2]/15">
|
|
89
|
+
<span className="text-[11px] font-medium text-[#4794e2]">
|
|
90
90
|
Editing {activeViewport === "tablet" ? "Tablet" : "Phone"} overrides
|
|
91
91
|
</span>
|
|
92
92
|
</div>
|
|
@@ -98,7 +98,7 @@ export default function ColumnV2Settings({
|
|
|
98
98
|
<span>
|
|
99
99
|
Span
|
|
100
100
|
{hasSpanOverride && (
|
|
101
|
-
<span className="ml-1 text-[9px] text-[#
|
|
101
|
+
<span className="ml-1 text-[9px] text-[#4794e2]">overridden</span>
|
|
102
102
|
)}
|
|
103
103
|
</span>
|
|
104
104
|
}>
|
|
@@ -109,7 +109,7 @@ export default function ColumnV2Settings({
|
|
|
109
109
|
max={gridColumns}
|
|
110
110
|
value={effectiveSpan}
|
|
111
111
|
onChange={(e) => handleSpanChange(parseInt(e.target.value))}
|
|
112
|
-
className="flex-1 accent-[#
|
|
112
|
+
className="flex-1 accent-[#4794e2]"
|
|
113
113
|
/>
|
|
114
114
|
<span className="text-xs text-neutral-900 w-12 text-right font-medium">
|
|
115
115
|
{effectiveSpan}/{gridColumns}
|
|
@@ -133,7 +133,7 @@ export default function ColumnV2Settings({
|
|
|
133
133
|
<div
|
|
134
134
|
key={i}
|
|
135
135
|
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
|
136
|
-
isActive ? "bg-[#
|
|
136
|
+
isActive ? "bg-[#4794e2]" : "bg-neutral-200"
|
|
137
137
|
}`}
|
|
138
138
|
/>
|
|
139
139
|
);
|
|
@@ -22,6 +22,7 @@ import { useBuilderStore } from "../../../lib/builder/store";
|
|
|
22
22
|
import type { CoverSection } from "../../../lib/sanity/types";
|
|
23
23
|
import {
|
|
24
24
|
BackgroundIcon,
|
|
25
|
+
NavbarColorIcon,
|
|
25
26
|
OverlayIcon,
|
|
26
27
|
SpacingIcon,
|
|
27
28
|
GridGapsIcon,
|
|
@@ -56,6 +57,7 @@ const HEIGHT_OPTIONS = [
|
|
|
56
57
|
{ value: "100vh", label: "Full Viewport (100vh)" },
|
|
57
58
|
{ value: "80vh", label: "80% Viewport (80vh)" },
|
|
58
59
|
{ value: "50vh", label: "50% Viewport (50vh)" },
|
|
60
|
+
{ value: "20vh", label: "20% Viewport (20vh)" },
|
|
59
61
|
];
|
|
60
62
|
|
|
61
63
|
const ALIGN_OPTIONS = [
|
|
@@ -83,7 +85,8 @@ export default function CoverSectionSettings({ section }: CoverSectionSettingsPr
|
|
|
83
85
|
const updateBg = (fields: Partial<Pick<CoverSection,
|
|
84
86
|
"background_type" | "background_color" | "background_image" | "background_video" |
|
|
85
87
|
"background_position" | "background_size" |
|
|
86
|
-
"background_overlay_color" | "background_overlay_opacity"
|
|
88
|
+
"background_overlay_color" | "background_overlay_opacity" |
|
|
89
|
+
"nav_color"
|
|
87
90
|
>>) => {
|
|
88
91
|
store.updateCoverBackground(section._key, fields);
|
|
89
92
|
};
|
|
@@ -162,6 +165,30 @@ export default function CoverSectionSettings({ section }: CoverSectionSettingsPr
|
|
|
162
165
|
)}
|
|
163
166
|
</SettingsSection>
|
|
164
167
|
|
|
168
|
+
{/* Navbar Color Override */}
|
|
169
|
+
<SettingsSection title="Navbar Color" defaultOpen={false} icon={<NavbarColorIcon />}>
|
|
170
|
+
<SettingsField label="Color">
|
|
171
|
+
<div className="flex items-center gap-2">
|
|
172
|
+
<ColorSwatchPicker
|
|
173
|
+
value={section.nav_color || ""}
|
|
174
|
+
onChange={(val) => updateBg({ nav_color: typeof val === "string" ? val : undefined })}
|
|
175
|
+
swatches={paletteSwatches}
|
|
176
|
+
/>
|
|
177
|
+
{section.nav_color && (
|
|
178
|
+
<button
|
|
179
|
+
onClick={() => updateBg({ nav_color: undefined })}
|
|
180
|
+
className="text-[10px] text-neutral-400 hover:text-neutral-600 transition-colors shrink-0"
|
|
181
|
+
>
|
|
182
|
+
Clear
|
|
183
|
+
</button>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</SettingsField>
|
|
187
|
+
<p className="text-[10px] text-neutral-400 leading-snug px-0.5">
|
|
188
|
+
Override the navbar text color while this cover section is on screen. Clears when the next section takes over.
|
|
189
|
+
</p>
|
|
190
|
+
</SettingsSection>
|
|
191
|
+
|
|
165
192
|
{/* Overlay */}
|
|
166
193
|
<SettingsSection title="Overlay" defaultOpen icon={<OverlayIcon />}>
|
|
167
194
|
<SettingsField label="Color">
|
|
@@ -101,7 +101,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
|
|
|
101
101
|
onClick={() => !isCustom && applyPresetV2(section._key, preset.id)}
|
|
102
102
|
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-all ${
|
|
103
103
|
isActive
|
|
104
|
-
? "border-[#
|
|
104
|
+
? "border-[#4794e2] bg-[#4794e2]/5"
|
|
105
105
|
: isCustom
|
|
106
106
|
? "border-neutral-200 bg-neutral-50 opacity-60 cursor-default"
|
|
107
107
|
: "border-neutral-200 bg-white hover:border-neutral-300 hover:bg-neutral-50"
|
|
@@ -121,7 +121,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
|
|
|
121
121
|
<div
|
|
122
122
|
key={i}
|
|
123
123
|
className={`rounded-sm transition-colors ${
|
|
124
|
-
isActive ? "bg-[#
|
|
124
|
+
isActive ? "bg-[#4794e2]" : "bg-neutral-300"
|
|
125
125
|
}`}
|
|
126
126
|
style={{ flex: span }}
|
|
127
127
|
/>
|
|
@@ -129,7 +129,7 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
|
|
|
129
129
|
)}
|
|
130
130
|
</div>
|
|
131
131
|
<span className={`text-[9px] font-medium ${
|
|
132
|
-
isActive ? "text-[#
|
|
132
|
+
isActive ? "text-[#4794e2]" : "text-neutral-500"
|
|
133
133
|
}`}>
|
|
134
134
|
{preset.label}
|
|
135
135
|
</span>
|
|
@@ -140,15 +140,15 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
|
|
|
140
140
|
{/* + Add Column button */}
|
|
141
141
|
<button
|
|
142
142
|
onClick={handleAddColumn}
|
|
143
|
-
className="flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#
|
|
143
|
+
className="flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#4794e2] hover:bg-[#4794e2]/5 group"
|
|
144
144
|
title="Add a column (fills first gap, or adds new row below)"
|
|
145
145
|
>
|
|
146
146
|
<div className="flex items-center justify-center w-full h-4">
|
|
147
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#
|
|
147
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#4794e2] transition-colors">
|
|
148
148
|
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
149
149
|
</svg>
|
|
150
150
|
</div>
|
|
151
|
-
<span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#
|
|
151
|
+
<span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#4794e2] transition-colors">
|
|
152
152
|
Add Col
|
|
153
153
|
</span>
|
|
154
154
|
</button>
|
|
@@ -204,8 +204,8 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
204
204
|
{/* Responsive info banner */}
|
|
205
205
|
{isResponsive && (
|
|
206
206
|
<div className="px-4 pt-3">
|
|
207
|
-
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#
|
|
208
|
-
<span className="text-[11px] font-medium text-[#
|
|
207
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#4794e2]/8 border border-[#4794e2]/15">
|
|
208
|
+
<span className="text-[11px] font-medium text-[#4794e2]">
|
|
209
209
|
Editing {activeViewport === "tablet" ? "Tablet" : "Phone"} overrides
|
|
210
210
|
</span>
|
|
211
211
|
</div>
|
|
@@ -218,7 +218,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
218
218
|
<div className="flex gap-2">
|
|
219
219
|
<button
|
|
220
220
|
onClick={handleStack}
|
|
221
|
-
className="flex-1 rounded-lg bg-[#
|
|
221
|
+
className="flex-1 rounded-lg bg-[#4794e2]/8 border border-[#4794e2]/20 py-2 text-xs font-medium text-[#4794e2] hover:bg-[#4794e2]/15 transition-colors"
|
|
222
222
|
title="Stack all columns vertically (full width, one per row)"
|
|
223
223
|
>
|
|
224
224
|
Stack Columns
|
|
@@ -237,7 +237,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
237
237
|
</button>
|
|
238
238
|
</div>
|
|
239
239
|
{hasAnyOverrides && (
|
|
240
|
-
<p className="text-[10px] text-[#
|
|
240
|
+
<p className="text-[10px] text-[#4794e2]/60 mt-1.5">
|
|
241
241
|
{hasColOverrides ? "Column layout" : ""}
|
|
242
242
|
{hasColOverrides && hasSettingsOverrides ? " + " : ""}
|
|
243
243
|
{hasSettingsOverrides ? "settings" : ""}
|
|
@@ -266,7 +266,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
266
266
|
<span>
|
|
267
267
|
Col Gap
|
|
268
268
|
{isResponsive && hasSectionV2SettingOverride(section, activeViewport, "col_gap") && (
|
|
269
|
-
<span className="ml-1 text-[9px] text-[#
|
|
269
|
+
<span className="ml-1 text-[9px] text-[#4794e2]">overridden</span>
|
|
270
270
|
)}
|
|
271
271
|
</span>
|
|
272
272
|
}>
|
|
@@ -278,7 +278,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
278
278
|
step={4}
|
|
279
279
|
value={getGapValue("col_gap", 20)}
|
|
280
280
|
onChange={(e) => updateSettingResponsive("col_gap", parseInt(e.target.value))}
|
|
281
|
-
className="flex-1 accent-[#
|
|
281
|
+
className="flex-1 accent-[#4794e2]"
|
|
282
282
|
/>
|
|
283
283
|
<span className="text-xs text-neutral-900 w-12 text-right">
|
|
284
284
|
{getGapValue("col_gap", 20)}px
|
|
@@ -298,7 +298,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
298
298
|
<span>
|
|
299
299
|
Row Gap
|
|
300
300
|
{isResponsive && hasSectionV2SettingOverride(section, activeViewport, "row_gap") && (
|
|
301
|
-
<span className="ml-1 text-[9px] text-[#
|
|
301
|
+
<span className="ml-1 text-[9px] text-[#4794e2]">overridden</span>
|
|
302
302
|
)}
|
|
303
303
|
</span>
|
|
304
304
|
}>
|
|
@@ -310,7 +310,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
|
|
|
310
310
|
step={4}
|
|
311
311
|
value={getGapValue("row_gap", 20)}
|
|
312
312
|
onChange={(e) => updateSettingResponsive("row_gap", parseInt(e.target.value))}
|
|
313
|
-
className="flex-1 accent-[#
|
|
313
|
+
className="flex-1 accent-[#4794e2]"
|
|
314
314
|
/>
|
|
315
315
|
<span className="text-xs text-neutral-900 w-12 text-right">
|
|
316
316
|
{getGapValue("row_gap", 20)}px
|
package/lib/assets.ts
CHANGED
|
@@ -23,6 +23,18 @@
|
|
|
23
23
|
|
|
24
24
|
import { logger } from "./logger";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Percent-encode each slash-separated segment of a path so special characters
|
|
28
|
+
* (spaces, `?`, `#`, etc.) don't break the URL, while preserving the `/`
|
|
29
|
+
* boundaries as part of the URL path.
|
|
30
|
+
*
|
|
31
|
+
* Using encodeURIComponent on the whole path would turn `/` into `%2F`, which
|
|
32
|
+
* CDNs treat as a single filename segment — breaking directory-based keys.
|
|
33
|
+
*/
|
|
34
|
+
function encodePath(path: string): string {
|
|
35
|
+
return path.split("/").map(encodeURIComponent).join("/");
|
|
36
|
+
}
|
|
37
|
+
|
|
26
38
|
/**
|
|
27
39
|
* Resolve a relative asset path to a full URL (public site).
|
|
28
40
|
*
|
|
@@ -41,19 +53,22 @@ export function assetUrl(path: string | undefined | null): string {
|
|
|
41
53
|
|
|
42
54
|
// #7: Normalize path — strip all leading slashes to prevent double-slash URLs
|
|
43
55
|
const cleanPath = path.replace(/^\/+/, "");
|
|
56
|
+
// #16: Per-segment percent-encode so paths with spaces / `?` / `#` work on
|
|
57
|
+
// both the R2 CDN and the proxy route. Slashes are preserved.
|
|
58
|
+
const encodedPath = encodePath(cleanPath);
|
|
44
59
|
|
|
45
60
|
// R2 direct mode: when env var is set, skip proxy entirely.
|
|
46
61
|
// This is the zero-latency path — URL resolves to R2 CDN directly.
|
|
47
62
|
const r2Base = process.env.NEXT_PUBLIC_R2_BUCKET_URL;
|
|
48
63
|
if (r2Base) {
|
|
49
|
-
return `${r2Base.replace(/\/+$/, "")}/${
|
|
64
|
+
return `${r2Base.replace(/\/+$/, "")}/${encodedPath}`;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
// Proxy mode: route through /api/assets which handles provider detection
|
|
53
68
|
// at runtime (supports provider switching without env var changes).
|
|
54
69
|
const base = process.env.NEXT_PUBLIC_ASSET_BASE_URL;
|
|
55
70
|
const resolvedBase = base || "/api/assets";
|
|
56
|
-
return `${resolvedBase.replace(/\/+$/, "")}/${
|
|
71
|
+
return `${resolvedBase.replace(/\/+$/, "")}/${encodedPath}`;
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
/**
|
package/lib/builder/constants.ts
CHANGED
|
@@ -55,27 +55,34 @@ export const ADMIN_ERROR_DARK = "#d42f1a";
|
|
|
55
55
|
// visual differentiation at a glance. This is a design rule that
|
|
56
56
|
// MUST be followed across all builder components.
|
|
57
57
|
//
|
|
58
|
-
// BLUE (#
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// BLUE (#4794e2) — Columns: outlines, resize handles, drag grip,
|
|
59
|
+
// column selection/hover chrome.
|
|
60
|
+
// BLUE (#4794e2) — Blocks: "+ Add Block" buttons, block toolbar
|
|
61
61
|
// pill, block selection ring, block-level actions.
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// "Create New" custom section button.
|
|
62
|
+
// (Same hue as columns — a future pass may split
|
|
63
|
+
// these into distinct hues if the shared blue is
|
|
64
|
+
// causing ambiguity.)
|
|
65
|
+
// VIOLET (#7500d5) — Sections: side pill, cover/parallax accent,
|
|
66
|
+
// section outlines, hover/selection chrome.
|
|
67
|
+
// Also used for Custom Sections card chrome.
|
|
69
68
|
//
|
|
70
69
|
// When adding new builder UI, pick the color that matches the entity
|
|
71
70
|
// being represented, not the action being performed. For example, a
|
|
72
71
|
// delete button on a column is BLUE (it belongs to the column chrome),
|
|
73
|
-
// while
|
|
72
|
+
// while the delete inside a block toolbar is BLUE too (inside the block
|
|
73
|
+
// pill, on hover it flashes red as a destructive cue).
|
|
74
74
|
|
|
75
|
-
export const BUILDER_BLUE = "#
|
|
76
|
-
export const
|
|
77
|
-
export const
|
|
78
|
-
export const
|
|
75
|
+
export const BUILDER_BLUE = "#4794e2"; // Columns (softened — was #076bff)
|
|
76
|
+
export const BUILDER_BLOCK = "#4794e2"; // Blocks — same hue as columns for now
|
|
77
|
+
export const BUILDER_VIOLET = "#7500d5"; // Sections (incl. Custom)
|
|
78
|
+
export const BUILDER_GREEN = "#22c55e"; // Success / confirmation cues (e.g. R2 asset check)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @deprecated Use `BUILDER_BLOCK` instead. The legacy name survived multiple
|
|
82
|
+
* colour migrations (orange → emerald → blue) and no longer reflects reality.
|
|
83
|
+
* Kept as an alias so third-party code doesn't break at the import boundary.
|
|
84
|
+
*/
|
|
85
|
+
export const BUILDER_ORANGE = BUILDER_BLOCK;
|
|
79
86
|
|
|
80
87
|
/**
|
|
81
88
|
* Padding map for Row settings (in pixels)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting helpers for builder UI.
|
|
3
|
+
*
|
|
4
|
+
* Kept as pure functions in their own module so they can be imported from
|
|
5
|
+
* any builder component and unit-tested in isolation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format a row height percentage with at most one decimal place, dropping
|
|
10
|
+
* the trailing `.0` when the rounded value is an integer. Used by the
|
|
11
|
+
* Cover Section row sub-pill in `SortableRow`.
|
|
12
|
+
*
|
|
13
|
+
* Examples:
|
|
14
|
+
* formatRowPercent(100) -> "100"
|
|
15
|
+
* formatRowPercent(50) -> "50"
|
|
16
|
+
* formatRowPercent(33.333) -> "33.3"
|
|
17
|
+
* formatRowPercent(66.6667) -> "66.7"
|
|
18
|
+
* formatRowPercent(0) -> "0"
|
|
19
|
+
* formatRowPercent(NaN) -> "0" (fallback — never expected in practice)
|
|
20
|
+
*/
|
|
21
|
+
export function formatRowPercent(p: number): string {
|
|
22
|
+
if (!Number.isFinite(p)) return "0";
|
|
23
|
+
const rounded = Math.round(p * 10) / 10;
|
|
24
|
+
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1);
|
|
25
|
+
}
|
package/lib/builder/history.ts
CHANGED
|
@@ -23,14 +23,11 @@ export interface HistoryState {
|
|
|
23
23
|
_history: HistorySnapshot[];
|
|
24
24
|
/** Future snapshots (for redo). Last element = next redo. */
|
|
25
25
|
_future: HistorySnapshot[];
|
|
26
|
-
/** Whether we're currently applying undo/redo (skip snapshot). */
|
|
27
|
-
_isTimeTraveling: boolean;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
export const initialHistoryState: HistoryState = {
|
|
31
29
|
_history: [],
|
|
32
30
|
_future: [],
|
|
33
|
-
_isTimeTraveling: false,
|
|
34
31
|
};
|
|
35
32
|
|
|
36
33
|
/**
|
|
@@ -124,7 +124,7 @@ export function getBackgroundStyles(
|
|
|
124
124
|
const imgUrl = assetBaseUrl
|
|
125
125
|
? `${assetBaseUrl.replace(/\/$/, "")}/${s.background_image}`
|
|
126
126
|
: s.background_image;
|
|
127
|
-
styles.backgroundImage = `url(${imgUrl})`;
|
|
127
|
+
styles.backgroundImage = `url("${imgUrl}")`;
|
|
128
128
|
styles.backgroundSize = s.background_size || "cover";
|
|
129
129
|
styles.backgroundPosition = s.background_position || "center center";
|
|
130
130
|
styles.backgroundRepeat = s.background_repeat || "no-repeat";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility helpers for scroll-driven section effects (e.g. the Cover
|
|
3
|
+
* Section's navbar colour override).
|
|
4
|
+
*
|
|
5
|
+
* Kept as pure functions so they're testable without mounting a renderer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fraction (0–1) of the viewport currently occupied by a section,
|
|
10
|
+
* computed from its top/bottom positions relative to the viewport and
|
|
11
|
+
* the viewport height.
|
|
12
|
+
*
|
|
13
|
+
* - 0 when the section is fully above or fully below the viewport
|
|
14
|
+
* - 1 when the section fully covers the viewport (top ≤ 0 and
|
|
15
|
+
* bottom ≥ vh)
|
|
16
|
+
* - fractional for partial overlaps
|
|
17
|
+
*
|
|
18
|
+
* Safe against pathological inputs: returns 0 if `viewportHeight <= 0`.
|
|
19
|
+
*/
|
|
20
|
+
export function sectionVisibilityRatio(
|
|
21
|
+
top: number,
|
|
22
|
+
bottom: number,
|
|
23
|
+
viewportHeight: number
|
|
24
|
+
): number {
|
|
25
|
+
if (viewportHeight <= 0) return 0;
|
|
26
|
+
const visible = Math.min(bottom, viewportHeight) - Math.max(top, 0);
|
|
27
|
+
if (visible <= 0) return 0;
|
|
28
|
+
return Math.min(1, visible / viewportHeight);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Threshold (as a fraction of the viewport) above which a Cover / Parallax
|
|
33
|
+
* section is considered "on screen" for the purposes of taking over the
|
|
34
|
+
* navbar colour.
|
|
35
|
+
*/
|
|
36
|
+
export const NAV_COLOR_OVERRIDE_THRESHOLD = 0.3;
|
|
@@ -13,6 +13,7 @@ import type { EnterAnimationConfig } from "../../../lib/animation/enter-types";
|
|
|
13
13
|
|
|
14
14
|
import { ensureKeys, normalizeBlockResponsive, SECTION_TYPE_MAP } from "./shared";
|
|
15
15
|
import { migrateProjectGridV1ToV2, normalizeBlockAnimationFields } from "./migrations";
|
|
16
|
+
import { normalizeRowHeights } from "../store-cover";
|
|
16
17
|
|
|
17
18
|
// ============================================
|
|
18
19
|
// Section Normalizers
|
|
@@ -178,12 +179,20 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
|
|
|
178
179
|
// CoverSection — normalize with defaults for null fields (Session 176)
|
|
179
180
|
if (item._type === "coverSection") {
|
|
180
181
|
const raw = item as unknown as CoverSection;
|
|
181
|
-
const
|
|
182
|
+
const rawRows = (raw.cover_rows ?? []).map((r) => ({
|
|
182
183
|
...r,
|
|
183
184
|
_key: r._key || generateKey(),
|
|
184
185
|
height_percent: r.height_percent ?? 100,
|
|
185
186
|
vertical_align: r.vertical_align ?? "start",
|
|
186
187
|
}));
|
|
188
|
+
// Defend the sum-to-100 invariant against hand-edited or legacy docs:
|
|
189
|
+
// the schema sum-check is newer than the oldest docs that may be in
|
|
190
|
+
// Sanity, so a page saved before Session 178 could have drift.
|
|
191
|
+
const normalizedPercents = normalizeRowHeights(rawRows.map((r) => r.height_percent));
|
|
192
|
+
const coverRows = rawRows.map((r, i) => ({
|
|
193
|
+
...r,
|
|
194
|
+
height_percent: normalizedPercents[i] ?? r.height_percent,
|
|
195
|
+
}));
|
|
187
196
|
return {
|
|
188
197
|
...raw,
|
|
189
198
|
_key: (raw._key as string) || generateKey(),
|
|
@@ -217,7 +226,7 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
|
|
|
217
226
|
/**
|
|
218
227
|
* Convert a Sanity `page` document into the builder's state shape.
|
|
219
228
|
*/
|
|
220
|
-
export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "
|
|
229
|
+
export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
|
|
221
230
|
const docRecord = doc as unknown as Record<string, unknown>;
|
|
222
231
|
const pageSettings = docRecord.page_settings as Record<string, unknown> | undefined;
|
|
223
232
|
|
|
@@ -254,10 +263,10 @@ export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSa
|
|
|
254
263
|
text_color: (pageSettings?.text_color as string) || DEFAULT_TEXT_COLOR,
|
|
255
264
|
nav_color,
|
|
256
265
|
enter_animation: pageSettings?.enter_animation as PageSettings["enter_animation"],
|
|
257
|
-
nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"])
|
|
258
|
-
nav_entrance_duration: (pageSettings?.nav_entrance_duration as number)
|
|
259
|
-
nav_entrance_delay: (pageSettings?.nav_entrance_delay as number)
|
|
260
|
-
nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean)
|
|
266
|
+
nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"]) ?? undefined,
|
|
267
|
+
nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) ?? undefined,
|
|
268
|
+
nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) ?? undefined,
|
|
269
|
+
nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) ?? undefined,
|
|
261
270
|
},
|
|
262
271
|
// Grid settings are loaded separately via applyGlobalStyles(), not from the page document.
|
|
263
272
|
// Provide defaults here to satisfy the type; they'll be overwritten on init.
|
|
@@ -349,9 +349,9 @@ export function stateToDocument(
|
|
|
349
349
|
nav_color: navColor || undefined,
|
|
350
350
|
enter_animation: hasEnterAnimation ? enterAnim : undefined,
|
|
351
351
|
nav_entrance_animation: navEntranceAnimation || undefined,
|
|
352
|
-
nav_entrance_duration: navEntranceDuration
|
|
353
|
-
nav_entrance_delay: navEntranceDelay
|
|
354
|
-
nav_entrance_disabled: navEntranceDisabled
|
|
352
|
+
nav_entrance_duration: navEntranceDuration ?? undefined,
|
|
353
|
+
nav_entrance_delay: navEntranceDelay ?? undefined,
|
|
354
|
+
nav_entrance_disabled: navEntranceDisabled ?? undefined,
|
|
355
355
|
}
|
|
356
356
|
: undefined,
|
|
357
357
|
draft_mode: state.draftMode,
|
|
@@ -4,6 +4,7 @@ import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sani
|
|
|
4
4
|
import { createDefaultBlock } from "./defaults";
|
|
5
5
|
import { generateKey } from "./utils";
|
|
6
6
|
import { findSectionPath, updateSectionAtPath, moveBlockInState } from "./store-helpers";
|
|
7
|
+
import { pushSnapshot } from "./history";
|
|
7
8
|
|
|
8
9
|
type StoreSet = (
|
|
9
10
|
partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
|
|
@@ -129,15 +130,21 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
|
|
|
129
130
|
},
|
|
130
131
|
|
|
131
132
|
moveBlock: (blockKey: string, targetSectionKey: string, targetColumnKey: string, toIndex: number): void => {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
// Do read+validate+snapshot+set atomically inside a single functional
|
|
134
|
+
// update so the snapshot can never drift from the rows the move was
|
|
135
|
+
// computed against. Previously the snapshot was captured via get() AFTER
|
|
136
|
+
// the read, leaving a window where a concurrent mutation could make the
|
|
137
|
+
// undo target inconsistent with the move that just landed.
|
|
138
|
+
set((state) => {
|
|
139
|
+
const result = moveBlockInState(state.rows, blockKey, targetSectionKey, targetColumnKey, toIndex);
|
|
140
|
+
if (!result) return state;
|
|
141
|
+
return {
|
|
142
|
+
rows: result.rows,
|
|
143
|
+
isDirty: true,
|
|
144
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
145
|
+
_future: [],
|
|
146
|
+
};
|
|
147
|
+
});
|
|
141
148
|
},
|
|
142
149
|
|
|
143
150
|
reorderBlocks: (sectionKey: string, columnKey: string, fromIndex: number, toIndex: number): void => {
|