@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,462 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, type ReactNode } from "react";
|
|
4
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
5
|
+
import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
|
|
6
|
+
import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
|
|
7
|
+
import type { DeviceViewport } from "../../../lib/builder/types";
|
|
8
|
+
import {
|
|
9
|
+
SettingsSection,
|
|
10
|
+
SettingsField,
|
|
11
|
+
ViewportBadge,
|
|
12
|
+
ResponsiveField,
|
|
13
|
+
useActiveViewport,
|
|
14
|
+
INPUT_CLASS,
|
|
15
|
+
SELECT_CLASS,
|
|
16
|
+
} from "./shared";
|
|
17
|
+
import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
|
|
18
|
+
import TextStylePicker, {
|
|
19
|
+
FALLBACK_PRESETS,
|
|
20
|
+
buildPresetsFromStyles,
|
|
21
|
+
type TextStylePreset,
|
|
22
|
+
} from "./TextStylePicker";
|
|
23
|
+
import {
|
|
24
|
+
AlignLeftIcon,
|
|
25
|
+
AlignCenterIcon,
|
|
26
|
+
AlignRightIcon,
|
|
27
|
+
AlignJustifyIcon,
|
|
28
|
+
} from "./TextAlignmentIcons";
|
|
29
|
+
|
|
30
|
+
// ============================================
|
|
31
|
+
// Responsive style field — MUST be defined outside the editor component
|
|
32
|
+
// to avoid React treating it as a new component on every re-render,
|
|
33
|
+
// which causes input elements to lose focus.
|
|
34
|
+
// ============================================
|
|
35
|
+
|
|
36
|
+
function ResponsiveStyleField({
|
|
37
|
+
label,
|
|
38
|
+
subProp,
|
|
39
|
+
viewport,
|
|
40
|
+
isOverridden,
|
|
41
|
+
onReset,
|
|
42
|
+
children,
|
|
43
|
+
hint,
|
|
44
|
+
}: {
|
|
45
|
+
label: string;
|
|
46
|
+
subProp: string;
|
|
47
|
+
viewport: DeviceViewport;
|
|
48
|
+
isOverridden: boolean;
|
|
49
|
+
onReset: (subProp: string) => void;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
hint?: string;
|
|
52
|
+
}) {
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex items-start gap-3 mb-2 last:mb-0">
|
|
55
|
+
<label className="text-[11px] text-neutral-400 w-[68px] min-w-[68px] shrink-0 pt-[7px] leading-tight">
|
|
56
|
+
{label}
|
|
57
|
+
{viewport !== "desktop" && !isOverridden && (
|
|
58
|
+
<span className="block text-[9px] text-neutral-300 italic mt-0.5">inherited</span>
|
|
59
|
+
)}
|
|
60
|
+
{isOverridden && (
|
|
61
|
+
<span className="block text-[9px] text-[#076bff] mt-0.5">overridden</span>
|
|
62
|
+
)}
|
|
63
|
+
</label>
|
|
64
|
+
<div className="flex-1 min-w-0">
|
|
65
|
+
{children}
|
|
66
|
+
{hint && <p className="text-[10px] text-neutral-400 mt-1">{hint}</p>}
|
|
67
|
+
{isOverridden && (
|
|
68
|
+
<button
|
|
69
|
+
onClick={() => onReset(subProp)}
|
|
70
|
+
className="text-[10px] text-neutral-400 hover:text-[var(--admin-error)] transition-colors mt-0.5"
|
|
71
|
+
>
|
|
72
|
+
Reset
|
|
73
|
+
</button>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================
|
|
81
|
+
// Main Editor
|
|
82
|
+
// ============================================
|
|
83
|
+
|
|
84
|
+
export default function TextBlockEditor({ block }: { block: TextBlock }) {
|
|
85
|
+
const store = useBuilderStore();
|
|
86
|
+
const viewport = useActiveViewport();
|
|
87
|
+
const paletteSwatches = usePaletteSwatches();
|
|
88
|
+
const pageTextColor = store.pageSettings.text_color || "#0a0a0a";
|
|
89
|
+
const [presets, setPresets] = useState<TextStylePreset[]>(FALLBACK_PRESETS);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
fetch("/api/admin/styles", { credentials: "include" })
|
|
93
|
+
.then((r) => r.json())
|
|
94
|
+
.then((data) => {
|
|
95
|
+
if (data.styles) {
|
|
96
|
+
const built = buildPresetsFromStyles(data.styles);
|
|
97
|
+
if (built.length > 0) setPresets(built);
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.catch(() => { /* Style presets unavailable — fallback presets used */ });
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const style = block.style || {};
|
|
104
|
+
// Undo snapshot strategy:
|
|
105
|
+
// - Continuous inputs (text fields, sliders): snapshot on focus (one snapshot per edit session)
|
|
106
|
+
// - Discrete actions (buttons, preset picks): snapshot immediately before mutation
|
|
107
|
+
const snapshotOnFocus = () => store._pushSnapshot();
|
|
108
|
+
|
|
109
|
+
// === Responsive helpers for nested style sub-properties ===
|
|
110
|
+
// The responsive system deep-merges 1-level objects, so we store
|
|
111
|
+
// partial style overrides at responsive[viewport].style = { fontSize: 24 }
|
|
112
|
+
// and resolveBlock merges { ...block.style, ...responsive[viewport].style }.
|
|
113
|
+
|
|
114
|
+
/** Get the current responsive style overrides for the active viewport */
|
|
115
|
+
const getViewportStyleOverrides = (): Record<string, unknown> => {
|
|
116
|
+
const responsive = (block as unknown as Record<string, unknown>).responsive as
|
|
117
|
+
| Record<string, Record<string, unknown>>
|
|
118
|
+
| undefined;
|
|
119
|
+
if (!responsive?.[viewport]?.style) return {};
|
|
120
|
+
return responsive[viewport].style as Record<string, unknown>;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Check if a style sub-property has a responsive override */
|
|
124
|
+
const hasStyleOverride = (subProp: string): boolean => {
|
|
125
|
+
if (viewport === "desktop") return true;
|
|
126
|
+
const overrides = getViewportStyleOverrides();
|
|
127
|
+
return subProp in overrides;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Get effective value of a style sub-property for the active viewport */
|
|
131
|
+
const getEffectiveStyleValue = <T,>(subProp: string, baseValue: T): T => {
|
|
132
|
+
if (viewport === "desktop") return baseValue;
|
|
133
|
+
const overrides = getViewportStyleOverrides();
|
|
134
|
+
return subProp in overrides ? (overrides[subProp] as T) : baseValue;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Update a style sub-property, responsive-aware */
|
|
138
|
+
const updateStyleResponsive = (subProp: string, value: unknown) => {
|
|
139
|
+
if (viewport === "desktop") {
|
|
140
|
+
store.updateBlock(block._key, {
|
|
141
|
+
style: { ...style, [subProp]: value },
|
|
142
|
+
} as Partial<ContentBlock>);
|
|
143
|
+
} else {
|
|
144
|
+
// Merge into responsive[viewport].style
|
|
145
|
+
const existing = (block as unknown as Record<string, unknown>).responsive as
|
|
146
|
+
| Record<string, Record<string, unknown>>
|
|
147
|
+
| undefined || {};
|
|
148
|
+
const vpOverrides = { ...(existing[viewport] || {}) };
|
|
149
|
+
const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
|
|
150
|
+
vpOverrides.style = styleOverrides;
|
|
151
|
+
store.updateBlock(block._key, {
|
|
152
|
+
responsive: { ...existing, [viewport]: vpOverrides },
|
|
153
|
+
} as Partial<ContentBlock>);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Update a style sub-property with debounce, responsive-aware */
|
|
158
|
+
const updateStyleDebouncedResponsive = (subProp: string, value: unknown) => {
|
|
159
|
+
if (viewport === "desktop") {
|
|
160
|
+
store.updateBlockDebounced(block._key, {
|
|
161
|
+
style: { ...style, [subProp]: value },
|
|
162
|
+
} as Partial<ContentBlock>);
|
|
163
|
+
} else {
|
|
164
|
+
const existing = (block as unknown as Record<string, unknown>).responsive as
|
|
165
|
+
| Record<string, Record<string, unknown>>
|
|
166
|
+
| undefined || {};
|
|
167
|
+
const vpOverrides = { ...(existing[viewport] || {}) };
|
|
168
|
+
const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}), [subProp]: value };
|
|
169
|
+
vpOverrides.style = styleOverrides;
|
|
170
|
+
store.updateBlockDebounced(block._key, {
|
|
171
|
+
responsive: { ...existing, [viewport]: vpOverrides },
|
|
172
|
+
} as Partial<ContentBlock>);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Reset a style sub-property override */
|
|
177
|
+
const resetStyleOverride = (subProp: string) => {
|
|
178
|
+
const existing = (block as unknown as Record<string, unknown>).responsive as
|
|
179
|
+
| Record<string, Record<string, unknown>>
|
|
180
|
+
| undefined || {};
|
|
181
|
+
const vpOverrides = { ...(existing[viewport] || {}) };
|
|
182
|
+
const styleOverrides = { ...((vpOverrides.style as Record<string, unknown>) || {}) };
|
|
183
|
+
delete styleOverrides[subProp];
|
|
184
|
+
if (Object.keys(styleOverrides).length === 0) {
|
|
185
|
+
delete vpOverrides.style;
|
|
186
|
+
} else {
|
|
187
|
+
vpOverrides.style = styleOverrides;
|
|
188
|
+
}
|
|
189
|
+
const responsive = { ...existing };
|
|
190
|
+
if (Object.keys(vpOverrides).length === 0) {
|
|
191
|
+
delete responsive[viewport];
|
|
192
|
+
} else {
|
|
193
|
+
responsive[viewport] = vpOverrides;
|
|
194
|
+
}
|
|
195
|
+
store.updateBlock(block._key, { responsive } as Partial<ContentBlock>);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// === Responsive helpers for top-level properties (e.g. columns) ===
|
|
199
|
+
const updateResponsive = (property: string, value: unknown) => {
|
|
200
|
+
if (viewport === "desktop") {
|
|
201
|
+
store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
|
|
202
|
+
} else {
|
|
203
|
+
const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
|
|
204
|
+
store.updateBlock(block._key, overrides as Partial<ContentBlock>);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const resetOverride = (property: string) => {
|
|
209
|
+
const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
|
|
210
|
+
store.updateBlock(block._key, overrides as Partial<ContentBlock>);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const baseFontSizePx = (() => {
|
|
214
|
+
const fs = style.fontSize;
|
|
215
|
+
if (typeof fs === "number") return fs;
|
|
216
|
+
const legacyMap: Record<string, number> = { small: 12, base: 14, large: 20, xl: 24, "2xl": 32, "3xl": 48 };
|
|
217
|
+
return legacyMap[fs || "base"] || 14;
|
|
218
|
+
})();
|
|
219
|
+
|
|
220
|
+
// Responsive-aware effective values
|
|
221
|
+
const currentFontSizePx = getEffectiveStyleValue<number>("fontSize", baseFontSizePx);
|
|
222
|
+
const currentAlignment = getEffectiveStyleValue<string>("alignment", style.alignment || "left");
|
|
223
|
+
const currentMaxWidth = getEffectiveStyleValue<string>("maxWidth", style.maxWidth || "");
|
|
224
|
+
const effectiveColumns = getEffectiveValue<number>(block as ContentBlock, viewport, "columns", block.columns || 1);
|
|
225
|
+
|
|
226
|
+
const baseFontWeight = (() => {
|
|
227
|
+
const fw = style.fontWeight;
|
|
228
|
+
if (!fw) return "400";
|
|
229
|
+
if (!isNaN(parseInt(fw, 10))) return fw;
|
|
230
|
+
if (fw === "bold") return "700";
|
|
231
|
+
if (fw === "medium") return "500";
|
|
232
|
+
return "400";
|
|
233
|
+
})();
|
|
234
|
+
const currentFontWeight = getEffectiveStyleValue<string>("fontWeight", baseFontWeight);
|
|
235
|
+
|
|
236
|
+
const handleStyleSelect = (preset: TextStylePreset) => {
|
|
237
|
+
store._pushSnapshot();
|
|
238
|
+
store.updateBlock(block._key, {
|
|
239
|
+
textStyle: preset.key,
|
|
240
|
+
style: {
|
|
241
|
+
...style,
|
|
242
|
+
fontSize: preset.fontSize,
|
|
243
|
+
fontWeight: preset.fontWeight,
|
|
244
|
+
lineHeight: preset.lineHeight,
|
|
245
|
+
letterSpacing: preset.letterSpacing,
|
|
246
|
+
textTransform: preset.textTransform as TextBlock["style"] extends undefined ? never : NonNullable<TextBlock["style"]>["textTransform"],
|
|
247
|
+
},
|
|
248
|
+
} as Partial<ContentBlock>);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const handleStyleClear = () => {
|
|
252
|
+
store._pushSnapshot();
|
|
253
|
+
store.updateBlock(block._key, {
|
|
254
|
+
textStyle: undefined,
|
|
255
|
+
} as Partial<ContentBlock>);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const alignments: { value: "left" | "center" | "right" | "justify"; icon: React.ReactNode }[] = [
|
|
259
|
+
{ value: "left", icon: <AlignLeftIcon /> },
|
|
260
|
+
{ value: "center", icon: <AlignCenterIcon /> },
|
|
261
|
+
{ value: "right", icon: <AlignRightIcon /> },
|
|
262
|
+
{ value: "justify", icon: <AlignJustifyIcon /> },
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<>
|
|
267
|
+
<ViewportBadge />
|
|
268
|
+
|
|
269
|
+
{/* Text section: Style, Color, Align */}
|
|
270
|
+
<SettingsSection title="Text" defaultOpen>
|
|
271
|
+
<SettingsField label="Style">
|
|
272
|
+
<TextStylePicker
|
|
273
|
+
presets={presets}
|
|
274
|
+
activeKey={block.textStyle}
|
|
275
|
+
onSelect={handleStyleSelect}
|
|
276
|
+
onClear={handleStyleClear}
|
|
277
|
+
/>
|
|
278
|
+
</SettingsField>
|
|
279
|
+
|
|
280
|
+
<ResponsiveStyleField label="Color" subProp="color" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("color")} onReset={resetStyleOverride}>
|
|
281
|
+
<ColorSwatchPicker
|
|
282
|
+
value={getEffectiveStyleValue<string>("color", style.color || "")}
|
|
283
|
+
onChange={(hex) => updateStyleResponsive("color", hex)}
|
|
284
|
+
swatches={paletteSwatches}
|
|
285
|
+
allowClear
|
|
286
|
+
/>
|
|
287
|
+
</ResponsiveStyleField>
|
|
288
|
+
|
|
289
|
+
<ResponsiveStyleField label="Align" subProp="alignment" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("alignment")} onReset={resetStyleOverride}>
|
|
290
|
+
<div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
|
|
291
|
+
{alignments.map(({ value, icon }) => (
|
|
292
|
+
<button
|
|
293
|
+
key={value}
|
|
294
|
+
onClick={() => updateStyleResponsive("alignment", value)}
|
|
295
|
+
className={`flex-1 flex items-center justify-center py-[5px] rounded-md transition-all ${
|
|
296
|
+
currentAlignment === value
|
|
297
|
+
? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)]"
|
|
298
|
+
: "text-neutral-300 hover:text-neutral-500"
|
|
299
|
+
}`}
|
|
300
|
+
title={value.charAt(0).toUpperCase() + value.slice(1)}
|
|
301
|
+
>
|
|
302
|
+
{icon}
|
|
303
|
+
</button>
|
|
304
|
+
))}
|
|
305
|
+
</div>
|
|
306
|
+
</ResponsiveStyleField>
|
|
307
|
+
</SettingsSection>
|
|
308
|
+
|
|
309
|
+
{/* Typography section: Size, Weight, Line height, Letter spacing */}
|
|
310
|
+
<SettingsSection title="Typography" defaultOpen>
|
|
311
|
+
<ResponsiveStyleField label="Size" subProp="fontSize" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontSize")} onReset={resetStyleOverride}>
|
|
312
|
+
<div className="flex items-center gap-0 bg-[#f5f5f5] rounded-lg overflow-hidden transition-all border border-transparent focus-within:bg-white focus-within:border-[#076bff] focus-within:shadow-[0_0_0_3px_rgba(7,107,255,0.06)]">
|
|
313
|
+
<input
|
|
314
|
+
type="number"
|
|
315
|
+
min={1}
|
|
316
|
+
max={999}
|
|
317
|
+
value={currentFontSizePx}
|
|
318
|
+
onFocus={snapshotOnFocus}
|
|
319
|
+
onChange={(e) => {
|
|
320
|
+
const val = parseInt(e.target.value, 10);
|
|
321
|
+
if (!isNaN(val) && val > 0) {
|
|
322
|
+
updateStyleDebouncedResponsive("fontSize", val);
|
|
323
|
+
if (viewport === "desktop" && block.textStyle) {
|
|
324
|
+
store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}}
|
|
328
|
+
className="flex-1 min-w-0 bg-transparent border-none px-2.5 py-[7px] text-xs text-neutral-900 outline-none"
|
|
329
|
+
/>
|
|
330
|
+
<span className="text-[10px] text-neutral-400 pr-2.5 shrink-0 select-none">px</span>
|
|
331
|
+
</div>
|
|
332
|
+
</ResponsiveStyleField>
|
|
333
|
+
|
|
334
|
+
<ResponsiveStyleField label="Weight" subProp="fontWeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontWeight")} onReset={resetStyleOverride}>
|
|
335
|
+
<select
|
|
336
|
+
value={currentFontWeight}
|
|
337
|
+
onChange={(e) => {
|
|
338
|
+
updateStyleResponsive("fontWeight", e.target.value);
|
|
339
|
+
if (viewport === "desktop" && block.textStyle) {
|
|
340
|
+
store.updateBlock(block._key, { textStyle: undefined } as Partial<ContentBlock>);
|
|
341
|
+
}
|
|
342
|
+
}}
|
|
343
|
+
className={SELECT_CLASS}
|
|
344
|
+
>
|
|
345
|
+
<option value="100">Thin (100)</option>
|
|
346
|
+
<option value="200">ExtraLight (200)</option>
|
|
347
|
+
<option value="300">Light (300)</option>
|
|
348
|
+
<option value="400">Regular (400)</option>
|
|
349
|
+
<option value="500">Medium (500)</option>
|
|
350
|
+
<option value="600">SemiBold (600)</option>
|
|
351
|
+
<option value="700">Bold (700)</option>
|
|
352
|
+
<option value="800">ExtraBold (800)</option>
|
|
353
|
+
<option value="900">Black (900)</option>
|
|
354
|
+
</select>
|
|
355
|
+
</ResponsiveStyleField>
|
|
356
|
+
|
|
357
|
+
<ResponsiveStyleField label="Line height" subProp="lineHeight" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("lineHeight")} onReset={resetStyleOverride}>
|
|
358
|
+
<input
|
|
359
|
+
type="text"
|
|
360
|
+
value={getEffectiveStyleValue<string>("lineHeight", style.lineHeight || "")}
|
|
361
|
+
onFocus={snapshotOnFocus}
|
|
362
|
+
onChange={(e) => {
|
|
363
|
+
updateStyleDebouncedResponsive("lineHeight", e.target.value);
|
|
364
|
+
if (viewport === "desktop" && block.textStyle) {
|
|
365
|
+
store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
|
|
366
|
+
}
|
|
367
|
+
}}
|
|
368
|
+
placeholder="1.5"
|
|
369
|
+
className={INPUT_CLASS}
|
|
370
|
+
/>
|
|
371
|
+
</ResponsiveStyleField>
|
|
372
|
+
|
|
373
|
+
<ResponsiveStyleField label="Spacing" subProp="letterSpacing" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("letterSpacing")} onReset={resetStyleOverride}>
|
|
374
|
+
<input
|
|
375
|
+
type="text"
|
|
376
|
+
value={getEffectiveStyleValue<string>("letterSpacing", style.letterSpacing || "")}
|
|
377
|
+
onFocus={snapshotOnFocus}
|
|
378
|
+
onChange={(e) => {
|
|
379
|
+
updateStyleDebouncedResponsive("letterSpacing", e.target.value);
|
|
380
|
+
if (viewport === "desktop" && block.textStyle) {
|
|
381
|
+
store.updateBlockDebounced(block._key, { textStyle: undefined } as Partial<ContentBlock>);
|
|
382
|
+
}
|
|
383
|
+
}}
|
|
384
|
+
placeholder="0, -0.02em, 2px"
|
|
385
|
+
className={INPUT_CLASS}
|
|
386
|
+
/>
|
|
387
|
+
</ResponsiveStyleField>
|
|
388
|
+
|
|
389
|
+
<ResponsiveStyleField label="Transform" subProp="textTransform" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("textTransform")} onReset={resetStyleOverride}>
|
|
390
|
+
<select
|
|
391
|
+
value={getEffectiveStyleValue<string>("textTransform", style.textTransform || "none")}
|
|
392
|
+
onChange={(e) => updateStyleResponsive("textTransform", e.target.value)}
|
|
393
|
+
className={SELECT_CLASS}
|
|
394
|
+
>
|
|
395
|
+
<option value="none">None</option>
|
|
396
|
+
<option value="uppercase">UPPERCASE</option>
|
|
397
|
+
<option value="lowercase">lowercase</option>
|
|
398
|
+
<option value="capitalize">Capitalize</option>
|
|
399
|
+
</select>
|
|
400
|
+
</ResponsiveStyleField>
|
|
401
|
+
</SettingsSection>
|
|
402
|
+
|
|
403
|
+
{/* Columns section */}
|
|
404
|
+
<SettingsSection title="Columns">
|
|
405
|
+
<ResponsiveField
|
|
406
|
+
label="Columns"
|
|
407
|
+
block={block as ContentBlock}
|
|
408
|
+
property="columns"
|
|
409
|
+
onReset={() => resetOverride("columns")}
|
|
410
|
+
>
|
|
411
|
+
<div className="flex gap-0.5 bg-[#f5f5f5] rounded-lg p-0.5">
|
|
412
|
+
{[1, 2, 3, 4].map((n) => (
|
|
413
|
+
<button
|
|
414
|
+
key={n}
|
|
415
|
+
onClick={() => {
|
|
416
|
+
store._pushSnapshot();
|
|
417
|
+
updateResponsive("columns", n);
|
|
418
|
+
}}
|
|
419
|
+
className={`flex-1 flex items-center justify-center py-[5px] rounded-md text-xs transition-all ${
|
|
420
|
+
effectiveColumns === n
|
|
421
|
+
? "bg-white text-neutral-900 shadow-[0_1px_3px_rgba(0,0,0,0.08)] font-medium"
|
|
422
|
+
: "text-neutral-400 hover:text-neutral-600"
|
|
423
|
+
}`}
|
|
424
|
+
>
|
|
425
|
+
{n}
|
|
426
|
+
</button>
|
|
427
|
+
))}
|
|
428
|
+
</div>
|
|
429
|
+
</ResponsiveField>
|
|
430
|
+
|
|
431
|
+
<ResponsiveStyleField label="Max Width" subProp="maxWidth" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("maxWidth")} onReset={resetStyleOverride}>
|
|
432
|
+
<input
|
|
433
|
+
type="text"
|
|
434
|
+
value={currentMaxWidth}
|
|
435
|
+
onFocus={snapshotOnFocus}
|
|
436
|
+
onChange={(e) => updateStyleDebouncedResponsive("maxWidth", e.target.value)}
|
|
437
|
+
placeholder="none, 600px, 80%"
|
|
438
|
+
className={INPUT_CLASS}
|
|
439
|
+
/>
|
|
440
|
+
</ResponsiveStyleField>
|
|
441
|
+
|
|
442
|
+
<ResponsiveStyleField label="Opacity" subProp="opacity" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("opacity")} onReset={resetStyleOverride}>
|
|
443
|
+
<div className="flex items-center gap-2">
|
|
444
|
+
<input
|
|
445
|
+
type="range"
|
|
446
|
+
min={0}
|
|
447
|
+
max={100}
|
|
448
|
+
value={Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}
|
|
449
|
+
onChange={(e) =>
|
|
450
|
+
updateStyleResponsive("opacity", parseInt(e.target.value) / 100)
|
|
451
|
+
}
|
|
452
|
+
className="flex-1 accent-[#076bff]"
|
|
453
|
+
/>
|
|
454
|
+
<span className="text-xs text-neutral-900 w-10 text-right tabular-nums">
|
|
455
|
+
{Math.round((getEffectiveStyleValue<number>("opacity", style.opacity ?? 1)) * 100)}%
|
|
456
|
+
</span>
|
|
457
|
+
</div>
|
|
458
|
+
</ResponsiveStyleField>
|
|
459
|
+
</SettingsSection>
|
|
460
|
+
</>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { SiteStyles, TypographyLevel } from "../../../lib/sanity/types";
|
|
5
|
+
|
|
6
|
+
// ============================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================
|
|
9
|
+
|
|
10
|
+
export interface TextStylePreset {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
tag: string;
|
|
14
|
+
fontSize: number;
|
|
15
|
+
fontWeight: string;
|
|
16
|
+
lineHeight: string;
|
|
17
|
+
letterSpacing: string;
|
|
18
|
+
textTransform?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============================================
|
|
22
|
+
// Preset builders
|
|
23
|
+
// ============================================
|
|
24
|
+
|
|
25
|
+
export const FALLBACK_PRESETS: TextStylePreset[] = [
|
|
26
|
+
{ key: "h1", label: "Heading 1", tag: "H1", fontSize: 48, fontWeight: "700", lineHeight: "1.1", letterSpacing: "-0.02em" },
|
|
27
|
+
{ key: "h2", label: "Heading 2", tag: "H2", fontSize: 32, fontWeight: "700", lineHeight: "1.2", letterSpacing: "-0.01em" },
|
|
28
|
+
{ key: "h3", label: "Heading 3", tag: "H3", fontSize: 24, fontWeight: "500", lineHeight: "1.3", letterSpacing: "0" },
|
|
29
|
+
{ key: "h4", label: "Heading 4", tag: "H4", fontSize: 18, fontWeight: "500", lineHeight: "1.4", letterSpacing: "0" },
|
|
30
|
+
{ key: "body", label: "Body", tag: "P", fontSize: 14, fontWeight: "400", lineHeight: "1.6", letterSpacing: "0" },
|
|
31
|
+
{ key: "small", label: "Small", tag: "P", fontSize: 12, fontWeight: "400", lineHeight: "1.5", letterSpacing: "0.02em" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function parsePxValue(val: string): number {
|
|
35
|
+
const match = val.match(/([\d.]+)/);
|
|
36
|
+
if (!match) return 14;
|
|
37
|
+
const num = parseFloat(match[1]);
|
|
38
|
+
if (val.includes("rem")) return Math.round(num * 16);
|
|
39
|
+
return Math.round(num);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildPresetsFromStyles(styles: SiteStyles): TextStylePreset[] {
|
|
43
|
+
const typo = styles.typography;
|
|
44
|
+
if (!typo) return FALLBACK_PRESETS;
|
|
45
|
+
|
|
46
|
+
const levels: { key: string; label: string; tag: string; data?: TypographyLevel | null }[] = [
|
|
47
|
+
{ key: "h1", label: "Heading 1", tag: "H1", data: typo.h1 },
|
|
48
|
+
{ key: "h2", label: "Heading 2", tag: "H2", data: typo.h2 },
|
|
49
|
+
{ key: "h3", label: "Heading 3", tag: "H3", data: typo.h3 },
|
|
50
|
+
{ key: "h4", label: "Heading 4", tag: "H4", data: typo.h4 },
|
|
51
|
+
{ key: "body", label: "Body", tag: "P", data: typo.body },
|
|
52
|
+
{ key: "small", label: "Small", tag: "P", data: typo.small },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
return levels
|
|
56
|
+
.filter((l) => l.data)
|
|
57
|
+
.map((l) => ({
|
|
58
|
+
key: l.key,
|
|
59
|
+
label: l.label,
|
|
60
|
+
tag: l.tag,
|
|
61
|
+
fontSize: parsePxValue(l.data!.font_size),
|
|
62
|
+
fontWeight: l.data!.font_weight || "400",
|
|
63
|
+
lineHeight: l.data!.line_height || "1.5",
|
|
64
|
+
letterSpacing: l.data!.letter_spacing || "0",
|
|
65
|
+
textTransform: l.data!.text_transform,
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================
|
|
70
|
+
// Style Picker — Framer-style chip + dropdown
|
|
71
|
+
// ============================================
|
|
72
|
+
|
|
73
|
+
export default function TextStylePicker({
|
|
74
|
+
presets,
|
|
75
|
+
activeKey,
|
|
76
|
+
onSelect,
|
|
77
|
+
onClear,
|
|
78
|
+
}: {
|
|
79
|
+
presets: TextStylePreset[];
|
|
80
|
+
activeKey: string | undefined;
|
|
81
|
+
onSelect: (preset: TextStylePreset) => void;
|
|
82
|
+
onClear: () => void;
|
|
83
|
+
}) {
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const [search, setSearch] = useState("");
|
|
86
|
+
|
|
87
|
+
const filtered = presets.filter(
|
|
88
|
+
(p) => p.label.toLowerCase().includes(search.toLowerCase())
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const active = presets.find((p) => p.key === activeKey);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="relative">
|
|
95
|
+
{/* Chip trigger */}
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => setOpen(!open)}
|
|
98
|
+
className="w-full flex items-center gap-2 rounded-lg bg-[#f5f5f5] px-2.5 py-[7px] text-xs text-neutral-900 hover:bg-[#efefef] transition-colors"
|
|
99
|
+
>
|
|
100
|
+
{active ? (
|
|
101
|
+
<>
|
|
102
|
+
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#e8e8e8] text-[9px] font-semibold text-neutral-500 shrink-0">
|
|
103
|
+
{active.tag}
|
|
104
|
+
</span>
|
|
105
|
+
<span className="flex-1 text-left truncate">{active.label}</span>
|
|
106
|
+
<button
|
|
107
|
+
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
|
108
|
+
className="w-[18px] h-[18px] rounded-full flex items-center justify-center text-neutral-300 hover:bg-[#e5e5e5] hover:text-neutral-500 transition-colors shrink-0 text-sm"
|
|
109
|
+
>
|
|
110
|
+
×
|
|
111
|
+
</button>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#e8e8e8] text-[9px] text-neutral-400 shrink-0">
|
|
116
|
+
—
|
|
117
|
+
</span>
|
|
118
|
+
<span className="flex-1 text-left text-neutral-400">Custom</span>
|
|
119
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" className="shrink-0 text-neutral-400">
|
|
120
|
+
<path d="M2.5 4L5 6.5L7.5 4" />
|
|
121
|
+
</svg>
|
|
122
|
+
</>
|
|
123
|
+
)}
|
|
124
|
+
</button>
|
|
125
|
+
|
|
126
|
+
{open && (
|
|
127
|
+
<>
|
|
128
|
+
<div className="fixed inset-0 z-[100]" onClick={() => { setOpen(false); setSearch(""); }} />
|
|
129
|
+
<div className="absolute left-0 right-0 top-full mt-1 z-[101] bg-white rounded-lg border border-neutral-200 shadow-xl overflow-hidden">
|
|
130
|
+
<div className="p-2 border-b border-[#f0f0f0]">
|
|
131
|
+
<input
|
|
132
|
+
type="text"
|
|
133
|
+
value={search}
|
|
134
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
135
|
+
placeholder="Search..."
|
|
136
|
+
autoFocus
|
|
137
|
+
className="w-full rounded-md bg-[#f5f5f5] px-2 py-1.5 text-xs text-neutral-900 border-none outline-none focus:bg-white focus:ring-1 focus:ring-[#076bff]/30"
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="max-h-[220px] overflow-y-auto py-1">
|
|
141
|
+
{filtered.map((preset) => (
|
|
142
|
+
<button
|
|
143
|
+
key={preset.key}
|
|
144
|
+
onClick={() => {
|
|
145
|
+
onSelect(preset);
|
|
146
|
+
setOpen(false);
|
|
147
|
+
setSearch("");
|
|
148
|
+
}}
|
|
149
|
+
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs transition-colors ${
|
|
150
|
+
activeKey === preset.key
|
|
151
|
+
? "bg-[#f5f5f5] text-neutral-900"
|
|
152
|
+
: "text-neutral-600 hover:bg-[#fafafa]"
|
|
153
|
+
}`}
|
|
154
|
+
>
|
|
155
|
+
<span className="flex items-center justify-center w-5 h-5 rounded bg-[#e8e8e8] text-[9px] font-semibold text-neutral-500 shrink-0">
|
|
156
|
+
{preset.tag}
|
|
157
|
+
</span>
|
|
158
|
+
<span className="flex-1 text-left">{preset.label}</span>
|
|
159
|
+
<span className="text-[10px] text-neutral-400">
|
|
160
|
+
{preset.fontSize}px / {preset.lineHeight}
|
|
161
|
+
</span>
|
|
162
|
+
</button>
|
|
163
|
+
))}
|
|
164
|
+
{filtered.length === 0 && (
|
|
165
|
+
<p className="px-3 py-2 text-xs text-neutral-400 text-center">No styles found</p>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
{activeKey && (
|
|
169
|
+
<div className="border-t border-[#f0f0f0] p-1">
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => { onClear(); setOpen(false); setSearch(""); }}
|
|
172
|
+
className="w-full text-left px-3 py-1.5 text-xs text-neutral-500 hover:text-neutral-900 hover:bg-[#fafafa] rounded transition-colors"
|
|
173
|
+
>
|
|
174
|
+
Detach style (Custom)
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|