@morphika/andami 0.3.1 → 0.4.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 +2 -2
- package/components/blocks/BlockRenderer.tsx +21 -42
- package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
- package/components/builder/BlockLivePreview.tsx +18 -42
- package/components/builder/ReadOnlyFrame.tsx +4 -23
- package/components/builder/SectionCardIcons.tsx +54 -0
- package/components/builder/SectionTypePicker.tsx +1 -1
- package/components/builder/blockStyles.tsx +6 -0
- package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
- package/components/builder/editors/index.ts +1 -0
- package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
- package/components/builder/live-preview/index.ts +1 -0
- package/components/builder/settings-panel/BlockSettings.tsx +18 -63
- package/lib/animation/enter-types.ts +1 -0
- package/lib/animation/hover-effect-types.ts +1 -0
- package/lib/builder/block-registrations.ts +335 -0
- package/lib/builder/block-registry.ts +195 -0
- package/lib/builder/defaults.ts +22 -81
- package/lib/builder/index.ts +16 -0
- package/lib/builder/registry.ts +44 -0
- package/lib/builder/store-sections.ts +1 -1
- package/lib/builder/types.ts +8 -3
- package/lib/sanity/types.ts +50 -1
- 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/pageSectionV2.ts +1 -0
|
@@ -14,24 +14,8 @@ import { memo } from "react";
|
|
|
14
14
|
import { resolveBlock } from "../../lib/builder/responsive";
|
|
15
15
|
import { getBlockLayoutStyles, hasBlockLayout } from "../../lib/builder/layout-styles";
|
|
16
16
|
import type { DeviceViewport } from "../../lib/builder/types";
|
|
17
|
-
import type {
|
|
18
|
-
|
|
19
|
-
TextBlock,
|
|
20
|
-
ImageBlock,
|
|
21
|
-
ImageGridBlock,
|
|
22
|
-
VideoBlock,
|
|
23
|
-
SpacerBlock,
|
|
24
|
-
ButtonBlock,
|
|
25
|
-
ProjectGridBlock,
|
|
26
|
-
} from "../../lib/sanity/types";
|
|
27
|
-
|
|
28
|
-
import { LiveTextEditor } from "./live-preview";
|
|
29
|
-
import { LiveImagePreview } from "./live-preview";
|
|
30
|
-
import { LiveImageGridPreview } from "./live-preview";
|
|
31
|
-
import { LiveVideoPreview } from "./live-preview";
|
|
32
|
-
import { LiveSpacerPreview } from "./live-preview";
|
|
33
|
-
import { LiveButtonPreview } from "./live-preview";
|
|
34
|
-
import { LiveProjectGridPreview } from "./live-preview";
|
|
17
|
+
import type { ContentBlock } from "../../lib/sanity/types";
|
|
18
|
+
import { getBlockRegistration } from "../../lib/builder/registry";
|
|
35
19
|
import { LivePlaceholder } from "./live-preview";
|
|
36
20
|
|
|
37
21
|
// ============================================
|
|
@@ -52,30 +36,22 @@ function BlockLivePreviewInner({ block, viewport = "desktop", editable = false }
|
|
|
52
36
|
|
|
53
37
|
let content: React.ReactNode;
|
|
54
38
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
case "buttonBlock":
|
|
72
|
-
content = <LiveButtonPreview block={resolved as ButtonBlock} />;
|
|
73
|
-
break;
|
|
74
|
-
case "projectGridBlock":
|
|
75
|
-
content = <LiveProjectGridPreview block={resolved as ProjectGridBlock} viewport={viewport} />;
|
|
76
|
-
break;
|
|
77
|
-
default:
|
|
78
|
-
content = <LivePlaceholder type={(resolved as ContentBlock)._type} />;
|
|
39
|
+
// Registry-driven dispatch. The registration's `livePreview` component
|
|
40
|
+
// is guaranteed (by Session B consistency tests) to be the same as the
|
|
41
|
+
// component previously imported and inlined in each switch case. Each
|
|
42
|
+
// live preview accepts an overlapping props subset — we pass the full
|
|
43
|
+
// set (block, viewport, editable) and individual previews ignore what
|
|
44
|
+
// they don't need.
|
|
45
|
+
const registration = getBlockRegistration(resolved._type);
|
|
46
|
+
if (registration) {
|
|
47
|
+
const LivePreview = registration.livePreview as React.ComponentType<{
|
|
48
|
+
block: ContentBlock;
|
|
49
|
+
viewport?: DeviceViewport;
|
|
50
|
+
editable?: boolean;
|
|
51
|
+
}>;
|
|
52
|
+
content = <LivePreview block={resolved} viewport={viewport} editable={editable} />;
|
|
53
|
+
} else {
|
|
54
|
+
content = <LivePlaceholder type={(resolved as ContentBlock)._type} />;
|
|
79
55
|
}
|
|
80
56
|
|
|
81
57
|
// Wrap in layout div if block has layout properties set (spacing, background, border, etc.)
|
|
@@ -255,18 +255,8 @@ const ReadOnlyParallaxGroup = memo(function ReadOnlyParallaxGroup({
|
|
|
255
255
|
overflow: "hidden",
|
|
256
256
|
}}
|
|
257
257
|
>
|
|
258
|
-
{/*
|
|
259
|
-
|
|
260
|
-
style={{
|
|
261
|
-
background: "linear-gradient(135deg, #f3f0ff 0%, #ede5ff 100%)",
|
|
262
|
-
padding: "4px 8px",
|
|
263
|
-
borderBottom: "1px solid rgba(139, 92, 246, 0.1)",
|
|
264
|
-
}}
|
|
265
|
-
>
|
|
266
|
-
<span style={{ fontSize: 8, fontWeight: 600, color: "#8b5cf6" }}>
|
|
267
|
-
▽ Parallax · {group.slides.length} slides
|
|
268
|
-
</span>
|
|
269
|
-
</div>
|
|
258
|
+
{/* Header banner removed — matches the active-viewport ParallaxGroupCanvas
|
|
259
|
+
cleanup. Read-only mirror stays unlabelled. */}
|
|
270
260
|
{/* Slides */}
|
|
271
261
|
{group.slides.map((slide, i) => (
|
|
272
262
|
<div
|
|
@@ -389,17 +379,8 @@ const ReadOnlyCoverSection = memo(function ReadOnlyCoverSection({
|
|
|
389
379
|
overflow: "hidden",
|
|
390
380
|
}}
|
|
391
381
|
>
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
background: "linear-gradient(135deg, #f0fdfa 0%, #e6fffa 100%)",
|
|
395
|
-
padding: "4px 8px",
|
|
396
|
-
borderBottom: "1px solid rgba(13, 148, 136, 0.1)",
|
|
397
|
-
}}
|
|
398
|
-
>
|
|
399
|
-
<span style={{ fontSize: 8, fontWeight: 600, color: "#0d9488" }}>
|
|
400
|
-
◆ Cover · {section.height} · {section.cover_rows.length} row{section.cover_rows.length !== 1 ? "s" : ""}
|
|
401
|
-
</span>
|
|
402
|
-
</div>
|
|
382
|
+
{/* Header banner removed — matches the active-viewport CoverSectionCanvas
|
|
383
|
+
cleanup. Read-only mirror stays unlabelled. */}
|
|
403
384
|
<div style={{ position: "relative", height: containerHeight, overflow: "hidden" }}>
|
|
404
385
|
{/* Background preview — image */}
|
|
405
386
|
{section.background_type === "image" && section.background_image && (
|
|
@@ -253,6 +253,59 @@ export function SavedSectionCardIcon() {
|
|
|
253
253
|
);
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
257
|
+
// Project Carousel — 3 horizontal cards with navigation arrows
|
|
258
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
259
|
+
export function ProjectCarouselCardIcon() {
|
|
260
|
+
return (
|
|
261
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 120" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
|
|
262
|
+
<defs>
|
|
263
|
+
<BgDefs prefix="pcarSec" />
|
|
264
|
+
<ShadowFilter id="shadPcarSec" />
|
|
265
|
+
<VertBevel id="cardBevelPcarSec" endColor={BEVEL_END} />
|
|
266
|
+
</defs>
|
|
267
|
+
<Bg prefix="pcarSec" />
|
|
268
|
+
|
|
269
|
+
{/* 3 cards with shadow — center one is taller (featured) */}
|
|
270
|
+
<g filter="url(#shadPcarSec)">
|
|
271
|
+
{/* Card 1 (left) */}
|
|
272
|
+
<path d="M25.7,30.1h29c0.3,0,0.6,0.6,0.6,1.3v36.7c0,0.7-0.3,1.3-0.6,1.3h-29c-0.3,0-0.6-0.6-0.6-1.3V31.4C25.1,30.7,25.3,30.1,25.7,30.1z"
|
|
273
|
+
fill="url(#cardBevelPcarSec)" stroke={NEUTRAL_STROKE} strokeWidth="0.7" />
|
|
274
|
+
{/* Card 2 (center, featured) */}
|
|
275
|
+
<path d="M62,24.7h39.5c0.5,0,0.8,0.8,0.8,1.8v50c0,1-0.4,1.8-0.8,1.8H62c-0.5-0.1-0.8-0.9-0.8-1.8v-50C61.2,25.5,61.6,24.7,62,24.7z"
|
|
276
|
+
fill="url(#cardBevelPcarSec)" stroke={NEUTRAL_STROKE} strokeWidth="0.7" />
|
|
277
|
+
{/* Card 3 (right) */}
|
|
278
|
+
<path d="M108.7,30.1h29c0.3,0,0.6,0.6,0.6,1.3v36.7c0,0.7-0.3,1.3-0.6,1.3h-29c-0.3,0-0.6-0.6-0.6-1.3V31.4C108.1,30.7,108.3,30.1,108.7,30.1z"
|
|
279
|
+
fill="url(#cardBevelPcarSec)" stroke={NEUTRAL_STROKE} strokeWidth="0.7" />
|
|
280
|
+
</g>
|
|
281
|
+
|
|
282
|
+
{/* Ghost content areas (image placeholders inside each card) */}
|
|
283
|
+
<g fill={GHOST}>
|
|
284
|
+
<path d="M28.1,33.2h24.1c0.2,0,0.3,0.2,0.3,0.5v20.2c0,0.3-0.1,0.5-0.3,0.5H28.1c-0.2,0-0.3-0.2-0.3-0.5V33.7C27.8,33.4,27.9,33.2,28.1,33.2z" />
|
|
285
|
+
<path d="M65.4,28.9h32.8c0.2,0,0.4,0.3,0.4,0.7V57c0,0.4-0.2,0.7-0.4,0.7H65.4c-0.2,0-0.4-0.3-0.4-0.7V29.5C65,29.1,65.1,28.9,65.4,28.9z" />
|
|
286
|
+
<path d="M111.1,33.2h24.1c0.2,0,0.3,0.2,0.3,0.5v20.2c0,0.3-0.1,0.5-0.3,0.5h-24.1c-0.2,0-0.3-0.2-0.3-0.5V33.7C110.8,33.4,111,33.2,111.1,33.2z" />
|
|
287
|
+
</g>
|
|
288
|
+
|
|
289
|
+
{/* Dashed violet outlines on all 3 cards */}
|
|
290
|
+
<g fill="none" stroke={ACCENT} strokeWidth="2" strokeDasharray="3,3">
|
|
291
|
+
<path d="M25.7,30.1H55c0.3,0,0.6,0.6,0.6,1.3v36.7c0,0.7-0.3,1.3-0.6,1.3H25.7c-0.3,0-0.6-0.6-0.6-1.3V31.4C25.1,30.7,25.3,30.1,25.7,30.1z" />
|
|
292
|
+
<path d="M62,24.7h39.9c0.5,0,0.8,0.8,0.8,1.8v50c0,1-0.4,1.8-0.8,1.8H62c-0.5-0.1-0.8-0.9-0.8-1.8v-50C61.2,25.5,61.6,24.7,62,24.7z" />
|
|
293
|
+
<path d="M108.7,30.1H138c0.3,0,0.6,0.6,0.6,1.3v36.7c0,0.7-0.3,1.3-0.6,1.3h-29.3c-0.3,0-0.6-0.6-0.6-1.3V31.4C108.1,30.7,108.3,30.1,108.7,30.1z" />
|
|
294
|
+
</g>
|
|
295
|
+
|
|
296
|
+
{/* Navigation arrows at the bottom — hint at horizontal swipe */}
|
|
297
|
+
<g fill={ACCENT} stroke={ACCENT} strokeMiterlimit="10">
|
|
298
|
+
{/* Right arrow (pointing right) */}
|
|
299
|
+
<line x1="103" y1="88.8" x2="136" y2="88.8" />
|
|
300
|
+
<polygon points="139.9,88.8 134.4,91 135.7,88.8 134.4,86.5" />
|
|
301
|
+
{/* Left arrow (pointing left) */}
|
|
302
|
+
<line x1="63.6" y1="88.8" x2="30.6" y2="88.8" />
|
|
303
|
+
<polygon points="26.6,88.8 32.2,86.5 30.8,88.8 32.2,91" />
|
|
304
|
+
</g>
|
|
305
|
+
</svg>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
256
309
|
// ─────────────────────────────────────────────────────────────────────
|
|
257
310
|
// Lookup map for the Add Section modal
|
|
258
311
|
// ─────────────────────────────────────────────────────────────────────
|
|
@@ -260,6 +313,7 @@ export const SECTION_CARD_ICONS: Record<string, React.FC> = {
|
|
|
260
313
|
"empty-v2": EmptySectionV2CardIcon,
|
|
261
314
|
coverSection: CoverSectionCardIcon,
|
|
262
315
|
projectGridBlock: ProjectGridCardIcon,
|
|
316
|
+
projectCarouselBlock: ProjectCarouselCardIcon,
|
|
263
317
|
parallaxGroup: ParallaxGroupCardIcon,
|
|
264
318
|
createCustom: CreateCustomSectionCardIcon,
|
|
265
319
|
savedCustom: SavedSectionCardIcon,
|
|
@@ -83,7 +83,7 @@ function SectionCard({
|
|
|
83
83
|
|
|
84
84
|
interface SectionTypePickerProps {
|
|
85
85
|
onSelectEmptyV2?: (preset: "full" | "halves" | "thirds" | "quarters" | "1/3+2/3" | "2/3+1/3") => void;
|
|
86
|
-
onSelectSection: (blockType: "projectGridBlock") => void;
|
|
86
|
+
onSelectSection: (blockType: "projectGridBlock" | "projectCarouselBlock") => void;
|
|
87
87
|
onSelectParallaxGroup?: () => void;
|
|
88
88
|
onSelectCoverSection?: () => void;
|
|
89
89
|
onSelectCustomSection?: (section: CustomSectionListItem) => void;
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
CoverSectionCardIcon,
|
|
26
26
|
EmptySectionV2CardIcon,
|
|
27
27
|
ParallaxGroupCardIcon,
|
|
28
|
+
ProjectCarouselCardIcon,
|
|
28
29
|
ProjectGridCardIcon,
|
|
29
30
|
SavedSectionCardIcon,
|
|
30
31
|
} from "./SectionCardIcons";
|
|
@@ -142,6 +143,10 @@ export function ProjectGridBlockIcon({ size = 28 }: { size?: number }) {
|
|
|
142
143
|
return <span style={scaleToHeight(size)}><ProjectGridCardIcon /></span>;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
export function ProjectCarouselBlockIcon({ size = 28 }: { size?: number }) {
|
|
147
|
+
return <span style={scaleToHeight(size)}><ProjectCarouselCardIcon /></span>;
|
|
148
|
+
}
|
|
149
|
+
|
|
145
150
|
export function ParallaxGroupIcon({ size = 28 }: { size?: number }) {
|
|
146
151
|
return <span style={scaleToHeight(size)}><ParallaxGroupCardIcon /></span>;
|
|
147
152
|
}
|
|
@@ -158,6 +163,7 @@ export const BLOCK_ICON_COMPONENTS: Record<string, React.FC<{ size?: number }>>
|
|
|
158
163
|
spacerBlock: SpacerBlockIcon,
|
|
159
164
|
buttonBlock: ButtonBlockIcon,
|
|
160
165
|
projectGridBlock: ProjectGridBlockIcon,
|
|
166
|
+
projectCarouselBlock: ProjectCarouselBlockIcon,
|
|
161
167
|
parallaxGroup: ParallaxGroupIcon,
|
|
162
168
|
coverSection: CoverSectionSettingsIcon,
|
|
163
169
|
customSectionInstance: CustomSectionInstanceIcon,
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProjectCarouselBlockEditor — Settings editor for the projectCarouselBlock.
|
|
5
|
+
*
|
|
6
|
+
* Sections:
|
|
7
|
+
* - Source (mode, number of projects, exclude-current toggle)
|
|
8
|
+
* - Layout (cards per view — desktop / tablet / phone — and gap)
|
|
9
|
+
* - Card Display (aspect ratio, show title / subtitle, border radius,
|
|
10
|
+
* hover effect)
|
|
11
|
+
* - Video (video mode)
|
|
12
|
+
* - Controls (show arrows / dots, snap scrolling)
|
|
13
|
+
* - Card Entrance Animation (enabled, preset, stagger, duration)
|
|
14
|
+
*
|
|
15
|
+
* Intentionally independent from ProjectGridEditor — they follow the same
|
|
16
|
+
* visual style but share no code so changes to one never break the other.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import React, { useCallback } from "react";
|
|
20
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
21
|
+
import type { ProjectCarouselBlock } from "../../../lib/sanity/types";
|
|
22
|
+
import {
|
|
23
|
+
SettingsField,
|
|
24
|
+
SettingsSection,
|
|
25
|
+
StyledCheckbox,
|
|
26
|
+
} from "./shared";
|
|
27
|
+
import {
|
|
28
|
+
SourceIcon,
|
|
29
|
+
LayoutIcon,
|
|
30
|
+
AppearanceIcon,
|
|
31
|
+
VideoIcon,
|
|
32
|
+
NavigationIcon,
|
|
33
|
+
AnimationIcon,
|
|
34
|
+
} from "./section-icons";
|
|
35
|
+
|
|
36
|
+
// ============================================
|
|
37
|
+
// Constants
|
|
38
|
+
// ============================================
|
|
39
|
+
|
|
40
|
+
const SOURCE_OPTIONS = [
|
|
41
|
+
{ value: "auto_latest", label: "Latest" },
|
|
42
|
+
{ value: "auto_random", label: "Random" },
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
const ASPECT_RATIO_OPTIONS = [
|
|
46
|
+
{ value: "16/9", label: "16:9" },
|
|
47
|
+
{ value: "4/3", label: "4:3" },
|
|
48
|
+
{ value: "1/1", label: "1:1" },
|
|
49
|
+
{ value: "3/4", label: "3:4" },
|
|
50
|
+
{ value: "9/16", label: "9:16" },
|
|
51
|
+
] as const;
|
|
52
|
+
|
|
53
|
+
const HOVER_EFFECT_OPTIONS = [
|
|
54
|
+
{ value: "scale", label: "Scale" },
|
|
55
|
+
{ value: "none", label: "None" },
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
const VIDEO_MODE_OPTIONS = [
|
|
59
|
+
{ value: "off", label: "Off" },
|
|
60
|
+
{ value: "hover", label: "Hover" },
|
|
61
|
+
{ value: "autoloop", label: "Auto" },
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
const ANIMATION_PRESET_OPTIONS = [
|
|
65
|
+
{ value: "fade", label: "Fade" },
|
|
66
|
+
{ value: "slide-up", label: "Slide Up" },
|
|
67
|
+
{ value: "scale", label: "Scale" },
|
|
68
|
+
] as const;
|
|
69
|
+
|
|
70
|
+
// ============================================
|
|
71
|
+
// Shared mini-components (inline copies — not imported from ProjectGridEditor
|
|
72
|
+
// to keep the two blocks decoupled, per user's explicit request).
|
|
73
|
+
// ============================================
|
|
74
|
+
|
|
75
|
+
function SegmentedControl<T extends string>({
|
|
76
|
+
options,
|
|
77
|
+
value,
|
|
78
|
+
onChange,
|
|
79
|
+
}: {
|
|
80
|
+
options: readonly { value: T; label: string }[];
|
|
81
|
+
value: T;
|
|
82
|
+
onChange: (v: T) => void;
|
|
83
|
+
}) {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex gap-1">
|
|
86
|
+
{options.map((opt) => {
|
|
87
|
+
const active = value === opt.value;
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
key={opt.value}
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => onChange(opt.value)}
|
|
93
|
+
className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${
|
|
94
|
+
active
|
|
95
|
+
? "bg-[#4794e2] text-white"
|
|
96
|
+
: "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
{opt.label}
|
|
100
|
+
</button>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function RangeSlider({
|
|
108
|
+
value,
|
|
109
|
+
onChange,
|
|
110
|
+
min,
|
|
111
|
+
max,
|
|
112
|
+
step = 1,
|
|
113
|
+
suffix = "",
|
|
114
|
+
decimals = 0,
|
|
115
|
+
}: {
|
|
116
|
+
value: number;
|
|
117
|
+
onChange: (v: number) => void;
|
|
118
|
+
min: number;
|
|
119
|
+
max: number;
|
|
120
|
+
step?: number;
|
|
121
|
+
suffix?: string;
|
|
122
|
+
decimals?: number;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex items-center gap-2">
|
|
126
|
+
<input
|
|
127
|
+
type="range"
|
|
128
|
+
min={min}
|
|
129
|
+
max={max}
|
|
130
|
+
step={step}
|
|
131
|
+
value={value}
|
|
132
|
+
onChange={(e) => onChange(Number(e.target.value))}
|
|
133
|
+
className="flex-1 h-1 accent-[#4794e2] cursor-pointer"
|
|
134
|
+
/>
|
|
135
|
+
<span className="text-[11px] text-neutral-500 w-10 text-right tabular-nums shrink-0">
|
|
136
|
+
{value.toFixed(decimals)}{suffix}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function Dropdown<T extends string>({
|
|
143
|
+
options,
|
|
144
|
+
value,
|
|
145
|
+
onChange,
|
|
146
|
+
}: {
|
|
147
|
+
options: readonly { value: T; label: string }[];
|
|
148
|
+
value: T;
|
|
149
|
+
onChange: (v: T) => void;
|
|
150
|
+
}) {
|
|
151
|
+
return (
|
|
152
|
+
<select
|
|
153
|
+
value={value}
|
|
154
|
+
onChange={(e) => onChange(e.target.value as T)}
|
|
155
|
+
className="w-full rounded-lg border border-transparent bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 font-normal outline-none transition-all hover:bg-[#efefef] focus:bg-white focus:border-[#4794e2] focus:shadow-[0_0_0_3px_rgba(71,148,226,0.06)]"
|
|
156
|
+
>
|
|
157
|
+
{options.map((opt) => (
|
|
158
|
+
<option key={opt.value} value={opt.value}>
|
|
159
|
+
{opt.label}
|
|
160
|
+
</option>
|
|
161
|
+
))}
|
|
162
|
+
</select>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================
|
|
167
|
+
// Main editor
|
|
168
|
+
// ============================================
|
|
169
|
+
|
|
170
|
+
interface ProjectCarouselBlockEditorProps {
|
|
171
|
+
block: ProjectCarouselBlock;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function ProjectCarouselBlockEditor({
|
|
175
|
+
block,
|
|
176
|
+
}: ProjectCarouselBlockEditorProps) {
|
|
177
|
+
const updateBlock = useBuilderStore((s) => s.updateBlock);
|
|
178
|
+
|
|
179
|
+
const update = useCallback(
|
|
180
|
+
(updates: Partial<ProjectCarouselBlock>) => {
|
|
181
|
+
updateBlock(block._key, updates as Partial<ProjectCarouselBlock>);
|
|
182
|
+
},
|
|
183
|
+
[updateBlock, block._key],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const updateCardEntrance = useCallback(
|
|
187
|
+
(updates: Partial<NonNullable<ProjectCarouselBlock["card_entrance"]>>) => {
|
|
188
|
+
update({
|
|
189
|
+
card_entrance: {
|
|
190
|
+
enabled: false,
|
|
191
|
+
preset: "slide-up",
|
|
192
|
+
stagger_delay: 80,
|
|
193
|
+
duration: 500,
|
|
194
|
+
...block.card_entrance,
|
|
195
|
+
...updates,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
[update, block.card_entrance],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// ─── Resolved values (with defaults) ───
|
|
203
|
+
const sourceMode = block.source_mode ?? "auto_latest";
|
|
204
|
+
const maxProjects = block.max_projects ?? 8;
|
|
205
|
+
const excludeCurrent = block.exclude_current !== false;
|
|
206
|
+
|
|
207
|
+
const cpvDesktop = block.cards_per_view_desktop ?? 3.5;
|
|
208
|
+
const cpvTablet = block.cards_per_view_tablet ?? 2.2;
|
|
209
|
+
const cpvPhone = block.cards_per_view_phone ?? 1.2;
|
|
210
|
+
const gap = block.gap ?? 16;
|
|
211
|
+
|
|
212
|
+
const aspectRatio = block.aspect_ratio ?? "4/3";
|
|
213
|
+
const showTitle = block.show_title !== false;
|
|
214
|
+
const showSubtitle = block.show_subtitle === true;
|
|
215
|
+
const borderRadius = block.border_radius ?? 0;
|
|
216
|
+
const hoverEffect = block.hover_effect ?? "scale";
|
|
217
|
+
|
|
218
|
+
const videoMode = block.video_mode ?? "off";
|
|
219
|
+
|
|
220
|
+
const showArrows = block.show_arrows !== false;
|
|
221
|
+
const showDots = block.show_dots === true;
|
|
222
|
+
const snapScroll = block.snap_scroll !== false;
|
|
223
|
+
|
|
224
|
+
const entranceEnabled = block.card_entrance?.enabled === true;
|
|
225
|
+
const entrancePreset = block.card_entrance?.preset ?? "slide-up";
|
|
226
|
+
const entranceStagger = block.card_entrance?.stagger_delay ?? 80;
|
|
227
|
+
const entranceDuration = block.card_entrance?.duration ?? 500;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<>
|
|
231
|
+
{/* ─── Source ──────────────────────────────────────────── */}
|
|
232
|
+
<SettingsSection title="Source" defaultOpen icon={<SourceIcon />}>
|
|
233
|
+
<SettingsField label="Mode">
|
|
234
|
+
<SegmentedControl
|
|
235
|
+
options={SOURCE_OPTIONS}
|
|
236
|
+
value={sourceMode}
|
|
237
|
+
onChange={(v) => update({ source_mode: v })}
|
|
238
|
+
/>
|
|
239
|
+
</SettingsField>
|
|
240
|
+
|
|
241
|
+
<SettingsField label="Projects">
|
|
242
|
+
<RangeSlider
|
|
243
|
+
value={maxProjects}
|
|
244
|
+
onChange={(v) => update({ max_projects: Math.round(v) })}
|
|
245
|
+
min={2}
|
|
246
|
+
max={20}
|
|
247
|
+
step={1}
|
|
248
|
+
/>
|
|
249
|
+
</SettingsField>
|
|
250
|
+
|
|
251
|
+
<SettingsField label="Exclude current">
|
|
252
|
+
<StyledCheckbox
|
|
253
|
+
checked={excludeCurrent}
|
|
254
|
+
onChange={(checked) => update({ exclude_current: checked })}
|
|
255
|
+
label="Hide the project currently being viewed"
|
|
256
|
+
/>
|
|
257
|
+
</SettingsField>
|
|
258
|
+
</SettingsSection>
|
|
259
|
+
|
|
260
|
+
{/* ─── Layout ──────────────────────────────────────────── */}
|
|
261
|
+
<SettingsSection title="Layout" defaultOpen icon={<LayoutIcon />}>
|
|
262
|
+
<SettingsField label="Desktop">
|
|
263
|
+
<RangeSlider
|
|
264
|
+
value={cpvDesktop}
|
|
265
|
+
onChange={(v) => update({ cards_per_view_desktop: v })}
|
|
266
|
+
min={1}
|
|
267
|
+
max={6}
|
|
268
|
+
step={0.5}
|
|
269
|
+
decimals={1}
|
|
270
|
+
suffix=" cards"
|
|
271
|
+
/>
|
|
272
|
+
</SettingsField>
|
|
273
|
+
|
|
274
|
+
<SettingsField label="Tablet">
|
|
275
|
+
<RangeSlider
|
|
276
|
+
value={cpvTablet}
|
|
277
|
+
onChange={(v) => update({ cards_per_view_tablet: v })}
|
|
278
|
+
min={1}
|
|
279
|
+
max={4}
|
|
280
|
+
step={0.5}
|
|
281
|
+
decimals={1}
|
|
282
|
+
suffix=" cards"
|
|
283
|
+
/>
|
|
284
|
+
</SettingsField>
|
|
285
|
+
|
|
286
|
+
<SettingsField label="Phone">
|
|
287
|
+
<RangeSlider
|
|
288
|
+
value={cpvPhone}
|
|
289
|
+
onChange={(v) => update({ cards_per_view_phone: v })}
|
|
290
|
+
min={1}
|
|
291
|
+
max={3}
|
|
292
|
+
step={0.1}
|
|
293
|
+
decimals={1}
|
|
294
|
+
suffix=" cards"
|
|
295
|
+
/>
|
|
296
|
+
</SettingsField>
|
|
297
|
+
|
|
298
|
+
<SettingsField label="Gap">
|
|
299
|
+
<RangeSlider
|
|
300
|
+
value={gap}
|
|
301
|
+
onChange={(v) => update({ gap: Math.round(v) })}
|
|
302
|
+
min={0}
|
|
303
|
+
max={80}
|
|
304
|
+
step={2}
|
|
305
|
+
suffix="px"
|
|
306
|
+
/>
|
|
307
|
+
</SettingsField>
|
|
308
|
+
</SettingsSection>
|
|
309
|
+
|
|
310
|
+
{/* ─── Card Display ────────────────────────────────────── */}
|
|
311
|
+
<SettingsSection title="Card Display" defaultOpen={false} icon={<AppearanceIcon />}>
|
|
312
|
+
<SettingsField label="Aspect">
|
|
313
|
+
<Dropdown
|
|
314
|
+
options={ASPECT_RATIO_OPTIONS}
|
|
315
|
+
value={aspectRatio}
|
|
316
|
+
onChange={(v) => update({ aspect_ratio: v as ProjectCarouselBlock["aspect_ratio"] })}
|
|
317
|
+
/>
|
|
318
|
+
</SettingsField>
|
|
319
|
+
|
|
320
|
+
<SettingsField label="Title">
|
|
321
|
+
<StyledCheckbox
|
|
322
|
+
checked={showTitle}
|
|
323
|
+
onChange={(checked) => update({ show_title: checked })}
|
|
324
|
+
label="Show project title"
|
|
325
|
+
/>
|
|
326
|
+
</SettingsField>
|
|
327
|
+
|
|
328
|
+
<SettingsField label="Subtitle">
|
|
329
|
+
<StyledCheckbox
|
|
330
|
+
checked={showSubtitle}
|
|
331
|
+
onChange={(checked) => update({ show_subtitle: checked })}
|
|
332
|
+
label="Show project subtitle"
|
|
333
|
+
/>
|
|
334
|
+
</SettingsField>
|
|
335
|
+
|
|
336
|
+
<SettingsField label="Border radius">
|
|
337
|
+
<RangeSlider
|
|
338
|
+
value={borderRadius}
|
|
339
|
+
onChange={(v) => update({ border_radius: Math.round(v) })}
|
|
340
|
+
min={0}
|
|
341
|
+
max={40}
|
|
342
|
+
step={1}
|
|
343
|
+
suffix="px"
|
|
344
|
+
/>
|
|
345
|
+
</SettingsField>
|
|
346
|
+
|
|
347
|
+
<SettingsField label="Hover">
|
|
348
|
+
<SegmentedControl
|
|
349
|
+
options={HOVER_EFFECT_OPTIONS}
|
|
350
|
+
value={hoverEffect}
|
|
351
|
+
onChange={(v) => update({ hover_effect: v })}
|
|
352
|
+
/>
|
|
353
|
+
</SettingsField>
|
|
354
|
+
</SettingsSection>
|
|
355
|
+
|
|
356
|
+
{/* ─── Video ───────────────────────────────────────────── */}
|
|
357
|
+
<SettingsSection title="Video" defaultOpen={false} icon={<VideoIcon />}>
|
|
358
|
+
<SettingsField label="Mode">
|
|
359
|
+
<SegmentedControl
|
|
360
|
+
options={VIDEO_MODE_OPTIONS}
|
|
361
|
+
value={videoMode}
|
|
362
|
+
onChange={(v) => update({ video_mode: v })}
|
|
363
|
+
/>
|
|
364
|
+
</SettingsField>
|
|
365
|
+
<p className="text-[10px] text-neutral-400 leading-snug px-0.5">
|
|
366
|
+
How project cover videos play in the carousel.
|
|
367
|
+
</p>
|
|
368
|
+
</SettingsSection>
|
|
369
|
+
|
|
370
|
+
{/* ─── Controls ────────────────────────────────────────── */}
|
|
371
|
+
<SettingsSection title="Controls" defaultOpen={false} icon={<NavigationIcon />}>
|
|
372
|
+
<SettingsField label="Arrows">
|
|
373
|
+
<StyledCheckbox
|
|
374
|
+
checked={showArrows}
|
|
375
|
+
onChange={(checked) => update({ show_arrows: checked })}
|
|
376
|
+
label="Show prev / next arrows on hover (desktop)"
|
|
377
|
+
/>
|
|
378
|
+
</SettingsField>
|
|
379
|
+
|
|
380
|
+
<SettingsField label="Dots">
|
|
381
|
+
<StyledCheckbox
|
|
382
|
+
checked={showDots}
|
|
383
|
+
onChange={(checked) => update({ show_dots: checked })}
|
|
384
|
+
label="Show pagination dots below the carousel"
|
|
385
|
+
/>
|
|
386
|
+
</SettingsField>
|
|
387
|
+
|
|
388
|
+
<SettingsField label="Snap">
|
|
389
|
+
<StyledCheckbox
|
|
390
|
+
checked={snapScroll}
|
|
391
|
+
onChange={(checked) => update({ snap_scroll: checked })}
|
|
392
|
+
label="Cards snap into place when scrolling"
|
|
393
|
+
/>
|
|
394
|
+
</SettingsField>
|
|
395
|
+
</SettingsSection>
|
|
396
|
+
|
|
397
|
+
{/* ─── Card Entrance Animation ─────────────────────────── */}
|
|
398
|
+
<SettingsSection title="Card Entrance" defaultOpen={false} icon={<AnimationIcon />}>
|
|
399
|
+
<SettingsField label="Enabled">
|
|
400
|
+
<StyledCheckbox
|
|
401
|
+
checked={entranceEnabled}
|
|
402
|
+
onChange={(checked) => updateCardEntrance({ enabled: checked })}
|
|
403
|
+
label="Animate cards as they appear"
|
|
404
|
+
/>
|
|
405
|
+
</SettingsField>
|
|
406
|
+
|
|
407
|
+
{entranceEnabled && (
|
|
408
|
+
<>
|
|
409
|
+
<SettingsField label="Preset">
|
|
410
|
+
<SegmentedControl
|
|
411
|
+
options={ANIMATION_PRESET_OPTIONS}
|
|
412
|
+
value={entrancePreset}
|
|
413
|
+
onChange={(v) => updateCardEntrance({ preset: v })}
|
|
414
|
+
/>
|
|
415
|
+
</SettingsField>
|
|
416
|
+
|
|
417
|
+
<SettingsField label="Stagger">
|
|
418
|
+
<RangeSlider
|
|
419
|
+
value={entranceStagger}
|
|
420
|
+
onChange={(v) => updateCardEntrance({ stagger_delay: Math.round(v) })}
|
|
421
|
+
min={0}
|
|
422
|
+
max={300}
|
|
423
|
+
step={10}
|
|
424
|
+
suffix="ms"
|
|
425
|
+
/>
|
|
426
|
+
</SettingsField>
|
|
427
|
+
|
|
428
|
+
<SettingsField label="Duration">
|
|
429
|
+
<RangeSlider
|
|
430
|
+
value={entranceDuration}
|
|
431
|
+
onChange={(v) => updateCardEntrance({ duration: Math.round(v) })}
|
|
432
|
+
min={100}
|
|
433
|
+
max={1500}
|
|
434
|
+
step={50}
|
|
435
|
+
suffix="ms"
|
|
436
|
+
/>
|
|
437
|
+
</SettingsField>
|
|
438
|
+
</>
|
|
439
|
+
)}
|
|
440
|
+
</SettingsSection>
|
|
441
|
+
</>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
@@ -5,5 +5,6 @@ export { default as VideoBlockEditor } from "./VideoBlockEditor";
|
|
|
5
5
|
export { default as SpacerBlockEditor } from "./SpacerBlockEditor";
|
|
6
6
|
export { default as ButtonBlockEditor } from "./ButtonBlockEditor";
|
|
7
7
|
export { default as ProjectGridEditor } from "./ProjectGridEditor";
|
|
8
|
+
export { default as ProjectCarouselBlockEditor } from "./ProjectCarouselBlockEditor";
|
|
8
9
|
export { SettingsField, SettingsSection, StyledSelect, StyledInput, StyledCheckbox } from "./shared";
|
|
9
10
|
export { getSpacerPx } from "./SpacerBlockEditor";
|