@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.
Files changed (49) hide show
  1. package/README.md +3 -0
  2. package/components/admin/nav-builder/NavBuilder.tsx +90 -14
  3. package/components/admin/nav-builder/NavGeneralSettings.tsx +521 -271
  4. package/components/admin/nav-builder/NavItemSettings.tsx +331 -312
  5. package/components/admin/nav-builder/NavMobileSettings.tsx +159 -140
  6. package/components/admin/nav-builder/NavSettingsFields.tsx +287 -21
  7. package/components/admin/nav-builder/NavSettingsPanel.tsx +137 -127
  8. package/components/blocks/TextBlockRenderer.tsx +1 -1
  9. package/components/builder/SettingsPanel.tsx +29 -543
  10. package/components/builder/editors/ButtonBlockEditor.tsx +8 -3
  11. package/components/builder/editors/CoverBlockEditor.tsx +14 -6
  12. package/components/builder/editors/ImageBlockEditor.tsx +8 -3
  13. package/components/builder/editors/ImageGridBlockEditor.tsx +8 -3
  14. package/components/builder/editors/ProjectGridEditor.tsx +7 -46
  15. package/components/builder/editors/SpacerBlockEditor.tsx +4 -1
  16. package/components/builder/editors/StaggerSettings.tsx +2 -1
  17. package/components/builder/editors/TextBlockEditor.tsx +8 -3
  18. package/components/builder/editors/VideoBlockEditor.tsx +10 -4
  19. package/components/builder/editors/section-icons.tsx +492 -0
  20. package/components/builder/editors/shared.tsx +23 -4
  21. package/components/builder/live-preview/GhostCard.tsx +84 -0
  22. package/components/builder/live-preview/LiveProjectGridPreview.tsx +294 -1010
  23. package/components/builder/live-preview/LiveTextEditor.tsx +1 -1
  24. package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -0
  25. package/components/builder/live-preview/drag-utils.tsx +89 -0
  26. package/components/builder/live-preview/useDragReorder.ts +370 -0
  27. package/components/builder/settings-panel/AnimationTab.tsx +152 -0
  28. package/components/builder/settings-panel/BlockLayoutTab.tsx +13 -58
  29. package/components/builder/settings-panel/CardEntranceSection.tsx +114 -0
  30. package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +32 -0
  31. package/components/builder/settings-panel/ColumnV2Settings.tsx +4 -1
  32. package/components/builder/settings-panel/CustomSectionSettings.tsx +150 -0
  33. package/components/builder/settings-panel/LayoutTab.tsx +11 -47
  34. package/components/builder/settings-panel/PageSettings.tsx +10 -4
  35. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +6 -2
  36. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +8 -3
  37. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +11 -47
  38. package/components/builder/settings-panel/SectionV2Settings.tsx +6 -27
  39. package/components/builder/settings-panel/index.ts +6 -0
  40. package/components/builder/settings-panel/useSettingsPanelSelection.ts +184 -0
  41. package/components/ui/Navbar.tsx +151 -30
  42. package/lib/builder/serializer/migrations.ts +107 -0
  43. package/lib/builder/serializer/normalizers.ts +278 -0
  44. package/lib/builder/serializer/serializers.ts +393 -0
  45. package/lib/builder/serializer/shared.ts +102 -0
  46. package/lib/builder/serializer.ts +11 -846
  47. package/lib/sanity/types.ts +22 -0
  48. package/package.json +13 -10
  49. package/styles/base.css +7 -3
