@morphika/andami 0.2.26 → 0.4.0
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 +41 -47
- 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 +15 -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/ProjectCarouselBlockRenderer.tsx +527 -0
- package/components/blocks/ShaderCanvas.tsx +10 -6
- package/components/builder/BlockCardIcons.tsx +227 -0
- package/components/builder/BlockLivePreview.tsx +5 -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/ReadOnlyFrame.tsx +4 -23
- package/components/builder/SectionCardIcons.tsx +320 -0
- package/components/builder/SectionEditorBar.tsx +17 -12
- package/components/builder/SectionTypePicker.tsx +34 -138
- 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 +59 -180
- package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
- package/components/builder/editors/index.ts +1 -0
- package/components/builder/iconPrimitives.tsx +78 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
- package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
- package/components/builder/live-preview/index.ts +1 -0
- package/components/builder/settings-panel/BlockSettings.tsx +7 -0
- 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/animation/enter-types.ts +1 -0
- package/lib/animation/hover-effect-types.ts +1 -0
- package/lib/assets.ts +17 -2
- package/lib/builder/block-registrations.ts +268 -0
- package/lib/builder/block-registry.ts +195 -0
- package/lib/builder/constants.ts +22 -15
- package/lib/builder/defaults.ts +21 -0
- package/lib/builder/format.ts +25 -0
- package/lib/builder/history.ts +0 -3
- package/lib/builder/index.ts +16 -0
- package/lib/builder/layout-styles.ts +1 -1
- package/lib/builder/registry.ts +44 -0
- 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-sections.ts +1 -1
- package/lib/builder/store.ts +0 -2
- package/lib/builder/types.ts +9 -5
- package/lib/csrf.ts +31 -0
- package/lib/sanity/types.ts +54 -2
- package/lib/security.ts +50 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/index.ts +2 -1
- package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
- package/sanity/schemas/index.ts +4 -1
- package/sanity/schemas/objects/coverSection.ts +35 -3
- package/sanity/schemas/pageSectionV2.ts +1 -0
- package/components/builder/ParallaxSlideHeader.tsx +0 -113
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LiveProjectCarouselPreview — Builder canvas preview for projectCarouselBlock.
|
|
5
|
+
*
|
|
6
|
+
* Renders N mockup cards (respecting `max_projects`, aspect_ratio, gap,
|
|
7
|
+
* cards-per-view per viewport, border_radius) so the designer can evaluate
|
|
8
|
+
* the layout inside the builder without hitting the projects API. Real
|
|
9
|
+
* thumbnails show up on the public site.
|
|
10
|
+
*
|
|
11
|
+
* Mock card visual matches the empty-state placeholder of the Image / Video
|
|
12
|
+
* blocks: soft grey background + centered landscape (or play) icon. Keeps
|
|
13
|
+
* the preview readable at any zoom level and communicates that the real
|
|
14
|
+
* content is image/video thumbnails without faking actual data.
|
|
15
|
+
*
|
|
16
|
+
* Intentionally independent from LiveProjectGridPreview — same design
|
|
17
|
+
* language, no shared code.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
21
|
+
import type { ProjectCarouselBlock } from "../../../lib/sanity/types";
|
|
22
|
+
import type { DeviceViewport } from "../../../lib/builder/types";
|
|
23
|
+
|
|
24
|
+
// ─── Helpers (duplicated from public renderer on purpose — per user request
|
|
25
|
+
// the two carousels are not linked so changes on one side can't cascade). ──
|
|
26
|
+
|
|
27
|
+
function resolveCardsPerView(
|
|
28
|
+
block: Pick<ProjectCarouselBlock, "cards_per_view_desktop" | "cards_per_view_tablet" | "cards_per_view_phone">,
|
|
29
|
+
viewport: DeviceViewport,
|
|
30
|
+
): number {
|
|
31
|
+
if (viewport === "phone") return block.cards_per_view_phone ?? 1.2;
|
|
32
|
+
if (viewport === "tablet") return block.cards_per_view_tablet ?? 2.2;
|
|
33
|
+
return block.cards_per_view_desktop ?? 3.5;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function computeCardWidth(
|
|
37
|
+
containerWidth: number,
|
|
38
|
+
cardsPerView: number,
|
|
39
|
+
gap: number,
|
|
40
|
+
): number {
|
|
41
|
+
if (cardsPerView <= 0 || containerWidth <= 0) return 0;
|
|
42
|
+
return Math.max(0, (containerWidth - (cardsPerView - 1) * gap) / cardsPerView);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Mock card glyphs ─────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function LandscapeGlyph() {
|
|
48
|
+
return (
|
|
49
|
+
<svg width="48" height="48" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
|
50
|
+
<rect x="6" y="10" width="44" height="36" rx="3" stroke="#b0b5bd" strokeWidth="1.5" fill="#FFFFFF" />
|
|
51
|
+
<circle cx="18" cy="21" r="3" fill="#b0b5bd" />
|
|
52
|
+
<path d="M12 42 L22 28 L28 34 L38 22 L46 42 Z" fill="#b0b5bd" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function PlayGlyph() {
|
|
58
|
+
return (
|
|
59
|
+
<svg width="48" height="48" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
|
60
|
+
<circle cx="28" cy="28" r="22" fill="#FFFFFF" stroke="#b0b5bd" strokeWidth="1.5" />
|
|
61
|
+
<path d="M24 20 L37 28 L24 36 Z" fill="#b0b5bd" />
|
|
62
|
+
</svg>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Main component ──────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export default function LiveProjectCarouselPreview({
|
|
69
|
+
block,
|
|
70
|
+
viewport: frameViewport = "desktop",
|
|
71
|
+
}: {
|
|
72
|
+
block: ProjectCarouselBlock;
|
|
73
|
+
viewport?: DeviceViewport;
|
|
74
|
+
}) {
|
|
75
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
77
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
78
|
+
|
|
79
|
+
// Config
|
|
80
|
+
const maxProjects = block.max_projects ?? 8;
|
|
81
|
+
const gap = block.gap ?? 16;
|
|
82
|
+
const aspectRatio = block.aspect_ratio ?? "4/3";
|
|
83
|
+
const showTitle = block.show_title !== false;
|
|
84
|
+
const showSubtitle = block.show_subtitle === true;
|
|
85
|
+
const borderRadius = block.border_radius ?? 0;
|
|
86
|
+
const snapScroll = block.snap_scroll !== false;
|
|
87
|
+
const videoMode = block.video_mode ?? "off";
|
|
88
|
+
const showDots = block.show_dots === true;
|
|
89
|
+
|
|
90
|
+
const cardsPerView = resolveCardsPerView(block, frameViewport);
|
|
91
|
+
const cardWidth = useMemo(
|
|
92
|
+
() => computeCardWidth(containerWidth, cardsPerView, gap),
|
|
93
|
+
[containerWidth, cardsPerView, gap],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// ResizeObserver
|
|
97
|
+
const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
|
|
98
|
+
if (roRef.current) {
|
|
99
|
+
roRef.current.disconnect();
|
|
100
|
+
roRef.current = null;
|
|
101
|
+
}
|
|
102
|
+
containerRef.current = node;
|
|
103
|
+
if (!node) return;
|
|
104
|
+
|
|
105
|
+
const ro = new ResizeObserver((entries) => {
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
108
|
+
if (w > 0) setContainerWidth(w);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
roRef.current = ro;
|
|
112
|
+
ro.observe(node);
|
|
113
|
+
|
|
114
|
+
const measure = () => {
|
|
115
|
+
const w = node.clientWidth;
|
|
116
|
+
if (w > 0) setContainerWidth(w);
|
|
117
|
+
};
|
|
118
|
+
measure();
|
|
119
|
+
requestAnimationFrame(measure);
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
useEffect(
|
|
123
|
+
() => () => {
|
|
124
|
+
if (roRef.current) {
|
|
125
|
+
roRef.current.disconnect();
|
|
126
|
+
roRef.current = null;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Build the mock project array — just indexes, each card shows its number.
|
|
133
|
+
const mockCards = useMemo(
|
|
134
|
+
() => Array.from({ length: Math.max(2, Math.min(20, maxProjects)) }, (_, i) => i),
|
|
135
|
+
[maxProjects],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const Glyph = videoMode !== "off" ? PlayGlyph : LandscapeGlyph;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div ref={containerCallbackRef} className="relative w-full">
|
|
142
|
+
{/* Scroll track — same mechanics as public renderer so layout is faithful */}
|
|
143
|
+
<div
|
|
144
|
+
className="flex overflow-x-auto"
|
|
145
|
+
style={{
|
|
146
|
+
gap: `${gap}px`,
|
|
147
|
+
scrollSnapType: snapScroll ? "x mandatory" : undefined,
|
|
148
|
+
scrollBehavior: "smooth",
|
|
149
|
+
WebkitOverflowScrolling: "touch",
|
|
150
|
+
// Hide native scrollbar
|
|
151
|
+
scrollbarWidth: "none",
|
|
152
|
+
msOverflowStyle: "none",
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{mockCards.map((i) => (
|
|
156
|
+
<div
|
|
157
|
+
key={i}
|
|
158
|
+
className="flex flex-col"
|
|
159
|
+
style={{
|
|
160
|
+
flex: `0 0 ${cardWidth}px`,
|
|
161
|
+
scrollSnapAlign: snapScroll ? "start" : "none",
|
|
162
|
+
minWidth: 0,
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
{/* Thumbnail area — mock */}
|
|
166
|
+
<div
|
|
167
|
+
className="relative w-full flex items-center justify-center"
|
|
168
|
+
style={{
|
|
169
|
+
aspectRatio: aspectRatio.replace("/", " / "),
|
|
170
|
+
background: "#f4f4f4",
|
|
171
|
+
borderRadius: borderRadius ? `${borderRadius}px` : undefined,
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<Glyph />
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Placeholder title + subtitle */}
|
|
178
|
+
{(showTitle || showSubtitle) && (
|
|
179
|
+
<div className="mt-3 px-0.5">
|
|
180
|
+
{showTitle && (
|
|
181
|
+
<div
|
|
182
|
+
className="h-[10px] rounded-sm"
|
|
183
|
+
style={{ background: "#e0e0e0", width: "60%" }}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
{showSubtitle && (
|
|
187
|
+
<div
|
|
188
|
+
className="h-[8px] rounded-sm mt-1.5"
|
|
189
|
+
style={{ background: "#ededed", width: "40%" }}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Dots preview — decorative only inside the builder */}
|
|
199
|
+
{showDots && mockCards.length > 1 && (
|
|
200
|
+
<div className="flex justify-center gap-1.5 mt-4">
|
|
201
|
+
{mockCards.slice(0, Math.min(6, mockCards.length)).map((i) => (
|
|
202
|
+
<span
|
|
203
|
+
key={i}
|
|
204
|
+
style={{
|
|
205
|
+
width: i === 0 ? 24 : 6,
|
|
206
|
+
height: 6,
|
|
207
|
+
borderRadius: 999,
|
|
208
|
+
background: i === 0 ? "#2b2f38" : "#c9c9c9",
|
|
209
|
+
display: "inline-block",
|
|
210
|
+
}}
|
|
211
|
+
/>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{/* Tiny info strip so the designer knows this is a preview */}
|
|
217
|
+
<div className="absolute top-1 right-1 pointer-events-none text-[9px] font-medium px-1.5 py-0.5 rounded bg-white/80 border border-black/10 text-neutral-500 shadow-sm">
|
|
218
|
+
Preview · {mockCards.length} mock cards
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Chrome-style inline CSS to hide the webkit scrollbar */}
|
|
222
|
+
<style>{`
|
|
223
|
+
.flex::-webkit-scrollbar { display: none; }
|
|
224
|
+
`}</style>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -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
|
}
|
|
@@ -8,4 +8,5 @@ export { default as LiveVideoPreview } from "./LiveVideoPreview";
|
|
|
8
8
|
export { default as LiveSpacerPreview } from "./LiveSpacerPreview";
|
|
9
9
|
export { default as LiveButtonPreview } from "./LiveButtonPreview";
|
|
10
10
|
export { default as LiveProjectGridPreview } from "./LiveProjectGridPreview";
|
|
11
|
+
export { default as LiveProjectCarouselPreview } from "./LiveProjectCarouselPreview";
|
|
11
12
|
export { ThumbBadge, LivePlaceholder, useProjectThumbnails, ProjectGridCard } from "./shared";
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SpacerBlockEditor,
|
|
16
16
|
ButtonBlockEditor,
|
|
17
17
|
ProjectGridEditor,
|
|
18
|
+
ProjectCarouselBlockEditor,
|
|
18
19
|
} from "../editors";
|
|
19
20
|
|
|
20
21
|
export default function BlockSettings({
|
|
@@ -73,6 +74,12 @@ function BlockTypeEditor({ block }: { block: ContentBlock }) {
|
|
|
73
74
|
block={block as import("../../../lib/sanity/types").ProjectGridBlock}
|
|
74
75
|
/>
|
|
75
76
|
);
|
|
77
|
+
case "projectCarouselBlock":
|
|
78
|
+
return (
|
|
79
|
+
<ProjectCarouselBlockEditor
|
|
80
|
+
block={block as import("../../../lib/sanity/types").ProjectCarouselBlock}
|
|
81
|
+
/>
|
|
82
|
+
);
|
|
76
83
|
default:
|
|
77
84
|
return (
|
|
78
85
|
<div className="p-4">
|
|
@@ -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
|
|
@@ -75,6 +75,7 @@ export const BLOCK_ENTER_PRESETS: Record<BlockType, readonly EnterPreset[]> = {
|
|
|
75
75
|
buttonBlock: ["fade", "slide-up", "scale"],
|
|
76
76
|
spacerBlock: [], // invisible — no animation
|
|
77
77
|
projectGridBlock: [], // uses card_entrance system
|
|
78
|
+
projectCarouselBlock: [], // uses card_entrance system
|
|
78
79
|
};
|
|
79
80
|
|
|
80
81
|
// ── Enter animation config ─────────────────────────────────────────
|
|
@@ -68,6 +68,7 @@ export const BLOCK_HOVER_PRESETS: Record<BlockType, readonly HoverPreset[]> = {
|
|
|
68
68
|
buttonBlock: ["scale-up", "lift", "border-glow"],
|
|
69
69
|
spacerBlock: [], // invisible
|
|
70
70
|
projectGridBlock: ["scale-up", "lift"], // per-card effect
|
|
71
|
+
projectCarouselBlock: [], // uses per-card hover_effect field directly
|
|
71
72
|
};
|
|
72
73
|
|
|
73
74
|
// ── Hover effect config ────────────────────────────────────────────
|
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
|
/**
|