@morphika/andami 0.1.2
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 +50 -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 +212 -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,304 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
4
|
+
import type { VideoBlock } from "../../lib/sanity/types";
|
|
5
|
+
import { useAssetUrl } from "../../lib/contexts/AssetContext";
|
|
6
|
+
import { handleVideoRetry, handleImageRetry } from "../../lib/asset-retry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* VideoBlockRenderer — Optimized for bandwidth and playback speed.
|
|
10
|
+
*
|
|
11
|
+
* Optimizations:
|
|
12
|
+
* 1. Facade pattern for Vimeo/YouTube: shows poster + play button, loads iframe on click
|
|
13
|
+
* 2. Lazy mount: uses IntersectionObserver to defer loading until near viewport
|
|
14
|
+
* 3. Conditional preload: autoplay → "auto", no autoplay → "none"
|
|
15
|
+
* 4. loading="lazy" on iframes as progressive enhancement
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ============================================
|
|
19
|
+
// Constants
|
|
20
|
+
// ============================================
|
|
21
|
+
|
|
22
|
+
const widthStyleMap: Record<string, { width: string; margin?: string; minWidth: number }> = {
|
|
23
|
+
full: { width: "100%", minWidth: 0 },
|
|
24
|
+
contained: { width: "75%", margin: "0 auto", minWidth: 0 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const aspectMap: Record<string, string> = {
|
|
28
|
+
"16:9": "56.25%",
|
|
29
|
+
"21:9": "42.86%",
|
|
30
|
+
"4:3": "75%",
|
|
31
|
+
auto: "56.25%",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ============================================
|
|
35
|
+
// URL Extractors
|
|
36
|
+
// ============================================
|
|
37
|
+
|
|
38
|
+
function getVimeoId(url: string): string | null {
|
|
39
|
+
const match = url.match(/vimeo\.com\/(\d+)/);
|
|
40
|
+
return match ? match[1] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getYouTubeId(url: string): string | null {
|
|
44
|
+
const match = url.match(
|
|
45
|
+
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
|
|
46
|
+
);
|
|
47
|
+
return match ? match[1] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================
|
|
51
|
+
// Hooks
|
|
52
|
+
// ============================================
|
|
53
|
+
|
|
54
|
+
/** Returns true once the element is within `rootMargin` of the viewport */
|
|
55
|
+
function useInView(rootMargin = "200px"): [React.RefObject<HTMLDivElement | null>, boolean] {
|
|
56
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
57
|
+
const [inView, setInView] = useState(false);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const el = ref.current;
|
|
61
|
+
if (!el || inView) return; // Once in view, stay in view
|
|
62
|
+
|
|
63
|
+
const observer = new IntersectionObserver(
|
|
64
|
+
([entry]) => {
|
|
65
|
+
if (entry.isIntersecting) {
|
|
66
|
+
setInView(true);
|
|
67
|
+
observer.disconnect();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{ rootMargin }
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
observer.observe(el);
|
|
74
|
+
return () => observer.disconnect();
|
|
75
|
+
}, [rootMargin, inView]);
|
|
76
|
+
|
|
77
|
+
return [ref, inView];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================
|
|
81
|
+
// Sub-components
|
|
82
|
+
// ============================================
|
|
83
|
+
|
|
84
|
+
/** Shared container with aspect ratio padding */
|
|
85
|
+
function AspectContainer({
|
|
86
|
+
paddingBottom,
|
|
87
|
+
children,
|
|
88
|
+
}: {
|
|
89
|
+
paddingBottom: string;
|
|
90
|
+
children: React.ReactNode;
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
position: "relative",
|
|
96
|
+
paddingBottom,
|
|
97
|
+
overflow: "hidden",
|
|
98
|
+
borderRadius: "inherit",
|
|
99
|
+
background: "#000",
|
|
100
|
+
lineHeight: 0,
|
|
101
|
+
fontSize: 0,
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Play button overlay for facade pattern */
|
|
110
|
+
function PlayButton({ onClick }: { onClick: () => void }) {
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
onClick={onClick}
|
|
114
|
+
aria-label="Play video"
|
|
115
|
+
style={{
|
|
116
|
+
position: "absolute",
|
|
117
|
+
inset: 0,
|
|
118
|
+
display: "flex",
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
justifyContent: "center",
|
|
121
|
+
background: "rgba(0,0,0,0.3)",
|
|
122
|
+
border: "none",
|
|
123
|
+
cursor: "pointer",
|
|
124
|
+
transition: "background 0.2s",
|
|
125
|
+
zIndex: 1,
|
|
126
|
+
}}
|
|
127
|
+
onMouseEnter={(e) => {
|
|
128
|
+
(e.currentTarget as HTMLButtonElement).style.background = "rgba(0,0,0,0.5)";
|
|
129
|
+
}}
|
|
130
|
+
onMouseLeave={(e) => {
|
|
131
|
+
(e.currentTarget as HTMLButtonElement).style.background = "rgba(0,0,0,0.3)";
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<svg
|
|
135
|
+
width="68"
|
|
136
|
+
height="68"
|
|
137
|
+
viewBox="0 0 68 68"
|
|
138
|
+
fill="none"
|
|
139
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
>
|
|
142
|
+
<circle cx="34" cy="34" r="34" fill="rgba(0,0,0,0.6)" />
|
|
143
|
+
<path d="M27 21L49 34L27 47V21Z" fill="white" />
|
|
144
|
+
</svg>
|
|
145
|
+
</button>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Poster image (Vimeo/YouTube thumbnail or custom poster) */
|
|
150
|
+
function PosterImage({ src, alt }: { src: string; alt: string }) {
|
|
151
|
+
return (
|
|
152
|
+
<img
|
|
153
|
+
src={src}
|
|
154
|
+
alt={alt}
|
|
155
|
+
loading="lazy"
|
|
156
|
+
onError={handleImageRetry}
|
|
157
|
+
style={{
|
|
158
|
+
position: "absolute",
|
|
159
|
+
inset: 0,
|
|
160
|
+
width: "100%",
|
|
161
|
+
height: "100%",
|
|
162
|
+
objectFit: "cover",
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ============================================
|
|
169
|
+
// Embed Renderers (with facade)
|
|
170
|
+
// ============================================
|
|
171
|
+
|
|
172
|
+
function VimeoEmbed({ block, paddingBottom }: { block: VideoBlock; paddingBottom: string }) {
|
|
173
|
+
const vimeoId = getVimeoId(block.url_or_path);
|
|
174
|
+
const [activated, setActivated] = useState(block.autoplay ?? false);
|
|
175
|
+
const [ref, inView] = useInView();
|
|
176
|
+
|
|
177
|
+
const activate = useCallback(() => setActivated(true), []);
|
|
178
|
+
|
|
179
|
+
if (!vimeoId) return null;
|
|
180
|
+
|
|
181
|
+
// Vimeo thumbnail URL (medium quality — loads fast, good enough for facade)
|
|
182
|
+
const posterUrl = `https://vumbnail.com/${vimeoId}.jpg`;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div ref={ref}>
|
|
186
|
+
<AspectContainer paddingBottom={paddingBottom}>
|
|
187
|
+
{inView && activated ? (
|
|
188
|
+
<iframe
|
|
189
|
+
src={`https://player.vimeo.com/video/${vimeoId}?autoplay=1&loop=${block.loop ? 1 : 0}&muted=${block.muted ? 1 : 0}&controls=${block.controls !== false ? 1 : 0}&dnt=1`}
|
|
190
|
+
className="absolute inset-0 w-full h-full"
|
|
191
|
+
allow="autoplay; fullscreen; picture-in-picture"
|
|
192
|
+
allowFullScreen
|
|
193
|
+
loading="lazy"
|
|
194
|
+
title="Video"
|
|
195
|
+
style={{ border: "none" }}
|
|
196
|
+
/>
|
|
197
|
+
) : inView ? (
|
|
198
|
+
<>
|
|
199
|
+
<PosterImage src={posterUrl} alt="Video thumbnail" />
|
|
200
|
+
<PlayButton onClick={activate} />
|
|
201
|
+
</>
|
|
202
|
+
) : null}
|
|
203
|
+
</AspectContainer>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function YouTubeEmbed({ block, paddingBottom }: { block: VideoBlock; paddingBottom: string }) {
|
|
209
|
+
const ytId = getYouTubeId(block.url_or_path);
|
|
210
|
+
const [activated, setActivated] = useState(block.autoplay ?? false);
|
|
211
|
+
const [ref, inView] = useInView();
|
|
212
|
+
|
|
213
|
+
const activate = useCallback(() => setActivated(true), []);
|
|
214
|
+
|
|
215
|
+
if (!ytId) return null;
|
|
216
|
+
|
|
217
|
+
// YouTube thumbnail (maxresdefault with fallback to hqdefault)
|
|
218
|
+
const posterUrl = `https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div ref={ref}>
|
|
222
|
+
<AspectContainer paddingBottom={paddingBottom}>
|
|
223
|
+
{inView && activated ? (
|
|
224
|
+
<iframe
|
|
225
|
+
src={`https://www.youtube.com/embed/${ytId}?autoplay=1&loop=${block.loop ? 1 : 0}&mute=${block.muted ? 1 : 0}&controls=${block.controls !== false ? 1 : 0}`}
|
|
226
|
+
className="absolute inset-0 w-full h-full"
|
|
227
|
+
allow="autoplay; fullscreen"
|
|
228
|
+
allowFullScreen
|
|
229
|
+
loading="lazy"
|
|
230
|
+
title="Video"
|
|
231
|
+
style={{ border: "none" }}
|
|
232
|
+
/>
|
|
233
|
+
) : inView ? (
|
|
234
|
+
<>
|
|
235
|
+
<PosterImage src={posterUrl} alt="Video thumbnail" />
|
|
236
|
+
<PlayButton onClick={activate} />
|
|
237
|
+
</>
|
|
238
|
+
) : null}
|
|
239
|
+
</AspectContainer>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function NativeVideo({ block, paddingBottom, resolveAsset }: {
|
|
245
|
+
block: VideoBlock;
|
|
246
|
+
paddingBottom: string;
|
|
247
|
+
resolveAsset: (path: string) => string;
|
|
248
|
+
}) {
|
|
249
|
+
const [ref, inView] = useInView();
|
|
250
|
+
|
|
251
|
+
const src = block.video_type === "mp4"
|
|
252
|
+
? resolveAsset(block.url_or_path)
|
|
253
|
+
: block.url_or_path;
|
|
254
|
+
const posterSrc = block.poster ? resolveAsset(block.poster) : undefined;
|
|
255
|
+
|
|
256
|
+
// Conditional preload: autoplay needs data immediately, others can wait
|
|
257
|
+
const preload = block.autoplay ? "auto" : "none";
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div ref={ref}>
|
|
261
|
+
<AspectContainer paddingBottom={paddingBottom}>
|
|
262
|
+
{inView ? (
|
|
263
|
+
<video
|
|
264
|
+
src={src}
|
|
265
|
+
poster={posterSrc}
|
|
266
|
+
autoPlay={block.autoplay ?? false}
|
|
267
|
+
loop={block.loop ?? false}
|
|
268
|
+
muted={block.muted ?? true}
|
|
269
|
+
controls={block.controls !== false}
|
|
270
|
+
playsInline
|
|
271
|
+
preload={preload}
|
|
272
|
+
onError={handleVideoRetry}
|
|
273
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
274
|
+
/>
|
|
275
|
+
) : posterSrc ? (
|
|
276
|
+
<PosterImage src={posterSrc} alt="Video poster" />
|
|
277
|
+
) : null}
|
|
278
|
+
</AspectContainer>
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ============================================
|
|
284
|
+
// Main Component
|
|
285
|
+
// ============================================
|
|
286
|
+
|
|
287
|
+
export default function VideoBlockRenderer({ block }: { block: VideoBlock }) {
|
|
288
|
+
const resolveAsset = useAssetUrl();
|
|
289
|
+
const widthStyle = widthStyleMap[block.width ?? "full"] || widthStyleMap.full;
|
|
290
|
+
const paddingBottom = aspectMap[block.aspect_ratio ?? "16:9"] || "56.25%";
|
|
291
|
+
const borderRadius = block.border_radius ? `${String(block.border_radius).replace(/px$/i, "")}px` : undefined;
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div style={{ ...widthStyle, borderRadius, overflow: borderRadius ? "hidden" : undefined }}>
|
|
295
|
+
{block.video_type === "vimeo" ? (
|
|
296
|
+
<VimeoEmbed block={block} paddingBottom={paddingBottom} />
|
|
297
|
+
) : block.video_type === "youtube" ? (
|
|
298
|
+
<YouTubeEmbed block={block} paddingBottom={paddingBottom} />
|
|
299
|
+
) : (
|
|
300
|
+
<NativeVideo block={block} paddingBottom={paddingBottom} resolveAsset={resolveAsset} />
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BlockLivePreview — renders blocks as they would appear on the public site.
|
|
5
|
+
*
|
|
6
|
+
* Used in both Design mode (content + subtle edit chrome on hover) and
|
|
7
|
+
* Preview mode (pure content, no chrome at all).
|
|
8
|
+
*
|
|
9
|
+
* This is a thin dispatcher that routes to focused sub-components
|
|
10
|
+
* in the `live-preview/` directory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { memo } from "react";
|
|
14
|
+
import { resolveBlock } from "../../lib/builder/responsive";
|
|
15
|
+
import { getBlockLayoutStyles, hasBlockLayout } from "../../lib/builder/layout-styles";
|
|
16
|
+
import type { DeviceViewport } from "../../lib/builder/types";
|
|
17
|
+
import type {
|
|
18
|
+
ContentBlock,
|
|
19
|
+
TextBlock,
|
|
20
|
+
ImageBlock,
|
|
21
|
+
ImageGridBlock,
|
|
22
|
+
VideoBlock,
|
|
23
|
+
SpacerBlock,
|
|
24
|
+
ButtonBlock,
|
|
25
|
+
CoverBlock,
|
|
26
|
+
ProjectGridBlock,
|
|
27
|
+
} from "../../lib/sanity/types";
|
|
28
|
+
|
|
29
|
+
import { LiveTextEditor } from "./live-preview";
|
|
30
|
+
import { LiveImagePreview } from "./live-preview";
|
|
31
|
+
import { LiveImageGridPreview } from "./live-preview";
|
|
32
|
+
import { LiveVideoPreview } from "./live-preview";
|
|
33
|
+
import { LiveSpacerPreview } from "./live-preview";
|
|
34
|
+
import { LiveButtonPreview } from "./live-preview";
|
|
35
|
+
import { LiveCoverPreview } from "./live-preview";
|
|
36
|
+
import { LiveProjectGridPreview } from "./live-preview";
|
|
37
|
+
import { LivePlaceholder } from "./live-preview";
|
|
38
|
+
|
|
39
|
+
// ============================================
|
|
40
|
+
// Main dispatcher
|
|
41
|
+
// ============================================
|
|
42
|
+
|
|
43
|
+
interface BlockLivePreviewProps {
|
|
44
|
+
block: ContentBlock;
|
|
45
|
+
/** Which viewport to render for. Defaults to "desktop". */
|
|
46
|
+
viewport?: DeviceViewport;
|
|
47
|
+
/** Whether inline editing is enabled (only for active frame, not read-only mirrors). */
|
|
48
|
+
editable?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function BlockLivePreviewInner({ block, viewport = "desktop", editable = false }: BlockLivePreviewProps) {
|
|
52
|
+
// Merge responsive overrides for the target viewport
|
|
53
|
+
const resolved = resolveBlock(block, viewport);
|
|
54
|
+
|
|
55
|
+
let content: React.ReactNode;
|
|
56
|
+
|
|
57
|
+
switch (resolved._type) {
|
|
58
|
+
case "textBlock":
|
|
59
|
+
content = <LiveTextEditor block={resolved as TextBlock} editable={editable} />;
|
|
60
|
+
break;
|
|
61
|
+
case "imageBlock":
|
|
62
|
+
content = <LiveImagePreview block={resolved as ImageBlock} />;
|
|
63
|
+
break;
|
|
64
|
+
case "imageGridBlock":
|
|
65
|
+
content = <LiveImageGridPreview block={resolved as ImageGridBlock} />;
|
|
66
|
+
break;
|
|
67
|
+
case "videoBlock":
|
|
68
|
+
content = <LiveVideoPreview block={resolved as VideoBlock} />;
|
|
69
|
+
break;
|
|
70
|
+
case "spacerBlock":
|
|
71
|
+
content = <LiveSpacerPreview block={resolved as SpacerBlock} />;
|
|
72
|
+
break;
|
|
73
|
+
case "buttonBlock":
|
|
74
|
+
content = <LiveButtonPreview block={resolved as ButtonBlock} />;
|
|
75
|
+
break;
|
|
76
|
+
case "coverBlock":
|
|
77
|
+
content = <LiveCoverPreview block={resolved as CoverBlock} />;
|
|
78
|
+
break;
|
|
79
|
+
case "projectGridBlock":
|
|
80
|
+
content = <LiveProjectGridPreview block={resolved as ProjectGridBlock} viewport={viewport} />;
|
|
81
|
+
break;
|
|
82
|
+
default:
|
|
83
|
+
content = <LivePlaceholder type={(resolved as ContentBlock)._type} />;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Wrap in layout div if block has layout properties set (spacing, background, border, etc.)
|
|
87
|
+
// NOTE: Alignment (align_h, align_v) is NOT applied here — it's handled by the parent
|
|
88
|
+
// wrapper (SortableBlock in builder, block wrapper div in public renderers) which is
|
|
89
|
+
// the direct flex child of the column. Applying alignment here would double-apply it.
|
|
90
|
+
const layout = (resolved as unknown as Record<string, unknown>).layout as ContentBlock["layout"] | undefined;
|
|
91
|
+
if (hasBlockLayout(layout)) {
|
|
92
|
+
const layoutStyles = getBlockLayoutStyles(layout);
|
|
93
|
+
content = <div style={layoutStyles}>{content}</div>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return <>{content}</>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Memoized wrapper — skips re-render when block, viewport, and editable are unchanged */
|
|
100
|
+
const BlockLivePreview = memo(BlockLivePreviewInner);
|
|
101
|
+
export default BlockLivePreview;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { BLOCK_TYPE_REGISTRY } from "../../lib/builder/types";
|
|
5
|
+
import type { BlockType, BlockTypeInfo } from "../../lib/builder/types";
|
|
6
|
+
import { BLOCK_GRADIENTS, BLOCK_ICON_COMPONENTS } from "./blockStyles";
|
|
7
|
+
|
|
8
|
+
interface BlockTypePickerProps {
|
|
9
|
+
onSelect: (type: BlockType) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
insertIndex?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// English labels for block picker (consistent with the rest of the admin UI)
|
|
15
|
+
const BLOCK_LABELS: Record<string, { label: string; description: string }> = {
|
|
16
|
+
textBlock: { label: "Text", description: "Rich text content with formatting" },
|
|
17
|
+
imageBlock: { label: "Image", description: "Single image with caption" },
|
|
18
|
+
imageGridBlock: { label: "Image Grid", description: "Multiple images in a grid layout" },
|
|
19
|
+
videoBlock: { label: "Video", description: "Vimeo, YouTube or MP4 file" },
|
|
20
|
+
spacerBlock: { label: "Spacer", description: "Customizable vertical spacing" },
|
|
21
|
+
buttonBlock: { label: "Button", description: "Call-to-action button (CTA)" },
|
|
22
|
+
coverBlock: { label: "Cover", description: "Full-screen hero section with image/video" },
|
|
23
|
+
projectGridBlock: { label: "Project Grid", description: "Staggered project showcase grid" },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function BlockCard({
|
|
27
|
+
block,
|
|
28
|
+
isHovered,
|
|
29
|
+
onSelect,
|
|
30
|
+
onHover,
|
|
31
|
+
onLeave,
|
|
32
|
+
}: {
|
|
33
|
+
block: BlockTypeInfo;
|
|
34
|
+
isHovered: boolean;
|
|
35
|
+
onSelect: () => void;
|
|
36
|
+
onHover: () => void;
|
|
37
|
+
onLeave: () => void;
|
|
38
|
+
}) {
|
|
39
|
+
const cardGradient = BLOCK_GRADIENTS[block.type];
|
|
40
|
+
const labels = BLOCK_LABELS[block.type];
|
|
41
|
+
const IconComponent = BLOCK_ICON_COMPONENTS[block.type];
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
onClick={onSelect}
|
|
46
|
+
onMouseEnter={onHover}
|
|
47
|
+
onMouseLeave={onLeave}
|
|
48
|
+
className="relative flex items-center gap-3 rounded-2xl px-3.5 py-3 transition-all text-left group overflow-hidden border-0"
|
|
49
|
+
style={{
|
|
50
|
+
background: cardGradient || "#f5f5f5",
|
|
51
|
+
transform: isHovered ? "translateY(-1px) scale(1.015)" : "translateY(0) scale(1)",
|
|
52
|
+
boxShadow: isHovered
|
|
53
|
+
? "0 8px 24px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.3)"
|
|
54
|
+
: "0 2px 8px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.2)",
|
|
55
|
+
transition: "all 0.3s cubic-bezier(0.23, 1, 0.32, 1)",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
{/* Glass overlay */}
|
|
59
|
+
<div
|
|
60
|
+
className="absolute inset-0 rounded-2xl pointer-events-none"
|
|
61
|
+
style={{
|
|
62
|
+
background: "linear-gradient(135deg, rgba(255,255,255,0.25) 0%, rgba(255,255,255,0.05) 100%)",
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
{/* Icon container — frosted glass */}
|
|
67
|
+
<div
|
|
68
|
+
className="relative shrink-0 flex items-center justify-center"
|
|
69
|
+
style={{
|
|
70
|
+
width: 44,
|
|
71
|
+
height: 44,
|
|
72
|
+
borderRadius: 12,
|
|
73
|
+
background: "rgba(255,255,255,0.4)",
|
|
74
|
+
backdropFilter: "blur(8px)",
|
|
75
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.5)",
|
|
76
|
+
transition: "transform 0.3s",
|
|
77
|
+
transform: isHovered ? "scale(1.08)" : "scale(1)",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{IconComponent ? <IconComponent /> : null}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Text */}
|
|
84
|
+
<div className="relative z-10 min-w-0">
|
|
85
|
+
<p
|
|
86
|
+
className="text-sm font-semibold truncate"
|
|
87
|
+
style={{
|
|
88
|
+
color: "rgba(0,0,0,0.72)",
|
|
89
|
+
textShadow: "0 1px 0 rgba(255,255,255,0.3)",
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{labels?.label || block.label}
|
|
93
|
+
</p>
|
|
94
|
+
<p
|
|
95
|
+
className="text-xs truncate leading-snug mt-0.5"
|
|
96
|
+
style={{ color: "rgba(0,0,0,0.42)" }}
|
|
97
|
+
>
|
|
98
|
+
{labels?.description || block.description}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</button>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default function BlockTypePicker({
|
|
106
|
+
onSelect,
|
|
107
|
+
onClose,
|
|
108
|
+
insertIndex,
|
|
109
|
+
}: BlockTypePickerProps) {
|
|
110
|
+
const [hovered, setHovered] = useState<string | null>(null);
|
|
111
|
+
|
|
112
|
+
// Only show content blocks — section-level blocks are in SectionTypePicker
|
|
113
|
+
const contentBlocks = BLOCK_TYPE_REGISTRY.filter((b) => b.category === "content");
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
|
|
118
|
+
onClick={onClose}
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
className="w-full max-w-2xl rounded-2xl bg-white max-h-[80vh] flex flex-col shadow-2xl border border-neutral-200/50 overflow-hidden"
|
|
122
|
+
style={{ fontFamily: "Inter, system-ui, sans-serif" }}
|
|
123
|
+
onClick={(e) => e.stopPropagation()}
|
|
124
|
+
>
|
|
125
|
+
{/* Header */}
|
|
126
|
+
<div className="px-6 pt-6 pb-4 border-b border-neutral-100">
|
|
127
|
+
<div className="flex items-center justify-between mb-1">
|
|
128
|
+
<h3 className="text-lg font-semibold text-neutral-900">
|
|
129
|
+
Add Block
|
|
130
|
+
</h3>
|
|
131
|
+
<div className="flex items-center gap-3">
|
|
132
|
+
{insertIndex !== undefined && (
|
|
133
|
+
<span className="text-xs text-neutral-400 bg-neutral-100 px-3 py-1 rounded-full font-medium">
|
|
134
|
+
Position: {insertIndex + 1}
|
|
135
|
+
</span>
|
|
136
|
+
)}
|
|
137
|
+
<button
|
|
138
|
+
onClick={onClose}
|
|
139
|
+
className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 transition-colors"
|
|
140
|
+
aria-label="Close block picker"
|
|
141
|
+
>
|
|
142
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
143
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
144
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
145
|
+
</svg>
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<p className="text-sm text-neutral-400">
|
|
150
|
+
Choose a block and add it to your page
|
|
151
|
+
</p>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Block grid */}
|
|
155
|
+
<div className="overflow-y-auto px-6 py-4 flex-1">
|
|
156
|
+
{/* Content blocks */}
|
|
157
|
+
<div>
|
|
158
|
+
<p className="text-[10px] font-semibold uppercase tracking-widest text-neutral-300 mb-2.5">
|
|
159
|
+
Content Blocks
|
|
160
|
+
</p>
|
|
161
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
162
|
+
{contentBlocks.map((block) => (
|
|
163
|
+
<BlockCard
|
|
164
|
+
key={block.type}
|
|
165
|
+
block={block}
|
|
166
|
+
isHovered={hovered === block.type}
|
|
167
|
+
onSelect={() => { onSelect(block.type); onClose(); }}
|
|
168
|
+
onHover={() => setHovered(block.type)}
|
|
169
|
+
onLeave={() => setHovered(null)}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|