@morphika/webframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +210 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, useRef } from "react";
|
|
4
|
+
import { useParams, usePathname } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { useBuilderStore } from "../../../../lib/builder/store";
|
|
7
|
+
import {
|
|
8
|
+
DndWrapper,
|
|
9
|
+
SortableRow,
|
|
10
|
+
SortableBlock,
|
|
11
|
+
SectionTypePicker,
|
|
12
|
+
BlockTypePicker,
|
|
13
|
+
SettingsPanel,
|
|
14
|
+
BuilderCanvas,
|
|
15
|
+
makeRowId,
|
|
16
|
+
} from "../../../../components/builder";
|
|
17
|
+
import {
|
|
18
|
+
SortableContext,
|
|
19
|
+
verticalListSortingStrategy,
|
|
20
|
+
} from "@dnd-kit/sortable";
|
|
21
|
+
import type { Page, PageSection, PageSectionV2, ParallaxGroup, SectionColumn, CustomSectionInstance, CustomSectionListItem } from "../../../../lib/sanity/types";
|
|
22
|
+
import { isPageSection, isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../../../lib/sanity/types";
|
|
23
|
+
import SectionEditorBar from "../../../../components/builder/SectionEditorBar";
|
|
24
|
+
import CustomSectionInstanceCard from "../../../../components/builder/CustomSectionInstanceCard";
|
|
25
|
+
import { ColumnDragProvider } from "../../../../components/builder/ColumnDragContext";
|
|
26
|
+
import type { BlockType } from "../../../../lib/builder/types";
|
|
27
|
+
import { findGaps } from "../../../../lib/builder/cascade";
|
|
28
|
+
import { isSectionBlockType } from "../../../../lib/builder/types";
|
|
29
|
+
import BlockLivePreview from "../../../../components/builder/BlockLivePreview";
|
|
30
|
+
import SectionV2Canvas from "../../../../components/builder/SectionV2Canvas";
|
|
31
|
+
import ParallaxGroupCanvas from "../../../../components/builder/ParallaxGroupCanvas";
|
|
32
|
+
import { ThumbStatusProvider } from "../../../../lib/contexts/ThumbStatusContext";
|
|
33
|
+
import PublishToggle from "../../../../components/admin/PublishToggle";
|
|
34
|
+
|
|
35
|
+
// ============================================
|
|
36
|
+
// Preview helper — opens the page in a new tab
|
|
37
|
+
// ============================================
|
|
38
|
+
|
|
39
|
+
function openPreview(store: { pageSlug: string }) {
|
|
40
|
+
// Use the dedicated /preview route which includes draft pages.
|
|
41
|
+
// All pages are fetched by slug.
|
|
42
|
+
const params = new URLSearchParams();
|
|
43
|
+
params.set("slug", store.pageSlug);
|
|
44
|
+
window.open(`/preview?${params}`, "_blank");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ============================================
|
|
48
|
+
// Help Modal — shows all keyboard shortcuts
|
|
49
|
+
// ============================================
|
|
50
|
+
|
|
51
|
+
function HelpModal({ onClose }: { onClose: () => void }) {
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
function handleKey(e: KeyboardEvent) {
|
|
54
|
+
if (e.key === "Escape" || e.key === "?") {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
onClose();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
window.addEventListener("keydown", handleKey);
|
|
60
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
61
|
+
}, [onClose]);
|
|
62
|
+
|
|
63
|
+
const shortcuts = [
|
|
64
|
+
{ keys: "Ctrl+S", action: "Save page" },
|
|
65
|
+
{ keys: "Ctrl+Z", action: "Undo" },
|
|
66
|
+
{ keys: "Ctrl+Shift+Z", action: "Redo" },
|
|
67
|
+
{ keys: "Ctrl+D", action: "Duplicate selected block" },
|
|
68
|
+
{ keys: "Delete / Backspace", action: "Delete selected block" },
|
|
69
|
+
{ keys: "Escape", action: "Clear selection" },
|
|
70
|
+
{ keys: "P", action: "Open preview in new tab" },
|
|
71
|
+
{ keys: "V", action: "Select tool" },
|
|
72
|
+
{ keys: "H", action: "Hand (pan) tool" },
|
|
73
|
+
{ keys: "Space + drag", action: "Temporary pan" },
|
|
74
|
+
{ keys: "Ctrl + / Ctrl −", action: "Zoom in / out" },
|
|
75
|
+
{ keys: "Ctrl+0", action: "Zoom to fit" },
|
|
76
|
+
{ keys: "Scroll", action: "Pan canvas" },
|
|
77
|
+
{ keys: "Ctrl+Scroll", action: "Zoom at cursor" },
|
|
78
|
+
{ keys: "?", action: "Toggle this help" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
|
84
|
+
onClick={onClose}
|
|
85
|
+
>
|
|
86
|
+
<div
|
|
87
|
+
className="bg-white border border-neutral-200 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl"
|
|
88
|
+
onClick={(e) => e.stopPropagation()}
|
|
89
|
+
>
|
|
90
|
+
<div className="flex items-center justify-between mb-5">
|
|
91
|
+
<h2 className="text-sm font-semibold text-neutral-900">Keyboard Shortcuts</h2>
|
|
92
|
+
<button
|
|
93
|
+
onClick={onClose}
|
|
94
|
+
className="text-neutral-400 hover:text-neutral-900 transition-colors text-sm"
|
|
95
|
+
>
|
|
96
|
+
×
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="space-y-2">
|
|
100
|
+
{shortcuts.map((s) => (
|
|
101
|
+
<div key={s.keys} className="flex items-center justify-between py-1.5">
|
|
102
|
+
<span className="text-xs text-neutral-500">{s.action}</span>
|
|
103
|
+
<kbd className="text-[10px] bg-neutral-100 border border-neutral-200 rounded px-2 py-0.5 text-neutral-600">
|
|
104
|
+
{s.keys}
|
|
105
|
+
</kbd>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
<div className="mt-5 pt-4 border-t border-neutral-200">
|
|
110
|
+
<p className="text-[10px] text-neutral-400">
|
|
111
|
+
Right-click a block for context menu (duplicate, delete)
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ============================================
|
|
120
|
+
// Context Menu for blocks
|
|
121
|
+
// ============================================
|
|
122
|
+
|
|
123
|
+
interface ContextMenuState {
|
|
124
|
+
x: number;
|
|
125
|
+
y: number;
|
|
126
|
+
rowKey: string;
|
|
127
|
+
colKey: string;
|
|
128
|
+
blockKey: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function BlockContextMenu({
|
|
132
|
+
menu,
|
|
133
|
+
onClose,
|
|
134
|
+
onDuplicate,
|
|
135
|
+
onDelete,
|
|
136
|
+
}: {
|
|
137
|
+
menu: ContextMenuState;
|
|
138
|
+
onClose: () => void;
|
|
139
|
+
onDuplicate: () => void;
|
|
140
|
+
onDelete: () => void;
|
|
141
|
+
}) {
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const close = () => onClose();
|
|
144
|
+
window.addEventListener("click", close);
|
|
145
|
+
window.addEventListener("contextmenu", close);
|
|
146
|
+
return () => {
|
|
147
|
+
window.removeEventListener("click", close);
|
|
148
|
+
window.removeEventListener("contextmenu", close);
|
|
149
|
+
};
|
|
150
|
+
}, [onClose]);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
className="fixed z-50 bg-white border border-neutral-200 rounded-xl shadow-xl py-1 min-w-[160px]"
|
|
155
|
+
style={{ left: menu.x, top: menu.y }}
|
|
156
|
+
onClick={(e) => e.stopPropagation()}
|
|
157
|
+
>
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => {
|
|
160
|
+
onDuplicate();
|
|
161
|
+
onClose();
|
|
162
|
+
}}
|
|
163
|
+
className="w-full text-left px-3 py-1.5 text-sm text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors flex items-center gap-2"
|
|
164
|
+
>
|
|
165
|
+
<span className="text-neutral-400">⧉</span> Duplicate
|
|
166
|
+
<span className="ml-auto text-[9px] text-neutral-400">Ctrl+D</span>
|
|
167
|
+
</button>
|
|
168
|
+
<button
|
|
169
|
+
onClick={() => {
|
|
170
|
+
onDelete();
|
|
171
|
+
onClose();
|
|
172
|
+
}}
|
|
173
|
+
className="w-full text-left px-3 py-1.5 text-sm text-neutral-600 hover:bg-[var(--admin-error)]/10 hover:text-[var(--admin-error)] transition-colors flex items-center gap-2"
|
|
174
|
+
>
|
|
175
|
+
<span className="text-neutral-400">×</span> Delete
|
|
176
|
+
<span className="ml-auto text-[9px] text-neutral-400">Del</span>
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ============================================
|
|
183
|
+
// Editor Shell
|
|
184
|
+
// ============================================
|
|
185
|
+
|
|
186
|
+
export default function PageEditorPage() {
|
|
187
|
+
const params = useParams();
|
|
188
|
+
const pathname = usePathname();
|
|
189
|
+
const slug = params.slug as string;
|
|
190
|
+
const isProjectRoute = pathname.startsWith("/admin/projects/");
|
|
191
|
+
|
|
192
|
+
const [loading, setLoading] = useState(true);
|
|
193
|
+
const [error, setError] = useState<string | null>(null);
|
|
194
|
+
const [showSectionPicker, setShowSectionPicker] = useState(false);
|
|
195
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
196
|
+
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
197
|
+
const [addBlockTarget, setAddBlockTarget] = useState<{
|
|
198
|
+
rowKey: string;
|
|
199
|
+
colKey: string;
|
|
200
|
+
insertIndex?: number;
|
|
201
|
+
isV2?: boolean;
|
|
202
|
+
} | null>(null);
|
|
203
|
+
|
|
204
|
+
// Preloaded custom sections list (fetched once, avoids per-modal-open fetch)
|
|
205
|
+
const [cachedCustomSections, setCachedCustomSections] = useState<CustomSectionListItem[] | null>(null);
|
|
206
|
+
|
|
207
|
+
// Preload custom sections list on mount
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
let cancelled = false;
|
|
210
|
+
fetch("/api/admin/custom-sections")
|
|
211
|
+
.then((res) => res.ok ? res.json() : null)
|
|
212
|
+
.then((data) => {
|
|
213
|
+
if (!cancelled && data?.sections) setCachedCustomSections(data.sections);
|
|
214
|
+
})
|
|
215
|
+
.catch(() => { /* silently fail — SectionTypePicker will retry if needed */ });
|
|
216
|
+
return () => { cancelled = true; };
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
/** Refresh the cached custom sections list (call after create/delete) */
|
|
220
|
+
const refreshCustomSections = useCallback(() => {
|
|
221
|
+
fetch("/api/admin/custom-sections")
|
|
222
|
+
.then((res) => res.ok ? res.json() : null)
|
|
223
|
+
.then((data) => {
|
|
224
|
+
if (data?.sections) setCachedCustomSections(data.sections);
|
|
225
|
+
})
|
|
226
|
+
.catch(() => {});
|
|
227
|
+
}, []);
|
|
228
|
+
|
|
229
|
+
// Canvas viewport ref (for zoom-to-fit from keyboard shortcut)
|
|
230
|
+
const canvasViewportRef = useRef<HTMLDivElement>(null);
|
|
231
|
+
|
|
232
|
+
// Zustand store
|
|
233
|
+
const store = useBuilderStore();
|
|
234
|
+
|
|
235
|
+
// Refresh custom sections list when returning from section editor (may have created a new one)
|
|
236
|
+
const prevEditorMode = useRef(store.editorMode);
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (prevEditorMode.current === "customSection" && store.editorMode !== "customSection") {
|
|
239
|
+
refreshCustomSections();
|
|
240
|
+
}
|
|
241
|
+
prevEditorMode.current = store.editorMode;
|
|
242
|
+
}, [store.editorMode, refreshCustomSections]);
|
|
243
|
+
|
|
244
|
+
// Load page data on mount
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
async function loadPage() {
|
|
247
|
+
try {
|
|
248
|
+
const res = await fetch(`/api/admin/pages/${slug}`);
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
setError(res.status === 404 ? "Page not found" : "Failed to load page");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const data = await res.json();
|
|
254
|
+
store.loadFromDocument(data.page as Page);
|
|
255
|
+
// Apply global styles as page defaults (background, text color)
|
|
256
|
+
store.applyGlobalStyles();
|
|
257
|
+
} catch {
|
|
258
|
+
setError("Network error");
|
|
259
|
+
} finally {
|
|
260
|
+
setLoading(false);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
loadPage();
|
|
264
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
265
|
+
}, [slug]);
|
|
266
|
+
|
|
267
|
+
// Warn before leaving with unsaved changes
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
270
|
+
if (store.isDirty) {
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
275
|
+
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
276
|
+
}, [store.isDirty]);
|
|
277
|
+
|
|
278
|
+
// Keyboard shortcuts
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
281
|
+
// Skip all shortcuts when inside asset browser modal (portal on document.body)
|
|
282
|
+
if ((e.target as HTMLElement)?.closest?.("[data-asset-modal]")) return;
|
|
283
|
+
|
|
284
|
+
// Don't intercept when typing in inputs
|
|
285
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
286
|
+
const isEditable = (e.target as HTMLElement)?.isContentEditable;
|
|
287
|
+
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || isEditable;
|
|
288
|
+
|
|
289
|
+
// Ctrl/Cmd+S to save
|
|
290
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
if (store.isDirty && !store.isSaving) {
|
|
293
|
+
store.save();
|
|
294
|
+
}
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Ctrl/Cmd+Z to undo (without Shift)
|
|
299
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
store.undo();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Ctrl/Cmd+Shift+Z to redo
|
|
306
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Z" && e.shiftKey) {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
store.redo();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Also support Ctrl+Y for redo
|
|
312
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "y") {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
store.redo();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Ctrl/Cmd+D to duplicate selected block
|
|
319
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "d") {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
if (store.selectedBlockKey) {
|
|
322
|
+
store.duplicateBlock(store.selectedBlockKey);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Escape to clear selection / close modals
|
|
328
|
+
if (e.key === "Escape") {
|
|
329
|
+
if (contextMenu) {
|
|
330
|
+
setContextMenu(null);
|
|
331
|
+
} else {
|
|
332
|
+
store.clearSelection();
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Delete/Backspace to delete selected block
|
|
338
|
+
if ((e.key === "Delete" || e.key === "Backspace") && store.selectedBlockKey && !isInput) {
|
|
339
|
+
store.deleteBlock(store.selectedBlockKey);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// P to open preview in new tab
|
|
343
|
+
if (e.key === "p" && !isInput && !e.metaKey && !e.ctrlKey) {
|
|
344
|
+
e.preventDefault();
|
|
345
|
+
openPreview(store);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ? to toggle help modal
|
|
350
|
+
if (e.key === "?" && !isInput) {
|
|
351
|
+
e.preventDefault();
|
|
352
|
+
setShowHelp((prev) => !prev);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ---- Canvas shortcuts ----
|
|
357
|
+
|
|
358
|
+
// V → Select tool
|
|
359
|
+
if (e.key === "v" && !isInput && !e.metaKey && !e.ctrlKey) {
|
|
360
|
+
e.preventDefault();
|
|
361
|
+
store.setCanvasTool("select");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// H → Hand tool
|
|
366
|
+
if (e.key === "h" && !isInput && !e.metaKey && !e.ctrlKey) {
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
store.setCanvasTool("hand");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Ctrl/Cmd + = or + → Zoom in
|
|
373
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
store.setCanvasZoom(store.canvasZoom + 0.1);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Ctrl/Cmd + - → Zoom out
|
|
380
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "-") {
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
store.setCanvasZoom(store.canvasZoom - 0.1);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Ctrl/Cmd + 0 → Zoom to fit
|
|
387
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "0") {
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
const el = canvasViewportRef.current;
|
|
390
|
+
if (el) {
|
|
391
|
+
const rect = el.getBoundingClientRect();
|
|
392
|
+
store.zoomToFit(rect.width, rect.height);
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
398
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
399
|
+
}, [store, contextMenu]);
|
|
400
|
+
|
|
401
|
+
// Handle save
|
|
402
|
+
const handleSave = useCallback(async () => {
|
|
403
|
+
await store.save();
|
|
404
|
+
}, [store]);
|
|
405
|
+
|
|
406
|
+
// Handle add empty section (with column layout preset)
|
|
407
|
+
// Handle add V2 empty section (with preset)
|
|
408
|
+
// afterRowKey=null → always appends to end of page (bottom "Add Section" button)
|
|
409
|
+
const handleAddEmptySectionV2 = useCallback(
|
|
410
|
+
(preset: "full" | "halves" | "thirds" | "quarters" | "1/3+2/3" | "2/3+1/3") => {
|
|
411
|
+
store.addSectionV2(preset, null);
|
|
412
|
+
setShowSectionPicker(false);
|
|
413
|
+
},
|
|
414
|
+
[store]
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// Handle add page section (project grid)
|
|
418
|
+
// afterRowKey=null → always appends to end of page (bottom "Add Section" button)
|
|
419
|
+
const handleAddSection = useCallback(
|
|
420
|
+
(blockType: "projectGridBlock") => {
|
|
421
|
+
store.addSection(blockType, null);
|
|
422
|
+
setShowSectionPicker(false);
|
|
423
|
+
},
|
|
424
|
+
[store]
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
// Handle insert custom section instance
|
|
428
|
+
const handleSelectCustomSection = useCallback(
|
|
429
|
+
(section: CustomSectionListItem) => {
|
|
430
|
+
store.addCustomSectionInstance(section._id, section.slug.current, section.title, null);
|
|
431
|
+
setShowSectionPicker(false);
|
|
432
|
+
},
|
|
433
|
+
[store]
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
// Handle create new custom section (enter section editor with blank section)
|
|
437
|
+
const handleCreateCustomSection = useCallback(() => {
|
|
438
|
+
store.enterSectionEditor(null, null, null);
|
|
439
|
+
setShowSectionPicker(false);
|
|
440
|
+
}, [store]);
|
|
441
|
+
|
|
442
|
+
// Handle add parallax group
|
|
443
|
+
const handleAddParallaxGroup = useCallback(() => {
|
|
444
|
+
store.addParallaxGroup(null);
|
|
445
|
+
setShowSectionPicker(false);
|
|
446
|
+
}, [store]);
|
|
447
|
+
|
|
448
|
+
// Handle add block — V2 sections only
|
|
449
|
+
const handleAddBlock = useCallback(
|
|
450
|
+
(type: BlockType) => {
|
|
451
|
+
if (!addBlockTarget) return;
|
|
452
|
+
store.addBlockV2(addBlockTarget.rowKey, addBlockTarget.colKey, type, addBlockTarget.insertIndex);
|
|
453
|
+
setAddBlockTarget(null);
|
|
454
|
+
},
|
|
455
|
+
[store, addBlockTarget]
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
// Handle add block target from V2 sections
|
|
459
|
+
const handleAddBlockTargetV2 = useCallback(
|
|
460
|
+
(sectionKey: string, colKey: string, insertIndex?: number) => {
|
|
461
|
+
setAddBlockTarget({ rowKey: sectionKey, colKey, insertIndex, isV2: true });
|
|
462
|
+
},
|
|
463
|
+
[]
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// Handle block context menu
|
|
467
|
+
const handleBlockContextMenu = useCallback(
|
|
468
|
+
(e: React.MouseEvent, rowKey: string, colKey: string, blockKey: string) => {
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
e.stopPropagation();
|
|
471
|
+
setContextMenu({ x: e.clientX, y: e.clientY, rowKey, colKey, blockKey });
|
|
472
|
+
},
|
|
473
|
+
[]
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Row IDs for sortable context
|
|
477
|
+
const rowIds = store.rows.map((r) => makeRowId(r._key));
|
|
478
|
+
|
|
479
|
+
// Loading state
|
|
480
|
+
if (loading) {
|
|
481
|
+
return (
|
|
482
|
+
<div className="flex items-center justify-center h-full">
|
|
483
|
+
<p className="text-xs text-neutral-400 animate-pulse">
|
|
484
|
+
Loading editor...
|
|
485
|
+
</p>
|
|
486
|
+
</div>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Error state
|
|
491
|
+
if (error) {
|
|
492
|
+
return (
|
|
493
|
+
<div className="flex flex-col items-center justify-center h-full gap-4">
|
|
494
|
+
<p className="text-sm text-[var(--admin-error)]">{error}</p>
|
|
495
|
+
<Link
|
|
496
|
+
href={isProjectRoute ? "/admin/projects" : "/admin/pages"}
|
|
497
|
+
className="text-xs text-neutral-400 hover:text-neutral-900"
|
|
498
|
+
>
|
|
499
|
+
← Back to {isProjectRoute ? "projects" : "pages"}
|
|
500
|
+
</Link>
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const editorMode = store.editorMode;
|
|
506
|
+
const isInSectionEditor = editorMode === "customSection";
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<div className="flex flex-col h-full">
|
|
510
|
+
{/* ---- Section Editor Bar (replaces toolbar in customSection mode) ---- */}
|
|
511
|
+
{isInSectionEditor && <SectionEditorBar />}
|
|
512
|
+
|
|
513
|
+
{/* ---- Toolbar (hidden in section editor mode) ---- */}
|
|
514
|
+
{!isInSectionEditor && <div className="flex items-center justify-between bg-white border-b border-neutral-200 px-4 h-14 shrink-0">
|
|
515
|
+
<div className="flex items-center gap-3">
|
|
516
|
+
<Link
|
|
517
|
+
href={store.pageType === "project" ? "/admin/projects" : "/admin/pages"}
|
|
518
|
+
className="text-xs text-neutral-400 hover:text-neutral-700 transition-colors flex items-center gap-1"
|
|
519
|
+
>
|
|
520
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><polyline points="15 18 9 12 15 6" /></svg>
|
|
521
|
+
{store.pageType === "project" ? "Projects" : "Pages"}
|
|
522
|
+
</Link>
|
|
523
|
+
<span className="text-neutral-200">|</span>
|
|
524
|
+
<span className="text-sm font-semibold text-neutral-800">{store.pageTitle}</span>
|
|
525
|
+
<span className="text-xs text-neutral-400 uppercase px-2 py-0.5 border border-neutral-200 rounded-lg bg-neutral-50">
|
|
526
|
+
{store.pageType}
|
|
527
|
+
</span>
|
|
528
|
+
<PublishToggle
|
|
529
|
+
mode="builder"
|
|
530
|
+
isDraft={store.draftMode}
|
|
531
|
+
onPublish={() => store.publishPage()}
|
|
532
|
+
onUnpublish={() => store.unpublishPage()}
|
|
533
|
+
/>
|
|
534
|
+
{store.isDirty && (
|
|
535
|
+
<span className="text-xs text-amber-500 animate-pulse">
|
|
536
|
+
Unsaved changes
|
|
537
|
+
</span>
|
|
538
|
+
)}
|
|
539
|
+
|
|
540
|
+
</div>
|
|
541
|
+
<div className="flex items-center gap-2">
|
|
542
|
+
{/* Undo/Redo buttons */}
|
|
543
|
+
<button
|
|
544
|
+
onClick={() => store.undo()}
|
|
545
|
+
disabled={!store.canUndo()}
|
|
546
|
+
className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-20 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400"
|
|
547
|
+
title="Undo (Ctrl+Z)"
|
|
548
|
+
>
|
|
549
|
+
↶
|
|
550
|
+
</button>
|
|
551
|
+
<button
|
|
552
|
+
onClick={() => store.redo()}
|
|
553
|
+
disabled={!store.canRedo()}
|
|
554
|
+
className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors disabled:opacity-20 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-neutral-400"
|
|
555
|
+
title="Redo (Ctrl+Shift+Z)"
|
|
556
|
+
>
|
|
557
|
+
↷
|
|
558
|
+
</button>
|
|
559
|
+
<span className="text-neutral-200 mx-1">|</span>
|
|
560
|
+
|
|
561
|
+
{/* Help button */}
|
|
562
|
+
<button
|
|
563
|
+
onClick={() => setShowHelp(true)}
|
|
564
|
+
className="rounded-lg px-2 py-1 text-xs text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
|
|
565
|
+
title="Keyboard shortcuts (?)"
|
|
566
|
+
>
|
|
567
|
+
?
|
|
568
|
+
</button>
|
|
569
|
+
|
|
570
|
+
{/* Page settings gear button */}
|
|
571
|
+
<button
|
|
572
|
+
onClick={() => store.clearSelection()}
|
|
573
|
+
className="rounded-lg px-2 py-1 text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 transition-colors"
|
|
574
|
+
title="Page settings"
|
|
575
|
+
>
|
|
576
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
577
|
+
<circle cx="12" cy="12" r="3" />
|
|
578
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
579
|
+
</svg>
|
|
580
|
+
</button>
|
|
581
|
+
<span className="text-neutral-200 mx-1">|</span>
|
|
582
|
+
|
|
583
|
+
{store.saveError && (
|
|
584
|
+
<span className="text-xs text-red-500">
|
|
585
|
+
{store.saveError}
|
|
586
|
+
</span>
|
|
587
|
+
)}
|
|
588
|
+
{store.lastSavedAt && !store.isDirty && (
|
|
589
|
+
<span className="text-xs text-neutral-400">
|
|
590
|
+
Saved {new Date(store.lastSavedAt).toLocaleTimeString()}
|
|
591
|
+
</span>
|
|
592
|
+
)}
|
|
593
|
+
<span className="text-xs text-neutral-300">
|
|
594
|
+
Ctrl+S
|
|
595
|
+
</span>
|
|
596
|
+
{/* Preview in new tab */}
|
|
597
|
+
<button
|
|
598
|
+
onClick={() => openPreview(store)}
|
|
599
|
+
className="flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-500 hover:text-neutral-700 hover:border-neutral-300 transition-colors"
|
|
600
|
+
title="Open page preview in new tab"
|
|
601
|
+
>
|
|
602
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="shrink-0">
|
|
603
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
604
|
+
<polyline points="15 3 21 3 21 9" />
|
|
605
|
+
<line x1="10" y1="14" x2="21" y2="3" />
|
|
606
|
+
</svg>
|
|
607
|
+
Preview
|
|
608
|
+
</button>
|
|
609
|
+
<button
|
|
610
|
+
onClick={handleSave}
|
|
611
|
+
disabled={store.isSaving || !store.isDirty}
|
|
612
|
+
className="rounded-lg bg-[#076bff] px-5 py-1.5 text-sm font-medium text-white hover:bg-[#0559d4] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
613
|
+
>
|
|
614
|
+
{store.isSaving ? "Saving..." : "Save"}
|
|
615
|
+
</button>
|
|
616
|
+
</div>
|
|
617
|
+
</div>}
|
|
618
|
+
|
|
619
|
+
{/* ---- Canvas + Settings ---- */}
|
|
620
|
+
<div className="flex flex-1 overflow-hidden">
|
|
621
|
+
{/* Canvas viewport */}
|
|
622
|
+
<ThumbStatusProvider>
|
|
623
|
+
<BuilderCanvas>
|
|
624
|
+
<div onClick={() => store.clearSelection()}>
|
|
625
|
+
<DndWrapper>
|
|
626
|
+
<ColumnDragProvider>
|
|
627
|
+
{isInSectionEditor ? (
|
|
628
|
+
/* Section editor mode: render single V2 section without SortableRow chrome */
|
|
629
|
+
<div>
|
|
630
|
+
{store.rows.length > 0 && isPageSectionV2(store.rows[0]) && (
|
|
631
|
+
<SectionV2Canvas
|
|
632
|
+
section={store.rows[0] as PageSectionV2}
|
|
633
|
+
onAddBlockTarget={handleAddBlockTargetV2}
|
|
634
|
+
/>
|
|
635
|
+
)}
|
|
636
|
+
</div>
|
|
637
|
+
) : store.rows.length === 0 ? (
|
|
638
|
+
/* Empty state */
|
|
639
|
+
<div className="flex flex-col items-center justify-center h-full min-h-[400px] border border-dashed border-neutral-300 rounded-lg py-20">
|
|
640
|
+
<div className="w-12 h-12 rounded-full border border-neutral-300 flex items-center justify-center mb-4">
|
|
641
|
+
<span className="text-neutral-400 text-lg">+</span>
|
|
642
|
+
</div>
|
|
643
|
+
<p className="text-sm text-neutral-500 mb-2">
|
|
644
|
+
This page has no content yet
|
|
645
|
+
</p>
|
|
646
|
+
<p className="text-[10px] text-neutral-400 mb-6">
|
|
647
|
+
Add your first section to start building
|
|
648
|
+
</p>
|
|
649
|
+
<button
|
|
650
|
+
onClick={(e) => {
|
|
651
|
+
e.stopPropagation();
|
|
652
|
+
setShowSectionPicker(true);
|
|
653
|
+
}}
|
|
654
|
+
className="rounded-lg bg-[#076bff] px-4 py-2 text-xs text-white hover:bg-[#0559d4] transition-colors"
|
|
655
|
+
>
|
|
656
|
+
+ Add First Section
|
|
657
|
+
</button>
|
|
658
|
+
</div>
|
|
659
|
+
) : (
|
|
660
|
+
/* Canvas with rows */
|
|
661
|
+
<div>
|
|
662
|
+
<SortableContext
|
|
663
|
+
items={rowIds}
|
|
664
|
+
strategy={verticalListSortingStrategy}
|
|
665
|
+
>
|
|
666
|
+
{store.rows.map((item, rowIndex) => {
|
|
667
|
+
const isV2Section = isPageSectionV2(item);
|
|
668
|
+
const isSection = isPageSection(item);
|
|
669
|
+
const isInstance = isCustomSectionInstance(item);
|
|
670
|
+
const isParallax = isParallaxGroup(item);
|
|
671
|
+
const section = isSection ? (item as PageSection) : null;
|
|
672
|
+
const v2Section = isV2Section ? (item as PageSectionV2) : null;
|
|
673
|
+
|
|
674
|
+
// Custom Section Instance — rendered directly without SortableRow chrome
|
|
675
|
+
if (isInstance) {
|
|
676
|
+
return (
|
|
677
|
+
<SortableRow
|
|
678
|
+
key={item._key}
|
|
679
|
+
rowKey={item._key}
|
|
680
|
+
row={item}
|
|
681
|
+
isSelected={store.selectedRowKey === item._key}
|
|
682
|
+
columnCount={0}
|
|
683
|
+
onSelect={() => store.selectRow(item._key)}
|
|
684
|
+
onDelete={() => store.deleteSection(item._key)}
|
|
685
|
+
onAddColumn={() => {}}
|
|
686
|
+
onDuplicate={() => {}}
|
|
687
|
+
onMoveUp={() => { if (rowIndex > 0) store.reorderRows(rowIndex, rowIndex - 1); }}
|
|
688
|
+
onMoveDown={() => { if (rowIndex < store.rows.length - 1) store.reorderRows(rowIndex, rowIndex + 1); }}
|
|
689
|
+
isFirst={rowIndex === 0}
|
|
690
|
+
isLast={rowIndex === store.rows.length - 1}
|
|
691
|
+
>
|
|
692
|
+
<CustomSectionInstanceCard
|
|
693
|
+
instance={item as CustomSectionInstance}
|
|
694
|
+
onAddBlockTarget={handleAddBlockTargetV2}
|
|
695
|
+
/>
|
|
696
|
+
</SortableRow>
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Parallax Group — rendered with its own canvas component
|
|
701
|
+
if (isParallax) {
|
|
702
|
+
const group = item as ParallaxGroup;
|
|
703
|
+
return (
|
|
704
|
+
<SortableRow
|
|
705
|
+
key={item._key}
|
|
706
|
+
rowKey={item._key}
|
|
707
|
+
row={item}
|
|
708
|
+
isSelected={store.selectedRowKey === item._key}
|
|
709
|
+
columnCount={0}
|
|
710
|
+
onSelect={() => store.selectRow(item._key)}
|
|
711
|
+
onDelete={() => store.deleteSection(item._key)}
|
|
712
|
+
onAddColumn={() => {}}
|
|
713
|
+
onDuplicate={() => store.duplicateSection(item._key)}
|
|
714
|
+
onMoveUp={() => { if (rowIndex > 0) store.reorderRows(rowIndex, rowIndex - 1); }}
|
|
715
|
+
onMoveDown={() => { if (rowIndex < store.rows.length - 1) store.reorderRows(rowIndex, rowIndex + 1); }}
|
|
716
|
+
isFirst={rowIndex === 0}
|
|
717
|
+
isLast={rowIndex === store.rows.length - 1}
|
|
718
|
+
>
|
|
719
|
+
<ParallaxGroupCanvas
|
|
720
|
+
group={group}
|
|
721
|
+
onAddBlockTarget={handleAddBlockTargetV2}
|
|
722
|
+
/>
|
|
723
|
+
</SortableRow>
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return (
|
|
728
|
+
<SortableRow
|
|
729
|
+
key={item._key}
|
|
730
|
+
rowKey={item._key}
|
|
731
|
+
row={item}
|
|
732
|
+
isSelected={store.selectedRowKey === item._key}
|
|
733
|
+
columnCount={isV2Section ? (v2Section!.columns || []).length : 0}
|
|
734
|
+
onSelect={() => {
|
|
735
|
+
store.selectRow(item._key);
|
|
736
|
+
}}
|
|
737
|
+
onDelete={() => {
|
|
738
|
+
store.deleteSection(item._key);
|
|
739
|
+
}}
|
|
740
|
+
onAddColumn={() => {
|
|
741
|
+
if (isV2Section) {
|
|
742
|
+
const sec = v2Section!;
|
|
743
|
+
const gridCols = sec.settings.grid_columns || 12;
|
|
744
|
+
const cascadeCols = sec.columns.map((c: SectionColumn) => ({
|
|
745
|
+
_key: c._key, grid_column: c.grid_column, grid_row: c.grid_row, span: c.span,
|
|
746
|
+
}));
|
|
747
|
+
const gapList = findGaps(cascadeCols, gridCols);
|
|
748
|
+
if (gapList.length > 0) {
|
|
749
|
+
store.addColumnV2(sec._key, gapList[0].grid_row, gapList[0].grid_column, gapList[0].span);
|
|
750
|
+
} else {
|
|
751
|
+
const maxRow = cascadeCols.reduce((max, c) => Math.max(max, c.grid_row), 1);
|
|
752
|
+
store.addColumnV2(sec._key, maxRow + 1, 1, gridCols);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}}
|
|
756
|
+
onDuplicate={() => {
|
|
757
|
+
store.duplicateSection(item._key);
|
|
758
|
+
}}
|
|
759
|
+
onMoveUp={() => {
|
|
760
|
+
if (rowIndex > 0) store.reorderRows(rowIndex, rowIndex - 1);
|
|
761
|
+
}}
|
|
762
|
+
onMoveDown={() => {
|
|
763
|
+
if (rowIndex < store.rows.length - 1) store.reorderRows(rowIndex, rowIndex + 1);
|
|
764
|
+
}}
|
|
765
|
+
isFirst={rowIndex === 0}
|
|
766
|
+
isLast={rowIndex === store.rows.length - 1}
|
|
767
|
+
>
|
|
768
|
+
{isV2Section && v2Section ? (
|
|
769
|
+
<SectionV2Canvas
|
|
770
|
+
section={v2Section}
|
|
771
|
+
onAddBlockTarget={handleAddBlockTargetV2}
|
|
772
|
+
/>
|
|
773
|
+
) : isSection && section?.block?.[0] ? (
|
|
774
|
+
<BlockLivePreview block={section.block[0]} viewport={store.activeViewport} />
|
|
775
|
+
) : null}
|
|
776
|
+
</SortableRow>
|
|
777
|
+
);
|
|
778
|
+
})}
|
|
779
|
+
</SortableContext>
|
|
780
|
+
|
|
781
|
+
{/* Add section button — hidden in section editor mode */}
|
|
782
|
+
{!isInSectionEditor && (
|
|
783
|
+
<div className="relative" style={{ height: 0 }}>
|
|
784
|
+
<div className="absolute left-0 right-0 top-2" style={{ zIndex: 4 }}>
|
|
785
|
+
<button
|
|
786
|
+
onClick={(e) => {
|
|
787
|
+
e.stopPropagation();
|
|
788
|
+
setShowSectionPicker(true);
|
|
789
|
+
}}
|
|
790
|
+
className="w-full rounded-xl py-3 text-xs font-medium text-white bg-[#93278f] hover:bg-[#7a1f76] transition-colors shadow-sm"
|
|
791
|
+
>
|
|
792
|
+
+ Add Section
|
|
793
|
+
</button>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
)}
|
|
797
|
+
</div>
|
|
798
|
+
)}
|
|
799
|
+
</ColumnDragProvider>
|
|
800
|
+
</DndWrapper>
|
|
801
|
+
</div>
|
|
802
|
+
</BuilderCanvas>
|
|
803
|
+
</ThumbStatusProvider>
|
|
804
|
+
|
|
805
|
+
{/* Section type picker modal */}
|
|
806
|
+
{showSectionPicker && (
|
|
807
|
+
<SectionTypePicker
|
|
808
|
+
onSelectEmptyV2={handleAddEmptySectionV2}
|
|
809
|
+
onSelectSection={handleAddSection}
|
|
810
|
+
onSelectParallaxGroup={handleAddParallaxGroup}
|
|
811
|
+
onSelectCustomSection={handleSelectCustomSection}
|
|
812
|
+
onCreateCustomSection={handleCreateCustomSection}
|
|
813
|
+
onClose={() => setShowSectionPicker(false)}
|
|
814
|
+
preloadedSections={cachedCustomSections}
|
|
815
|
+
/>
|
|
816
|
+
)}
|
|
817
|
+
|
|
818
|
+
{/* Block type picker modal */}
|
|
819
|
+
{addBlockTarget && (
|
|
820
|
+
<BlockTypePicker
|
|
821
|
+
onSelect={handleAddBlock}
|
|
822
|
+
onClose={() => setAddBlockTarget(null)}
|
|
823
|
+
insertIndex={addBlockTarget.insertIndex}
|
|
824
|
+
/>
|
|
825
|
+
)}
|
|
826
|
+
|
|
827
|
+
{/* Settings Panel */}
|
|
828
|
+
<SettingsPanel />
|
|
829
|
+
</div>
|
|
830
|
+
|
|
831
|
+
{/* ---- Help Modal ---- */}
|
|
832
|
+
{showHelp && <HelpModal onClose={() => setShowHelp(false)} />}
|
|
833
|
+
|
|
834
|
+
{/* ---- Block Context Menu ---- */}
|
|
835
|
+
{contextMenu && (
|
|
836
|
+
<BlockContextMenu
|
|
837
|
+
menu={contextMenu}
|
|
838
|
+
onClose={() => setContextMenu(null)}
|
|
839
|
+
onDuplicate={() => {
|
|
840
|
+
store.duplicateBlock(contextMenu.blockKey);
|
|
841
|
+
}}
|
|
842
|
+
onDelete={() =>
|
|
843
|
+
store.deleteBlock(contextMenu.blockKey)
|
|
844
|
+
}
|
|
845
|
+
/>
|
|
846
|
+
)}
|
|
847
|
+
</div>
|
|
848
|
+
);
|
|
849
|
+
}
|