@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,648 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProjectGridEditor v2 — Settings editor for the projectGridBlock.
5
+ *
6
+ * Phase 6 rewrite (Session 105). Full new control layout:
7
+ * - Grid: columns slider, aspect ratio multi-select chips, gap V/H sliders
8
+ * - Appearance: hover effect segmented, subtitle toggle, border radius slider
9
+ * - Video: mode segmented
10
+ * - Per-card: aspect ratio override, custom thumbnail, custom subtitle
11
+ * - Projects list: reorder, remove, add picker
12
+ *
13
+ * All grid settings are responsive-aware (tablet/phone write to overrides).
14
+ */
15
+
16
+ import React, { useState, useEffect, useCallback, useMemo } from "react";
17
+ import { useBuilderStore } from "../../../lib/builder/store";
18
+ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
19
+ import type { DeviceViewport } from "../../../lib/builder/types";
20
+ import type { ProjectGridBlock, ProjectGridItem, PageListItem, ContentBlock } from "../../../lib/sanity/types";
21
+ import {
22
+ SettingsField,
23
+ SettingsSection,
24
+ ViewportBadge,
25
+ StyledCheckbox,
26
+ } from "./shared";
27
+
28
+ // ============================================
29
+ // Constants
30
+ // ============================================
31
+
32
+ const ASPECT_RATIO_OPTIONS = [
33
+ { value: "16/9", label: "16:9" },
34
+ { value: "1/1", label: "1:1" },
35
+ { value: "9/16", label: "9:16" },
36
+ ] as const;
37
+
38
+ const HOVER_EFFECT_OPTIONS = [
39
+ { value: "scale", label: "Scale" },
40
+ { value: "3d", label: "3D" },
41
+ { value: "none", label: "None" },
42
+ ] as const;
43
+
44
+ const VIDEO_MODE_OPTIONS = [
45
+ { value: "off", label: "Off" },
46
+ { value: "hover", label: "Hover" },
47
+ { value: "autoloop", label: "Auto" },
48
+ ] as const;
49
+
50
+
51
+ // ============================================
52
+ // Segmented Control Component
53
+ // ============================================
54
+
55
+ function SegmentedControl<T extends string>({
56
+ options,
57
+ value,
58
+ onChange,
59
+ }: {
60
+ options: readonly { value: T; label: string }[];
61
+ value: T;
62
+ onChange: (v: T) => void;
63
+ }) {
64
+ return (
65
+ <div className="flex gap-1">
66
+ {options.map((opt) => {
67
+ const active = value === opt.value;
68
+ return (
69
+ <button
70
+ key={opt.value}
71
+ type="button"
72
+ onClick={() => onChange(opt.value)}
73
+ className={`flex-1 px-2 py-1.5 text-xs rounded transition-colors ${
74
+ active
75
+ ? "bg-[#076bff] text-white"
76
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200"
77
+ }`}
78
+ >
79
+ {opt.label}
80
+ </button>
81
+ );
82
+ })}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ // ============================================
88
+ // Range Slider Component
89
+ // ============================================
90
+
91
+ function RangeSlider({
92
+ value,
93
+ onChange,
94
+ min,
95
+ max,
96
+ step = 1,
97
+ suffix = "",
98
+ }: {
99
+ value: number;
100
+ onChange: (v: number) => void;
101
+ min: number;
102
+ max: number;
103
+ step?: number;
104
+ suffix?: string;
105
+ }) {
106
+ return (
107
+ <div className="flex items-center gap-2">
108
+ <input
109
+ type="range"
110
+ min={min}
111
+ max={max}
112
+ step={step}
113
+ value={value}
114
+ onChange={(e) => onChange(Number(e.target.value))}
115
+ className="flex-1 h-1 accent-[#076bff] cursor-pointer"
116
+ />
117
+ <span className="text-[11px] text-neutral-500 w-8 text-right tabular-nums shrink-0">
118
+ {value}{suffix}
119
+ </span>
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // ============================================
125
+ // Aspect Ratio Multi-Select Chips
126
+ // ============================================
127
+
128
+ function RatioChips({
129
+ selected,
130
+ onChange,
131
+ }: {
132
+ selected: string[];
133
+ onChange: (ratios: string[]) => void;
134
+ }) {
135
+ const toggle = (ratio: string) => {
136
+ const isActive = selected.includes(ratio);
137
+ // Prevent deselecting the last one
138
+ if (isActive && selected.length <= 1) return;
139
+ if (isActive) {
140
+ onChange(selected.filter((r) => r !== ratio));
141
+ } else {
142
+ onChange([...selected, ratio]);
143
+ }
144
+ };
145
+
146
+ return (
147
+ <div className="flex gap-1">
148
+ {ASPECT_RATIO_OPTIONS.map((opt) => {
149
+ const active = selected.includes(opt.value);
150
+ return (
151
+ <button
152
+ key={opt.value}
153
+ type="button"
154
+ onClick={() => toggle(opt.value)}
155
+ className={`flex-1 flex flex-col items-center gap-1 px-1.5 py-2 rounded-lg text-[10px] transition-colors ${
156
+ active
157
+ ? "bg-[#076bff] text-white"
158
+ : "bg-neutral-100 text-neutral-500 hover:bg-neutral-200"
159
+ }`}
160
+ >
161
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
162
+ {opt.value === "16/9" && (
163
+ <rect x="2" y="5" width="16" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
164
+ )}
165
+ {opt.value === "1/1" && (
166
+ <rect x="4" y="4" width="12" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
167
+ )}
168
+ {opt.value === "9/16" && (
169
+ <rect x="5" y="2" width="10" height="16" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
170
+ )}
171
+ </svg>
172
+ <span>{opt.label}</span>
173
+ </button>
174
+ );
175
+ })}
176
+ </div>
177
+ );
178
+ }
179
+
180
+ // ============================================
181
+ // Per-Card Override Chips (Inherit + ratios)
182
+ // ============================================
183
+
184
+ function CardRatioChips({
185
+ value,
186
+ onChange,
187
+ }: {
188
+ value: string | null | undefined;
189
+ onChange: (v: "16/9" | "1/1" | "9/16" | null) => void;
190
+ }) {
191
+ const options = [
192
+ { value: null as string | null, label: "Inherit" },
193
+ { value: "16/9", label: "16:9" },
194
+ { value: "1/1", label: "1:1" },
195
+ { value: "9/16", label: "9:16" },
196
+ ] as const;
197
+
198
+ return (
199
+ <div className="grid grid-cols-4 gap-1">
200
+ {options.map((opt) => {
201
+ const active = (value || null) === opt.value;
202
+ return (
203
+ <button
204
+ key={opt.label}
205
+ type="button"
206
+ onClick={() => onChange(opt.value as "16/9" | "1/1" | "9/16" | null)}
207
+ className={`flex flex-col items-center gap-1 px-1.5 py-2 rounded-lg text-[10px] transition-colors ${
208
+ active
209
+ ? "bg-[#076bff] text-white"
210
+ : "bg-neutral-100 text-neutral-500 hover:bg-neutral-200"
211
+ }`}
212
+ >
213
+ <svg width="16" height="16" viewBox="0 0 20 20" fill="none">
214
+ {opt.value === null && (
215
+ <text x="10" y="14" textAnchor="middle" fill="currentColor" fontSize="11" fontWeight="500">A</text>
216
+ )}
217
+ {opt.value === "16/9" && (
218
+ <rect x="2" y="5" width="16" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
219
+ )}
220
+ {opt.value === "1/1" && (
221
+ <rect x="4" y="4" width="12" height="12" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
222
+ )}
223
+ {opt.value === "9/16" && (
224
+ <rect x="5" y="2" width="10" height="16" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
225
+ )}
226
+ </svg>
227
+ <span>{opt.label}</span>
228
+ </button>
229
+ );
230
+ })}
231
+ </div>
232
+ );
233
+ }
234
+
235
+ // ============================================
236
+ // Main Editor
237
+ // ============================================
238
+
239
+ interface ProjectGridEditorProps {
240
+ block: ProjectGridBlock;
241
+ }
242
+
243
+ /** Minimal project info for the picker */
244
+ interface AvailableProject {
245
+ _id: string;
246
+ title: string;
247
+ slug: string;
248
+ thumbnail_path?: string;
249
+ }
250
+
251
+ export default function ProjectGridEditor({ block }: ProjectGridEditorProps) {
252
+ const updateBlock = useBuilderStore((s) => s.updateBlock);
253
+ const activeViewport = useBuilderStore((s) => s.activeViewport) as DeviceViewport;
254
+ const selectedProjectCardKey = useBuilderStore((s) => s.selectedProjectCardKey);
255
+ const selectProjectCard = useBuilderStore((s) => s.selectProjectCard);
256
+
257
+ const [availableProjects, setAvailableProjects] = useState<AvailableProject[]>([]);
258
+ const [loading, setLoading] = useState(true);
259
+ const [search, setSearch] = useState("");
260
+ const [showPicker, setShowPicker] = useState(false);
261
+
262
+ // ─── Fetch published projects ───
263
+ useEffect(() => {
264
+ fetch("/api/admin/pages")
265
+ .then((r) => r.json())
266
+ .then((data) => {
267
+ const projects = (data.pages || [])
268
+ .filter((p: PageListItem) => p.page_type === "project" && !p.draft_mode)
269
+ .map((p: PageListItem) => ({
270
+ _id: p._id,
271
+ title: p.title,
272
+ slug: p.slug.current,
273
+ thumbnail_path: p.thumbnail_path,
274
+ }));
275
+ setAvailableProjects(projects);
276
+ })
277
+ .catch((err) => {
278
+ if (process.env.NODE_ENV === "development") console.warn("[ProjectGridEditor] Failed to load projects", err);
279
+ setAvailableProjects([]);
280
+ })
281
+ .finally(() => setLoading(false));
282
+ }, []);
283
+
284
+ // ─── Update helpers ───
285
+ const update = useCallback(
286
+ (updates: Partial<ProjectGridBlock>) => {
287
+ updateBlock(block._key, updates as Partial<ProjectGridBlock>);
288
+ },
289
+ [block._key, updateBlock]
290
+ );
291
+
292
+ /** Update a responsive-aware property */
293
+ const updateResponsive = useCallback(
294
+ (property: string, value: unknown) => {
295
+ if (activeViewport === "desktop") {
296
+ update({ [property]: value } as Partial<ProjectGridBlock>);
297
+ } else {
298
+ const resp = setResponsiveOverride(block as unknown as ContentBlock, activeViewport, property, value);
299
+ update(resp as unknown as Partial<ProjectGridBlock>);
300
+ }
301
+ },
302
+ [activeViewport, block, update]
303
+ );
304
+
305
+ /** Get effective value for current viewport */
306
+ const getVal = useCallback(
307
+ <T,>(property: string, base: T): T => {
308
+ return getEffectiveValue(block as unknown as ContentBlock, activeViewport, property, base);
309
+ },
310
+ [block, activeViewport]
311
+ );
312
+
313
+ const isResponsive = activeViewport !== "desktop";
314
+
315
+ // ─── Effective values ───
316
+ const effectiveColumns = getVal<number>("columns", block.columns ?? 3);
317
+ const effectiveRatios = getVal<string[]>("aspect_ratios", block.aspect_ratios ?? ["16/9"]);
318
+ const effectiveGapV = getVal<number>("gap_v", block.gap_v ?? 16);
319
+ const effectiveGapH = getVal<number>("gap_h", block.gap_h ?? 16);
320
+
321
+ const selectedSlugs = new Set((block.projects || []).map((p) => p.project_slug));
322
+
323
+ // ─── Selected card data ───
324
+ const selectedCard = useMemo(
325
+ () => (block.projects || []).find((p) => p._key === selectedProjectCardKey) || null,
326
+ [block.projects, selectedProjectCardKey]
327
+ );
328
+
329
+ const updateCard = useCallback(
330
+ (cardKey: string, updates: Partial<ProjectGridItem>) => {
331
+ const projects = (block.projects || []).map((p) =>
332
+ p._key === cardKey ? { ...p, ...updates } : p
333
+ );
334
+ update({ projects });
335
+ },
336
+ [block.projects, update]
337
+ );
338
+
339
+ /** Update a per-card property respecting viewport (writes to card.responsive for tablet/phone) */
340
+ const updateCardResponsive = useCallback(
341
+ (cardKey: string, property: keyof ProjectGridItem, value: unknown) => {
342
+ if (activeViewport === "desktop") {
343
+ updateCard(cardKey, { [property]: value } as Partial<ProjectGridItem>);
344
+ } else {
345
+ // Write to card.responsive[viewport][property]
346
+ const card = (block.projects || []).find((p) => p._key === cardKey);
347
+ if (!card) return;
348
+ const vp = activeViewport as "tablet" | "phone";
349
+ const projects = (block.projects || []).map((p) => {
350
+ if (p._key !== cardKey) return p;
351
+ return {
352
+ ...p,
353
+ responsive: {
354
+ ...p.responsive,
355
+ [vp]: {
356
+ ...p.responsive?.[vp],
357
+ [property]: value,
358
+ },
359
+ },
360
+ };
361
+ });
362
+ update({ projects });
363
+ }
364
+ },
365
+ [activeViewport, block.projects, update, updateCard]
366
+ );
367
+
368
+ /** Get effective per-card value for current viewport */
369
+ const getCardVal = useCallback(
370
+ <T,>(card: ProjectGridItem, property: keyof ProjectGridItem, base: T): T => {
371
+ if (activeViewport === "desktop") return (card[property] as T) ?? base;
372
+ const vp = activeViewport as "tablet" | "phone";
373
+ const override = card.responsive?.[vp]?.[property as keyof typeof card.responsive.tablet];
374
+ return (override as T) ?? (card[property] as T) ?? base;
375
+ },
376
+ [activeViewport]
377
+ );
378
+
379
+ const addProject = (project: AvailableProject) => {
380
+ const newItem: ProjectGridItem = {
381
+ _key: crypto.randomUUID().replace(/-/g, "").substring(0, 12),
382
+ project_slug: project.slug,
383
+ };
384
+ update({ projects: [...(block.projects || []), newItem] });
385
+ };
386
+
387
+ const removeProject = (key: string) => {
388
+ if (selectedProjectCardKey === key) selectProjectCard(null);
389
+ update({ projects: (block.projects || []).filter((p) => p._key !== key) });
390
+ };
391
+
392
+ const moveProject = (index: number, direction: -1 | 1) => {
393
+ const arr = [...(block.projects || [])];
394
+ const newIndex = index + direction;
395
+ if (newIndex < 0 || newIndex >= arr.length) return;
396
+ [arr[index], arr[newIndex]] = [arr[newIndex], arr[index]];
397
+ update({ projects: arr });
398
+ };
399
+
400
+ const filteredAvailable = availableProjects.filter(
401
+ (p) =>
402
+ !selectedSlugs.has(p.slug) &&
403
+ (search === "" || p.title.toLowerCase().includes(search.toLowerCase()))
404
+ );
405
+
406
+ return (
407
+ <div className="space-y-4">
408
+ {/* Responsive badge */}
409
+ {isResponsive && <ViewportBadge />}
410
+
411
+ {/* ─── Grid Settings ─── */}
412
+ <SettingsSection title="Grid">
413
+ <SettingsField label="Columns">
414
+ <RangeSlider
415
+ value={effectiveColumns}
416
+ onChange={(v) => updateResponsive("columns", v)}
417
+ min={1}
418
+ max={6}
419
+ />
420
+ </SettingsField>
421
+
422
+ <SettingsField label="Ratios">
423
+ <RatioChips
424
+ selected={effectiveRatios}
425
+ onChange={(v) => updateResponsive("aspect_ratios", v)}
426
+ />
427
+ </SettingsField>
428
+
429
+ <SettingsField label="Gap V">
430
+ <RangeSlider
431
+ value={effectiveGapV}
432
+ onChange={(v) => updateResponsive("gap_v", v)}
433
+ min={0}
434
+ max={64}
435
+ step={2}
436
+ suffix="px"
437
+ />
438
+ </SettingsField>
439
+
440
+ <SettingsField label="Gap H">
441
+ <RangeSlider
442
+ value={effectiveGapH}
443
+ onChange={(v) => updateResponsive("gap_h", v)}
444
+ min={0}
445
+ max={64}
446
+ step={2}
447
+ suffix="px"
448
+ />
449
+ </SettingsField>
450
+ </SettingsSection>
451
+
452
+ {/* ─── Appearance ─── */}
453
+ <SettingsSection title="Appearance">
454
+ <SettingsField label="Hover">
455
+ <SegmentedControl
456
+ options={HOVER_EFFECT_OPTIONS}
457
+ value={block.hover_effect || "scale"}
458
+ onChange={(v) => update({ hover_effect: v })}
459
+ />
460
+ </SettingsField>
461
+
462
+ <StyledCheckbox
463
+ label="Subtitle"
464
+ checked={block.show_subtitle !== false}
465
+ onChange={(v) => update({ show_subtitle: v })}
466
+ />
467
+
468
+ <SettingsField label="Radius">
469
+ <RangeSlider
470
+ value={block.border_radius ?? 0}
471
+ onChange={(v) => update({ border_radius: v })}
472
+ min={0}
473
+ max={24}
474
+ suffix="px"
475
+ />
476
+ </SettingsField>
477
+ </SettingsSection>
478
+
479
+ {/* ─── Video ─── */}
480
+ <SettingsSection title="Video">
481
+ <SettingsField label="Mode">
482
+ <SegmentedControl
483
+ options={VIDEO_MODE_OPTIONS}
484
+ value={block.video_mode || "off"}
485
+ onChange={(v) => update({ video_mode: v })}
486
+ />
487
+ </SettingsField>
488
+ </SettingsSection>
489
+
490
+ {/* ─── Projects ─── */}
491
+ <SettingsSection title={`Projects (${(block.projects || []).length})`}>
492
+ {(block.projects || []).length === 0 ? (
493
+ <p className="text-xs text-neutral-400 py-2">
494
+ No projects selected. Add projects below.
495
+ </p>
496
+ ) : (
497
+ <div className="space-y-1">
498
+ {(block.projects || []).map((item, i) => {
499
+ const proj = availableProjects.find((p) => p.slug === item.project_slug);
500
+ const isCardSelected = selectedProjectCardKey === item._key;
501
+ const hasOverride = !!item.aspect_ratio_override;
502
+ return (
503
+ <React.Fragment key={item._key}>
504
+ <div
505
+ className={`flex items-center gap-2 px-2 py-1.5 group cursor-pointer transition-colors ${
506
+ isCardSelected ? "bg-[#076bff]/10 ring-1 ring-[#076bff]/30 rounded-t-lg" : "bg-[#f5f5f5] hover:bg-[#efefef] rounded-lg"
507
+ }`}
508
+ onClick={() => selectProjectCard(isCardSelected ? null : item._key)}
509
+ >
510
+ {/* Drag grip */}
511
+ <span className="text-neutral-300 shrink-0 cursor-grab" title="Reorder">
512
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
513
+ <circle cx="3" cy="2" r="1" />
514
+ <circle cx="7" cy="2" r="1" />
515
+ <circle cx="3" cy="5" r="1" />
516
+ <circle cx="7" cy="5" r="1" />
517
+ <circle cx="3" cy="8" r="1" />
518
+ <circle cx="7" cy="8" r="1" />
519
+ </svg>
520
+ </span>
521
+
522
+ {/* Project name */}
523
+ <span className="text-xs text-neutral-700 truncate flex-1">
524
+ {proj?.title || item.project_slug}
525
+ </span>
526
+
527
+ {/* Override badge */}
528
+ {hasOverride && (
529
+ <span className="text-[9px] text-neutral-400 bg-neutral-200 px-1.5 py-0.5 rounded-full shrink-0">
530
+ override
531
+ </span>
532
+ )}
533
+
534
+ {/* Move up/down */}
535
+ <button
536
+ onClick={(e) => { e.stopPropagation(); moveProject(i, -1); }}
537
+ disabled={i === 0}
538
+ className="p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-20 transition-colors"
539
+ title="Move up"
540
+ >
541
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
542
+ <polyline points="18 15 12 9 6 15" />
543
+ </svg>
544
+ </button>
545
+ <button
546
+ onClick={(e) => { e.stopPropagation(); moveProject(i, 1); }}
547
+ disabled={i === (block.projects || []).length - 1}
548
+ className="p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-20 transition-colors"
549
+ title="Move down"
550
+ >
551
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
552
+ <polyline points="6 9 12 15 18 9" />
553
+ </svg>
554
+ </button>
555
+
556
+ {/* Remove */}
557
+ <button
558
+ onClick={(e) => { e.stopPropagation(); removeProject(item._key); }}
559
+ className="p-0.5 text-neutral-400 hover:text-red-500 transition-colors"
560
+ title="Remove"
561
+ >
562
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
563
+ <line x1="18" y1="6" x2="6" y2="18" />
564
+ <line x1="6" y1="6" x2="18" y2="18" />
565
+ </svg>
566
+ </button>
567
+ </div>
568
+
569
+ {/* Per-card settings — expanded below this project row */}
570
+ {isCardSelected && (
571
+ <div className="px-2 pb-2 pt-1 -mt-1 rounded-b-lg bg-[#076bff]/5 border-x border-b border-[#076bff]/15">
572
+ {isResponsive && (
573
+ <div className="flex items-center gap-1.5 px-2 py-1 mb-1 rounded bg-[#076bff]/8">
574
+ <span className="text-[10px] font-medium text-[#076bff]">
575
+ {activeViewport === "tablet" ? "Tablet" : "Phone"} override
576
+ </span>
577
+ </div>
578
+ )}
579
+ <SettingsField label="Ratio">
580
+ <CardRatioChips
581
+ value={getCardVal(item, "aspect_ratio_override", null)}
582
+ onChange={(v) => updateCardResponsive(item._key, "aspect_ratio_override", v)}
583
+ />
584
+ </SettingsField>
585
+ </div>
586
+ )}
587
+ </React.Fragment>
588
+ );
589
+ })}
590
+ </div>
591
+ )}
592
+
593
+ {/* Add project button / picker */}
594
+ {!showPicker ? (
595
+ <button
596
+ onClick={() => setShowPicker(true)}
597
+ className="w-full mt-2 py-2 rounded-lg border border-dashed border-neutral-300 text-neutral-400 text-[11px] uppercase tracking-widest cursor-pointer hover:border-neutral-400 hover:text-neutral-600 transition-colors"
598
+ >
599
+ + Add Project
600
+ </button>
601
+ ) : (
602
+ <div className="mt-2 rounded-lg border border-neutral-200 bg-white overflow-hidden">
603
+ <div className="p-2 border-b border-neutral-100">
604
+ <input
605
+ type="text"
606
+ placeholder="Search projects..."
607
+ value={search}
608
+ onChange={(e) => setSearch(e.target.value)}
609
+ className="w-full rounded-md bg-[#f5f5f5] px-2.5 py-1.5 text-xs text-neutral-900 placeholder:text-neutral-400 outline-none focus:bg-white focus:ring-1 focus:ring-[#076bff]/20"
610
+ autoFocus
611
+ />
612
+ </div>
613
+ <div className="max-h-[200px] overflow-y-auto">
614
+ {loading ? (
615
+ <p className="text-xs text-neutral-400 px-3 py-4 text-center">Loading...</p>
616
+ ) : filteredAvailable.length === 0 ? (
617
+ <p className="text-xs text-neutral-400 px-3 py-4 text-center">
618
+ {availableProjects.length === 0
619
+ ? "No published projects found"
620
+ : "All projects already added"}
621
+ </p>
622
+ ) : (
623
+ filteredAvailable.map((proj) => (
624
+ <button
625
+ key={proj._id}
626
+ onClick={() => addProject(proj)}
627
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[#f5f5f5] transition-colors"
628
+ >
629
+ <span className="text-xs text-neutral-700">{proj.title}</span>
630
+ <span className="text-[10px] text-neutral-400 ml-auto">/{proj.slug}</span>
631
+ </button>
632
+ ))
633
+ )}
634
+ </div>
635
+ <div className="p-2 border-t border-neutral-100">
636
+ <button
637
+ onClick={() => { setShowPicker(false); setSearch(""); }}
638
+ className="w-full py-1.5 rounded-md text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
639
+ >
640
+ Done
641
+ </button>
642
+ </div>
643
+ </div>
644
+ )}
645
+ </SettingsSection>
646
+ </div>
647
+ );
648
+ }