@morphika/andami 0.1.7 → 0.1.9
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/app/(site)/[slug]/page.tsx +3 -2
- package/app/(site)/page.tsx +3 -2
- package/app/(site)/work/[slug]/page.tsx +3 -3
- package/app/robots.ts +38 -1
- package/components/builder/SettingsPanel.tsx +29 -543
- package/components/builder/live-preview/GhostCard.tsx +84 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +294 -1010
- 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/CardEntranceSection.tsx +114 -0
- package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +32 -0
- package/components/builder/settings-panel/CustomSectionSettings.tsx +150 -0
- package/components/builder/settings-panel/index.ts +6 -0
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +184 -0
- package/lib/bot-guard.ts +138 -0
- 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/package.json +10 -9
|
@@ -10,36 +10,22 @@
|
|
|
10
10
|
* settings-panel/ColumnSettings.tsx — Column width, alignment, gap
|
|
11
11
|
* settings-panel/BlockSettings.tsx — Block type editor router
|
|
12
12
|
* settings-panel/responsive-helpers.ts — Viewport-aware setting resolution
|
|
13
|
+
*
|
|
14
|
+
* Session C (refactor split):
|
|
15
|
+
* settings-panel/useSettingsPanelSelection.ts — Selection resolution hook
|
|
16
|
+
* settings-panel/AnimationTab.tsx — Block/section/page animation routing
|
|
17
|
+
* settings-panel/ColumnV2AnimationTab.tsx — Column enter animation
|
|
18
|
+
* settings-panel/CardEntranceSection.tsx — ProjectGrid card entrance controls
|
|
19
|
+
* settings-panel/CustomSectionSettings.tsx — Edit/detach for custom section instances
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
|
-
import { useState, useEffect, useRef
|
|
22
|
+
import { useState, useEffect, useRef } from "react";
|
|
16
23
|
import { useBuilderStore } from "../../lib/builder/store";
|
|
17
|
-
import { ALL_BLOCK_INFO } from "../../lib/builder/types";
|
|
18
|
-
import { BUILDER_VIOLET } from "../../lib/builder/constants";
|
|
19
|
-
import { BLOCK_GRADIENTS, BLOCK_ICON_COMPONENTS } from "./blockStyles";
|
|
20
|
-
import type { ContentBlock, ContentItem, PageSection, PageSectionV2, CustomSectionInstance, ParallaxGroup, ParallaxSlideV2, SectionColumn, ProjectGridBlock, CardEntranceConfig } from "../../lib/sanity/types";
|
|
21
|
-
import { isPageSection, isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
|
|
22
|
-
|
|
23
|
-
import type { HoverEffectConfig } from "../../lib/animation/hover-effect-types";
|
|
24
|
-
import EnterAnimationPicker from "./editors/EnterAnimationPicker";
|
|
25
|
-
import HoverEffectPicker from "./editors/HoverEffectPicker";
|
|
26
|
-
import {
|
|
27
|
-
getBlockAnimationValue,
|
|
28
|
-
hasBlockAnimationOverride,
|
|
29
|
-
setBlockAnimationOverride,
|
|
30
|
-
} from "./settings-panel/responsive-helpers";
|
|
31
24
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// ProjectGridBlock hover_effect is the old per-card string — not HoverEffectConfig
|
|
37
|
-
if (block._type === "projectGridBlock") return undefined;
|
|
38
|
-
const val = (block as unknown as Record<string, unknown>).hover_effect;
|
|
39
|
-
if (val === undefined || val === null) return undefined;
|
|
40
|
-
if (typeof val === "object") return val as HoverEffectConfig;
|
|
41
|
-
return undefined;
|
|
42
|
-
}
|
|
25
|
+
import { useSettingsPanelSelection } from "./settings-panel/useSettingsPanelSelection";
|
|
26
|
+
import { AnimationTab } from "./settings-panel/AnimationTab";
|
|
27
|
+
import { ColumnV2AnimationTab } from "./settings-panel/ColumnV2AnimationTab";
|
|
28
|
+
import { CustomSectionSettings } from "./settings-panel/CustomSectionSettings";
|
|
43
29
|
import {
|
|
44
30
|
LayoutTab,
|
|
45
31
|
BlockLayoutTab,
|
|
@@ -60,125 +46,23 @@ export default function SettingsPanel() {
|
|
|
60
46
|
const store = useBuilderStore();
|
|
61
47
|
const [activeTab, setActiveTab] = useState<SettingsTab>("settings");
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
_type: "pageSectionV2",
|
|
81
|
-
_key: slide._key,
|
|
82
|
-
section_type: "empty-v2",
|
|
83
|
-
columns: slide.columns,
|
|
84
|
-
settings: slide.section_settings,
|
|
85
|
-
};
|
|
86
|
-
return { group, slide, virtualSection };
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return null;
|
|
90
|
-
})();
|
|
91
|
-
|
|
92
|
-
// V2 column: when a V2 section (or parallax slide) is selected and a column key is set
|
|
93
|
-
const effectiveSectionV2 = selectedSectionV2 || selectedParallaxSlide?.virtualSection || null;
|
|
94
|
-
const selectedColumnV2: SectionColumn | null = effectiveSectionV2 && store.selectedColumnKey
|
|
95
|
-
? effectiveSectionV2.columns.find((c) => c._key === store.selectedColumnKey) || null
|
|
96
|
-
: null;
|
|
97
|
-
|
|
98
|
-
// For PageSections, the "block" is section.block[0] — selected automatically
|
|
99
|
-
const selectedBlock = (() => {
|
|
100
|
-
// If a PageSection is selected, its block is the section block
|
|
101
|
-
if (selectedSection) {
|
|
102
|
-
const block = selectedSection.block[0];
|
|
103
|
-
if (block) return { block, rowKey: selectedSection._key, colKey: "", isSection: true };
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
// Regular block search inside rows, V2 sections, and parallax slides
|
|
107
|
-
if (!store.selectedBlockKey) return null;
|
|
108
|
-
for (const item of store.rows) {
|
|
109
|
-
// V2 sections: search inside columns
|
|
110
|
-
if (isPageSectionV2(item)) {
|
|
111
|
-
for (const col of (item as PageSectionV2).columns || []) {
|
|
112
|
-
const block = (col.blocks || []).find(
|
|
113
|
-
(b) => b._key === store.selectedBlockKey
|
|
114
|
-
);
|
|
115
|
-
if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
// Parallax groups: search inside slide columns
|
|
119
|
-
if (isParallaxGroup(item)) {
|
|
120
|
-
const group = item as ParallaxGroup;
|
|
121
|
-
for (const slide of group.slides) {
|
|
122
|
-
for (const col of slide.columns || []) {
|
|
123
|
-
const block = (col.blocks || []).find(
|
|
124
|
-
(b) => b._key === store.selectedBlockKey
|
|
125
|
-
);
|
|
126
|
-
if (block) return { block, rowKey: slide._key, colKey: col._key, isSection: false };
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
return null;
|
|
132
|
-
})();
|
|
133
|
-
|
|
134
|
-
// Derive the panel title + icon from what's selected
|
|
135
|
-
const blockInfo = selectedBlock
|
|
136
|
-
? ALL_BLOCK_INFO.find((b) => b.type === selectedBlock.block._type)
|
|
137
|
-
: null;
|
|
138
|
-
|
|
139
|
-
// BUG-V2-003 fix: Block selection takes priority over V2 column/section
|
|
140
|
-
const panelTitle = selectedBlock
|
|
141
|
-
? blockInfo?.label || selectedBlock.block._type
|
|
142
|
-
: selectedColumnV2
|
|
143
|
-
? "Column"
|
|
144
|
-
: selectedParallaxSlide
|
|
145
|
-
? `Slide ${selectedParallaxSlide.group.slides.findIndex((s) => s._key === selectedParallaxSlide.slide._key) + 1}`
|
|
146
|
-
: selectedParallaxGroup
|
|
147
|
-
? "Parallax Showcase"
|
|
148
|
-
: selectedCustomSectionInstance
|
|
149
|
-
? (selectedCustomSectionInstance.custom_section_title || "Saved Section")
|
|
150
|
-
: selectedSectionV2
|
|
151
|
-
? "Section"
|
|
152
|
-
: selectedSection
|
|
153
|
-
? (selectedSection.section_type === "projectGrid" ? "Project Grid" : "Parallax Section")
|
|
154
|
-
: "Page";
|
|
155
|
-
|
|
156
|
-
// Resolve gradient + icon component for the header
|
|
157
|
-
const headerStyleKey = selectedBlock
|
|
158
|
-
? selectedBlock.block._type
|
|
159
|
-
: selectedColumnV2
|
|
160
|
-
? "column"
|
|
161
|
-
: (selectedParallaxSlide || selectedParallaxGroup)
|
|
162
|
-
? "parallaxGroup"
|
|
163
|
-
: selectedCustomSectionInstance
|
|
164
|
-
? "customSectionInstance"
|
|
165
|
-
: selectedSectionV2
|
|
166
|
-
? "row"
|
|
167
|
-
: selectedSection
|
|
168
|
-
? (selectedSection.block[0]?._type || "row")
|
|
169
|
-
: "page";
|
|
170
|
-
const headerGradient = BLOCK_GRADIENTS[headerStyleKey] || BLOCK_GRADIENTS.page;
|
|
171
|
-
const HeaderIconComponent = BLOCK_ICON_COMPONENTS[headerStyleKey];
|
|
172
|
-
|
|
173
|
-
const hasSelection = !!(store.selectedRowKey || store.selectedColumnKey || store.selectedBlockKey);
|
|
174
|
-
// V2 columns: show Settings + Animation tabs (not Layout) — but NOT when a block inside the column is selected
|
|
175
|
-
const isColumnOnly = !!(selectedColumnV2 && !selectedBlock);
|
|
176
|
-
// Parallax group header: show Settings + Animation (no Layout)
|
|
177
|
-
const isParallaxGroupOnly = !!(selectedParallaxGroup && !selectedParallaxSlide && !selectedBlock);
|
|
178
|
-
// Custom section instance: show all 3 tabs (Settings with Edit/Detach, Layout, Animation)
|
|
179
|
-
const isCustomSectionOnly = !!(selectedCustomSectionInstance && !selectedBlock);
|
|
180
|
-
// Page level: nothing selected — show Settings + SEO + Animation (no Layout)
|
|
181
|
-
const isPageLevel = !hasSelection;
|
|
49
|
+
const sel = useSettingsPanelSelection();
|
|
50
|
+
const {
|
|
51
|
+
selectedSection,
|
|
52
|
+
selectedSectionV2,
|
|
53
|
+
selectedCustomSectionInstance,
|
|
54
|
+
selectedParallaxGroup,
|
|
55
|
+
selectedParallaxSlide,
|
|
56
|
+
effectiveSectionV2,
|
|
57
|
+
selectedColumnV2,
|
|
58
|
+
selectedBlock,
|
|
59
|
+
panelTitle,
|
|
60
|
+
headerGradient,
|
|
61
|
+
HeaderIconComponent,
|
|
62
|
+
isColumnOnly,
|
|
63
|
+
isParallaxGroupOnly,
|
|
64
|
+
isPageLevel,
|
|
65
|
+
} = sel;
|
|
182
66
|
|
|
183
67
|
// Reset to "settings" tab when selection changes
|
|
184
68
|
const selectionKey = `${store.selectedRowKey}-${store.selectedColumnKey}-${store.selectedBlockKey}`;
|
|
@@ -510,402 +394,4 @@ export default function SettingsPanel() {
|
|
|
510
394
|
);
|
|
511
395
|
}
|
|
512
396
|
|
|
513
|
-
// ============================================
|
|
514
|
-
// Animation Tab — extracted inline for clarity
|
|
515
|
-
// ============================================
|
|
516
|
-
|
|
517
|
-
function AnimationTab({
|
|
518
|
-
selectedBlock,
|
|
519
|
-
selectedSection,
|
|
520
|
-
}: {
|
|
521
|
-
selectedBlock: { block: ContentBlock; rowKey: string; colKey: string; isSection: boolean } | null;
|
|
522
|
-
selectedSection: PageSection | null;
|
|
523
|
-
}) {
|
|
524
|
-
const store = useBuilderStore();
|
|
525
|
-
|
|
526
|
-
// PageSection (V1): enter animation on the section settings level
|
|
527
|
-
if (selectedSection) {
|
|
528
|
-
return (
|
|
529
|
-
<EnterAnimationPicker
|
|
530
|
-
mode={{ level: "section", parentConfig: store.pageSettings.enter_animation }}
|
|
531
|
-
config={selectedSection.settings?.enter_animation}
|
|
532
|
-
onChange={(cfg) => {
|
|
533
|
-
store.updateSectionSettings(selectedSection._key, { enter_animation: cfg });
|
|
534
|
-
}}
|
|
535
|
-
/>
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// Block level: type-specific enter picker + card entrance for projectGrid
|
|
540
|
-
if (selectedBlock) {
|
|
541
|
-
const isProjectGrid = selectedBlock.block._type === "projectGridBlock";
|
|
542
|
-
const pgBlock = isProjectGrid ? (selectedBlock.block as ProjectGridBlock) : null;
|
|
543
|
-
const bvp = store.activeViewport;
|
|
544
|
-
const isBlockResponsive = bvp !== "desktop";
|
|
545
|
-
|
|
546
|
-
const effectiveEnterAnim = getBlockAnimationValue(
|
|
547
|
-
selectedBlock.block, bvp, "enter_animation", undefined
|
|
548
|
-
);
|
|
549
|
-
const effectiveHoverEffect = getBlockAnimationValue(
|
|
550
|
-
selectedBlock.block, bvp, "hover_effect", undefined
|
|
551
|
-
) as HoverEffectConfig | undefined;
|
|
552
|
-
|
|
553
|
-
const hasEnterOverride = hasBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation");
|
|
554
|
-
const hasHoverOverride = hasBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect");
|
|
555
|
-
|
|
556
|
-
return (
|
|
557
|
-
<>
|
|
558
|
-
{isBlockResponsive && (
|
|
559
|
-
<div className="px-4 pt-3">
|
|
560
|
-
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#076bff]/8 border border-[#076bff]/15">
|
|
561
|
-
<span className="text-[11px] font-medium text-[#076bff]">
|
|
562
|
-
Editing {bvp === "tablet" ? "Tablet" : "Phone"} overrides
|
|
563
|
-
</span>
|
|
564
|
-
</div>
|
|
565
|
-
</div>
|
|
566
|
-
)}
|
|
567
|
-
<div className="relative">
|
|
568
|
-
{hasEnterOverride && (
|
|
569
|
-
<div className="flex items-center justify-between px-4 pt-2">
|
|
570
|
-
<span className="text-[9px] text-[#076bff] font-medium">overridden</span>
|
|
571
|
-
<button
|
|
572
|
-
onClick={() => {
|
|
573
|
-
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation", undefined);
|
|
574
|
-
store.updateBlock(selectedBlock.block._key, updates);
|
|
575
|
-
}}
|
|
576
|
-
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
|
|
577
|
-
>
|
|
578
|
-
Reset
|
|
579
|
-
</button>
|
|
580
|
-
</div>
|
|
581
|
-
)}
|
|
582
|
-
<EnterAnimationPicker
|
|
583
|
-
mode={{ level: "block", blockType: selectedBlock.block._type }}
|
|
584
|
-
config={effectiveEnterAnim}
|
|
585
|
-
onChange={(cfg) => {
|
|
586
|
-
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "enter_animation", cfg);
|
|
587
|
-
store.updateBlock(selectedBlock.block._key, updates);
|
|
588
|
-
}}
|
|
589
|
-
/>
|
|
590
|
-
</div>
|
|
591
|
-
{/* Hover Effect — block-level only, shown if block type has hover presets */}
|
|
592
|
-
<div className="border-t border-neutral-200 my-1" />
|
|
593
|
-
<div className="relative">
|
|
594
|
-
{hasHoverOverride && (
|
|
595
|
-
<div className="flex items-center justify-between px-4 pt-2">
|
|
596
|
-
<span className="text-[9px] text-[#076bff] font-medium">overridden</span>
|
|
597
|
-
<button
|
|
598
|
-
onClick={() => {
|
|
599
|
-
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect", undefined);
|
|
600
|
-
store.updateBlock(selectedBlock.block._key, updates);
|
|
601
|
-
}}
|
|
602
|
-
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors"
|
|
603
|
-
>
|
|
604
|
-
Reset
|
|
605
|
-
</button>
|
|
606
|
-
</div>
|
|
607
|
-
)}
|
|
608
|
-
<HoverEffectPicker
|
|
609
|
-
blockType={selectedBlock.block._type}
|
|
610
|
-
config={effectiveHoverEffect ?? getBlockHoverEffect(selectedBlock.block)}
|
|
611
|
-
onChange={(cfg) => {
|
|
612
|
-
const updates = setBlockAnimationOverride(selectedBlock.block, bvp, "hover_effect", cfg);
|
|
613
|
-
store.updateBlock(selectedBlock.block._key, updates);
|
|
614
|
-
}}
|
|
615
|
-
/>
|
|
616
|
-
</div>
|
|
617
|
-
{isProjectGrid && pgBlock && (
|
|
618
|
-
<>
|
|
619
|
-
<div className="border-t border-neutral-200 my-1" />
|
|
620
|
-
<CardEntranceSection block={pgBlock} />
|
|
621
|
-
</>
|
|
622
|
-
)}
|
|
623
|
-
</>
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Page-level: generic enter animation (no hover at page level)
|
|
628
|
-
return (
|
|
629
|
-
<EnterAnimationPicker
|
|
630
|
-
mode={{ level: "page" }}
|
|
631
|
-
config={store.pageSettings.enter_animation}
|
|
632
|
-
onChange={(cfg) => {
|
|
633
|
-
store.updatePageSettings({ enter_animation: cfg });
|
|
634
|
-
}}
|
|
635
|
-
/>
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// ============================================
|
|
640
|
-
// Column V2 Animation Tab — enter animation with inherit from section
|
|
641
|
-
// ============================================
|
|
642
|
-
|
|
643
|
-
function ColumnV2AnimationTab({
|
|
644
|
-
section,
|
|
645
|
-
column,
|
|
646
|
-
}: {
|
|
647
|
-
section: PageSectionV2;
|
|
648
|
-
column: SectionColumn;
|
|
649
|
-
}) {
|
|
650
|
-
const store = useBuilderStore();
|
|
651
|
-
|
|
652
|
-
return (
|
|
653
|
-
<EnterAnimationPicker
|
|
654
|
-
mode={{ level: "column", parentConfig: section.settings.enter_animation }}
|
|
655
|
-
config={column.enter_animation}
|
|
656
|
-
onChange={(cfg) => {
|
|
657
|
-
store.updateColumnEnterAnimation(section._key, column._key, cfg);
|
|
658
|
-
}}
|
|
659
|
-
/>
|
|
660
|
-
);
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// ============================================
|
|
664
|
-
// Card Entrance Section — for ProjectGridBlock Animation tab
|
|
665
|
-
// ============================================
|
|
666
|
-
|
|
667
|
-
const ENTRANCE_PRESETS = [
|
|
668
|
-
{ value: "fade", label: "Fade" },
|
|
669
|
-
{ value: "slide-up", label: "Slide Up" },
|
|
670
|
-
{ value: "scale", label: "Scale" },
|
|
671
|
-
] as const;
|
|
672
|
-
|
|
673
|
-
const CARD_ENTRANCE_SELECT_CLASS =
|
|
674
|
-
"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-[#076bff] focus:shadow-[0_0_0_3px_rgba(7,107,255,0.06)]";
|
|
675
|
-
|
|
676
|
-
const CARD_ENTRANCE_SLIDER_CLASS =
|
|
677
|
-
"w-full h-1.5 rounded-full bg-[#e5e5e5] appearance-none cursor-pointer accent-[#076bff] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#076bff] [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:shadow-sm";
|
|
678
|
-
|
|
679
|
-
function CardEntranceSection({ block }: { block: ProjectGridBlock }) {
|
|
680
|
-
const updateBlock = useBuilderStore((s) => s.updateBlock);
|
|
681
|
-
const entrance = block.card_entrance;
|
|
682
|
-
const enabled = entrance?.enabled ?? false;
|
|
683
|
-
|
|
684
|
-
const update = (updates: Partial<CardEntranceConfig>) => {
|
|
685
|
-
updateBlock(block._key, {
|
|
686
|
-
card_entrance: { ...entrance, ...updates },
|
|
687
|
-
} as Partial<ProjectGridBlock>);
|
|
688
|
-
};
|
|
689
|
-
|
|
690
|
-
return (
|
|
691
|
-
<div className="px-4 py-3">
|
|
692
|
-
<div className="flex items-center justify-between mb-2.5">
|
|
693
|
-
<span className="text-xs font-medium text-neutral-700">Card Entrance</span>
|
|
694
|
-
<button
|
|
695
|
-
type="button"
|
|
696
|
-
onClick={() => update({ enabled: !enabled })}
|
|
697
|
-
className={`relative w-8 h-[18px] rounded-full transition-colors ${
|
|
698
|
-
enabled ? "bg-[#076bff]" : "bg-neutral-300"
|
|
699
|
-
}`}
|
|
700
|
-
>
|
|
701
|
-
<span
|
|
702
|
-
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow transition-transform ${
|
|
703
|
-
enabled ? "translate-x-[16px]" : "translate-x-[2px]"
|
|
704
|
-
}`}
|
|
705
|
-
/>
|
|
706
|
-
</button>
|
|
707
|
-
</div>
|
|
708
|
-
|
|
709
|
-
{enabled && (
|
|
710
|
-
<div className="space-y-3">
|
|
711
|
-
{/* Preset — dropdown instead of segmented buttons */}
|
|
712
|
-
<div>
|
|
713
|
-
<label className="text-[11px] text-neutral-500 mb-1 block">Preset</label>
|
|
714
|
-
<select
|
|
715
|
-
value={entrance?.preset || "slide-up"}
|
|
716
|
-
onChange={(e) => update({ preset: e.target.value as "fade" | "slide-up" | "scale" })}
|
|
717
|
-
className={CARD_ENTRANCE_SELECT_CLASS}
|
|
718
|
-
>
|
|
719
|
-
{ENTRANCE_PRESETS.map((opt) => (
|
|
720
|
-
<option key={opt.value} value={opt.value}>
|
|
721
|
-
{opt.label}
|
|
722
|
-
</option>
|
|
723
|
-
))}
|
|
724
|
-
</select>
|
|
725
|
-
</div>
|
|
726
|
-
|
|
727
|
-
{/* Stagger delay */}
|
|
728
|
-
<div>
|
|
729
|
-
<div className="flex items-center justify-between mb-1">
|
|
730
|
-
<label className="text-[11px] text-neutral-500">Stagger</label>
|
|
731
|
-
<span className="text-[11px] text-neutral-500 tabular-nums">
|
|
732
|
-
{entrance?.stagger_delay ?? 80}ms
|
|
733
|
-
</span>
|
|
734
|
-
</div>
|
|
735
|
-
<input
|
|
736
|
-
type="range"
|
|
737
|
-
min={0}
|
|
738
|
-
max={5000}
|
|
739
|
-
step={10}
|
|
740
|
-
value={entrance?.stagger_delay ?? 80}
|
|
741
|
-
onChange={(e) => update({ stagger_delay: Number(e.target.value) })}
|
|
742
|
-
className={CARD_ENTRANCE_SLIDER_CLASS}
|
|
743
|
-
/>
|
|
744
|
-
</div>
|
|
745
|
-
|
|
746
|
-
{/* Duration */}
|
|
747
|
-
<div>
|
|
748
|
-
<div className="flex items-center justify-between mb-1">
|
|
749
|
-
<label className="text-[11px] text-neutral-500">Duration</label>
|
|
750
|
-
<span className="text-[11px] text-neutral-500 tabular-nums">
|
|
751
|
-
{entrance?.duration ?? 500}ms
|
|
752
|
-
</span>
|
|
753
|
-
</div>
|
|
754
|
-
<input
|
|
755
|
-
type="range"
|
|
756
|
-
min={200}
|
|
757
|
-
max={5000}
|
|
758
|
-
step={50}
|
|
759
|
-
value={entrance?.duration ?? 500}
|
|
760
|
-
onChange={(e) => update({ duration: Number(e.target.value) })}
|
|
761
|
-
className={CARD_ENTRANCE_SLIDER_CLASS}
|
|
762
|
-
/>
|
|
763
|
-
</div>
|
|
764
|
-
</div>
|
|
765
|
-
)}
|
|
766
|
-
</div>
|
|
767
|
-
);
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// ============================================
|
|
771
|
-
// Custom Section Instance Settings — Edit & Detach actions
|
|
772
|
-
// ============================================
|
|
773
|
-
|
|
774
|
-
function CustomSectionSettings({ instance }: { instance: CustomSectionInstance }) {
|
|
775
|
-
const store = useBuilderStore();
|
|
776
|
-
const [showDetachConfirm, setShowDetachConfirm] = useState(false);
|
|
777
|
-
const [sectionData, setSectionData] = useState<PageSectionV2 | null>(null);
|
|
778
|
-
const [loadingEdit, setLoadingEdit] = useState(false);
|
|
779
|
-
|
|
780
|
-
// Fetch section data for detach
|
|
781
|
-
useEffect(() => {
|
|
782
|
-
let cancelled = false;
|
|
783
|
-
fetch(`/api/custom-sections/${instance.custom_section_id}`)
|
|
784
|
-
.then((res) => res.ok ? res.json() : null)
|
|
785
|
-
.then((data) => {
|
|
786
|
-
if (!cancelled && data?.section) setSectionData(data.section);
|
|
787
|
-
})
|
|
788
|
-
.catch(() => {});
|
|
789
|
-
return () => { cancelled = true; };
|
|
790
|
-
}, [instance.custom_section_id]);
|
|
791
|
-
|
|
792
|
-
const handleEdit = useCallback(async () => {
|
|
793
|
-
setLoadingEdit(true);
|
|
794
|
-
try {
|
|
795
|
-
const res = await fetch(`/api/admin/custom-sections/${instance.custom_section_slug}`);
|
|
796
|
-
if (!res.ok) throw new Error("Failed to load section");
|
|
797
|
-
const data = await res.json();
|
|
798
|
-
|
|
799
|
-
const remoteTitle = data.section.title;
|
|
800
|
-
if (remoteTitle && remoteTitle !== instance.custom_section_title) {
|
|
801
|
-
store.updateCustomSectionInstanceTitle(instance._key, remoteTitle);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
store.enterSectionEditor(
|
|
805
|
-
instance.custom_section_slug,
|
|
806
|
-
remoteTitle,
|
|
807
|
-
data.section.section
|
|
808
|
-
);
|
|
809
|
-
} catch {
|
|
810
|
-
if (sectionData) {
|
|
811
|
-
store.enterSectionEditor(
|
|
812
|
-
instance.custom_section_slug,
|
|
813
|
-
instance.custom_section_title,
|
|
814
|
-
sectionData
|
|
815
|
-
);
|
|
816
|
-
}
|
|
817
|
-
} finally {
|
|
818
|
-
setLoadingEdit(false);
|
|
819
|
-
}
|
|
820
|
-
}, [instance, sectionData, store]);
|
|
821
|
-
|
|
822
|
-
const handleDetach = useCallback(() => {
|
|
823
|
-
if (!sectionData) return;
|
|
824
|
-
store.detachCustomSectionInstance(instance._key, sectionData);
|
|
825
|
-
setShowDetachConfirm(false);
|
|
826
|
-
}, [instance._key, sectionData, store]);
|
|
827
|
-
|
|
828
|
-
return (
|
|
829
|
-
<div className="px-4 py-3 space-y-3">
|
|
830
|
-
{/* Linked badge */}
|
|
831
|
-
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[#f3f0ff] border border-[#8b5cf6]/20">
|
|
832
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
|
833
|
-
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
834
|
-
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
835
|
-
</svg>
|
|
836
|
-
<span className="text-[11px] text-[#8b5cf6] font-medium truncate">
|
|
837
|
-
Linked Section
|
|
838
|
-
</span>
|
|
839
|
-
</div>
|
|
840
|
-
|
|
841
|
-
{/* Edit button */}
|
|
842
|
-
<button
|
|
843
|
-
onClick={handleEdit}
|
|
844
|
-
disabled={loadingEdit}
|
|
845
|
-
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium text-white transition-colors disabled:opacity-50"
|
|
846
|
-
style={{ backgroundColor: BUILDER_VIOLET }}
|
|
847
|
-
>
|
|
848
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
849
|
-
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
|
850
|
-
<path d="m15 5 4 4" />
|
|
851
|
-
</svg>
|
|
852
|
-
{loadingEdit ? "Loading..." : "Edit Section"}
|
|
853
|
-
</button>
|
|
854
|
-
<p className="text-[10px] text-neutral-400 -mt-1">
|
|
855
|
-
Changes apply to all pages using this section.
|
|
856
|
-
</p>
|
|
857
|
-
|
|
858
|
-
{/* Detach button */}
|
|
859
|
-
<button
|
|
860
|
-
onClick={() => setShowDetachConfirm(true)}
|
|
861
|
-
disabled={!sectionData}
|
|
862
|
-
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium text-neutral-600 bg-[#f5f5f5] hover:bg-[#ebebeb] transition-colors disabled:opacity-30"
|
|
863
|
-
>
|
|
864
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
865
|
-
<path d="M18.84 12.25l1.72-1.71a5 5 0 0 0-7.07-7.07l-3 3a5 5 0 0 0 .54 7.54" />
|
|
866
|
-
<path d="M5.16 11.75l-1.72 1.71a5 5 0 0 0 7.07 7.07l3-3a5 5 0 0 0-.54-7.54" />
|
|
867
|
-
<line x1="2" y1="2" x2="22" y2="22" />
|
|
868
|
-
</svg>
|
|
869
|
-
Detach
|
|
870
|
-
</button>
|
|
871
|
-
<p className="text-[10px] text-neutral-400 -mt-1">
|
|
872
|
-
Convert to an independent inline section on this page.
|
|
873
|
-
</p>
|
|
874
|
-
|
|
875
|
-
{/* Detach confirmation dialog */}
|
|
876
|
-
{showDetachConfirm && (
|
|
877
|
-
<div
|
|
878
|
-
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60"
|
|
879
|
-
onClick={(e) => { e.stopPropagation(); setShowDetachConfirm(false); }}
|
|
880
|
-
>
|
|
881
|
-
<div
|
|
882
|
-
className="bg-white rounded-lg border border-[#e5e5e5] p-6 max-w-sm shadow-xl"
|
|
883
|
-
onClick={(e) => e.stopPropagation()}
|
|
884
|
-
>
|
|
885
|
-
<h3 className="text-neutral-900 text-sm font-medium mb-2">Detach section?</h3>
|
|
886
|
-
<p className="text-neutral-500 text-xs mb-4">
|
|
887
|
-
This will create an independent copy of “{instance.custom_section_title}”.
|
|
888
|
-
Future changes to the saved section won't affect this page.
|
|
889
|
-
</p>
|
|
890
|
-
<div className="flex justify-end gap-2">
|
|
891
|
-
<button
|
|
892
|
-
onClick={() => setShowDetachConfirm(false)}
|
|
893
|
-
className="px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-900 rounded border border-[#e5e5e5] hover:border-[#ccc] transition-colors"
|
|
894
|
-
>
|
|
895
|
-
Cancel
|
|
896
|
-
</button>
|
|
897
|
-
<button
|
|
898
|
-
onClick={handleDetach}
|
|
899
|
-
className="px-3 py-1.5 text-sm text-white rounded font-medium transition-colors"
|
|
900
|
-
style={{ backgroundColor: BUILDER_VIOLET }}
|
|
901
|
-
>
|
|
902
|
-
Detach
|
|
903
|
-
</button>
|
|
904
|
-
</div>
|
|
905
|
-
</div>
|
|
906
|
-
</div>
|
|
907
|
-
)}
|
|
908
|
-
</div>
|
|
909
|
-
);
|
|
910
|
-
}
|
|
911
397
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GhostCard — Floating card that follows the cursor during drag.
|
|
5
|
+
* Portaled to document.body by the parent to escape canvas CSS transforms.
|
|
6
|
+
*
|
|
7
|
+
* Session 162: Extracted from LiveProjectGridPreview.tsx (Phase B3).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { ProjectGridCard } from "./shared";
|
|
11
|
+
import { ADMIN_BLUE, CANCEL_DURATION, CrossArrowIcon, type DragState } from "./drag-utils";
|
|
12
|
+
import type { ProjectGridItem } from "../../../lib/sanity/types";
|
|
13
|
+
|
|
14
|
+
export default function GhostCard({
|
|
15
|
+
item,
|
|
16
|
+
thumbMap,
|
|
17
|
+
borderRadius,
|
|
18
|
+
dragState,
|
|
19
|
+
}: {
|
|
20
|
+
item: ProjectGridItem;
|
|
21
|
+
thumbMap: Map<string, string | undefined>;
|
|
22
|
+
borderRadius: number;
|
|
23
|
+
dragState: DragState;
|
|
24
|
+
}) {
|
|
25
|
+
const isCancelling = dragState.phase === "cancelling";
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
style={{
|
|
29
|
+
position: "fixed",
|
|
30
|
+
left: dragState.mouseX - dragState.offsetX,
|
|
31
|
+
top: dragState.mouseY - dragState.offsetY,
|
|
32
|
+
width: dragState.cardWidth,
|
|
33
|
+
height: dragState.cardHeight,
|
|
34
|
+
zIndex: 9999,
|
|
35
|
+
pointerEvents: "none",
|
|
36
|
+
borderRadius: borderRadius > 0 ? borderRadius : 8,
|
|
37
|
+
overflow: "hidden",
|
|
38
|
+
transform: isCancelling ? "scale(1)" : "scale(1.03)",
|
|
39
|
+
outline: `2px solid ${ADMIN_BLUE}`,
|
|
40
|
+
outlineOffset: -2,
|
|
41
|
+
boxShadow: isCancelling
|
|
42
|
+
? "0 4px 16px rgba(0,0,0,0.15)"
|
|
43
|
+
: "0 16px 48px rgba(0,0,0,0.3)",
|
|
44
|
+
transition: isCancelling
|
|
45
|
+
? `left ${CANCEL_DURATION}ms ease, top ${CANCEL_DURATION}ms ease, transform ${CANCEL_DURATION}ms ease, box-shadow ${CANCEL_DURATION}ms ease`
|
|
46
|
+
: undefined,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<ProjectGridCard
|
|
50
|
+
slug={item.project_slug}
|
|
51
|
+
thumbPath={thumbMap.get(item.project_slug)}
|
|
52
|
+
customThumb={item.custom_thumbnail}
|
|
53
|
+
borderRadius={borderRadius > 0 ? String(borderRadius) : undefined}
|
|
54
|
+
style={{ width: "100%", height: "100%" }}
|
|
55
|
+
/>
|
|
56
|
+
{/* Centered cross-arrow icon */}
|
|
57
|
+
<div
|
|
58
|
+
style={{
|
|
59
|
+
position: "absolute",
|
|
60
|
+
inset: 0,
|
|
61
|
+
display: "flex",
|
|
62
|
+
alignItems: "center",
|
|
63
|
+
justifyContent: "center",
|
|
64
|
+
pointerEvents: "none",
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
style={{
|
|
69
|
+
width: 40,
|
|
70
|
+
height: 40,
|
|
71
|
+
borderRadius: "50%",
|
|
72
|
+
backgroundColor: "rgba(255,255,255,0.92)",
|
|
73
|
+
display: "flex",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
boxShadow: "0 2px 12px rgba(0,0,0,0.2)",
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<CrossArrowIcon size={20} color="#333" />
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|