@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.
Files changed (81) hide show
  1. package/app/admin/pages/[slug]/page.tsx +41 -47
  2. package/app/api/admin/assets/scan/route.ts +40 -13
  3. package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
  4. package/app/api/admin/custom-sections/route.ts +4 -1
  5. package/app/api/admin/pages/[slug]/route.ts +7 -1
  6. package/app/api/admin/pages/route.ts +4 -1
  7. package/app/api/admin/r2/connect/route.ts +19 -1
  8. package/app/api/admin/r2/disconnect/route.ts +3 -0
  9. package/app/api/admin/r2/rename/route.ts +52 -13
  10. package/app/api/admin/r2/upload-url/route.ts +8 -1
  11. package/app/api/admin/settings/route.ts +4 -1
  12. package/app/api/admin/styles/route.ts +4 -1
  13. package/components/admin/styles/GridLayoutEditor.tsx +46 -46
  14. package/components/blocks/BlockRenderer.tsx +15 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +75 -3
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
  19. package/components/blocks/ShaderCanvas.tsx +10 -6
  20. package/components/builder/BlockCardIcons.tsx +227 -0
  21. package/components/builder/BlockLivePreview.tsx +5 -0
  22. package/components/builder/BlockTypePicker.tsx +36 -63
  23. package/components/builder/BuilderCanvas.tsx +6 -2
  24. package/components/builder/ColumnDragOverlay.tsx +3 -3
  25. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  26. package/components/builder/CoverSectionCanvas.tsx +45 -52
  27. package/components/builder/DndWrapper.tsx +1 -1
  28. package/components/builder/InsertionLines.tsx +1 -1
  29. package/components/builder/ParallaxGroupCanvas.tsx +12 -71
  30. package/components/builder/ReadOnlyFrame.tsx +4 -23
  31. package/components/builder/SectionCardIcons.tsx +320 -0
  32. package/components/builder/SectionEditorBar.tsx +17 -12
  33. package/components/builder/SectionTypePicker.tsx +34 -138
  34. package/components/builder/SectionV2Canvas.tsx +1 -1
  35. package/components/builder/SectionV2Column.tsx +19 -30
  36. package/components/builder/SettingsPanel.tsx +8 -32
  37. package/components/builder/SortableBlock.tsx +42 -50
  38. package/components/builder/SortableRow.tsx +207 -19
  39. package/components/builder/blockStyles.tsx +59 -180
  40. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
  41. package/components/builder/editors/index.ts +1 -0
  42. package/components/builder/iconPrimitives.tsx +78 -0
  43. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  44. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
  45. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  46. package/components/builder/live-preview/index.ts +1 -0
  47. package/components/builder/settings-panel/BlockSettings.tsx +7 -0
  48. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  49. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  50. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  51. package/lib/animation/enter-types.ts +1 -0
  52. package/lib/animation/hover-effect-types.ts +1 -0
  53. package/lib/assets.ts +17 -2
  54. package/lib/builder/block-registrations.ts +268 -0
  55. package/lib/builder/block-registry.ts +195 -0
  56. package/lib/builder/constants.ts +22 -15
  57. package/lib/builder/defaults.ts +21 -0
  58. package/lib/builder/format.ts +25 -0
  59. package/lib/builder/history.ts +0 -3
  60. package/lib/builder/index.ts +16 -0
  61. package/lib/builder/layout-styles.ts +1 -1
  62. package/lib/builder/registry.ts +44 -0
  63. package/lib/builder/section-visibility.ts +36 -0
  64. package/lib/builder/serializer/normalizers.ts +15 -6
  65. package/lib/builder/serializer/serializers.ts +3 -3
  66. package/lib/builder/store-blocks.ts +16 -9
  67. package/lib/builder/store-cover.ts +76 -8
  68. package/lib/builder/store-sections.ts +1 -1
  69. package/lib/builder/store.ts +0 -2
  70. package/lib/builder/types.ts +9 -5
  71. package/lib/csrf.ts +31 -0
  72. package/lib/sanity/types.ts +54 -2
  73. package/lib/security.ts +50 -0
  74. package/lib/version.ts +1 -1
  75. package/package.json +1 -1
  76. package/sanity/schemas/blocks/index.ts +2 -1
  77. package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
  78. package/sanity/schemas/index.ts +4 -1
  79. package/sanity/schemas/objects/coverSection.ts +35 -3
  80. package/sanity/schemas/pageSectionV2.ts +1 -0
  81. 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 className="border border-dashed border-neutral-700 rounded bg-neutral-900/50 flex items-center justify-center py-12">
22
- <span className="text-neutral-600 text-xs">No video set</span>
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-[#076bff]/8 border border-[#076bff]/15">
89
- <span className="text-[11px] font-medium text-[#076bff]">
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-[#076bff]">overridden</span>
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-[#076bff]"
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-[#076bff]" : "bg-neutral-200"
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-[#076bff] bg-[#076bff]/5"
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-[#076bff]" : "bg-neutral-300"
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-[#076bff]" : "text-neutral-500"
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-[#076bff] hover:bg-[#076bff]/5 group"
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-[#076bff] transition-colors">
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-[#076bff] transition-colors">
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-[#076bff]/8 border border-[#076bff]/15">
208
- <span className="text-[11px] font-medium text-[#076bff]">
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-[#076bff]/8 border border-[#076bff]/20 py-2 text-xs font-medium text-[#076bff] hover:bg-[#076bff]/15 transition-colors"
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-[#076bff]/60 mt-1.5">
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-[#076bff]">overridden</span>
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-[#076bff]"
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-[#076bff]">overridden</span>
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-[#076bff]"
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(/\/+$/, "")}/${cleanPath}`;
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(/\/+$/, "")}/${cleanPath}`;
71
+ return `${resolvedBase.replace(/\/+$/, "")}/${encodedPath}`;
57
72
  }
58
73
 
59
74
  /**