@morphika/andami 0.2.12 → 0.2.14
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/README.md +2 -1
- package/app/admin/pages/[slug]/page.tsx +39 -2
- package/components/blocks/BlockRenderer.tsx +0 -7
- package/components/blocks/CoverSectionRenderer.tsx +295 -0
- package/components/blocks/ImageBlockRenderer.tsx +12 -10
- package/components/blocks/PageRenderer.tsx +13 -9
- package/components/blocks/VideoBlockRenderer.tsx +11 -6
- package/components/builder/BlockLivePreview.tsx +0 -5
- package/components/builder/BlockTypePicker.tsx +0 -1
- package/components/builder/ColorSwatchPicker.tsx +2 -2
- package/components/builder/CoverRowResizeHandle.tsx +180 -0
- package/components/builder/CoverSectionCanvas.tsx +260 -0
- package/components/builder/ReadOnlyFrame.tsx +127 -3
- package/components/builder/SectionTypePicker.tsx +29 -0
- package/components/builder/SectionV2Canvas.tsx +4 -1
- package/components/builder/SectionV2Column.tsx +15 -20
- package/components/builder/SettingsPanel.tsx +14 -0
- package/components/builder/SortableRow.tsx +7 -21
- package/components/builder/blockStyles.tsx +13 -14
- package/components/builder/editors/ImageBlockEditor.tsx +1 -0
- package/components/builder/editors/VideoBlockEditor.tsx +1 -0
- package/components/builder/editors/index.ts +0 -1
- package/components/builder/index.ts +1 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +21 -2
- package/components/builder/live-preview/LiveVideoPreview.tsx +8 -3
- package/components/builder/live-preview/RichTextEditor.tsx +23 -2
- package/components/builder/live-preview/index.ts +0 -1
- package/components/builder/settings-panel/BlockSettings.tsx +0 -7
- package/components/builder/settings-panel/CoverSectionSettings.tsx +296 -0
- package/components/builder/settings-panel/index.ts +1 -0
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +36 -2
- package/lib/animation/enter-types.ts +0 -1
- package/lib/animation/hover-effect-types.ts +0 -1
- package/lib/builder/defaults.ts +43 -22
- package/lib/builder/serializer/normalizers.ts +34 -1
- package/lib/builder/serializer/serializers.ts +39 -2
- package/lib/builder/store-blocks.ts +11 -3
- package/lib/builder/store-cover.ts +220 -0
- package/lib/builder/store-helpers.ts +81 -4
- package/lib/builder/store-sections.ts +12 -2
- package/lib/builder/store.ts +11 -2
- package/lib/builder/types.ts +15 -2
- package/lib/sanity/queries.ts +18 -4
- package/lib/sanity/types.ts +81 -45
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/imageBlock.ts +1 -0
- package/sanity/schemas/blocks/index.ts +1 -2
- package/sanity/schemas/blocks/videoBlock.ts +1 -0
- package/sanity/schemas/index.ts +5 -3
- package/sanity/schemas/objects/coverSection.ts +317 -0
- package/sanity/schemas/objects/parallaxSlide.ts +0 -1
- package/sanity/schemas/page.ts +1 -1
- package/sanity/schemas/pageSectionV2.ts +0 -1
- package/components/blocks/CoverBlockRenderer.tsx +0 -261
- package/components/builder/editors/CoverBlockEditor.tsx +0 -550
- package/components/builder/live-preview/LiveCoverPreview.tsx +0 -146
- package/sanity/schemas/blocks/coverBlock.ts +0 -229
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef } from "react";
|
|
4
|
+
import { useBuilderStore } from "../../lib/builder/store";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CoverRowResizeHandle — horizontal drag handle between two adjacent cover rows.
|
|
8
|
+
*
|
|
9
|
+
* 3-state visual matching the column resize handles in SectionV2Column:
|
|
10
|
+
* - Idle: invisible (transparent hit area only)
|
|
11
|
+
* - Hover: teal pill line appears
|
|
12
|
+
* - Dragging: full-width line with glow + center dot
|
|
13
|
+
*
|
|
14
|
+
* Session 176: Cover Sections — Phase 5.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface CoverRowResizeHandleProps {
|
|
18
|
+
sectionKey: string;
|
|
19
|
+
handleIndex: number;
|
|
20
|
+
abovePercent: number;
|
|
21
|
+
belowPercent: number;
|
|
22
|
+
containerHeight: number;
|
|
23
|
+
isSectionHovered: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HANDLE_COLOR = "#0d9488";
|
|
27
|
+
|
|
28
|
+
export default function CoverRowResizeHandle({
|
|
29
|
+
sectionKey,
|
|
30
|
+
handleIndex,
|
|
31
|
+
abovePercent,
|
|
32
|
+
belowPercent,
|
|
33
|
+
containerHeight,
|
|
34
|
+
isSectionHovered,
|
|
35
|
+
}: CoverRowResizeHandleProps) {
|
|
36
|
+
const resizeCoverRow = useBuilderStore((s) => s.resizeCoverRow);
|
|
37
|
+
const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
|
|
38
|
+
|
|
39
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
40
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
41
|
+
|
|
42
|
+
const startYRef = useRef(0);
|
|
43
|
+
const startAboveRef = useRef(0);
|
|
44
|
+
const startBelowRef = useRef(0);
|
|
45
|
+
const snapshotPushedRef = useRef(false);
|
|
46
|
+
|
|
47
|
+
const handleMouseDown = useCallback(
|
|
48
|
+
(e: React.MouseEvent) => {
|
|
49
|
+
e.stopPropagation();
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
|
|
52
|
+
startYRef.current = e.clientY;
|
|
53
|
+
startAboveRef.current = abovePercent;
|
|
54
|
+
startBelowRef.current = belowPercent;
|
|
55
|
+
snapshotPushedRef.current = false;
|
|
56
|
+
setIsDragging(true);
|
|
57
|
+
|
|
58
|
+
const onMouseMove = (moveEvent: MouseEvent) => {
|
|
59
|
+
if (!snapshotPushedRef.current) {
|
|
60
|
+
_pushSnapshot();
|
|
61
|
+
snapshotPushedRef.current = true;
|
|
62
|
+
}
|
|
63
|
+
const deltaY = moveEvent.clientY - startYRef.current;
|
|
64
|
+
const deltaPercent = (deltaY / containerHeight) * 100;
|
|
65
|
+
resizeCoverRow(sectionKey, handleIndex, deltaPercent, startAboveRef.current, startBelowRef.current);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const onMouseUp = () => {
|
|
69
|
+
document.removeEventListener("mousemove", onMouseMove);
|
|
70
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
71
|
+
setIsDragging(false);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
document.addEventListener("mousemove", onMouseMove);
|
|
75
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
76
|
+
},
|
|
77
|
+
[sectionKey, handleIndex, containerHeight, resizeCoverRow, _pushSnapshot]
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const isActive = isDragging;
|
|
81
|
+
const isVisible = isDragging || isHovered || isSectionHovered;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
role="separator"
|
|
86
|
+
aria-orientation="horizontal"
|
|
87
|
+
aria-label={`Resize rows ${handleIndex + 1} and ${handleIndex + 2}`}
|
|
88
|
+
className="absolute left-0 right-0 z-10 flex items-center justify-center"
|
|
89
|
+
style={{
|
|
90
|
+
height: 20,
|
|
91
|
+
marginTop: -10,
|
|
92
|
+
cursor: "row-resize",
|
|
93
|
+
bottom: 0,
|
|
94
|
+
opacity: isVisible ? 1 : 0,
|
|
95
|
+
pointerEvents: isVisible ? "auto" : "none",
|
|
96
|
+
transition: isDragging ? "none" : "opacity 150ms ease-out",
|
|
97
|
+
}}
|
|
98
|
+
onMouseEnter={() => setIsHovered(true)}
|
|
99
|
+
onMouseLeave={() => { if (!isDragging) setIsHovered(false); }}
|
|
100
|
+
onMouseDown={handleMouseDown}
|
|
101
|
+
>
|
|
102
|
+
{/* Line */}
|
|
103
|
+
<div
|
|
104
|
+
className="pointer-events-none absolute left-0 right-0 flex items-center justify-center"
|
|
105
|
+
style={{ height: "100%" }}
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
style={{
|
|
109
|
+
width: isActive ? "100%" : isHovered ? "80%" : "50%",
|
|
110
|
+
height: isActive ? 3 : isHovered ? 2 : 1,
|
|
111
|
+
borderRadius: 999,
|
|
112
|
+
backgroundColor: isActive
|
|
113
|
+
? HANDLE_COLOR
|
|
114
|
+
: isHovered
|
|
115
|
+
? `${HANDLE_COLOR}bb`
|
|
116
|
+
: `${HANDLE_COLOR}40`,
|
|
117
|
+
transition: isDragging ? "none" : "all 150ms ease-out",
|
|
118
|
+
boxShadow: isActive ? `0 0 8px ${HANDLE_COLOR}50` : undefined,
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Center pill/dot */}
|
|
124
|
+
<div
|
|
125
|
+
className="pointer-events-none relative"
|
|
126
|
+
style={{
|
|
127
|
+
width: isActive ? 16 : isHovered ? 10 : 4,
|
|
128
|
+
height: isActive ? 16 : isHovered ? 10 : 4,
|
|
129
|
+
borderRadius: "50%",
|
|
130
|
+
backgroundColor: isActive
|
|
131
|
+
? HANDLE_COLOR
|
|
132
|
+
: isHovered
|
|
133
|
+
? `${HANDLE_COLOR}aa`
|
|
134
|
+
: `${HANDLE_COLOR}30`,
|
|
135
|
+
transition: isDragging ? "none" : "all 150ms ease-out",
|
|
136
|
+
boxShadow: isActive ? `0 0 10px ${HANDLE_COLOR}50` : undefined,
|
|
137
|
+
zIndex: 1,
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
|
|
141
|
+
{/* Percentage labels during drag */}
|
|
142
|
+
{isDragging && (
|
|
143
|
+
<>
|
|
144
|
+
<div
|
|
145
|
+
className="absolute pointer-events-none"
|
|
146
|
+
style={{
|
|
147
|
+
top: -16,
|
|
148
|
+
left: "50%",
|
|
149
|
+
transform: "translateX(-50%)",
|
|
150
|
+
fontSize: 9,
|
|
151
|
+
fontWeight: 700,
|
|
152
|
+
color: HANDLE_COLOR,
|
|
153
|
+
background: "rgba(255,255,255,0.9)",
|
|
154
|
+
padding: "1px 5px",
|
|
155
|
+
borderRadius: 4,
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{Math.round(abovePercent)}%
|
|
159
|
+
</div>
|
|
160
|
+
<div
|
|
161
|
+
className="absolute pointer-events-none"
|
|
162
|
+
style={{
|
|
163
|
+
bottom: -16,
|
|
164
|
+
left: "50%",
|
|
165
|
+
transform: "translateX(-50%)",
|
|
166
|
+
fontSize: 9,
|
|
167
|
+
fontWeight: 700,
|
|
168
|
+
color: HANDLE_COLOR,
|
|
169
|
+
background: "rgba(255,255,255,0.9)",
|
|
170
|
+
padding: "1px 5px",
|
|
171
|
+
borderRadius: 4,
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{Math.round(belowPercent)}%
|
|
175
|
+
</div>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { useBuilderStore } from "../../lib/builder/store";
|
|
5
|
+
import type { CoverSection, CoverRow, PageSectionV2 } from "../../lib/sanity/types";
|
|
6
|
+
import type { DeviceViewport } from "../../lib/builder/types";
|
|
7
|
+
import SectionV2Canvas from "./SectionV2Canvas";
|
|
8
|
+
import CoverRowResizeHandle from "./CoverRowResizeHandle";
|
|
9
|
+
import { DEVICE_HEIGHTS } from "../../lib/builder/types";
|
|
10
|
+
import { useAssetUrl } from "../../lib/contexts/AssetContext";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CoverSectionCanvas — renders a CoverSection in the builder canvas.
|
|
14
|
+
*
|
|
15
|
+
* Displays proportional rows (based on cover_rows height_percent) inside
|
|
16
|
+
* a fixed-height container that simulates the section's vh height.
|
|
17
|
+
* Each row contains a SectionV2Canvas (full V2 grid editor reuse) via
|
|
18
|
+
* a virtual PageSectionV2 scoped to that row's columns.
|
|
19
|
+
*
|
|
20
|
+
* Background image/video is shown as a faint preview behind all rows.
|
|
21
|
+
* Row resize handles are rendered between adjacent rows (Phase 5).
|
|
22
|
+
*
|
|
23
|
+
* Session 176: Cover Sections — Phase 4 (Builder Canvas).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
interface CoverSectionCanvasProps {
|
|
27
|
+
section: CoverSection;
|
|
28
|
+
onAddBlockTarget: (sectionKey: string, colKey: string, insertIndex?: number) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const COVER_ACCENT = "#0d9488";
|
|
32
|
+
|
|
33
|
+
function getEffectiveCoverRows(section: CoverSection, viewport: DeviceViewport): CoverRow[] {
|
|
34
|
+
if (viewport === "desktop") return section.cover_rows;
|
|
35
|
+
const vp = viewport as "tablet" | "phone";
|
|
36
|
+
const overrides = section.responsive?.[vp]?.cover_rows;
|
|
37
|
+
if (!overrides || overrides.length === 0) return section.cover_rows;
|
|
38
|
+
return section.cover_rows.map((row) => {
|
|
39
|
+
const override = overrides.find((o) => o._key === row._key);
|
|
40
|
+
if (override?.height_percent !== undefined) {
|
|
41
|
+
return { ...row, height_percent: override.height_percent };
|
|
42
|
+
}
|
|
43
|
+
return row;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function CoverSectionCanvas({
|
|
48
|
+
section,
|
|
49
|
+
onAddBlockTarget,
|
|
50
|
+
}: CoverSectionCanvasProps) {
|
|
51
|
+
const store = useBuilderStore();
|
|
52
|
+
const activeViewport = store.activeViewport || "desktop";
|
|
53
|
+
const previewMode = store.previewMode;
|
|
54
|
+
const assetUrl = useAssetUrl();
|
|
55
|
+
const [isSectionHovered, setIsSectionHovered] = useState(false);
|
|
56
|
+
|
|
57
|
+
const vhPixels = DEVICE_HEIGHTS[activeViewport];
|
|
58
|
+
const heightMultiplier = (parseInt(section.height || "100", 10) || 100) / 100;
|
|
59
|
+
const containerHeight = Math.round(vhPixels * heightMultiplier);
|
|
60
|
+
|
|
61
|
+
const bgImageUrl = section.background_type === "image" && section.background_image
|
|
62
|
+
? assetUrl(section.background_image)
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const effectiveRows = useMemo(
|
|
66
|
+
() => getEffectiveCoverRows(section, activeViewport),
|
|
67
|
+
[section, activeViewport]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const virtualSectionsPerRow = useMemo(() => {
|
|
71
|
+
return effectiveRows.map((row, rowIndex) => {
|
|
72
|
+
const rowNumber = rowIndex + 1;
|
|
73
|
+
const rowColumns = section.columns
|
|
74
|
+
.filter((c) => c.grid_row === rowNumber)
|
|
75
|
+
.map((c) => ({ ...c, grid_row: 1 }));
|
|
76
|
+
const virtualSection: PageSectionV2 = {
|
|
77
|
+
_type: "pageSectionV2",
|
|
78
|
+
_key: section._key,
|
|
79
|
+
section_type: "empty-v2",
|
|
80
|
+
columns: rowColumns,
|
|
81
|
+
settings: {
|
|
82
|
+
preset: "custom",
|
|
83
|
+
grid_columns: section.settings.grid_columns || 12,
|
|
84
|
+
col_gap: section.settings.col_gap ?? 20,
|
|
85
|
+
row_gap: section.settings.row_gap ?? 20,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return { row, rowNumber, virtualSection };
|
|
89
|
+
});
|
|
90
|
+
}, [effectiveRows, section]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
className="relative"
|
|
95
|
+
style={{
|
|
96
|
+
borderRadius: 12,
|
|
97
|
+
border: `1.5px solid ${COVER_ACCENT}40`,
|
|
98
|
+
overflow: "visible",
|
|
99
|
+
}}
|
|
100
|
+
onMouseEnter={() => setIsSectionHovered(true)}
|
|
101
|
+
onMouseLeave={() => setIsSectionHovered(false)}
|
|
102
|
+
>
|
|
103
|
+
{/* Header bar */}
|
|
104
|
+
<div
|
|
105
|
+
className="flex items-center gap-2 px-3 py-2 cursor-pointer"
|
|
106
|
+
style={{
|
|
107
|
+
background: store.selectedRowKey === section._key
|
|
108
|
+
? `linear-gradient(135deg, #ccfbf1 0%, #b2f5ea 100%)`
|
|
109
|
+
: `linear-gradient(135deg, #f0fdfa 0%, #e6fffa 100%)`,
|
|
110
|
+
borderBottom: `1px solid ${COVER_ACCENT}25`,
|
|
111
|
+
borderRadius: "12px 12px 0 0",
|
|
112
|
+
}}
|
|
113
|
+
onClick={(e) => {
|
|
114
|
+
e.stopPropagation();
|
|
115
|
+
store.selectRow(section._key);
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<span className="text-[11px] font-semibold" style={{ color: COVER_ACCENT }}>
|
|
119
|
+
◆ Cover Section
|
|
120
|
+
</span>
|
|
121
|
+
<span
|
|
122
|
+
className="inline-flex items-center justify-center rounded-full text-[9px] font-bold text-white min-w-[18px] h-[18px] px-1"
|
|
123
|
+
style={{ background: COVER_ACCENT }}
|
|
124
|
+
>
|
|
125
|
+
{section.cover_rows.length}
|
|
126
|
+
</span>
|
|
127
|
+
<div className="flex-1" />
|
|
128
|
+
<span className="text-[9px] text-neutral-400 uppercase tracking-wider">
|
|
129
|
+
{section.height}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Cover container — simulated viewport height */}
|
|
134
|
+
<div
|
|
135
|
+
className="relative"
|
|
136
|
+
style={{ height: containerHeight }}
|
|
137
|
+
>
|
|
138
|
+
{/* Background preview — clipped to container bounds */}
|
|
139
|
+
{bgImageUrl && (
|
|
140
|
+
<div
|
|
141
|
+
className="absolute inset-0 pointer-events-none"
|
|
142
|
+
style={{
|
|
143
|
+
backgroundImage: `url(${bgImageUrl})`,
|
|
144
|
+
backgroundSize: section.background_size || "cover",
|
|
145
|
+
backgroundPosition: section.background_position || "center center",
|
|
146
|
+
opacity: 0.15,
|
|
147
|
+
overflow: "hidden",
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Overlay preview */}
|
|
153
|
+
{(section.background_overlay_opacity ?? 0) > 0 && (
|
|
154
|
+
<div
|
|
155
|
+
className="absolute inset-0 pointer-events-none"
|
|
156
|
+
style={{
|
|
157
|
+
backgroundColor: section.background_overlay_color || "#000000",
|
|
158
|
+
opacity: (section.background_overlay_opacity || 0) / 100 * 0.3,
|
|
159
|
+
overflow: "hidden",
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{/* Proportional rows */}
|
|
165
|
+
<div
|
|
166
|
+
className="relative flex flex-col h-full"
|
|
167
|
+
style={{ zIndex: 1 }}
|
|
168
|
+
>
|
|
169
|
+
{virtualSectionsPerRow.map(({ row, rowNumber, virtualSection }, rowIndex) => {
|
|
170
|
+
const alignMap = { start: "flex-start", center: "center", end: "flex-end" };
|
|
171
|
+
const isLastRow = rowIndex === effectiveRows.length - 1;
|
|
172
|
+
const hasColumns = virtualSection.columns.length > 0;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
key={row._key}
|
|
177
|
+
className="relative"
|
|
178
|
+
style={{
|
|
179
|
+
height: `${row.height_percent}%`,
|
|
180
|
+
minHeight: 0,
|
|
181
|
+
display: "flex",
|
|
182
|
+
flexDirection: "column",
|
|
183
|
+
justifyContent: alignMap[row.vertical_align] || "flex-start",
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{/* Row label */}
|
|
187
|
+
<div
|
|
188
|
+
className="absolute top-1 right-2 pointer-events-none z-10"
|
|
189
|
+
style={{
|
|
190
|
+
fontSize: 9,
|
|
191
|
+
color: `${COVER_ACCENT}99`,
|
|
192
|
+
fontWeight: 600,
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
Row {rowNumber} · {row.height_percent}%
|
|
196
|
+
{row.vertical_align !== "start" && ` · ${row.vertical_align}`}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{hasColumns ? (
|
|
200
|
+
/* V2 grid for this row's columns — overflow hidden clips content to row bounds */
|
|
201
|
+
<div className="flex-1 min-h-0 flex flex-col" style={{ overflow: "hidden" }}>
|
|
202
|
+
<SectionV2Canvas
|
|
203
|
+
section={virtualSection}
|
|
204
|
+
onAddBlockTarget={onAddBlockTarget}
|
|
205
|
+
fillHeight
|
|
206
|
+
gridRowOffset={rowNumber - 1}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
) : (
|
|
210
|
+
/* Empty row: direct + Add Column button with correct gridRow */
|
|
211
|
+
<div
|
|
212
|
+
className="flex-1 min-h-0 flex items-center justify-center"
|
|
213
|
+
style={{
|
|
214
|
+
border: isSectionHovered ? `1.5px dashed ${COVER_ACCENT}30` : "1.5px dashed transparent",
|
|
215
|
+
borderRadius: 6,
|
|
216
|
+
margin: 4,
|
|
217
|
+
transition: "border-color 150ms",
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
<button
|
|
221
|
+
onClick={(e) => {
|
|
222
|
+
e.stopPropagation();
|
|
223
|
+
store.addColumnV2(section._key, rowNumber, 1, section.settings.grid_columns || 12);
|
|
224
|
+
}}
|
|
225
|
+
className="rounded-full text-[10px] font-medium transition-all hover:scale-105"
|
|
226
|
+
style={{
|
|
227
|
+
padding: "5px 16px",
|
|
228
|
+
background: `rgba(7, 107, 255, 0.10)`,
|
|
229
|
+
color: "#076bff",
|
|
230
|
+
border: "1px dashed rgba(7, 107, 255, 0.4)",
|
|
231
|
+
opacity: isSectionHovered ? 1 : 0,
|
|
232
|
+
pointerEvents: isSectionHovered ? "auto" : "none",
|
|
233
|
+
transition: "opacity 150ms",
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
+ Add Column
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{/* Resize handle between this row and the next */}
|
|
242
|
+
{!isLastRow && (
|
|
243
|
+
<CoverRowResizeHandle
|
|
244
|
+
sectionKey={section._key}
|
|
245
|
+
handleIndex={rowIndex}
|
|
246
|
+
abovePercent={row.height_percent}
|
|
247
|
+
belowPercent={effectiveRows[rowIndex + 1]?.height_percent ?? 0}
|
|
248
|
+
containerHeight={containerHeight}
|
|
249
|
+
isSectionHovered={isSectionHovered}
|
|
250
|
+
/>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
import { memo, useMemo, useState, useEffect } from "react";
|
|
20
20
|
import { useBuilderStore } from "../../lib/builder/store";
|
|
21
21
|
import type { DeviceViewport } from "../../lib/builder/types";
|
|
22
|
-
import type { ContentItem, PageSectionV2, CustomSectionInstance, ParallaxGroup, ParallaxSlideV2 } from "../../lib/sanity/types";
|
|
23
|
-
import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
|
|
22
|
+
import type { ContentItem, PageSectionV2, CustomSectionInstance, ParallaxGroup, ParallaxSlideV2, CoverSection } from "../../lib/sanity/types";
|
|
23
|
+
import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup, isCoverSection } from "../../lib/sanity/types";
|
|
24
24
|
import { DEVICE_HEIGHTS } from "../../lib/builder/types";
|
|
25
25
|
import { getEffectiveColumnsV2, getSectionV2SettingValue } from "./settings-panel/responsive-helpers";
|
|
26
26
|
import BlockLivePreview from "./BlockLivePreview";
|
|
@@ -331,6 +331,124 @@ const ReadOnlyCustomSection = memo(function ReadOnlyCustomSection({
|
|
|
331
331
|
// ReadOnlyFrame — orchestrator
|
|
332
332
|
// ============================================
|
|
333
333
|
|
|
334
|
+
// ============================================
|
|
335
|
+
// Read-only cover section — renders proportional rows with background
|
|
336
|
+
// ============================================
|
|
337
|
+
|
|
338
|
+
interface ReadOnlyCoverSectionProps {
|
|
339
|
+
section: CoverSection;
|
|
340
|
+
viewport: DeviceViewport;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ReadOnlyCoverSection = memo(function ReadOnlyCoverSection({
|
|
344
|
+
section,
|
|
345
|
+
viewport,
|
|
346
|
+
}: ReadOnlyCoverSectionProps) {
|
|
347
|
+
const deviceHeight = DEVICE_HEIGHTS[viewport];
|
|
348
|
+
const heightMultiplier = parseInt(section.height, 10) / 100;
|
|
349
|
+
const containerHeight = Math.round(deviceHeight * heightMultiplier);
|
|
350
|
+
|
|
351
|
+
const gridColumns = section.settings.grid_columns || 12;
|
|
352
|
+
const colGap = section.settings.col_gap ?? 20;
|
|
353
|
+
const rowGap = section.settings.row_gap ?? 20;
|
|
354
|
+
|
|
355
|
+
const effectiveRows = (() => {
|
|
356
|
+
if (viewport === "desktop") return section.cover_rows;
|
|
357
|
+
const vp = viewport as "tablet" | "phone";
|
|
358
|
+
const overrides = section.responsive?.[vp]?.cover_rows;
|
|
359
|
+
if (!overrides || overrides.length === 0) return section.cover_rows;
|
|
360
|
+
return section.cover_rows.map((row) => {
|
|
361
|
+
const o = overrides.find((ov) => ov._key === row._key);
|
|
362
|
+
return o?.height_percent !== undefined ? { ...row, height_percent: o.height_percent } : row;
|
|
363
|
+
});
|
|
364
|
+
})();
|
|
365
|
+
|
|
366
|
+
const rowTemplate = effectiveRows.map((r) => `${r.height_percent}%`).join(" ");
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<div
|
|
370
|
+
style={{
|
|
371
|
+
borderRadius: 8,
|
|
372
|
+
border: "1px solid rgba(13, 148, 136, 0.15)",
|
|
373
|
+
overflow: "hidden",
|
|
374
|
+
}}
|
|
375
|
+
>
|
|
376
|
+
<div
|
|
377
|
+
style={{
|
|
378
|
+
background: "linear-gradient(135deg, #f0fdfa 0%, #e6fffa 100%)",
|
|
379
|
+
padding: "4px 8px",
|
|
380
|
+
borderBottom: "1px solid rgba(13, 148, 136, 0.1)",
|
|
381
|
+
}}
|
|
382
|
+
>
|
|
383
|
+
<span style={{ fontSize: 8, fontWeight: 600, color: "#0d9488" }}>
|
|
384
|
+
◆ Cover · {section.height} · {section.cover_rows.length} row{section.cover_rows.length !== 1 ? "s" : ""}
|
|
385
|
+
</span>
|
|
386
|
+
</div>
|
|
387
|
+
<div style={{ position: "relative", height: containerHeight, overflow: "hidden" }}>
|
|
388
|
+
{section.background_type === "image" && section.background_image && (
|
|
389
|
+
<div
|
|
390
|
+
className="absolute inset-0 pointer-events-none"
|
|
391
|
+
style={{
|
|
392
|
+
backgroundImage: `url(/api/assets/${section.background_image})`,
|
|
393
|
+
backgroundSize: section.background_size || "cover",
|
|
394
|
+
backgroundPosition: section.background_position || "center center",
|
|
395
|
+
opacity: 0.15,
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
)}
|
|
399
|
+
{(section.background_overlay_opacity ?? 0) > 0 && (
|
|
400
|
+
<div
|
|
401
|
+
className="absolute inset-0 pointer-events-none"
|
|
402
|
+
style={{
|
|
403
|
+
backgroundColor: section.background_overlay_color || "#000000",
|
|
404
|
+
opacity: (section.background_overlay_opacity || 0) / 100 * 0.3,
|
|
405
|
+
}}
|
|
406
|
+
/>
|
|
407
|
+
)}
|
|
408
|
+
<div
|
|
409
|
+
style={{
|
|
410
|
+
position: "relative",
|
|
411
|
+
zIndex: 1,
|
|
412
|
+
display: "grid",
|
|
413
|
+
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
|
414
|
+
gridTemplateRows: rowTemplate,
|
|
415
|
+
height: "100%",
|
|
416
|
+
columnGap: `${colGap}px`,
|
|
417
|
+
rowGap: `${rowGap}px`,
|
|
418
|
+
}}
|
|
419
|
+
>
|
|
420
|
+
{section.columns.map((col) => {
|
|
421
|
+
const rowAlign = effectiveRows[col.grid_row - 1]?.vertical_align || "start";
|
|
422
|
+
const alignSelf = rowAlign === "center" ? "center" : rowAlign === "end" ? "end" : "start";
|
|
423
|
+
const colJustify = getColumnVerticalAlign(col.blocks || []);
|
|
424
|
+
return (
|
|
425
|
+
<div
|
|
426
|
+
key={col._key}
|
|
427
|
+
style={{
|
|
428
|
+
gridColumn: `${col.grid_column} / span ${col.span}`,
|
|
429
|
+
gridRow: col.grid_row,
|
|
430
|
+
alignSelf,
|
|
431
|
+
display: "flex",
|
|
432
|
+
flexDirection: "column",
|
|
433
|
+
...(colJustify ? { justifyContent: colJustify } : {}),
|
|
434
|
+
minWidth: 0,
|
|
435
|
+
overflow: "hidden",
|
|
436
|
+
}}
|
|
437
|
+
>
|
|
438
|
+
{(col.blocks || []).map((block) => (
|
|
439
|
+
<div key={block._key} style={{ width: "100%", minWidth: 0 }}>
|
|
440
|
+
<BlockLivePreview block={block} viewport={viewport} />
|
|
441
|
+
</div>
|
|
442
|
+
))}
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
})}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
334
452
|
interface ReadOnlyFrameProps {
|
|
335
453
|
/** The device viewport this frame represents */
|
|
336
454
|
viewport: DeviceViewport;
|
|
@@ -350,7 +468,13 @@ export default function ReadOnlyFrame({ viewport }: ReadOnlyFrameProps) {
|
|
|
350
468
|
return (
|
|
351
469
|
<div>
|
|
352
470
|
{rows.map((item) =>
|
|
353
|
-
|
|
471
|
+
isCoverSection(item) ? (
|
|
472
|
+
<ReadOnlyCoverSection
|
|
473
|
+
key={item._key}
|
|
474
|
+
section={item as CoverSection}
|
|
475
|
+
viewport={viewport}
|
|
476
|
+
/>
|
|
477
|
+
) : isParallaxGroup(item) ? (
|
|
354
478
|
<ReadOnlyParallaxGroup
|
|
355
479
|
key={item._key}
|
|
356
480
|
group={item as ParallaxGroup}
|
|
@@ -29,6 +29,28 @@ function EmptySectionV2Icon({ size = 28 }: { size?: number }) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function CoverSectionIcon({ size = 28 }: { size?: number }) {
|
|
33
|
+
const accent = "#0d9488";
|
|
34
|
+
return (
|
|
35
|
+
<svg width={size} height={size} viewBox="0 0 40 40" fill="none">
|
|
36
|
+
<defs>
|
|
37
|
+
<linearGradient id="csGrad" x1="5" y1="5" x2="35" y2="35">
|
|
38
|
+
<stop offset="0%" stopColor={accent} />
|
|
39
|
+
<stop offset="100%" stopColor="#0f766e" />
|
|
40
|
+
</linearGradient>
|
|
41
|
+
</defs>
|
|
42
|
+
<rect x="3" y="3" width="34" height="34" rx="4" fill="url(#csGrad)" opacity="0.15" />
|
|
43
|
+
<rect x="3" y="3" width="34" height="34" rx="4" stroke="url(#csGrad)" strokeWidth="2" fill="none" opacity="0.5" />
|
|
44
|
+
{/* Top row (large) */}
|
|
45
|
+
<rect x="6" y="6" width="28" height="18" rx="2" fill="url(#csGrad)" opacity="0.25" />
|
|
46
|
+
{/* Bottom row (small) */}
|
|
47
|
+
<rect x="6" y="26" width="28" height="8" rx="2" fill="url(#csGrad)" opacity="0.4" />
|
|
48
|
+
{/* Divider line */}
|
|
49
|
+
<line x1="8" y1="25" x2="32" y2="25" stroke={accent} strokeWidth="1" opacity="0.5" strokeDasharray="2 2" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
function SavedSectionIcon({ size = 28 }: { size?: number }) {
|
|
33
55
|
return (
|
|
34
56
|
<svg width={size} height={size} viewBox="0 0 40 40" fill="none">
|
|
@@ -51,12 +73,14 @@ function SavedSectionIcon({ size = 28 }: { size?: number }) {
|
|
|
51
73
|
|
|
52
74
|
const SECTION_ICON_COMPONENTS: Record<string, React.FC<{ size?: number }>> = {
|
|
53
75
|
"empty-v2": EmptySectionV2Icon,
|
|
76
|
+
coverSection: CoverSectionIcon,
|
|
54
77
|
projectGridBlock: ProjectGridBlockIcon,
|
|
55
78
|
parallaxGroup: ParallaxGroupIcon,
|
|
56
79
|
};
|
|
57
80
|
|
|
58
81
|
const SECTION_GRADIENTS: Record<string, string> = {
|
|
59
82
|
"empty-v2": "linear-gradient(135deg, #d0e0f8 0%, #b8d0f0 50%, #a0c0e8 100%)",
|
|
83
|
+
coverSection: "linear-gradient(135deg, #b2f5ea 0%, #81e6d9 50%, #5eead4 100%)",
|
|
60
84
|
projectGridBlock: BLOCK_GRADIENTS.projectGridBlock,
|
|
61
85
|
parallaxGroup: BLOCK_GRADIENTS.parallaxGroup,
|
|
62
86
|
};
|
|
@@ -160,6 +184,7 @@ interface SectionTypePickerProps {
|
|
|
160
184
|
onSelectEmptyV2?: (preset: "full" | "halves" | "thirds" | "quarters" | "1/3+2/3" | "2/3+1/3") => void;
|
|
161
185
|
onSelectSection: (blockType: "projectGridBlock") => void;
|
|
162
186
|
onSelectParallaxGroup?: () => void;
|
|
187
|
+
onSelectCoverSection?: () => void;
|
|
163
188
|
onSelectCustomSection?: (section: CustomSectionListItem) => void;
|
|
164
189
|
onCreateCustomSection?: () => void;
|
|
165
190
|
onClose: () => void;
|
|
@@ -171,6 +196,7 @@ export default function SectionTypePicker({
|
|
|
171
196
|
onSelectEmptyV2,
|
|
172
197
|
onSelectSection,
|
|
173
198
|
onSelectParallaxGroup,
|
|
199
|
+
onSelectCoverSection,
|
|
174
200
|
onSelectCustomSection,
|
|
175
201
|
onCreateCustomSection,
|
|
176
202
|
onClose,
|
|
@@ -327,6 +353,9 @@ export default function SectionTypePicker({
|
|
|
327
353
|
onClick={() => {
|
|
328
354
|
if (section.type === "empty-v2") {
|
|
329
355
|
setStep("layout");
|
|
356
|
+
} else if (section.type === "coverSection") {
|
|
357
|
+
onSelectCoverSection?.();
|
|
358
|
+
onClose();
|
|
330
359
|
} else if (section.type === "parallaxGroup") {
|
|
331
360
|
onSelectParallaxGroup?.();
|
|
332
361
|
onClose();
|
|
@@ -25,12 +25,15 @@ interface SectionV2CanvasProps {
|
|
|
25
25
|
onAddBlockTarget: (sectionKey: string, colKey: string, insertIndex?: number) => void;
|
|
26
26
|
/** When true, the grid stretches to fill its parent height (used inside parallax slides) */
|
|
27
27
|
fillHeight?: boolean;
|
|
28
|
+
/** Offset added to grid_row when adding columns via gaps (used by cover sections where columns are normalized to grid_row 1) */
|
|
29
|
+
gridRowOffset?: number;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export default function SectionV2Canvas({
|
|
31
33
|
section,
|
|
32
34
|
onAddBlockTarget,
|
|
33
35
|
fillHeight,
|
|
36
|
+
gridRowOffset = 0,
|
|
34
37
|
}: SectionV2CanvasProps) {
|
|
35
38
|
const previewMode = useBuilderStore((s) => s.previewMode);
|
|
36
39
|
const canvasZoom = useBuilderStore((s) => s.canvasZoom);
|
|
@@ -269,7 +272,7 @@ export default function SectionV2Canvas({
|
|
|
269
272
|
data-gap-span={gap.span}
|
|
270
273
|
onClick={(e) => {
|
|
271
274
|
e.stopPropagation();
|
|
272
|
-
addColumnV2(section._key, gap.grid_row, gap.grid_column, gap.span);
|
|
275
|
+
addColumnV2(section._key, gap.grid_row + gridRowOffset, gap.grid_column, gap.span);
|
|
273
276
|
}}
|
|
274
277
|
style={{
|
|
275
278
|
gridColumn: `${gap.grid_column} / span ${gap.span}`,
|