@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,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";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared SVG primitives for the block and section card icons.
|
|
5
|
+
*
|
|
6
|
+
* Both `BlockCardIcons.tsx` and `SectionCardIcons.tsx` consume these so the
|
|
7
|
+
* visual language (brick background + fade + shadow + bevel) stays 1:1
|
|
8
|
+
* consistent. Each helper accepts an `idPrefix` / `id` so multiple icons can
|
|
9
|
+
* render side by side without filter/gradient/mask collisions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Brick-rect positions — 24 rects, 4 rows, top half only. */
|
|
13
|
+
export const BRICK: ReadonlyArray<readonly [number, number]> = [
|
|
14
|
+
[18.7, 0.1], [53.5, 0.1], [88.4, 0.1], [123.2, 0.1], [158.1, 0.1], [192.9, 0.1],
|
|
15
|
+
[1.3, 17.6], [36.1, 17.6], [71, 17.6], [105.8, 17.6], [140.7, 17.6], [175.5, 17.6],
|
|
16
|
+
[18.7, 34.8], [53.5, 34.8], [88.4, 34.8], [123.2, 34.8], [158.1, 34.8], [192.9, 34.8],
|
|
17
|
+
[1.3, 52.3], [36.1, 52.3], [71, 52.3], [105.8, 52.3], [140.7, 52.3], [175.5, 52.3],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Grid line pattern + right-side fade gradient defs.
|
|
21
|
+
*
|
|
22
|
+
* The fade reaches full opacity at x=200 (viewBox units) rather than at
|
|
23
|
+
* x=220 (the right edge). This guarantees that the rightmost ~20 viewBox
|
|
24
|
+
* units of the SVG are solid #F4F4F4 — eliminating sub-pixel anti-aliasing
|
|
25
|
+
* artifacts when the SVG is downscaled to fit a container whose width is
|
|
26
|
+
* slightly smaller than the full viewBox (e.g. 176px for a 220-wide viewBox).
|
|
27
|
+
*/
|
|
28
|
+
export function BgDefs({ prefix }: { prefix: string }) {
|
|
29
|
+
return (
|
|
30
|
+
<>
|
|
31
|
+
<pattern id={`${prefix}-grid`} x="0.8" y="-0.3" width="17.5" height="17.5" patternUnits="userSpaceOnUse">
|
|
32
|
+
<path d="M 17.5 0 L 0 0 0 17.5" fill="none" stroke="#BABABA" strokeWidth="0.5" opacity="0.49" />
|
|
33
|
+
</pattern>
|
|
34
|
+
<linearGradient id={`${prefix}-fade`} x1="140.2" y1="60" x2="200" y2="60" gradientUnits="userSpaceOnUse">
|
|
35
|
+
<stop offset="0" stopColor="#F4F4F4" stopOpacity="0" />
|
|
36
|
+
<stop offset="1" stopColor="#F4F4F4" />
|
|
37
|
+
</linearGradient>
|
|
38
|
+
</>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Background body: solid fill → grid pattern → brick overlay → right-side fade. */
|
|
43
|
+
export function Bg({ prefix }: { prefix: string }) {
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
<rect width="220" height="120" fill="#F4F4F4" />
|
|
47
|
+
<rect width="220" height="120" fill={`url(#${prefix}-grid)`} />
|
|
48
|
+
<g fill="none" stroke="#FFFFFF" strokeWidth="0.5">
|
|
49
|
+
{BRICK.map(([x, y]) => (
|
|
50
|
+
<rect key={`${x}-${y}`} x={x} y={y} width="15.5" height="15.3" />
|
|
51
|
+
))}
|
|
52
|
+
</g>
|
|
53
|
+
<rect x="140.2" y="0" width="79.8" height="120" fill={`url(#${prefix}-fade)`} />
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Vector drop-shadow filter. */
|
|
59
|
+
export function ShadowFilter({ id }: { id: string }) {
|
|
60
|
+
return (
|
|
61
|
+
<filter id={id} x="-30%" y="-30%" width="160%" height="160%">
|
|
62
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="3" />
|
|
63
|
+
<feOffset dy="3" />
|
|
64
|
+
<feComponentTransfer><feFuncA type="linear" slope="0.22" /></feComponentTransfer>
|
|
65
|
+
<feMerge><feMergeNode /><feMergeNode in="SourceGraphic" /></feMerge>
|
|
66
|
+
</filter>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Vertical bevel gradient: #FFFFFF → endColor (default #E6ECF6 for blocks, #EBEAEF for sections). */
|
|
71
|
+
export function VertBevel({ id, endColor = "#E6ECF6" }: { id: string; endColor?: string }) {
|
|
72
|
+
return (
|
|
73
|
+
<linearGradient id={id} x1="0.5" y1="0" x2="0.5" y2="1">
|
|
74
|
+
<stop offset="0" stopColor="#FFFFFF" />
|
|
75
|
+
<stop offset="1" stopColor={endColor} />
|
|
76
|
+
</linearGradient>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -11,9 +11,23 @@ export default function LiveImagePreview({ block }: { block: ImageBlock }) {
|
|
|
11
11
|
const [useFallback, setUseFallback] = useState(false);
|
|
12
12
|
|
|
13
13
|
if (!block.asset_path) {
|
|
14
|
+
// Empty state: fills the column (min 240px) with a light-gray backdrop
|
|
15
|
+
// and a landscape placeholder glyph. Once the user picks an image the
|
|
16
|
+
// block sizes itself normally.
|
|
17
|
+
const isFill = block.width === "fill";
|
|
18
|
+
const wrapperStyle: React.CSSProperties = isFill
|
|
19
|
+
? { position: "absolute", inset: 0 }
|
|
20
|
+
: { width: "100%" };
|
|
14
21
|
return (
|
|
15
|
-
<div
|
|
16
|
-
<
|
|
22
|
+
<div style={wrapperStyle}>
|
|
23
|
+
<div className="w-full h-full min-h-[240px] rounded flex flex-col items-center justify-center gap-2.5" style={{ background: "#f4f4f4" }}>
|
|
24
|
+
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" aria-hidden="true">
|
|
25
|
+
<rect x="6" y="10" width="44" height="36" rx="3" stroke="#b0b5bd" strokeWidth="1.5" fill="#FFFFFF" />
|
|
26
|
+
<circle cx="18" cy="21" r="3" fill="#b0b5bd" />
|
|
27
|
+
<path d="M12 42 L22 28 L28 34 L38 22 L46 42 Z" fill="#b0b5bd" />
|
|
28
|
+
</svg>
|
|
29
|
+
<span className="text-[11px] text-neutral-500">No image yet</span>
|
|
30
|
+
</div>
|
|
17
31
|
</div>
|
|
18
32
|
);
|
|
19
33
|
}
|