@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,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CustomSectionInstanceRenderer — Renders a custom section instance on the public site.
|
|
5
|
+
*
|
|
6
|
+
* Fetches the section data from the cached public API endpoint and renders it
|
|
7
|
+
* using SectionV2Renderer. The API response is CDN-cached for 1 hour.
|
|
8
|
+
*
|
|
9
|
+
* Session 108: Created as part of Custom Sections Phase 2.
|
|
10
|
+
* Session 110: M2 fix — loading skeleton to prevent CLS.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
import type { CustomSectionInstance, PageSectionV2 } from "../../lib/sanity/types";
|
|
15
|
+
import SectionV2Renderer from "./SectionV2Renderer";
|
|
16
|
+
|
|
17
|
+
interface CustomSectionInstanceRendererProps {
|
|
18
|
+
instance: CustomSectionInstance;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function CustomSectionInstanceRenderer({
|
|
22
|
+
instance,
|
|
23
|
+
}: CustomSectionInstanceRendererProps) {
|
|
24
|
+
const [section, setSection] = useState<PageSectionV2 | null>(null);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
|
|
31
|
+
fetch(`/api/custom-sections/${instance.custom_section_id}`)
|
|
32
|
+
.then((res) => {
|
|
33
|
+
if (!res.ok) throw new Error("Not found");
|
|
34
|
+
return res.json();
|
|
35
|
+
})
|
|
36
|
+
.then((data) => {
|
|
37
|
+
if (!cancelled && data.section) {
|
|
38
|
+
setSection(data.section);
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {
|
|
42
|
+
if (!cancelled) setError(true);
|
|
43
|
+
})
|
|
44
|
+
.finally(() => {
|
|
45
|
+
if (!cancelled) setLoading(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return () => { cancelled = true; };
|
|
49
|
+
}, [instance.custom_section_id]);
|
|
50
|
+
|
|
51
|
+
// Error — render nothing (silent fail, no layout shift)
|
|
52
|
+
if (error) return null;
|
|
53
|
+
|
|
54
|
+
// Loading — minimal skeleton preserving vertical space
|
|
55
|
+
if (loading || !section) {
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className="w-full animate-pulse"
|
|
59
|
+
style={{ minHeight: 120, background: "transparent" }}
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Merge per-instance settings overrides on top of the section's base settings
|
|
66
|
+
let mergedSection: PageSectionV2 = instance.settings_overrides
|
|
67
|
+
? { ...section, settings: { ...section.settings, ...instance.settings_overrides } }
|
|
68
|
+
: section;
|
|
69
|
+
|
|
70
|
+
// Merge per-instance responsive overrides (viewport-specific spacing/border/etc.)
|
|
71
|
+
if (instance.responsive_overrides) {
|
|
72
|
+
mergedSection = {
|
|
73
|
+
...mergedSection,
|
|
74
|
+
responsive: {
|
|
75
|
+
...mergedSection.responsive,
|
|
76
|
+
...instance.responsive_overrides,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return <SectionV2Renderer section={mergedSection} />;
|
|
82
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EnterAnimationWrapper — applies enter (reveal) animations on the public site.
|
|
5
|
+
*
|
|
6
|
+
* Replaces ScrollAnimationWrapper (Session 120).
|
|
7
|
+
*
|
|
8
|
+
* Single trigger mode: IntersectionObserver (15% threshold).
|
|
9
|
+
* - Above-the-fold: if already intersecting on mount, plays immediately.
|
|
10
|
+
* - Below-the-fold: waits for scroll to reveal, then plays once.
|
|
11
|
+
*
|
|
12
|
+
* Applies inline @keyframes from getEnterKeyframes() — no CSS animation-timeline,
|
|
13
|
+
* no scroll-linked animation, no Firefox fallback.
|
|
14
|
+
*
|
|
15
|
+
* Respects prefers-reduced-motion: skips animation, shows element immediately.
|
|
16
|
+
* Sets data-entered when complete (for PageExitContext exit animations).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useRef, useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
20
|
+
import type { ResolvedEnterAnimationConfig, EnterPreset } from "../../lib/animation/enter-types";
|
|
21
|
+
import { getEnterKeyframes } from "../../lib/animation/enter-presets";
|
|
22
|
+
|
|
23
|
+
interface EnterAnimationWrapperProps {
|
|
24
|
+
config: ResolvedEnterAnimationConfig;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
/** HTML tag for the wrapper element */
|
|
27
|
+
as?: "div" | "section";
|
|
28
|
+
className?: string;
|
|
29
|
+
style?: CSSProperties;
|
|
30
|
+
/** Stagger index within parent — offsets animation delay for sequential reveals */
|
|
31
|
+
staggerIndex?: number;
|
|
32
|
+
/** Stagger delay in ms (multiplied by staggerIndex) */
|
|
33
|
+
staggerDelay?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Map enter preset names to @keyframes names in globals.css.
|
|
38
|
+
*/
|
|
39
|
+
const PRESET_KEYFRAME_MAP: Record<string, string> = {
|
|
40
|
+
fade: "enter-fade",
|
|
41
|
+
"slide-up": "enter-slide-up",
|
|
42
|
+
"slide-down": "enter-slide-down",
|
|
43
|
+
scale: "enter-scale",
|
|
44
|
+
blur: "enter-blur",
|
|
45
|
+
"blur-in": "enter-blur-in",
|
|
46
|
+
reveal: "enter-reveal",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default function EnterAnimationWrapper({
|
|
50
|
+
config,
|
|
51
|
+
children,
|
|
52
|
+
as: Tag = "div",
|
|
53
|
+
className,
|
|
54
|
+
style,
|
|
55
|
+
staggerIndex,
|
|
56
|
+
staggerDelay,
|
|
57
|
+
}: EnterAnimationWrapperProps) {
|
|
58
|
+
const { preset, duration, delay, easing } = config;
|
|
59
|
+
|
|
60
|
+
const ref = useRef<HTMLElement>(null);
|
|
61
|
+
const [hasEntered, setHasEntered] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Compute total delay including stagger
|
|
64
|
+
const totalDelay = delay + (staggerIndex !== undefined && staggerDelay ? staggerIndex * staggerDelay : 0);
|
|
65
|
+
|
|
66
|
+
// Get keyframe data for initial hidden state
|
|
67
|
+
const keyframes = getEnterKeyframes(preset);
|
|
68
|
+
|
|
69
|
+
// ── IntersectionObserver: above-the-fold detection + scroll reveal ──
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const el = ref.current;
|
|
72
|
+
if (!el || hasEntered) return;
|
|
73
|
+
|
|
74
|
+
// Respect prefers-reduced-motion
|
|
75
|
+
if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
76
|
+
setHasEntered(true);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const observer = new IntersectionObserver(
|
|
81
|
+
([entry]) => {
|
|
82
|
+
if (entry.isIntersecting) {
|
|
83
|
+
// Whether above-the-fold (already visible on mount) or below-the-fold
|
|
84
|
+
// (scrolled into view), behavior is the same: play animation once.
|
|
85
|
+
setHasEntered(true);
|
|
86
|
+
observer.disconnect();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{ threshold: 0.15 },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
observer.observe(el);
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, [hasEntered]);
|
|
95
|
+
|
|
96
|
+
// If preset is "none" or has no keyframes (typewriter), just render children
|
|
97
|
+
if (preset === "none" || !keyframes) {
|
|
98
|
+
return (
|
|
99
|
+
<Tag className={className} style={style}>
|
|
100
|
+
{children}
|
|
101
|
+
</Tag>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const animName = PRESET_KEYFRAME_MAP[preset];
|
|
106
|
+
|
|
107
|
+
// Build styles based on state
|
|
108
|
+
let animStyle: CSSProperties;
|
|
109
|
+
|
|
110
|
+
if (hasEntered) {
|
|
111
|
+
// Play the animation
|
|
112
|
+
animStyle = {
|
|
113
|
+
animationName: animName,
|
|
114
|
+
animationDuration: `${duration}ms`,
|
|
115
|
+
animationDelay: `${totalDelay}ms`,
|
|
116
|
+
animationTimingFunction: easing,
|
|
117
|
+
animationFillMode: "both",
|
|
118
|
+
};
|
|
119
|
+
} else {
|
|
120
|
+
// Initial hidden state from keyframe "from" values
|
|
121
|
+
animStyle = keyframes.from as unknown as CSSProperties;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const mergedStyle: CSSProperties = {
|
|
125
|
+
...style,
|
|
126
|
+
...animStyle,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<Tag
|
|
131
|
+
ref={ref as React.Ref<HTMLDivElement> & React.Ref<HTMLElement>}
|
|
132
|
+
data-enter-animation={preset}
|
|
133
|
+
data-entered={hasEntered ? "" : undefined}
|
|
134
|
+
className={className}
|
|
135
|
+
style={mergedStyle}
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</Tag>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HoverAnimationWrapper — applies hover effects to blocks on the public site.
|
|
5
|
+
*
|
|
6
|
+
* Handles both CSS-based hovers (scale-up, lift, tilt-3d, etc.) and
|
|
7
|
+
* shader-based hovers (ripple, rgb-shift, pixelate) via ShaderCanvas.
|
|
8
|
+
*
|
|
9
|
+
* Block-level only — no cascade. Config comes directly from block.hover_effect.
|
|
10
|
+
*
|
|
11
|
+
* Respects:
|
|
12
|
+
* - prefers-reduced-motion: disables all hover effects (returns children unwrapped)
|
|
13
|
+
* - Touch devices: hover effects are naturally absent on touch
|
|
14
|
+
*
|
|
15
|
+
* Session 60, updated Session 64 (throttled tilt-3d, reduced-motion).
|
|
16
|
+
* Session 120: Updated to accept HoverEffectConfig, merged ShaderEffectWrapper.
|
|
17
|
+
* Session 128: Fixed hooks ordering — all hooks must be called before any
|
|
18
|
+
* conditional return to comply with React's Rules of Hooks. The shader path
|
|
19
|
+
* early return was skipping useCallback/useEffect, causing React error #300
|
|
20
|
+
* ("Rendered fewer hooks than expected").
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useRef, useCallback, useEffect, useState, lazy, Suspense, type CSSProperties, type ReactNode, Component, type ErrorInfo } from "react";
|
|
24
|
+
import type { HoverEffectConfig } from "../../lib/animation/hover-effect-types";
|
|
25
|
+
import { getHoverEffectStyles, isShaderPreset, SMOOTHNESS_VALUES } from "../../lib/animation/hover-effect-presets";
|
|
26
|
+
import type { ResolvedShaderEffectConfig } from "../../lib/animation/hover-effect-types";
|
|
27
|
+
import { getSiteConfig } from "../../lib/config";
|
|
28
|
+
|
|
29
|
+
// Lazy import — zero bundle impact when shaders aren't used.
|
|
30
|
+
// Uses React.lazy + Suspense instead of next/dynamic to avoid potential
|
|
31
|
+
// SSR hydration issues with { ssr: false } in Next.js App Router.
|
|
32
|
+
const ShaderCanvas = lazy(() => import("./ShaderCanvas"));
|
|
33
|
+
|
|
34
|
+
// ── Error boundary for shader rendering ──
|
|
35
|
+
// Catches WebGL / shader errors and falls back to a plain image.
|
|
36
|
+
interface ShaderErrorBoundaryProps {
|
|
37
|
+
fallbackSrc: string;
|
|
38
|
+
fallbackAlt: string;
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
interface ShaderErrorBoundaryState { hasError: boolean }
|
|
42
|
+
|
|
43
|
+
class ShaderErrorBoundary extends Component<ShaderErrorBoundaryProps, ShaderErrorBoundaryState> {
|
|
44
|
+
state: ShaderErrorBoundaryState = { hasError: false };
|
|
45
|
+
|
|
46
|
+
static getDerivedStateFromError(): ShaderErrorBoundaryState {
|
|
47
|
+
return { hasError: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.warn("[ShaderErrorBoundary] Caught error, falling back to plain image:", error, info.componentStack);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render() {
|
|
56
|
+
if (this.state.hasError) {
|
|
57
|
+
return (
|
|
58
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
59
|
+
<img
|
|
60
|
+
src={this.props.fallbackSrc}
|
|
61
|
+
alt={this.props.fallbackAlt}
|
|
62
|
+
style={{ display: "block", width: "100%", height: "100%", objectFit: "cover" }}
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return this.props.children;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface HoverAnimationWrapperProps {
|
|
71
|
+
config: HoverEffectConfig;
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
as?: "div" | "section";
|
|
74
|
+
className?: string;
|
|
75
|
+
style?: CSSProperties;
|
|
76
|
+
/**
|
|
77
|
+
* Required for shader presets — the image src to apply the shader to.
|
|
78
|
+
* If not provided and a shader preset is selected, falls back to CSS-only.
|
|
79
|
+
*/
|
|
80
|
+
shaderSrc?: string;
|
|
81
|
+
shaderAlt?: string;
|
|
82
|
+
/** Style for the shader container */
|
|
83
|
+
shaderContainerStyle?: CSSProperties;
|
|
84
|
+
shaderContainerClassName?: string;
|
|
85
|
+
shaderImgProps?: React.ImgHTMLAttributes<HTMLImageElement>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Capability checks (memoized) ──
|
|
89
|
+
|
|
90
|
+
let _webglSupported: boolean | null = null;
|
|
91
|
+
function isWebGLSupported(): boolean {
|
|
92
|
+
if (_webglSupported !== null) return _webglSupported;
|
|
93
|
+
try {
|
|
94
|
+
const canvas = document.createElement("canvas");
|
|
95
|
+
const ctx = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
96
|
+
_webglSupported = !!ctx;
|
|
97
|
+
} catch {
|
|
98
|
+
_webglSupported = false;
|
|
99
|
+
}
|
|
100
|
+
return _webglSupported;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isTouchOnlyDevice(): boolean {
|
|
104
|
+
const hasFinePointer = window.matchMedia("(pointer: fine)").matches;
|
|
105
|
+
if (hasFinePointer) return false;
|
|
106
|
+
const hasCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
|
107
|
+
return hasCoarsePointer || "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default function HoverAnimationWrapper({
|
|
111
|
+
config,
|
|
112
|
+
children,
|
|
113
|
+
as: Tag = "div",
|
|
114
|
+
className,
|
|
115
|
+
style,
|
|
116
|
+
shaderSrc,
|
|
117
|
+
shaderAlt,
|
|
118
|
+
shaderContainerStyle,
|
|
119
|
+
shaderContainerClassName,
|
|
120
|
+
shaderImgProps,
|
|
121
|
+
}: HoverAnimationWrapperProps) {
|
|
122
|
+
const {
|
|
123
|
+
preset = "none",
|
|
124
|
+
duration = 300,
|
|
125
|
+
easing = "ease-out",
|
|
126
|
+
shader_speed = 1.0,
|
|
127
|
+
shader_smoothness = "normal",
|
|
128
|
+
} = config;
|
|
129
|
+
|
|
130
|
+
const isShader = isShaderPreset(preset);
|
|
131
|
+
|
|
132
|
+
// ── ALL hooks must be called unconditionally (Rules of Hooks) ──
|
|
133
|
+
// These hooks are used by the CSS hover path, but must be called even
|
|
134
|
+
// on the shader path to maintain consistent hook ordering across renders.
|
|
135
|
+
|
|
136
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
137
|
+
const rafId = useRef<number>(0);
|
|
138
|
+
const leaveTimerId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
139
|
+
|
|
140
|
+
// Respect prefers-reduced-motion
|
|
141
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
142
|
+
const [canRenderShader, setCanRenderShader] = useState(false);
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
146
|
+
setReducedMotion(mql.matches);
|
|
147
|
+
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
|
|
148
|
+
mql.addEventListener("change", handler);
|
|
149
|
+
return () => mql.removeEventListener("change", handler);
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
// Check shader capability once
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!isShaderPreset(preset)) return;
|
|
155
|
+
const siteConfig = getSiteConfig();
|
|
156
|
+
if (!(siteConfig.features as Record<string, boolean>).shaderEffects) {
|
|
157
|
+
setCanRenderShader(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const webgl = isWebGLSupported();
|
|
161
|
+
const touchOnly = isTouchOnlyDevice();
|
|
162
|
+
const rm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
163
|
+
setCanRenderShader(webgl && !touchOnly && !rm);
|
|
164
|
+
}, [preset]);
|
|
165
|
+
|
|
166
|
+
// Throttled mouse move handler for tilt-3d preset (rAF-based)
|
|
167
|
+
// Called unconditionally to maintain hook ordering.
|
|
168
|
+
const handleMouseMove = useCallback(
|
|
169
|
+
(e: React.MouseEvent) => {
|
|
170
|
+
if (preset !== "tilt-3d") return;
|
|
171
|
+
const el = ref.current;
|
|
172
|
+
if (!el) return;
|
|
173
|
+
|
|
174
|
+
if (rafId.current) cancelAnimationFrame(rafId.current);
|
|
175
|
+
|
|
176
|
+
const clientX = e.clientX;
|
|
177
|
+
const clientY = e.clientY;
|
|
178
|
+
|
|
179
|
+
rafId.current = requestAnimationFrame(() => {
|
|
180
|
+
const rect = el.getBoundingClientRect();
|
|
181
|
+
const x = (clientX - rect.left) / rect.width;
|
|
182
|
+
const y = (clientY - rect.top) / rect.height;
|
|
183
|
+
|
|
184
|
+
const maxTilt = 8;
|
|
185
|
+
const rotateY = (x - 0.5) * 2 * maxTilt;
|
|
186
|
+
const rotateX = (0.5 - y) * 2 * maxTilt;
|
|
187
|
+
|
|
188
|
+
el.style.transform = `perspective(800px) rotateX(${rotateX.toFixed(1)}deg) rotateY(${rotateY.toFixed(1)}deg)`;
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
[preset]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const handleMouseLeave = useCallback(() => {
|
|
195
|
+
if (preset !== "tilt-3d") return;
|
|
196
|
+
if (rafId.current) {
|
|
197
|
+
cancelAnimationFrame(rafId.current);
|
|
198
|
+
rafId.current = 0;
|
|
199
|
+
}
|
|
200
|
+
if (leaveTimerId.current) clearTimeout(leaveTimerId.current);
|
|
201
|
+
leaveTimerId.current = setTimeout(() => {
|
|
202
|
+
const el = ref.current;
|
|
203
|
+
if (!el) return;
|
|
204
|
+
el.style.transform = "perspective(800px) rotateX(0deg) rotateY(0deg)";
|
|
205
|
+
}, 50);
|
|
206
|
+
}, [preset]);
|
|
207
|
+
|
|
208
|
+
// Cleanup rAF and debounce timer on unmount
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
return () => {
|
|
211
|
+
if (rafId.current) cancelAnimationFrame(rafId.current);
|
|
212
|
+
if (leaveTimerId.current) clearTimeout(leaveTimerId.current);
|
|
213
|
+
};
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
// ── All hooks called. Conditional returns are now safe. ──
|
|
217
|
+
|
|
218
|
+
// Shader preset without shaderSrc: passthrough (no wrapper div, no CSS hover)
|
|
219
|
+
if (isShader && !shaderSrc) {
|
|
220
|
+
return <>{children}</>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Shader preset rendering ──
|
|
224
|
+
// Renders children normally (for layout/dimensions) and overlays ShaderCanvas on top.
|
|
225
|
+
// Children provide the natural size; the shader canvas fills the container absolutely.
|
|
226
|
+
if (isShader && shaderSrc && canRenderShader) {
|
|
227
|
+
const shaderConfig: ResolvedShaderEffectConfig = {
|
|
228
|
+
preset: preset,
|
|
229
|
+
trigger: "hover",
|
|
230
|
+
intensity: 1.0,
|
|
231
|
+
speed: shader_speed,
|
|
232
|
+
smoothing: SMOOTHNESS_VALUES[shader_smoothness] ?? 0.05,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Fallback image shown while ShaderCanvas loads or if it fails
|
|
236
|
+
const fallbackImg = (
|
|
237
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
238
|
+
<img
|
|
239
|
+
src={shaderSrc}
|
|
240
|
+
alt={shaderAlt ?? ""}
|
|
241
|
+
style={{
|
|
242
|
+
display: "block",
|
|
243
|
+
width: "100%",
|
|
244
|
+
height: "100%",
|
|
245
|
+
objectFit: "cover",
|
|
246
|
+
}}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div
|
|
252
|
+
style={{ position: "relative", overflow: "hidden", ...shaderContainerStyle }}
|
|
253
|
+
className={shaderContainerClassName || className}
|
|
254
|
+
>
|
|
255
|
+
{/* Children provide layout dimensions (padding, border-radius, natural image size) */}
|
|
256
|
+
<div aria-hidden="true" style={{ visibility: "hidden" }}>
|
|
257
|
+
{children}
|
|
258
|
+
</div>
|
|
259
|
+
{/* Shader canvas fills the container on top of the hidden children */}
|
|
260
|
+
<ShaderErrorBoundary fallbackSrc={shaderSrc} fallbackAlt={shaderAlt ?? ""}>
|
|
261
|
+
<Suspense fallback={fallbackImg}>
|
|
262
|
+
<ShaderCanvas
|
|
263
|
+
src={shaderSrc}
|
|
264
|
+
config={shaderConfig}
|
|
265
|
+
style={{ position: "absolute", inset: 0 }}
|
|
266
|
+
/>
|
|
267
|
+
</Suspense>
|
|
268
|
+
</ShaderErrorBoundary>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── CSS hover preset rendering ──
|
|
274
|
+
|
|
275
|
+
const hoverStyles = getHoverEffectStyles(preset, duration, easing);
|
|
276
|
+
|
|
277
|
+
// When reduced motion is preferred or shader preset falls through (canRenderShader=false),
|
|
278
|
+
// render children without any animation wrapper
|
|
279
|
+
if (reducedMotion || !hoverStyles) {
|
|
280
|
+
return (
|
|
281
|
+
<Tag className={className} style={style}>
|
|
282
|
+
{children}
|
|
283
|
+
</Tag>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Merge base styles with user styles
|
|
288
|
+
const mergedStyle: CSSProperties = {
|
|
289
|
+
...style,
|
|
290
|
+
...(hoverStyles.base as unknown as CSSProperties),
|
|
291
|
+
transition: `all ${duration}ms ${easing}`,
|
|
292
|
+
} as CSSProperties;
|
|
293
|
+
|
|
294
|
+
const isTilt = preset === "tilt-3d";
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<Tag
|
|
298
|
+
ref={ref as React.Ref<HTMLDivElement>}
|
|
299
|
+
data-hover-effect={preset}
|
|
300
|
+
className={className}
|
|
301
|
+
style={mergedStyle}
|
|
302
|
+
onMouseMove={isTilt ? handleMouseMove : undefined}
|
|
303
|
+
onMouseLeave={isTilt ? handleMouseLeave : undefined}
|
|
304
|
+
>
|
|
305
|
+
{children}
|
|
306
|
+
</Tag>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ImageBlock } from "../../lib/sanity/types";
|
|
4
|
+
import { useAssetUrl } from "../../lib/contexts/AssetContext";
|
|
5
|
+
import { handleImageRetry } from "../../lib/asset-retry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Width handling — must match builder LiveImage in BlockLivePreview.tsx.
|
|
9
|
+
* Builder uses: full=100%, contained=75%, small=50% with margin auto.
|
|
10
|
+
*/
|
|
11
|
+
const widthStyleMap: Record<string, { width: string; margin?: string }> = {
|
|
12
|
+
full: { width: "100%" },
|
|
13
|
+
contained: { width: "75%", margin: "0 auto" },
|
|
14
|
+
small: { width: "50%", margin: "0 auto" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const aspectMap: Record<string, string | undefined> = {
|
|
18
|
+
auto: undefined,
|
|
19
|
+
"16:9": "16/9",
|
|
20
|
+
"4:3": "4/3",
|
|
21
|
+
"1:1": "1/1",
|
|
22
|
+
"21:9": "21/9",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default function ImageBlockRenderer({ block }: { block: ImageBlock }) {
|
|
26
|
+
const resolveAsset = useAssetUrl();
|
|
27
|
+
const src = resolveAsset(block.asset_path);
|
|
28
|
+
const widthStyle = widthStyleMap[block.width ?? "full"] || widthStyleMap.full;
|
|
29
|
+
const aspect = aspectMap[block.aspect_ratio ?? "auto"];
|
|
30
|
+
|
|
31
|
+
const borderRadius = block.border_radius ? `${String(block.border_radius).replace(/px$/i, "")}px` : undefined;
|
|
32
|
+
|
|
33
|
+
const imgStyle: React.CSSProperties = {
|
|
34
|
+
width: "100%",
|
|
35
|
+
display: "block",
|
|
36
|
+
objectFit: aspect ? "cover" : undefined,
|
|
37
|
+
aspectRatio: aspect,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const imgClassName = block.shadow ? "shadow-lg" : "";
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<figure style={{ ...widthStyle, borderRadius, overflow: "hidden" }}>
|
|
44
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
45
|
+
<img
|
|
46
|
+
src={src}
|
|
47
|
+
alt={block.alt ?? ""}
|
|
48
|
+
loading={block.lazy !== false ? "lazy" : "eager"}
|
|
49
|
+
decoding="async"
|
|
50
|
+
onError={handleImageRetry}
|
|
51
|
+
style={imgStyle}
|
|
52
|
+
className={imgClassName}
|
|
53
|
+
/>
|
|
54
|
+
{block.caption && (
|
|
55
|
+
<figcaption className="mt-2 font-mono text-xs uppercase tracking-wider text-brand-muted">
|
|
56
|
+
{block.caption}
|
|
57
|
+
</figcaption>
|
|
58
|
+
)}
|
|
59
|
+
</figure>
|
|
60
|
+
);
|
|
61
|
+
}
|