@morphika/andami 0.1.8 → 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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A reusable Visual Page Builder framework for Next.js. Build custom websites with a drag-and-drop visual editor, Sanity CMS backend, and zero-config deployment.
4
4
 
5
+ **Designed for Vercel Hobby tier** — the framework is engineered to run comfortably within Vercel's free/Hobby plan limits (4h Fluid Active CPU, 10 GB Fast Origin Transfer per month). Aggressive ISR caching, Sanity CDN routing, bot mitigation, and Edge-first middleware keep serverless CPU usage minimal even under crawler traffic.
6
+
5
7
  ## Features
6
8
 
7
9
  - **Visual Page Builder** — Infinite canvas editor with device previews (desktop, tablet, phone)
@@ -13,6 +15,7 @@ A reusable Visual Page Builder framework for Next.js. Build custom websites with
13
15
  - **Asset Management** — Cloudflare R2 storage with browser, thumbnails, and CDN serving
14
16
  - **Setup Wizard** — Guided onboarding for new sites
15
17
  - **Sanity v3 CMS** — Structured content with custom schemas, GROQ queries, and ISR
18
+ - **Vercel-Optimized** — Sanity CDN for public queries, 24h ISR, R2 direct CDN shortcut, bot guard middleware, aggressive robots.txt with crawl-delay — all tuned to minimize serverless CPU on Hobby tier
16
19
 
17
20
  ## Quick Start
18
21
 
@@ -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