@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.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +210 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. package/tsconfig.json +25 -0
@@ -0,0 +1,189 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef } from "react";
4
+ import type { RegisteredAsset } from "../../../lib/sanity/types";
5
+ import { csrfHeaders } from "../../../lib/csrf-client";
6
+ import type { FolderNode } from "./types";
7
+
8
+ // ============================================
9
+ // R2 CRUD operations hook
10
+ // ============================================
11
+
12
+ export interface RenameTarget {
13
+ type: "file" | "folder";
14
+ key: string;
15
+ name: string;
16
+ }
17
+
18
+ export interface UseR2OperationsOptions {
19
+ onRetry: () => void;
20
+ onMutationComplete?: () => void;
21
+ onFolderCreated?: (folderPath: string) => void;
22
+ currentFolder: string;
23
+ }
24
+
25
+ export function useR2Operations({
26
+ onRetry,
27
+ onMutationComplete,
28
+ onFolderCreated,
29
+ currentFolder,
30
+ }: UseR2OperationsOptions) {
31
+ const [showNewFolderInput, setShowNewFolderInput] = useState(false);
32
+ const [newFolderName, setNewFolderName] = useState("");
33
+ const [renameTarget, setRenameTarget] = useState<RenameTarget | null>(null);
34
+ const [renameValue, setRenameValue] = useState("");
35
+ const [actionLoading, setActionLoading] = useState(false);
36
+ const fileInputRef = useRef<HTMLInputElement>(null);
37
+
38
+ // Create folder — R2 doesn't have real folders, but we create a placeholder object
39
+ const handleCreateFolder = useCallback(async () => {
40
+ if (!newFolderName.trim()) return;
41
+ const clean = newFolderName.trim().replace(/[/\\]/g, "");
42
+ if (!clean) return;
43
+
44
+ const folderPath = currentFolder ? `${currentFolder}/${clean}` : clean;
45
+ setActionLoading(true);
46
+ try {
47
+ // Create a zero-byte placeholder to make the folder visible
48
+ const urlRes = await fetch("/api/admin/r2/upload-url", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
51
+ body: JSON.stringify({ filename: ".folder", folder: folderPath, contentType: "application/x-empty" }),
52
+ });
53
+
54
+ if (urlRes.ok) {
55
+ const { uploadUrl } = await urlRes.json();
56
+ await fetch(uploadUrl, { method: "PUT", body: new Blob([]), headers: { "Content-Type": "application/x-empty" } });
57
+ onFolderCreated?.(folderPath);
58
+ }
59
+ } catch {
60
+ // Folder creation is best-effort — the folder will appear when files are uploaded into it
61
+ } finally {
62
+ setNewFolderName("");
63
+ setShowNewFolderInput(false);
64
+ setActionLoading(false);
65
+ onMutationComplete?.();
66
+ }
67
+ }, [newFolderName, currentFolder, onFolderCreated, onMutationComplete]);
68
+
69
+ // Delete file or folder
70
+ const handleDelete = useCallback(async (key: string, isFolder: boolean) => {
71
+ const label = isFolder ? `folder "${key}" and all its contents` : `file "${key}"`;
72
+ if (!confirm(`Delete ${label}? This cannot be undone.`)) return;
73
+
74
+ setActionLoading(true);
75
+ try {
76
+ const res = await fetch("/api/admin/r2/delete", {
77
+ method: "POST",
78
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
79
+ body: JSON.stringify({ key, isFolder }),
80
+ });
81
+ if (!res.ok) {
82
+ const data = await res.json().catch(() => ({}));
83
+ alert(data.error || "Delete failed");
84
+ }
85
+ } catch {
86
+ alert("Delete failed — network error");
87
+ } finally {
88
+ setActionLoading(false);
89
+ onRetry();
90
+ onMutationComplete?.();
91
+ }
92
+ }, [onRetry, onMutationComplete]);
93
+
94
+ // Rename file or folder
95
+ const handleRename = useCallback(async () => {
96
+ if (!renameTarget || !renameValue.trim()) return;
97
+
98
+ const clean = renameValue.trim();
99
+ if (clean === renameTarget.name) {
100
+ setRenameTarget(null);
101
+ return;
102
+ }
103
+
104
+ setActionLoading(true);
105
+ try {
106
+ let oldKey: string, newKey: string;
107
+
108
+ if (renameTarget.type === "file") {
109
+ oldKey = renameTarget.key;
110
+ const parts = oldKey.split("/");
111
+ parts.pop();
112
+ newKey = parts.length > 0 ? `${parts.join("/")}/${clean}` : clean;
113
+ } else {
114
+ oldKey = renameTarget.key;
115
+ const parts = oldKey.split("/");
116
+ parts.pop();
117
+ newKey = parts.length > 0 ? `${parts.join("/")}/${clean}` : clean;
118
+ }
119
+
120
+ const res = await fetch("/api/admin/r2/rename", {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
123
+ body: JSON.stringify({ oldKey, newKey, isFolder: renameTarget.type === "folder" }),
124
+ });
125
+
126
+ if (!res.ok) {
127
+ const data = await res.json().catch(() => ({}));
128
+ alert(data.error || "Rename failed");
129
+ }
130
+ } catch {
131
+ alert("Rename failed — network error");
132
+ } finally {
133
+ setRenameTarget(null);
134
+ setRenameValue("");
135
+ setActionLoading(false);
136
+ onRetry();
137
+ onMutationComplete?.();
138
+ }
139
+ }, [renameTarget, renameValue, onRetry, onMutationComplete]);
140
+
141
+ // Initiate rename for a file
142
+ const startRenameFile = useCallback((asset: RegisteredAsset) => {
143
+ setRenameTarget({ type: "file", key: asset.path, name: asset.filename });
144
+ setRenameValue(asset.filename);
145
+ }, []);
146
+
147
+ // Initiate rename for a folder
148
+ const startRenameFolder = useCallback((folder: FolderNode) => {
149
+ setRenameTarget({ type: "folder", key: folder.path, name: folder.name });
150
+ setRenameValue(folder.name);
151
+ }, []);
152
+
153
+ // Open new folder input
154
+ const openNewFolderInput = useCallback(() => {
155
+ setShowNewFolderInput(true);
156
+ setNewFolderName("");
157
+ }, []);
158
+
159
+ // Cancel new folder input
160
+ const cancelNewFolderInput = useCallback(() => {
161
+ setShowNewFolderInput(false);
162
+ }, []);
163
+
164
+ // Cancel rename
165
+ const cancelRename = useCallback(() => {
166
+ setRenameTarget(null);
167
+ }, []);
168
+
169
+ return {
170
+ // State
171
+ showNewFolderInput,
172
+ newFolderName,
173
+ setNewFolderName,
174
+ renameTarget,
175
+ renameValue,
176
+ setRenameValue,
177
+ actionLoading,
178
+ fileInputRef,
179
+ // Actions
180
+ handleCreateFolder,
181
+ handleDelete,
182
+ handleRename,
183
+ startRenameFile,
184
+ startRenameFolder,
185
+ openNewFolderInput,
186
+ cancelNewFolderInput,
187
+ cancelRename,
188
+ };
189
+ }
@@ -0,0 +1,295 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Shared block visual styles — gradients and SVG icon components.
5
+ * Used by BlockTypePicker (add block cards) and SettingsPanel (header).
6
+ */
7
+
8
+ // ── Gradient backgrounds per block type ──
9
+
10
+ export const BLOCK_GRADIENTS: Record<string, string> = {
11
+ textBlock: "linear-gradient(135deg, #c9b8ff 0%, #8eb5ff 50%, #b8d4ff 100%)",
12
+ imageBlock: "linear-gradient(135deg, #b8ffb8 0%, #d4ffa8 50%, #f0ffc0 100%)",
13
+ imageGridBlock: "linear-gradient(135deg, #a8c8ff 0%, #c0d8ff 50%, #d8e8ff 100%)",
14
+ videoBlock: "linear-gradient(135deg, #ffb8d4 0%, #ffc8a8 50%, #ffe0b8 100%)",
15
+ spacerBlock: "linear-gradient(135deg, #d8d8e8 0%, #e8e8f0 50%, #f0f0f8 100%)",
16
+ buttonBlock: "linear-gradient(135deg, #ffb8e0 0%, #b8ffe8 50%, #a8ffd8 100%)",
17
+ coverBlock: "linear-gradient(135deg, #ffd0a8 0%, #ffc090 50%, #ffb080 100%)",
18
+ projectGridBlock: "linear-gradient(135deg, #ffd4a8 0%, #ffe8b8 50%, #fff0c8 100%)",
19
+ parallaxGroup: "linear-gradient(135deg, #c8a8ff 0%, #d8b8ff 50%, #e8d0ff 100%)",
20
+ customSectionInstance: "linear-gradient(135deg, #d0b8ff 0%, #b8a8f8 50%, #c8b8ff 100%)",
21
+ // Non-block contexts
22
+ row: "linear-gradient(135deg, #e0e0f0 0%, #d0d8e8 50%, #c8d0e0 100%)",
23
+ column: "linear-gradient(135deg, #d8e8f8 0%, #c8ddf0 50%, #b8d0e8 100%)",
24
+ page: "linear-gradient(135deg, #f0e8d8 0%, #e8dcc8 50%, #e0d0b8 100%)",
25
+ };
26
+
27
+ // ── SVG Icon Components ──
28
+
29
+ export function TextBlockIcon({ size = 28 }: { size?: number }) {
30
+ return (
31
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
32
+ <defs>
33
+ <linearGradient id="tGrad" x1="10" y1="2" x2="30" y2="38">
34
+ <stop offset="0%" stopColor="#a08ee0" />
35
+ <stop offset="100%" stopColor="#7060b8" />
36
+ </linearGradient>
37
+ <filter id="textDrop">
38
+ <feDropShadow dx="0" dy="1.5" stdDeviation="1.2" floodColor="rgba(80,40,140,0.3)" />
39
+ </filter>
40
+ </defs>
41
+ <path d="M 6,5 L 34,5 L 34,8 L 33,9.5 L 23,9.5 L 23,33 L 26.5,33 L 28,34.5 L 28,37 L 12,37 L 12,34.5 L 13.5,33 L 17,33 L 17,9.5 L 7,9.5 L 6,8 Z" fill="url(#tGrad)" filter="url(#textDrop)" />
42
+ <path d="M 6,5 L 8,3 L 14,3 L 14,5 Z" fill="url(#tGrad)" opacity="0.85" />
43
+ <path d="M 34,5 L 32,3 L 26,3 L 26,5 Z" fill="url(#tGrad)" opacity="0.85" />
44
+ <path d="M 12,37 L 13,38.5 L 18,38.5 L 17,37 Z" fill="url(#tGrad)" opacity="0.8" />
45
+ <path d="M 28,37 L 27,38.5 L 22,38.5 L 23,37 Z" fill="url(#tGrad)" opacity="0.8" />
46
+ <path d="M 6,5 L 34,5 L 34,6.5 L 6,6.5 Z" fill="white" opacity="0.22" />
47
+ <path d="M 17,9.5 L 19,9.5 L 19,33 L 17,33 Z" fill="white" opacity="0.12" />
48
+ </svg>
49
+ );
50
+ }
51
+
52
+ export function ImageBlockIcon({ size = 28 }: { size?: number }) {
53
+ return (
54
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
55
+ <defs>
56
+ <linearGradient id="mtnBack" x1="10" y1="8" x2="30" y2="32">
57
+ <stop offset="0%" stopColor="#6abf6a" />
58
+ <stop offset="100%" stopColor="#3d9e3d" />
59
+ </linearGradient>
60
+ <linearGradient id="mtnFront" x1="5" y1="12" x2="25" y2="34">
61
+ <stop offset="0%" stopColor="#4da84d" />
62
+ <stop offset="100%" stopColor="#2d7e2d" />
63
+ </linearGradient>
64
+ <filter id="mtnDrop">
65
+ <feDropShadow dx="0" dy="1.5" stdDeviation="1.5" floodColor="rgba(0,0,0,0.15)" />
66
+ </filter>
67
+ </defs>
68
+ <polygon points="20,6 35,32 5,32" fill="url(#mtnBack)" filter="url(#mtnDrop)" />
69
+ <polygon points="20,6 24,13 16,13" fill="white" opacity="0.5" />
70
+ <polygon points="12,14 26,32 -2,32" fill="url(#mtnFront)" filter="url(#mtnDrop)" />
71
+ <polygon points="12,14 15,19 9,19" fill="white" opacity="0.45" />
72
+ </svg>
73
+ );
74
+ }
75
+
76
+ export function ImageGridBlockIcon({ size = 28 }: { size?: number }) {
77
+ return (
78
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
79
+ <defs>
80
+ <linearGradient id="gridFill" x1="0" y1="0" x2="40" y2="40">
81
+ <stop offset="0%" stopColor="#6ea8e8" />
82
+ <stop offset="100%" stopColor="#4080c8" />
83
+ </linearGradient>
84
+ <filter id="gridDrop">
85
+ <feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="rgba(0,0,0,0.12)" />
86
+ </filter>
87
+ </defs>
88
+ <rect x="4" y="4" width="14" height="14" rx="3" fill="url(#gridFill)" opacity="0.85" filter="url(#gridDrop)" />
89
+ <rect x="22" y="4" width="14" height="14" rx="3" fill="url(#gridFill)" opacity="0.65" filter="url(#gridDrop)" />
90
+ <rect x="4" y="22" width="14" height="14" rx="3" fill="url(#gridFill)" opacity="0.55" filter="url(#gridDrop)" />
91
+ <rect x="22" y="22" width="14" height="14" rx="3" fill="url(#gridFill)" opacity="0.75" filter="url(#gridDrop)" />
92
+ </svg>
93
+ );
94
+ }
95
+
96
+ export function VideoBlockIcon({ size = 28 }: { size?: number }) {
97
+ return (
98
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
99
+ <defs>
100
+ <linearGradient id="playGrad" x1="12" y1="8" x2="32" y2="32">
101
+ <stop offset="0%" stopColor="#f06060" />
102
+ <stop offset="100%" stopColor="#d83838" />
103
+ </linearGradient>
104
+ <filter id="playDrop">
105
+ <feDropShadow dx="0" dy="1.5" stdDeviation="2" floodColor="rgba(200,50,50,0.3)" />
106
+ </filter>
107
+ </defs>
108
+ <path d="M12,6 L34,20 L12,34 Z" fill="url(#playGrad)" filter="url(#playDrop)" />
109
+ <path d="M12,6 L34,20 L12,20 Z" fill="white" opacity="0.15" />
110
+ </svg>
111
+ );
112
+ }
113
+
114
+ export function SpacerBlockIcon({ size = 28 }: { size?: number }) {
115
+ return (
116
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
117
+ <defs>
118
+ <filter id="spacerDrop">
119
+ <feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="rgba(0,0,0,0.1)" />
120
+ </filter>
121
+ </defs>
122
+ <path d="M20,4 L27,13 L23,13 L23,17 L17,17 L17,13 L13,13 Z" fill="#9898b8" opacity="0.7" filter="url(#spacerDrop)" />
123
+ <path d="M20,36 L27,27 L23,27 L23,23 L17,23 L17,27 L13,27 Z" fill="#9898b8" opacity="0.7" filter="url(#spacerDrop)" />
124
+ <line x1="8" y1="20" x2="32" y2="20" stroke="#b0b0c8" strokeWidth="1.5" strokeDasharray="3 2" opacity="0.5" />
125
+ </svg>
126
+ );
127
+ }
128
+
129
+ export function ButtonBlockIcon({ size = 28 }: { size?: number }) {
130
+ return (
131
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
132
+ <defs>
133
+ <linearGradient id="toggleGrad" x1="0" y1="10" x2="40" y2="30">
134
+ <stop offset="0%" stopColor="#3cc87c" />
135
+ <stop offset="100%" stopColor="#28a85c" />
136
+ </linearGradient>
137
+ <filter id="toggleDrop">
138
+ <feDropShadow dx="0" dy="1.5" stdDeviation="1.5" floodColor="rgba(0,0,0,0.15)" />
139
+ </filter>
140
+ <filter id="knobDrop">
141
+ <feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="rgba(0,0,0,0.2)" />
142
+ </filter>
143
+ </defs>
144
+ <rect x="3" y="11" width="34" height="18" rx="9" fill="url(#toggleGrad)" filter="url(#toggleDrop)" />
145
+ <rect x="3" y="11" width="34" height="9" rx="9" fill="white" opacity="0.12" />
146
+ <circle cx="28" cy="20" r="7" fill="white" filter="url(#knobDrop)" />
147
+ <circle cx="27" cy="18.5" r="2.5" fill="white" opacity="0.5" />
148
+ </svg>
149
+ );
150
+ }
151
+
152
+ export function CoverBlockIcon({ size = 28 }: { size?: number }) {
153
+ return (
154
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
155
+ <defs>
156
+ <linearGradient id="starGrad" x1="10" y1="4" x2="30" y2="36">
157
+ <stop offset="0%" stopColor="#f0a040" />
158
+ <stop offset="100%" stopColor="#e07820" />
159
+ </linearGradient>
160
+ <filter id="starDrop">
161
+ <feDropShadow dx="0" dy="1.5" stdDeviation="2" floodColor="rgba(200,100,20,0.3)" />
162
+ </filter>
163
+ </defs>
164
+ <path d="M20,2 L24,15 L37,20 L24,25 L20,38 L16,25 L3,20 L16,15 Z" fill="url(#starGrad)" filter="url(#starDrop)" />
165
+ <path d="M20,2 L24,15 L37,20 L16,15 Z" fill="white" opacity="0.2" />
166
+ <circle cx="20" cy="20" r="2.5" fill="white" opacity="0.4" />
167
+ </svg>
168
+ );
169
+ }
170
+
171
+ // ── Non-block context icons ──
172
+
173
+ export function RowIcon({ size = 28 }: { size?: number }) {
174
+ return (
175
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
176
+ <defs>
177
+ <linearGradient id="rowGrad" x1="4" y1="4" x2="36" y2="36">
178
+ <stop offset="0%" stopColor="#8888b0" />
179
+ <stop offset="100%" stopColor="#6868a0" />
180
+ </linearGradient>
181
+ <filter id="rowDrop">
182
+ <feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="rgba(0,0,0,0.12)" />
183
+ </filter>
184
+ </defs>
185
+ <rect x="3" y="10" width="34" height="6" rx="2" fill="url(#rowGrad)" opacity="0.6" filter="url(#rowDrop)" />
186
+ <rect x="3" y="20" width="15" height="10" rx="2" fill="url(#rowGrad)" opacity="0.85" filter="url(#rowDrop)" />
187
+ <rect x="22" y="20" width="15" height="10" rx="2" fill="url(#rowGrad)" opacity="0.85" filter="url(#rowDrop)" />
188
+ <rect x="3" y="20" width="15" height="4" rx="2" fill="white" opacity="0.15" />
189
+ <rect x="22" y="20" width="15" height="4" rx="2" fill="white" opacity="0.15" />
190
+ </svg>
191
+ );
192
+ }
193
+
194
+ export function ColumnIcon({ size = 28 }: { size?: number }) {
195
+ return (
196
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
197
+ <defs>
198
+ <linearGradient id="colGrad" x1="4" y1="4" x2="36" y2="36">
199
+ <stop offset="0%" stopColor="#6090c8" />
200
+ <stop offset="100%" stopColor="#4878b8" />
201
+ </linearGradient>
202
+ <filter id="colDrop">
203
+ <feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="rgba(0,0,0,0.12)" />
204
+ </filter>
205
+ </defs>
206
+ <rect x="6" y="4" width="10" height="32" rx="3" fill="url(#colGrad)" opacity="0.9" filter="url(#colDrop)" />
207
+ <rect x="6" y="4" width="10" height="10" rx="3" fill="white" opacity="0.18" />
208
+ <rect x="20" y="4" width="14" height="32" rx="3" fill="url(#colGrad)" opacity="0.55" filter="url(#colDrop)" />
209
+ <rect x="20" y="4" width="14" height="10" rx="3" fill="white" opacity="0.12" />
210
+ </svg>
211
+ );
212
+ }
213
+
214
+ export function PageIcon({ size = 28 }: { size?: number }) {
215
+ return (
216
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
217
+ <defs>
218
+ <linearGradient id="pageGrad" x1="8" y1="2" x2="32" y2="38">
219
+ <stop offset="0%" stopColor="#c8a870" />
220
+ <stop offset="100%" stopColor="#a88850" />
221
+ </linearGradient>
222
+ <filter id="pageDrop">
223
+ <feDropShadow dx="0" dy="1.5" stdDeviation="1.5" floodColor="rgba(0,0,0,0.15)" />
224
+ </filter>
225
+ </defs>
226
+ <rect x="8" y="3" width="24" height="34" rx="3" fill="url(#pageGrad)" filter="url(#pageDrop)" />
227
+ <rect x="8" y="3" width="24" height="10" rx="3" fill="white" opacity="0.2" />
228
+ <rect x="12" y="17" width="16" height="2" rx="1" fill="white" opacity="0.35" />
229
+ <rect x="12" y="22" width="12" height="2" rx="1" fill="white" opacity="0.25" />
230
+ <rect x="12" y="27" width="14" height="2" rx="1" fill="white" opacity="0.2" />
231
+ </svg>
232
+ );
233
+ }
234
+
235
+ // ── Lookup maps ──
236
+
237
+ export function ProjectGridBlockIcon({ size = 28 }: { size?: number }) {
238
+ return (
239
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
240
+ <defs>
241
+ <linearGradient id="pgGrad" x1="5" y1="5" x2="35" y2="35">
242
+ <stop offset="0%" stopColor="#d4880a" />
243
+ <stop offset="100%" stopColor="#b06e08" />
244
+ </linearGradient>
245
+ </defs>
246
+ <rect x="3" y="3" width="34" height="14" rx="2" fill="url(#pgGrad)" opacity="0.9" />
247
+ <rect x="3" y="21" width="16" height="16" rx="2" fill="url(#pgGrad)" opacity="0.7" />
248
+ <rect x="22" y="21" width="15" height="16" rx="2" fill="url(#pgGrad)" opacity="0.5" />
249
+ </svg>
250
+ );
251
+ }
252
+
253
+ export function ParallaxGroupIcon({ size = 28 }: { size?: number }) {
254
+ return (
255
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
256
+ <defs>
257
+ <linearGradient id="pxGrad" x1="5" y1="5" x2="35" y2="35">
258
+ <stop offset="0%" stopColor="#9060d8" />
259
+ <stop offset="100%" stopColor="#7040b8" />
260
+ </linearGradient>
261
+ </defs>
262
+ <rect x="3" y="3" width="34" height="10" rx="2" fill="url(#pxGrad)" opacity="0.9" />
263
+ <rect x="3" y="16" width="34" height="10" rx="2" fill="url(#pxGrad)" opacity="0.6" />
264
+ <rect x="3" y="29" width="34" height="8" rx="2" fill="url(#pxGrad)" opacity="0.35" />
265
+ <path d="M18 6 L24 8 L18 10 Z" fill="white" opacity="0.5" />
266
+ <path d="M18 19 L24 21 L18 23 Z" fill="white" opacity="0.5" />
267
+ </svg>
268
+ );
269
+ }
270
+
271
+ export function CustomSectionInstanceIcon({ size = 28 }: { size?: number }) {
272
+ return (
273
+ <svg width={size} height={size} viewBox="0 0 40 40" fill="none">
274
+ <rect x="3" y="6" width="34" height="28" rx="4" stroke="#8b5cf6" strokeWidth="2" fill="none" opacity="0.7" />
275
+ <path d="M16 17a3 3 0 0 0 4.5.32l1.8-1.8a3 3 0 0 0-4.24-4.24l-1.03 1.03" stroke="#8b5cf6" strokeWidth="1.8" strokeLinecap="round" fill="none" />
276
+ <path d="M24 23a3 3 0 0 0-4.5-.32l-1.8 1.8a3 3 0 0 0 4.24 4.24l1.03-1.03" stroke="#8b5cf6" strokeWidth="1.8" strokeLinecap="round" fill="none" />
277
+ </svg>
278
+ );
279
+ }
280
+
281
+ export const BLOCK_ICON_COMPONENTS: Record<string, React.FC<{ size?: number }>> = {
282
+ textBlock: TextBlockIcon,
283
+ imageBlock: ImageBlockIcon,
284
+ imageGridBlock: ImageGridBlockIcon,
285
+ videoBlock: VideoBlockIcon,
286
+ spacerBlock: SpacerBlockIcon,
287
+ buttonBlock: ButtonBlockIcon,
288
+ coverBlock: CoverBlockIcon,
289
+ projectGridBlock: ProjectGridBlockIcon,
290
+ parallaxGroup: ParallaxGroupIcon,
291
+ customSectionInstance: CustomSectionInstanceIcon,
292
+ row: RowIcon,
293
+ column: ColumnIcon,
294
+ page: PageIcon,
295
+ };
@@ -0,0 +1,184 @@
1
+ "use client";
2
+
3
+ import { useBuilderStore } from "../../../lib/builder/store";
4
+ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
5
+ import type { ButtonBlock, ContentBlock } from "../../../lib/sanity/types";
6
+ import {
7
+ SettingsField,
8
+ SettingsSection,
9
+ StyledCheckbox,
10
+ ViewportBadge,
11
+ ResponsiveField,
12
+ useActiveViewport,
13
+ INPUT_CLASS,
14
+ } from "./shared";
15
+
16
+ interface Props {
17
+ block: ButtonBlock;
18
+ }
19
+
20
+ export default function ButtonBlockEditor({ block }: Props) {
21
+ const store = useBuilderStore();
22
+ const viewport = useActiveViewport();
23
+ const snapshotOnFocus = () => store._pushSnapshot();
24
+
25
+ // Responsive-aware update
26
+ const updateResponsive = (property: string, value: unknown) => {
27
+ if (viewport === "desktop") {
28
+ store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
29
+ } else {
30
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
31
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
32
+ }
33
+ };
34
+
35
+ const resetOverride = (property: string) => {
36
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
37
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
38
+ };
39
+
40
+ // Direct update (base block, not responsive)
41
+ const update = (updates: Partial<ButtonBlock>) => {
42
+ store.updateBlock(block._key, updates as Partial<ContentBlock>);
43
+ };
44
+
45
+ const updateDebounced = (updates: Partial<ButtonBlock>) => {
46
+ store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
47
+ };
48
+
49
+ // Effective values for the active viewport
50
+ const effectiveSize = getEffectiveValue<string>(
51
+ block as ContentBlock, viewport, "size", block.size || "medium"
52
+ );
53
+ const effectiveAlignment = getEffectiveValue<string>(
54
+ block as ContentBlock, viewport, "alignment", block.alignment || "left"
55
+ );
56
+ const effectiveFullWidth = getEffectiveValue<boolean>(
57
+ block as ContentBlock, viewport, "full_width", block.full_width || false
58
+ );
59
+
60
+ return (
61
+ <>
62
+ <ViewportBadge />
63
+
64
+ <SettingsSection title="Content" defaultOpen>
65
+ <SettingsField label="Button Text">
66
+ <input
67
+ type="text"
68
+ value={block.text || ""}
69
+ onFocus={snapshotOnFocus}
70
+ onChange={(e) => updateDebounced({ text: e.target.value })}
71
+ className={INPUT_CLASS}
72
+ placeholder="Click me"
73
+ />
74
+ </SettingsField>
75
+ <SettingsField label="URL">
76
+ <input
77
+ type="text"
78
+ value={block.url || ""}
79
+ onFocus={snapshotOnFocus}
80
+ onChange={(e) => updateDebounced({ url: e.target.value })}
81
+ className={INPUT_CLASS}
82
+ placeholder="https://..."
83
+ />
84
+ </SettingsField>
85
+ </SettingsSection>
86
+
87
+ <SettingsSection title="Style">
88
+ <SettingsField label="Variant">
89
+ <div className="flex gap-1">
90
+ {(["primary", "secondary", "outline", "text"] as const).map(
91
+ (s) => (
92
+ <button
93
+ key={s}
94
+ onClick={() => update({ style: s })}
95
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
96
+ (block.style || "primary") === s
97
+ ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
98
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
99
+ }`}
100
+ >
101
+ {s}
102
+ </button>
103
+ )
104
+ )}
105
+ </div>
106
+ </SettingsField>
107
+
108
+ <ResponsiveField
109
+ label="Size"
110
+ block={block as ContentBlock}
111
+ property="size"
112
+ onReset={() => resetOverride("size")}
113
+ >
114
+ <div className="flex gap-1">
115
+ {(["small", "medium", "large"] as const).map((s) => (
116
+ <button
117
+ key={s}
118
+ onClick={() => updateResponsive("size", s)}
119
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
120
+ effectiveSize === s
121
+ ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
122
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
123
+ }`}
124
+ >
125
+ {s[0].toUpperCase()}
126
+ </button>
127
+ ))}
128
+ </div>
129
+ </ResponsiveField>
130
+
131
+ <ResponsiveField
132
+ label="Alignment"
133
+ block={block as ContentBlock}
134
+ property="alignment"
135
+ onReset={() => resetOverride("alignment")}
136
+ >
137
+ <div className="flex gap-1">
138
+ {(["left", "center", "right"] as const).map((a) => (
139
+ <button
140
+ key={a}
141
+ onClick={() => updateResponsive("alignment", a)}
142
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
143
+ effectiveAlignment === a
144
+ ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
145
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
146
+ }`}
147
+ >
148
+ {a === "left" ? "◁" : a === "center" ? "◈" : "▷"}
149
+ </button>
150
+ ))}
151
+ </div>
152
+ </ResponsiveField>
153
+ </SettingsSection>
154
+
155
+ <SettingsSection title="Options">
156
+ <StyledCheckbox
157
+ label="Open in new tab"
158
+ checked={block.target || false}
159
+ onChange={(v) => update({ target: v })}
160
+ />
161
+ <ResponsiveField
162
+ label="Full width"
163
+ block={block as ContentBlock}
164
+ property="full_width"
165
+ onReset={() => resetOverride("full_width")}
166
+ >
167
+ <button
168
+ type="button"
169
+ onClick={() => updateResponsive("full_width", !effectiveFullWidth)}
170
+ className={`relative w-8 h-[18px] rounded-full transition-colors ${
171
+ effectiveFullWidth ? "bg-[#076bff]" : "bg-neutral-200 hover:bg-neutral-300"
172
+ }`}
173
+ >
174
+ <span
175
+ className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow-sm transition-transform ${
176
+ effectiveFullWidth ? "left-[16px]" : "left-[2px]"
177
+ }`}
178
+ />
179
+ </button>
180
+ </ResponsiveField>
181
+ </SettingsSection>
182
+ </>
183
+ );
184
+ }