@@ -87,7 +87,7 @@ export default function TextBlockRenderer({ block }: { block: TextBlock }) {
87
87
  return (
88
88
  <div
89
89
  className={`${className} space-y-[0.75em]`}
90
- style={{ overflowWrap: "break-word", wordBreak: "break-word", minWidth: 0, ...style }}
90
+ style={{ overflowWrap: "anywhere", wordBreak: "normal", minWidth: 0, ...style }}
91
91
  >
92
92
  <PortableText value={block.text} />
93
93
  </div>
@@ -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, useCallback } from "react";
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
- /** Safely extract hover_effect (new unified type) from any content block.
33
- * ProjectGridBlock has a legacy `hover_effect: "3d" | "scale" | "none"` string field
34
- * that collides skip it (returns undefined). Other blocks have HoverEffectConfig. */
35
- function getBlockHoverEffect(block: ContentBlock): HoverEffectConfig | undefined {
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
- // Find selected elements — handle page sections, V2 sections, and parallax groups/slides
64
- const selectedItem: ContentItem | undefined = store.rows.find((r) => r._key === store.selectedRowKey);
65
- const selectedSection: PageSection | null = selectedItem && isPageSection(selectedItem) ? selectedItem : null;
66
- const selectedSectionV2: PageSectionV2 | null = selectedItem && isPageSectionV2(selectedItem) ? selectedItem : null;
67
- const selectedCustomSectionInstance: CustomSectionInstance | null = selectedItem && isCustomSectionInstance(selectedItem) ? selectedItem as CustomSectionInstance : null;
68
-
69
- // Parallax detection: group selected directly, or slide selected (search inside groups)
70
- const selectedParallaxGroup: ParallaxGroup | null = selectedItem && isParallaxGroup(selectedItem) ? selectedItem as ParallaxGroup : null;
71
- const selectedParallaxSlide: { group: ParallaxGroup; slide: ParallaxSlideV2; virtualSection: PageSectionV2 } | null = (() => {
72
- if (!store.selectedRowKey) return null;
73
- for (const item of store.rows) {
74
- if (!isParallaxGroup(item)) continue;
75
- const group = item as ParallaxGroup;
76
- const slide = group.slides.find((s) => s._key === store.selectedRowKey);
77
- if (slide) {
78
- // Create a virtual PageSectionV2 for the slide so we can delegate to SectionV2Settings etc.
79
- const virtualSection: PageSectionV2 = {
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 &ldquo;{instance.custom_section_title}&rdquo;.
888
- Future changes to the saved section won&apos;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
 
@@ -3,6 +3,11 @@
3
3
  import { useBuilderStore } from "../../../lib/builder/store";
4
4
  import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
5
5
  import type { ButtonBlock, ContentBlock } from "../../../lib/sanity/types";
6
+ import {
7
+ ContentIcon,
8
+ StyleIcon,
9
+ OptionsIcon,
10
+ } from "./section-icons";
6
11
  import {
7
12
  SettingsField,
8
13
  SettingsSection,
@@ -61,7 +66,7 @@ export default function ButtonBlockEditor({ block }: Props) {
61
66
  <>
62
67
  <ViewportBadge />
63
68
 
64
- <SettingsSection title="Content" defaultOpen>
69
+ <SettingsSection title="Content" defaultOpen icon={<ContentIcon />}>
65
70
  <SettingsField label="Button Text">
66
71
  <input
67
72
  type="text"
@@ -84,7 +89,7 @@ export default function ButtonBlockEditor({ block }: Props) {
84
89
  </SettingsField>
85
90
  </SettingsSection>
86
91
 
87
- <SettingsSection title="Style">
92
+ <SettingsSection title="Style" icon={<StyleIcon />}>
88
93
  <SettingsField label="Variant">
89
94
  <div className="flex gap-1">
90
95
  {(["primary", "secondary", "outline", "text"] as const).map(
@@ -152,7 +157,7 @@ export default function ButtonBlockEditor({ block }: Props) {
152
157
  </ResponsiveField>
153
158
  </SettingsSection>
154
159
 
155
- <SettingsSection title="Options">
160
+ <SettingsSection title="Options" icon={<OptionsIcon />}>
156
161
  <StyledCheckbox
157
162
  label="Open in new tab"
158
163
  checked={block.target || false}