@morphika/andami 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/components/admin/nav-builder/NavBuilder.tsx +90 -14
- package/components/admin/nav-builder/NavGeneralSettings.tsx +521 -271
- package/components/admin/nav-builder/NavItemSettings.tsx +331 -312
- package/components/admin/nav-builder/NavMobileSettings.tsx +159 -140
- package/components/admin/nav-builder/NavSettingsFields.tsx +287 -21
- package/components/admin/nav-builder/NavSettingsPanel.tsx +137 -127
- package/components/blocks/TextBlockRenderer.tsx +1 -1
- package/components/builder/SettingsPanel.tsx +29 -543
- package/components/builder/editors/ButtonBlockEditor.tsx +8 -3
- package/components/builder/editors/CoverBlockEditor.tsx +14 -6
- package/components/builder/editors/ImageBlockEditor.tsx +8 -3
- package/components/builder/editors/ImageGridBlockEditor.tsx +8 -3
- package/components/builder/editors/ProjectGridEditor.tsx +7 -46
- package/components/builder/editors/SpacerBlockEditor.tsx +4 -1
- package/components/builder/editors/StaggerSettings.tsx +2 -1
- package/components/builder/editors/TextBlockEditor.tsx +8 -3
- package/components/builder/editors/VideoBlockEditor.tsx +10 -4
- package/components/builder/editors/section-icons.tsx +492 -0
- package/components/builder/editors/shared.tsx +23 -4
- package/components/builder/live-preview/GhostCard.tsx +84 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +294 -1010
- package/components/builder/live-preview/LiveTextEditor.tsx +1 -1
- package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -0
- package/components/builder/live-preview/drag-utils.tsx +89 -0
- package/components/builder/live-preview/useDragReorder.ts +370 -0
- package/components/builder/settings-panel/AnimationTab.tsx +152 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +13 -58
- package/components/builder/settings-panel/CardEntranceSection.tsx +114 -0
- package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +32 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +4 -1
- package/components/builder/settings-panel/CustomSectionSettings.tsx +150 -0
- package/components/builder/settings-panel/LayoutTab.tsx +11 -47
- package/components/builder/settings-panel/PageSettings.tsx +10 -4
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +6 -2
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +8 -3
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +11 -47
- package/components/builder/settings-panel/SectionV2Settings.tsx +6 -27
- package/components/builder/settings-panel/index.ts +6 -0
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +184 -0
- package/components/ui/Navbar.tsx +151 -30
- package/lib/builder/serializer/migrations.ts +107 -0
- package/lib/builder/serializer/normalizers.ts +278 -0
- package/lib/builder/serializer/serializers.ts +393 -0
- package/lib/builder/serializer/shared.ts +102 -0
- package/lib/builder/serializer.ts +11 -846
- package/lib/sanity/types.ts +22 -0
- package/package.json +13 -10
- package/styles/base.css +7 -3
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useDragReorder — Custom hook encapsulating all drag-to-reorder logic
|
|
3
|
+
* for the ProjectGrid masonry preview.
|
|
4
|
+
*
|
|
5
|
+
* Manages: drag state machine (grabbed → dragging → swap/cancel),
|
|
6
|
+
* hold-to-drag timer, global pointer listeners, cancel fly-back animation,
|
|
7
|
+
* coordinate hit-testing, and project array swap.
|
|
8
|
+
*
|
|
9
|
+
* Session 162: Extracted from LiveProjectGridPreview.tsx (Phase B4).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
13
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
14
|
+
import type { MasonryOutput } from "../../../lib/builder/masonry";
|
|
15
|
+
import type { ProjectGridBlock } from "../../../lib/sanity/types";
|
|
16
|
+
import {
|
|
17
|
+
HOLD_DELAY,
|
|
18
|
+
MOVE_THRESHOLD_SQ,
|
|
19
|
+
CANCEL_DURATION,
|
|
20
|
+
type DragState,
|
|
21
|
+
hitTestCards,
|
|
22
|
+
} from "./drag-utils";
|
|
23
|
+
|
|
24
|
+
// ─── Hook Options & Return Types ─────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
export interface UseDragReorderOptions {
|
|
27
|
+
block: ProjectGridBlock;
|
|
28
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
29
|
+
containerWidth: number;
|
|
30
|
+
masonry: MasonryOutput;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseDragReorderResult {
|
|
34
|
+
dragState: DragState | null;
|
|
35
|
+
handleDragStart: (
|
|
36
|
+
key: string,
|
|
37
|
+
e: React.PointerEvent,
|
|
38
|
+
cardEl: HTMLDivElement,
|
|
39
|
+
fromHandle: boolean,
|
|
40
|
+
) => void;
|
|
41
|
+
handleSelect: (key: string) => void;
|
|
42
|
+
isAnyDragActive: boolean;
|
|
43
|
+
draggedKey: string | null;
|
|
44
|
+
hoverTargetKey: string | null;
|
|
45
|
+
dragPhase: DragState["phase"] | null;
|
|
46
|
+
draggedItem: NonNullable<ProjectGridBlock["projects"]>[number] | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Hook ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export function useDragReorder({
|
|
52
|
+
block,
|
|
53
|
+
containerRef,
|
|
54
|
+
containerWidth,
|
|
55
|
+
masonry,
|
|
56
|
+
}: UseDragReorderOptions): UseDragReorderResult {
|
|
57
|
+
const updateBlock = useBuilderStore((s) => s.updateBlock);
|
|
58
|
+
const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
|
|
59
|
+
const selectProjectCard = useBuilderStore((s) => s.selectProjectCard);
|
|
60
|
+
const selectedProjectCardKey = useBuilderStore(
|
|
61
|
+
(s) => s.selectedProjectCardKey,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const [dragState, setDragState] = useState<DragState | null>(null);
|
|
65
|
+
|
|
66
|
+
// ── Stable refs for event handlers ────────────────────────────
|
|
67
|
+
|
|
68
|
+
const blockRef = useRef(block);
|
|
69
|
+
blockRef.current = block;
|
|
70
|
+
const updateBlockRef = useRef(updateBlock);
|
|
71
|
+
updateBlockRef.current = updateBlock;
|
|
72
|
+
const pushSnapshotRef = useRef(_pushSnapshot);
|
|
73
|
+
pushSnapshotRef.current = _pushSnapshot;
|
|
74
|
+
const containerWidthRef = useRef(containerWidth);
|
|
75
|
+
containerWidthRef.current = containerWidth;
|
|
76
|
+
const dragStateRef = useRef(dragState);
|
|
77
|
+
dragStateRef.current = dragState;
|
|
78
|
+
const masonryRef = useRef(masonry);
|
|
79
|
+
masonryRef.current = masonry;
|
|
80
|
+
|
|
81
|
+
// Timer & cleanup refs
|
|
82
|
+
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
83
|
+
const holdCleanupRef = useRef<(() => void) | null>(null);
|
|
84
|
+
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
|
+
const cancelRafRef = useRef<number | null>(null);
|
|
86
|
+
const didDragRef = useRef(false);
|
|
87
|
+
|
|
88
|
+
// ── Drag: initiate ────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const initiateDrag = useCallback(
|
|
91
|
+
(
|
|
92
|
+
key: string,
|
|
93
|
+
clientX: number,
|
|
94
|
+
clientY: number,
|
|
95
|
+
cardEl: HTMLDivElement,
|
|
96
|
+
) => {
|
|
97
|
+
// Clean up any pending hold
|
|
98
|
+
if (holdCleanupRef.current) {
|
|
99
|
+
holdCleanupRef.current();
|
|
100
|
+
holdCleanupRef.current = null;
|
|
101
|
+
}
|
|
102
|
+
// Clean up any cancel animation in progress
|
|
103
|
+
if (cancelTimerRef.current) {
|
|
104
|
+
clearTimeout(cancelTimerRef.current);
|
|
105
|
+
cancelTimerRef.current = null;
|
|
106
|
+
}
|
|
107
|
+
if (cancelRafRef.current) {
|
|
108
|
+
cancelAnimationFrame(cancelRafRef.current);
|
|
109
|
+
cancelRafRef.current = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rect = cardEl.getBoundingClientRect();
|
|
113
|
+
didDragRef.current = true;
|
|
114
|
+
setDragState({
|
|
115
|
+
phase: "grabbed",
|
|
116
|
+
draggedKey: key,
|
|
117
|
+
hoverTargetKey: null,
|
|
118
|
+
mouseX: clientX,
|
|
119
|
+
mouseY: clientY,
|
|
120
|
+
startMouseX: clientX,
|
|
121
|
+
startMouseY: clientY,
|
|
122
|
+
offsetX: clientX - rect.left,
|
|
123
|
+
offsetY: clientY - rect.top,
|
|
124
|
+
cardWidth: rect.width,
|
|
125
|
+
cardHeight: rect.height,
|
|
126
|
+
origScreenX: rect.left,
|
|
127
|
+
origScreenY: rect.top,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const handleDragStart = useCallback(
|
|
134
|
+
(
|
|
135
|
+
key: string,
|
|
136
|
+
e: React.PointerEvent,
|
|
137
|
+
cardEl: HTMLDivElement,
|
|
138
|
+
fromHandle: boolean,
|
|
139
|
+
) => {
|
|
140
|
+
if (fromHandle) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
initiateDrag(key, e.clientX, e.clientY, cardEl);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Card body → hold-to-drag
|
|
147
|
+
const cx = e.clientX;
|
|
148
|
+
const cy = e.clientY;
|
|
149
|
+
didDragRef.current = false;
|
|
150
|
+
|
|
151
|
+
// Clean any previous pending hold
|
|
152
|
+
if (holdCleanupRef.current) holdCleanupRef.current();
|
|
153
|
+
|
|
154
|
+
const cleanup = () => {
|
|
155
|
+
if (holdTimerRef.current) {
|
|
156
|
+
clearTimeout(holdTimerRef.current);
|
|
157
|
+
holdTimerRef.current = null;
|
|
158
|
+
}
|
|
159
|
+
window.removeEventListener("pointerup", onEarlyRelease);
|
|
160
|
+
window.removeEventListener("pointermove", onEarlyMove);
|
|
161
|
+
holdCleanupRef.current = null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const onEarlyRelease = () => cleanup();
|
|
165
|
+
|
|
166
|
+
const onEarlyMove = (ev: PointerEvent) => {
|
|
167
|
+
// If user moved > 10px before hold time, cancel (not a drag intent)
|
|
168
|
+
const dx = ev.clientX - cx;
|
|
169
|
+
const dy = ev.clientY - cy;
|
|
170
|
+
if (dx * dx + dy * dy > 100) cleanup();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
holdTimerRef.current = setTimeout(() => {
|
|
174
|
+
holdTimerRef.current = null;
|
|
175
|
+
window.removeEventListener("pointerup", onEarlyRelease);
|
|
176
|
+
window.removeEventListener("pointermove", onEarlyMove);
|
|
177
|
+
holdCleanupRef.current = null;
|
|
178
|
+
initiateDrag(key, cx, cy, cardEl);
|
|
179
|
+
}, HOLD_DELAY);
|
|
180
|
+
|
|
181
|
+
window.addEventListener("pointerup", onEarlyRelease);
|
|
182
|
+
window.addEventListener("pointermove", onEarlyMove);
|
|
183
|
+
holdCleanupRef.current = cleanup;
|
|
184
|
+
},
|
|
185
|
+
[initiateDrag],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// ── Drag: click handler (selection) ───────────────────────────
|
|
189
|
+
|
|
190
|
+
const handleSelect = useCallback(
|
|
191
|
+
(key: string) => {
|
|
192
|
+
// Suppress click that follows a completed drag
|
|
193
|
+
if (didDragRef.current) {
|
|
194
|
+
didDragRef.current = false;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
selectProjectCard(selectedProjectCardKey === key ? null : key);
|
|
198
|
+
},
|
|
199
|
+
[selectProjectCard, selectedProjectCardKey],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// ── Global pointer handlers during drag ───────────────────────
|
|
203
|
+
|
|
204
|
+
const hasDrag = dragState !== null;
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!hasDrag) return;
|
|
208
|
+
|
|
209
|
+
const onMove = (e: PointerEvent) => {
|
|
210
|
+
setDragState((prev) => {
|
|
211
|
+
if (!prev || prev.phase === "cancelling") return prev;
|
|
212
|
+
|
|
213
|
+
const next: DragState = {
|
|
214
|
+
...prev,
|
|
215
|
+
mouseX: e.clientX,
|
|
216
|
+
mouseY: e.clientY,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Transition: grabbed → dragging on sufficient movement
|
|
220
|
+
if (prev.phase === "grabbed") {
|
|
221
|
+
const dx = e.clientX - prev.startMouseX;
|
|
222
|
+
const dy = e.clientY - prev.startMouseY;
|
|
223
|
+
if (dx * dx + dy * dy > MOVE_THRESHOLD_SQ) {
|
|
224
|
+
next.phase = "dragging";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Coordinate-based hit-testing for drop targets
|
|
229
|
+
if (next.phase === "dragging" && containerRef.current) {
|
|
230
|
+
next.hoverTargetKey = hitTestCards(
|
|
231
|
+
e.clientX,
|
|
232
|
+
e.clientY,
|
|
233
|
+
containerRef.current,
|
|
234
|
+
containerWidthRef.current,
|
|
235
|
+
masonryRef.current,
|
|
236
|
+
prev.draggedKey,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return next;
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const onUp = () => {
|
|
245
|
+
const prev = dragStateRef.current;
|
|
246
|
+
if (!prev) {
|
|
247
|
+
setDragState(null);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Grabbed with no movement → cancel (no visual action, allow click through)
|
|
252
|
+
if (prev.phase === "grabbed") {
|
|
253
|
+
setDragState(null);
|
|
254
|
+
setTimeout(() => {
|
|
255
|
+
didDragRef.current = false;
|
|
256
|
+
}, 0);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Dragging with a valid drop target → swap
|
|
261
|
+
if (prev.phase === "dragging" && prev.hoverTargetKey) {
|
|
262
|
+
const blk = blockRef.current;
|
|
263
|
+
const arr = [...(blk.projects || [])];
|
|
264
|
+
const fromIdx = arr.findIndex((p) => p._key === prev.draggedKey);
|
|
265
|
+
const toIdx = arr.findIndex((p) => p._key === prev.hoverTargetKey);
|
|
266
|
+
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
|
|
267
|
+
pushSnapshotRef.current();
|
|
268
|
+
[arr[fromIdx], arr[toIdx]] = [arr[toIdx], arr[fromIdx]];
|
|
269
|
+
updateBlockRef.current(blk._key, {
|
|
270
|
+
projects: arr,
|
|
271
|
+
} as Partial<ProjectGridBlock>);
|
|
272
|
+
}
|
|
273
|
+
setDragState(null);
|
|
274
|
+
setTimeout(() => {
|
|
275
|
+
didDragRef.current = false;
|
|
276
|
+
}, 0);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Dragging with no target → cancel with fly-back animation
|
|
281
|
+
if (prev.phase === "dragging") {
|
|
282
|
+
setDragState({ ...prev, phase: "cancelling", hoverTargetKey: null });
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
didDragRef.current = false;
|
|
285
|
+
}, 0);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Fallback: clear
|
|
290
|
+
setDragState(null);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
window.addEventListener("pointermove", onMove);
|
|
294
|
+
window.addEventListener("pointerup", onUp);
|
|
295
|
+
return () => {
|
|
296
|
+
window.removeEventListener("pointermove", onMove);
|
|
297
|
+
window.removeEventListener("pointerup", onUp);
|
|
298
|
+
};
|
|
299
|
+
}, [hasDrag]);
|
|
300
|
+
|
|
301
|
+
// ── Cancel animation: fly ghost back to original position ─────
|
|
302
|
+
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (dragState?.phase !== "cancelling") return;
|
|
305
|
+
|
|
306
|
+
// Double rAF ensures the transition CSS is painted before position change
|
|
307
|
+
cancelRafRef.current = requestAnimationFrame(() => {
|
|
308
|
+
cancelRafRef.current = requestAnimationFrame(() => {
|
|
309
|
+
setDragState((prev) => {
|
|
310
|
+
if (prev?.phase !== "cancelling") return prev;
|
|
311
|
+
return {
|
|
312
|
+
...prev,
|
|
313
|
+
mouseX: prev.origScreenX + prev.offsetX,
|
|
314
|
+
mouseY: prev.origScreenY + prev.offsetY,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
cancelRafRef.current = null;
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Clear state after transition completes
|
|
322
|
+
cancelTimerRef.current = setTimeout(() => {
|
|
323
|
+
setDragState(null);
|
|
324
|
+
cancelTimerRef.current = null;
|
|
325
|
+
}, CANCEL_DURATION + 50);
|
|
326
|
+
|
|
327
|
+
return () => {
|
|
328
|
+
if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
|
|
329
|
+
if (cancelTimerRef.current) {
|
|
330
|
+
clearTimeout(cancelTimerRef.current);
|
|
331
|
+
cancelTimerRef.current = null;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}, [dragState?.phase]);
|
|
335
|
+
|
|
336
|
+
// ── Cleanup on unmount ────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
useEffect(
|
|
339
|
+
() => () => {
|
|
340
|
+
if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
|
|
341
|
+
if (holdCleanupRef.current) holdCleanupRef.current();
|
|
342
|
+
if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
|
|
343
|
+
if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
|
|
344
|
+
},
|
|
345
|
+
[],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// ── Derived drag values ───────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
const projects = block.projects || [];
|
|
351
|
+
const draggedKey = dragState?.draggedKey ?? null;
|
|
352
|
+
const hoverTargetKey =
|
|
353
|
+
dragState?.phase === "dragging" ? dragState.hoverTargetKey : null;
|
|
354
|
+
const dragPhase = dragState?.phase ?? null;
|
|
355
|
+
const isAnyDragActive = dragState !== null;
|
|
356
|
+
const draggedItem = dragState
|
|
357
|
+
? projects.find((p) => p._key === dragState.draggedKey) ?? null
|
|
358
|
+
: null;
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
dragState,
|
|
362
|
+
handleDragStart,
|
|
363
|
+
handleSelect,
|
|
364
|
+
isAnyDragActive,
|
|
365
|
+
draggedKey,
|
|
366
|
+
hoverTargetKey,
|
|
367
|
+
dragPhase,
|
|
368
|
+
draggedItem,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AnimationTab — Routes to EnterAnimationPicker / HoverEffectPicker based on selection.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from SettingsPanel.tsx in Session C (refactor split).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
10
|
+
import type { ContentBlock, PageSection, ProjectGridBlock } from "../../../lib/sanity/types";
|
|
11
|
+
import type { HoverEffectConfig } from "../../../lib/animation/hover-effect-types";
|
|
12
|
+
import EnterAnimationPicker from "../editors/EnterAnimationPicker";
|
|
13
|
+
import HoverEffectPicker from "../editors/HoverEffectPicker";
|
|
14
|
+
import {
|
|
15
|
+
getBlockAnimationValue,
|
|
16
|
+
hasBlockAnimationOverride,
|
|
17
|
+
setBlockAnimationOverride,
|
|
18
|
+
} from "./responsive-helpers";
|
|
19
|
+
import { CardEntranceSection } from "./CardEntranceSection";
|
|
20
|
+
|
|
21
|
+
/** Safely extract hover_effect (new unified type) from any content block.
|
|
22
|
+
* ProjectGridBlock has a legacy `hover_effect: "3d" | "scale" | "none"` string field
|
|
23
|
+
* that collides — skip it (returns undefined). Other blocks have HoverEffectConfig. */
|
|
24
|
+
export function getBlockHoverEffect(block: ContentBlock): HoverEffectConfig | undefined {
|
|
25
|
+
// ProjectGridBlock hover_effect is the old per-card string — not HoverEffectConfig
|
|
26
|
+
if (block._type === "projectGridBlock") return undefined;
|
|
27
|
+
const val = (block as unknown as Record<string, unknown>).hover_effect;
|
|
28
|
+
if (val === undefined || val === null) return undefined;
|
|
29
|
+
if (typeof val === "object") return val as HoverEffectConfig;
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface AnimationTabProps {
|
|
34
|
+
selectedBlock: { block: ContentBlock; rowKey: string; colKey: string; isSection: boolean } | null;
|
|
35
|
+
selectedSection: PageSection | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AnimationTab({ selectedBlock, selectedSection }: AnimationTabProps) {
|
|
39
|
+
const store = useBuilderStore();
|
|
40
|
+
|
|
41
|
+
// PageSection (V1): enter animation on the section settings level
|
|
42
|
+
if (selectedSection) {
|
|
43
|
+
return (
|
|
44
|
+
<EnterAnimationPicker
|
|
45
|
+
mode={{ level: "section", parentConfig: store.pageSettings.enter_animation }}
|
|
46
|
+
config={selectedSection.settings?.enter_animation}
|
|
47
|
+
onChange={(cfg) => {
|
|
48
|
+
store.updateSectionSettings(selectedSection._key, { enter_animation: cfg });
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Block level: type-specific enter picker + card entrance for projectGrid
|
|
55
|
+
if (selectedBlock) {
|
|
56
|
+
const isProjectGrid = selectedBlock.block._type === "projectGridBlock";
|
|
57
|
+
const pgBlock = isProjectGrid ? (selectedBlock.block as ProjectGridBlock) : null;
|
|
58
|
+
const bvp = store.activeViewport;
|
|
59
|
+
const isBlockResponsive = bvp !== "desktop";
|
|
60
|
+
|
|
61
|
+
const effectiveEnterAnim = getBlockAnimationValue(
|
|
62
|
+
selectedBlock.block, bvp, "enter_animation", undefined
|
|
63
|
+
);
|
|
64
|
+
const effectiveHoverEffect = getBlockAnimationValue(
|
|
65
|
+
selectedBlock.block, bvp, "hover_effect", undefined
|
|
66
|
+
) as HoverEffectConfig | undefined;
|
|
67
|
+
|
|
68
|
+
const hasEnterOverride = hasBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation");
|
|
69
|
+
const hasHoverOverride = hasBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect");
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
{isBlockResponsive && (
|
|
74
|
+
<div className="px-4 pt-3">
|
|
75
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
|
|
76
|
+
<span className="text-[11px] font-medium text-[#076bff]">
|
|
77
|
+
Editing {bvp === "tablet" ? "Tablet" : "Phone"} overrides
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<div className="relative">
|
|
83
|
+
{hasEnterOverride && (
|
|
84
|
+
<div className="flex items-center justify-between px-4 pt-2">
|
|
85
|
+
<span className="text-[9px] text-[#076bff] font-medium">overridden</span>
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => {
|
|
88
|
+
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation", undefined);
|
|
89
|
+
store.updateBlock(selectedBlock.block._key, updates);
|
|
90
|
+
}}
|
|
91
|
+
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
|
|
92
|
+
>
|
|
93
|
+
Reset
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
<EnterAnimationPicker
|
|
98
|
+
mode={{ level: "block", blockType: selectedBlock.block._type }}
|
|
99
|
+
config={effectiveEnterAnim}
|
|
100
|
+
onChange={(cfg) => {
|
|
101
|
+
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation", cfg);
|
|
102
|
+
store.updateBlock(selectedBlock.block._key, updates);
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
{/* Hover Effect — block-level only, shown if block type has hover presets */}
|
|
107
|
+
<div className="border-t border-neutral-200 my-1" />
|
|
108
|
+
<div className="relative">
|
|
109
|
+
{hasHoverOverride && (
|
|
110
|
+
<div className="flex items-center justify-between px-4 pt-2">
|
|
111
|
+
<span className="text-[9px] text-[#076bff] font-medium">overridden</span>
|
|
112
|
+
<button
|
|
113
|
+
onClick={() => {
|
|
114
|
+
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect", undefined);
|
|
115
|
+
store.updateBlock(selectedBlock.block._key, updates);
|
|
116
|
+
}}
|
|
117
|
+
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
|
|
118
|
+
>
|
|
119
|
+
Reset
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
<HoverEffectPicker
|
|
124
|
+
blockType={selectedBlock.block._type}
|
|
125
|
+
config={effectiveHoverEffect ?? getBlockHoverEffect(selectedBlock.block)}
|
|
126
|
+
onChange={(cfg) => {
|
|
127
|
+
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect", cfg);
|
|
128
|
+
store.updateBlock(selectedBlock.block._key, updates);
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
{isProjectGrid && pgBlock && (
|
|
133
|
+
<>
|
|
134
|
+
<div className="border-t border-neutral-200 my-1" />
|
|
135
|
+
<CardEntranceSection block={pgBlock} />
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Page-level: generic enter animation (no hover at page level)
|
|
143
|
+
return (
|
|
144
|
+
<EnterAnimationPicker
|
|
145
|
+
mode={{ level: "page" }}
|
|
146
|
+
config={store.pageSettings.enter_animation}
|
|
147
|
+
onChange={(cfg) => {
|
|
148
|
+
store.updatePageSettings({ enter_animation: cfg });
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -145,59 +145,14 @@ function AlignmentButtons<T extends string>({
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
// ── Section title icons (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
</svg>
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function SpacingSectionIcon() {
|
|
161
|
-
return (
|
|
162
|
-
<svg width={14} height={14} viewBox="0 0 14 14" fill="none">
|
|
163
|
-
<rect x="4" y="4" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.5" />
|
|
164
|
-
<path d="M7 1 L7 3.5" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
|
|
165
|
-
<path d="M7 10.5 L7 13" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
|
|
166
|
-
<path d="M1 7 L3.5 7" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
|
|
167
|
-
<path d="M10.5 7 L13 7" stroke="currentColor" strokeWidth="0.8" opacity="0.7" />
|
|
168
|
-
</svg>
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function OffsetSectionIcon() {
|
|
173
|
-
return (
|
|
174
|
-
<svg width={14} height={14} viewBox="0 0 14 14" fill="none">
|
|
175
|
-
<rect x="3" y="3" width="8" height="8" rx="1" stroke="currentColor" strokeWidth="0.8" strokeDasharray="2 1" fill="none" opacity="0.35" />
|
|
176
|
-
<rect x="5" y="5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1" fill="none" opacity="0.7" />
|
|
177
|
-
<path d="M4 4 L5 5" stroke="currentColor" strokeWidth="0.6" opacity="0.5" />
|
|
178
|
-
</svg>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function BackgroundSectionIcon() {
|
|
183
|
-
return (
|
|
184
|
-
<svg width={14} height={14} viewBox="0 0 14 14" fill="none">
|
|
185
|
-
<rect x="1.5" y="1.5" width="11" height="11" rx="2" fill="currentColor" opacity="0.15" />
|
|
186
|
-
<rect x="1.5" y="1.5" width="11" height="11" rx="2" stroke="currentColor" strokeWidth="0.8" opacity="0.5" fill="none" />
|
|
187
|
-
<circle cx="5" cy="5" r="1.5" fill="currentColor" opacity="0.5" />
|
|
188
|
-
<path d="M1.5 10 L5 7 L8 9 L10.5 6.5 L12.5 8.5 L12.5 11 C12.5 11.8 11.8 12.5 11 12.5 L3 12.5 C2.2 12.5 1.5 11.8 1.5 11 Z" fill="currentColor" opacity="0.3" />
|
|
189
|
-
</svg>
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function BorderSectionIcon() {
|
|
194
|
-
return (
|
|
195
|
-
<svg width={14} height={14} viewBox="0 0 14 14" fill="none">
|
|
196
|
-
<rect x="2" y="2" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.2" fill="none" opacity="0.6" />
|
|
197
|
-
<rect x="2" y="2" width="10" height="1.2" rx="0.5" fill="currentColor" opacity="0.7" />
|
|
198
|
-
</svg>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
148
|
+
// ── Section title icons (centralized colored icons — Session 163) ──
|
|
149
|
+
import {
|
|
150
|
+
AlignmentIcon,
|
|
151
|
+
SpacingIcon,
|
|
152
|
+
OffsetIcon,
|
|
153
|
+
BackgroundIcon,
|
|
154
|
+
BorderIcon,
|
|
155
|
+
} from "../editors/section-icons";
|
|
201
156
|
|
|
202
157
|
// ── Override indicator badge ──
|
|
203
158
|
|
|
@@ -287,7 +242,7 @@ export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
|
287
242
|
)}
|
|
288
243
|
|
|
289
244
|
{/* Alignment */}
|
|
290
|
-
<SettingsSection title="Alignment" defaultOpen icon={<
|
|
245
|
+
<SettingsSection title="Alignment" defaultOpen icon={<AlignmentIcon />}>
|
|
291
246
|
<SettingsField label="Horizontal">
|
|
292
247
|
<AlignmentButtons<AlignH>
|
|
293
248
|
options={[
|
|
@@ -329,7 +284,7 @@ export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
|
329
284
|
</SettingsSection>
|
|
330
285
|
|
|
331
286
|
{/* Spacing (Padding) */}
|
|
332
|
-
<SettingsSection title="Spacing" defaultOpen icon={<
|
|
287
|
+
<SettingsSection title="Spacing" defaultOpen icon={<SpacingIcon />}>
|
|
333
288
|
<TRBLInputs
|
|
334
289
|
top={getBlockLayoutValue<string>(block, activeViewport, "spacing_top", "0")}
|
|
335
290
|
right={getBlockLayoutValue<string>(block, activeViewport, "spacing_right", "0")}
|
|
@@ -348,7 +303,7 @@ export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
|
348
303
|
</SettingsSection>
|
|
349
304
|
|
|
350
305
|
{/* Offset (Margin) */}
|
|
351
|
-
<SettingsSection title="Offset" icon={<
|
|
306
|
+
<SettingsSection title="Offset" icon={<OffsetIcon />}>
|
|
352
307
|
<TRBLInputs
|
|
353
308
|
top={getBlockLayoutValue<string>(block, activeViewport, "offset_top", "0")}
|
|
354
309
|
right={getBlockLayoutValue<string>(block, activeViewport, "offset_right", "0")}
|
|
@@ -367,7 +322,7 @@ export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
|
367
322
|
</SettingsSection>
|
|
368
323
|
|
|
369
324
|
{/* Background */}
|
|
370
|
-
<SettingsSection title="Background" defaultOpen icon={<
|
|
325
|
+
<SettingsSection title="Background" defaultOpen icon={<BackgroundIcon />}>
|
|
371
326
|
<SettingsField label="Color">
|
|
372
327
|
<ColorSwatchPicker
|
|
373
328
|
value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "background_color", ""))}
|
|
@@ -466,7 +421,7 @@ export function BlockLayoutTab({ block }: { block: ContentBlock }) {
|
|
|
466
421
|
</SettingsSection>
|
|
467
422
|
|
|
468
423
|
{/* Border */}
|
|
469
|
-
<SettingsSection title="Border" icon={<
|
|
424
|
+
<SettingsSection title="Border" icon={<BorderIcon />}>
|
|
470
425
|
<SettingsField label="Color">
|
|
471
426
|
<ColorSwatchPicker
|
|
472
427
|
value={parseColorField(getBlockLayoutValue<string>(block, activeViewport, "border_color", ""))}
|