@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.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -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 +212 -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,243 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import { csrfHeaders } from "../../../lib/csrf-client";
5
+ import { revalidateSite } from "../../../lib/revalidate";
6
+ import type { SiteStyles, TypographyLevel, GridSettings } from "../../../lib/sanity/types";
7
+ import { DEFAULT_GRID_WIDTH } from "../../../lib/builder/constants";
8
+ import {
9
+ GridLayoutEditor,
10
+ FontsEditor,
11
+ TypographyEditor,
12
+ ColorsEditor,
13
+ LinksButtonsEditor,
14
+ } from "../../../components/admin/styles";
15
+
16
+ // ============================================
17
+ // Tab definitions
18
+ // ============================================
19
+
20
+ type TabId = "grid" | "typography" | "colors" | "buttons";
21
+
22
+ function TabIcon({ id, size = 15 }: { id: TabId; size?: number }) {
23
+ const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none" as const, stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
24
+ switch (id) {
25
+ case "grid":
26
+ return (
27
+ <svg {...props}>
28
+ <rect x="3" y="3" width="7" height="7" />
29
+ <rect x="14" y="3" width="7" height="7" />
30
+ <rect x="3" y="14" width="7" height="7" />
31
+ <rect x="14" y="14" width="7" height="7" />
32
+ </svg>
33
+ );
34
+ case "typography":
35
+ return (
36
+ <svg {...props}>
37
+ <polyline points="4 7 4 4 20 4 20 7" />
38
+ <line x1="9" y1="20" x2="15" y2="20" />
39
+ <line x1="12" y1="4" x2="12" y2="20" />
40
+ </svg>
41
+ );
42
+ case "colors":
43
+ return (
44
+ <svg {...props}>
45
+ <circle cx="12" cy="12" r="10" />
46
+ <path d="M12 2a7 7 0 0 0 0 14 3.5 3.5 0 0 1 0 7 10 10 0 0 0 0-20Z" fill="currentColor" opacity="0.15" />
47
+ <circle cx="12" cy="8" r="1.5" fill="currentColor" />
48
+ <circle cx="8" cy="12" r="1.5" fill="currentColor" />
49
+ <circle cx="16" cy="12" r="1.5" fill="currentColor" />
50
+ </svg>
51
+ );
52
+ case "buttons":
53
+ return (
54
+ <svg {...props}>
55
+ <rect x="2" y="7" width="20" height="10" rx="2" />
56
+ <line x1="8" y1="12" x2="16" y2="12" />
57
+ </svg>
58
+ );
59
+ }
60
+ }
61
+
62
+ const TABS: { id: TabId; label: string }[] = [
63
+ { id: "grid", label: "Grid & Layout" },
64
+ { id: "typography", label: "Typography" },
65
+ { id: "colors", label: "Colors" },
66
+ { id: "buttons", label: "Buttons & Links" },
67
+ ];
68
+
69
+ // ============================================
70
+ // Default values
71
+ // ============================================
72
+
73
+ const DEFAULT_GRID: GridSettings = {
74
+ width: DEFAULT_GRID_WIDTH,
75
+ outer_padding: "30",
76
+ gutter_desktop: "30",
77
+ gutter_responsive: "30",
78
+ gutter_phone: "16",
79
+ };
80
+
81
+ const DEFAULT_TYPOGRAPHY: Record<string, TypographyLevel> = {
82
+ h1: { font_size: "3rem", font_weight: "700", line_height: "1.1", letter_spacing: "-0.02em" },
83
+ h2: { font_size: "2rem", font_weight: "700", line_height: "1.2", letter_spacing: "-0.01em" },
84
+ h3: { font_size: "1.5rem", font_weight: "500", line_height: "1.3", letter_spacing: "0" },
85
+ h4: { font_size: "1.125rem", font_weight: "500", line_height: "1.4", letter_spacing: "0" },
86
+ body: { font_size: "0.875rem", font_weight: "400", line_height: "1.6", letter_spacing: "0" },
87
+ small: { font_size: "0.75rem", font_weight: "400", line_height: "1.5", letter_spacing: "0.02em" },
88
+ };
89
+
90
+ // ============================================
91
+ // Main Page
92
+ // ============================================
93
+
94
+ export default function AdminStylesPage() {
95
+ const [styles, setStyles] = useState<SiteStyles | null>(null);
96
+ const [loading, setLoading] = useState(true);
97
+ const [saving, setSaving] = useState<string | null>(null);
98
+ const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
99
+ const [activeTab, setActiveTab] = useState<TabId>("grid");
100
+
101
+ const fetchStyles = useCallback(async () => {
102
+ try {
103
+ const res = await fetch("/api/admin/styles");
104
+ if (res.ok) {
105
+ const data = await res.json();
106
+ setStyles(data.styles || null);
107
+ }
108
+ } catch {
109
+ setMessage({ type: "error", text: "Failed to load styles" });
110
+ } finally {
111
+ setLoading(false);
112
+ }
113
+ }, []);
114
+
115
+ useEffect(() => { fetchStyles(); }, [fetchStyles]);
116
+
117
+ useEffect(() => {
118
+ if (!message) return;
119
+ const timer = setTimeout(() => setMessage(null), 5000);
120
+ return () => clearTimeout(timer);
121
+ }, [message]);
122
+
123
+ const saveSection = async (section: string, data: Record<string, unknown>) => {
124
+ setSaving(section);
125
+ setMessage(null);
126
+ try {
127
+ const res = await fetch("/api/admin/styles", {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json", ...csrfHeaders() },
130
+ body: JSON.stringify({ section, data }),
131
+ });
132
+ if (!res.ok) {
133
+ const errData = await res.json();
134
+ throw new Error(errData.error || "Save failed");
135
+ }
136
+ setMessage({ type: "success", text: `${section} saved` });
137
+ await fetchStyles();
138
+ // Revalidate public site so style changes appear immediately
139
+ revalidateSite();
140
+ } catch (err) {
141
+ setMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" });
142
+ } finally {
143
+ setSaving(null);
144
+ }
145
+ };
146
+
147
+ if (loading) {
148
+ return (
149
+ <div className="flex items-center justify-center py-20">
150
+ <span className="text-sm text-neutral-400 animate-pulse">Loading styles...</span>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ return (
156
+ <div className="space-y-6">
157
+ {/* Page header */}
158
+ <div>
159
+ <h1 className="text-2xl font-semibold text-neutral-900">Customize</h1>
160
+ <p className="text-sm text-neutral-500 mt-1">
161
+ Global design system — grid, typography, colors, spacing, and button styles.
162
+ </p>
163
+ </div>
164
+
165
+ {/* Tab navigation — matches admin webapp style */}
166
+ <div className="flex items-center gap-1 border-b border-neutral-200">
167
+ {TABS.map((tab) => {
168
+ const isActive = activeTab === tab.id;
169
+ return (
170
+ <button
171
+ key={tab.id}
172
+ onClick={() => setActiveTab(tab.id)}
173
+ className={`flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium transition-colors border-b-2 -mb-px ${
174
+ isActive
175
+ ? "text-neutral-900 border-[#076bff]"
176
+ : "text-neutral-400 border-transparent hover:text-neutral-600"
177
+ }`}
178
+ >
179
+ <TabIcon id={tab.id} />
180
+ {tab.label}
181
+ </button>
182
+ );
183
+ })}
184
+ </div>
185
+
186
+ {/* Toast message */}
187
+ {message && (
188
+ <div className={`p-3 rounded-xl border ${
189
+ message.type === "success"
190
+ ? "border-green-200 bg-green-50 text-green-700"
191
+ : "border-red-200 bg-red-50 text-red-700"
192
+ }`}>
193
+ <p className="text-sm">{message.text}</p>
194
+ </div>
195
+ )}
196
+
197
+ {/* Tab content */}
198
+ {activeTab === "grid" && (
199
+ <GridLayoutEditor
200
+ grid={styles?.grid}
201
+ disableMobile={styles?.disable_scroll_animations_mobile ?? false}
202
+ onSaveGrid={(data) => saveSection("grid", data)}
203
+ onSaveAnimations={(data) => saveSection("animations", data)}
204
+ savingGrid={saving === "grid"}
205
+ savingAnimations={saving === "animations"}
206
+ />
207
+ )}
208
+
209
+ {activeTab === "typography" && (
210
+ <div className="space-y-8">
211
+ <FontsEditor
212
+ fonts={styles?.fonts || []}
213
+ onSave={(fonts) => saveSection("fonts", { fonts })}
214
+ saving={saving === "fonts"}
215
+ />
216
+ <TypographyEditor
217
+ typography={styles?.typography}
218
+ fontFamilies={(styles?.fonts || []).map((f) => f.family)}
219
+ onSave={(data) => saveSection("typography", data)}
220
+ saving={saving === "typography"}
221
+ />
222
+ </div>
223
+ )}
224
+
225
+ {activeTab === "colors" && (
226
+ <ColorsEditor
227
+ colors={styles?.colors}
228
+ onSave={(data) => saveSection("colors", data)}
229
+ saving={saving === "colors"}
230
+ />
231
+ )}
232
+
233
+ {activeTab === "buttons" && (
234
+ <LinksButtonsEditor
235
+ linkStyle={styles?.link_style}
236
+ buttonStyle={styles?.button_style}
237
+ onSave={(data) => saveSection("links", data)}
238
+ saving={saving === "links"}
239
+ />
240
+ )}
241
+ </div>
242
+ );
243
+ }
@@ -0,0 +1,81 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { getCachedProviderConfig } from "../../../../../lib/storage";
4
+ import { logger } from "../../../../../lib/logger";
5
+
6
+ /**
7
+ * GET /api/admin/assets/file?path=Projects/House of Delights/image.jpg
8
+ *
9
+ * Provider-aware admin asset proxy. Returns a 302 redirect to the asset's
10
+ * CDN URL based on the active storage provider.
11
+ *
12
+ * Currently only R2 is supported:
13
+ * - **R2:** Redirect to direct public URL (no auth needed — bucket is public).
14
+ * Cache for 24 hours since R2 URLs are permanent.
15
+ *
16
+ * Auth: Requires admin authentication (cookies). This prevents unauthorized
17
+ * access to the proxy endpoint, even though the underlying R2 URLs
18
+ * may be publicly accessible.
19
+ */
20
+ export async function GET(request: NextRequest) {
21
+ if (!(await isAdminAuthenticated())) {
22
+ return new NextResponse("Unauthorized", { status: 401 });
23
+ }
24
+
25
+ const filePath = request.nextUrl.searchParams.get("path");
26
+ if (!filePath) {
27
+ return new NextResponse("Missing path", { status: 400 });
28
+ }
29
+
30
+ try {
31
+ // ── Texture mode: stream bytes instead of redirecting ──
32
+ // Used by ShaderCanvas (WebGL) in preview mode. Cross-origin 302
33
+ // redirects lack CORS headers, breaking WebGL texture loading.
34
+ const isTextureMode = request.nextUrl.searchParams.get("texture") === "1";
35
+
36
+ // ── Check active provider (cached 5min) ──
37
+ const config = await getCachedProviderConfig();
38
+
39
+ // ── R2: direct redirect to public URL ──
40
+ if (config.r2BucketUrl) {
41
+ const cleanPath = filePath.replace(/^\/+/, "");
42
+ const r2Url = `${config.r2BucketUrl}/${cleanPath}`;
43
+
44
+ // Texture mode: fetch from R2 and stream bytes back
45
+ if (isTextureMode) {
46
+ const r2Resp = await fetch(r2Url);
47
+ if (!r2Resp.ok) {
48
+ return new NextResponse("Asset not found", { status: r2Resp.status });
49
+ }
50
+ const contentType = r2Resp.headers.get("content-type") || "image/png";
51
+ return new NextResponse(r2Resp.body, {
52
+ status: 200,
53
+ headers: {
54
+ "Content-Type": contentType,
55
+ "Cache-Control": "private, max-age=3600",
56
+ },
57
+ });
58
+ }
59
+
60
+ // #12: For SVG files, add CSP to prevent XSS if viewed directly
61
+ const isSvg = cleanPath.toLowerCase().endsWith(".svg");
62
+ const headers: Record<string, string> = {
63
+ "Cache-Control": "private, max-age=86400",
64
+ };
65
+ if (isSvg) {
66
+ headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'";
67
+ }
68
+
69
+ return NextResponse.redirect(r2Url, {
70
+ status: 302,
71
+ headers,
72
+ });
73
+ }
74
+
75
+ // No provider configured
76
+ return new NextResponse("Storage provider not configured", { status: 500 });
77
+ } catch (err) {
78
+ logger.error("[Admin:AssetFile]", "Error", err);
79
+ return new NextResponse("Failed to get file link", { status: 500 });
80
+ }
81
+ }
@@ -0,0 +1,170 @@
1
+ import { NextResponse } from "next/server";
2
+ import { client } from "../../../../../lib/sanity/client";
3
+ import { writeClient } from "../../../../../lib/sanity/writeClient";
4
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
5
+ import { logger } from "../../../../../lib/logger";
6
+
7
+ /**
8
+ * Validate that a seed URL is safe to use for health checks.
9
+ * Prevents SSRF by blocking private IPs, localhost, and non-http(s) schemes.
10
+ */
11
+ function isValidSeedUrl(urlStr: string): boolean {
12
+ try {
13
+ const url = new URL(urlStr);
14
+
15
+ // Only allow http(s)
16
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
17
+
18
+ const hostname = url.hostname.toLowerCase();
19
+
20
+ // Block localhost variants
21
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false;
22
+ if (hostname === "0.0.0.0") return false;
23
+
24
+ // Block private IP ranges
25
+ if (hostname.startsWith("10.")) return false;
26
+ if (hostname.startsWith("192.168.")) return false;
27
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return false;
28
+
29
+ // Block link-local
30
+ if (hostname.startsWith("169.254.")) return false;
31
+
32
+ // Block metadata endpoints (cloud providers)
33
+ if (hostname === "metadata.google.internal") return false;
34
+
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * GET /api/admin/assets/health — Check if registered assets are accessible
43
+ *
44
+ * Sends HEAD requests to each asset URL to verify availability.
45
+ * Updates asset statuses in the registry.
46
+ */
47
+ export async function GET() {
48
+ if (!(await isAdminAuthenticated())) {
49
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
50
+ }
51
+
52
+ try {
53
+ const registry = await client.fetch(
54
+ `*[_type == "assetRegistry"][0]{ seed_url, assets }`
55
+ );
56
+
57
+ if (!registry) {
58
+ return NextResponse.json(
59
+ { error: "Asset registry not found" },
60
+ { status: 404 }
61
+ );
62
+ }
63
+
64
+ if (!registry.seed_url) {
65
+ return NextResponse.json(
66
+ { error: "No seed URL configured" },
67
+ { status: 400 }
68
+ );
69
+ }
70
+
71
+ // Validate seed URL to prevent SSRF
72
+ if (!isValidSeedUrl(registry.seed_url)) {
73
+ return NextResponse.json(
74
+ { error: "Seed URL is not a valid public URL" },
75
+ { status: 400 }
76
+ );
77
+ }
78
+
79
+ const assets = registry.assets || [];
80
+ const seedUrl = registry.seed_url.replace(/\/$/, "");
81
+ const now = new Date().toISOString();
82
+
83
+ // Check all assets including "missing" ones (they may have recovered)
84
+ const assetsToCheck = assets.filter(
85
+ (a: { status: string }) =>
86
+ a.status === "active" || a.status === "new" || a.status === "missing"
87
+ );
88
+
89
+ // Limit concurrent requests
90
+ const BATCH_SIZE = 10;
91
+ const results: { path: string; ok: boolean; status: number }[] = [];
92
+
93
+ for (let i = 0; i < assetsToCheck.length; i += BATCH_SIZE) {
94
+ const batch = assetsToCheck.slice(i, i + BATCH_SIZE);
95
+ const batchResults = await Promise.allSettled(
96
+ batch.map(async (asset: { path: string }) => {
97
+ const url = `${seedUrl}/${asset.path.replace(/^\//, "")}`;
98
+ try {
99
+ const res = await fetch(url, {
100
+ method: "HEAD",
101
+ signal: AbortSignal.timeout(10000),
102
+ });
103
+ return { path: asset.path, ok: res.ok, status: res.status };
104
+ } catch {
105
+ return { path: asset.path, ok: false, status: 0 };
106
+ }
107
+ })
108
+ );
109
+
110
+ for (const result of batchResults) {
111
+ if (result.status === "fulfilled") {
112
+ results.push(result.value);
113
+ }
114
+ }
115
+ }
116
+
117
+ // Build sets
118
+ const missingPaths = new Set(
119
+ results.filter((r) => !r.ok).map((r) => r.path)
120
+ );
121
+ const healthyPaths = new Set(
122
+ results.filter((r) => r.ok).map((r) => r.path)
123
+ );
124
+
125
+ // Update asset statuses
126
+ const updatedAssets = assets.map(
127
+ (asset: { _key: string; path: string; status: string }) => {
128
+ if (missingPaths.has(asset.path)) {
129
+ return { ...asset, status: "missing", last_checked_at: now };
130
+ }
131
+ if (healthyPaths.has(asset.path)) {
132
+ // Preserve "new" status for newly scanned assets; mark recovered "missing" as "active"
133
+ const newStatus = asset.status === "new" ? "new" : "active";
134
+ return { ...asset, status: newStatus, last_checked_at: now };
135
+ }
136
+ return asset;
137
+ }
138
+ );
139
+
140
+ // Count recovered assets (were "missing", now healthy)
141
+ const recoveredCount = assets.filter(
142
+ (a: { path: string; status: string }) =>
143
+ a.status === "missing" && healthyPaths.has(a.path)
144
+ ).length;
145
+
146
+ // Persist
147
+ await writeClient
148
+ .patch("assetRegistry")
149
+ .set({ assets: updatedAssets })
150
+ .commit();
151
+
152
+ return NextResponse.json({
153
+ healthy_count: healthyPaths.size,
154
+ missing_count: missingPaths.size,
155
+ recovered_count: recoveredCount,
156
+ checked_count: results.length,
157
+ total_assets: assets.length,
158
+ missing_assets: results
159
+ .filter((r) => !r.ok)
160
+ .map((r) => r.path),
161
+ checked_at: now,
162
+ });
163
+ } catch (err) {
164
+ logger.error("[Admin:Health]", "Health check failed", err);
165
+ return NextResponse.json(
166
+ { error: "Health check failed" },
167
+ { status: 500 }
168
+ );
169
+ }
170
+ }
@@ -0,0 +1,163 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
4
+ import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError, isValidAssetPath } from "../../../../../lib/security";
5
+ import { writeClient } from "../../../../../lib/sanity/writeClient";
6
+ import { client } from "../../../../../lib/sanity/client";
7
+ import { isMediaFile, getMimeType } from "../../../../../lib/storage/types";
8
+ import { auditLog } from "../../../../../lib/audit";
9
+ import { logger } from "../../../../../lib/logger";
10
+
11
+ /**
12
+ * POST /api/admin/assets/register — Register a newly uploaded asset in the registry.
13
+ *
14
+ * Called after a successful direct upload to R2 via presigned URL.
15
+ * Adds the asset to the Sanity assetRegistry if it doesn't already exist,
16
+ * or updates it if the path already exists (e.g., overwriting a file).
17
+ *
18
+ * Body: { key, fileSize, contentType?, has_thumbnail? }
19
+ * - key: relative path in R2 (e.g. "projects/hero.jpg")
20
+ * - fileSize: size in bytes
21
+ * - contentType: MIME type (optional, inferred from extension if omitted)
22
+ * - has_thumbnail: whether a _thumbs/ counterpart was uploaded (optional, default false)
23
+ */
24
+ export async function POST(request: NextRequest) {
25
+ if (!(await isAdminAuthenticated())) {
26
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27
+ }
28
+ if (!validateCsrf(request)) {
29
+ return csrfErrorResponse();
30
+ }
31
+ if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
32
+ return jsonError("Request body too large", 413);
33
+ }
34
+
35
+ try {
36
+ const body = await request.json();
37
+ const { key, fileSize, contentType, has_thumbnail: hasThumbnailParam } = body;
38
+ // Normalize has_thumbnail — only accept explicit boolean true
39
+ const hasThumbnail = hasThumbnailParam === true;
40
+
41
+ // ── Validate key (relative path) ──
42
+ if (!key || typeof key !== "string") {
43
+ return jsonError("Asset key is required", 400);
44
+ }
45
+
46
+ if (!isValidAssetPath(key)) {
47
+ return jsonError("Invalid asset path", 400);
48
+ }
49
+
50
+ if (!isMediaFile(key)) {
51
+ return jsonError("Unsupported file type", 400);
52
+ }
53
+
54
+ // ── Extract metadata ──
55
+ const filename = key.split("/").pop() || key;
56
+ const extension = filename.includes(".")
57
+ ? filename.split(".").pop()!.toLowerCase()
58
+ : "";
59
+ const mimeType =
60
+ contentType && typeof contentType === "string"
61
+ ? contentType
62
+ : getMimeType(filename);
63
+ const size =
64
+ typeof fileSize === "number" && fileSize > 0 ? fileSize : undefined;
65
+
66
+ // ── Fetch current registry ──
67
+ const registry = await client.fetch(
68
+ `*[_type == "assetRegistry"][0]{ _id, assets, storage_provider }`
69
+ );
70
+
71
+ if (!registry) {
72
+ return jsonError("Asset registry not found. Scan your storage first.", 404);
73
+ }
74
+
75
+ // #20: Validate that R2 is the active provider for R2-uploaded assets
76
+ const activeProvider = registry.storage_provider || "r2";
77
+ if (activeProvider !== "r2") {
78
+ return jsonError(
79
+ "Cannot register R2-uploaded assets: R2 is not the active provider. Switch to R2 first.",
80
+ 400
81
+ );
82
+ }
83
+
84
+ const existingAssets: Array<Record<string, unknown>> = registry.assets || [];
85
+
86
+ // Check if this path already exists in the registry
87
+ const existingIndex = existingAssets.findIndex(
88
+ (a) => a.path === key
89
+ );
90
+
91
+ const now = new Date().toISOString();
92
+ // #66: Use crypto.randomUUID for collision-resistant key generation
93
+ const assetKey = `upload-${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
94
+
95
+ if (existingIndex >= 0) {
96
+ // ── Update existing entry ──
97
+ // Use Sanity array patching: unset the old entry, then append the updated one
98
+ const existing = existingAssets[existingIndex];
99
+ const updatedAsset = {
100
+ _key: existing._key || assetKey,
101
+ _type: "assetEntry",
102
+ path: key,
103
+ filename,
104
+ extension,
105
+ file_size: size ?? existing.file_size,
106
+ mime_type: mimeType,
107
+ file_hash: existing.file_hash, // Keep old hash — we don't know the new one yet
108
+ has_thumbnail: hasThumbnail || existing.has_thumbnail || false,
109
+ status: "active",
110
+ last_checked_at: now,
111
+ previous_paths: existing.previous_paths || [],
112
+ };
113
+
114
+ // Replace the asset at the existing index
115
+ const patchKey = existing._key as string;
116
+ await writeClient
117
+ .patch(registry._id)
118
+ .set({
119
+ [`assets[_key=="${patchKey}"]`]: updatedAsset,
120
+ })
121
+ .commit();
122
+
123
+ auditLog("asset.register.update", { path: key, fileSize: size });
124
+
125
+ return NextResponse.json({
126
+ success: true,
127
+ action: "updated",
128
+ asset: { path: key, filename, extension, file_size: size },
129
+ });
130
+ } else {
131
+ // ── Insert new entry ──
132
+ const newAsset = {
133
+ _key: assetKey,
134
+ _type: "assetEntry",
135
+ path: key,
136
+ filename,
137
+ extension,
138
+ file_size: size,
139
+ mime_type: mimeType,
140
+ has_thumbnail: hasThumbnail,
141
+ status: "active",
142
+ last_checked_at: now,
143
+ };
144
+
145
+ await writeClient
146
+ .patch(registry._id)
147
+ .setIfMissing({ assets: [] })
148
+ .append("assets", [newAsset])
149
+ .commit();
150
+
151
+ auditLog("asset.register.create", { path: key, fileSize: size });
152
+
153
+ return NextResponse.json({
154
+ success: true,
155
+ action: "created",
156
+ asset: { path: key, filename, extension, file_size: size },
157
+ });
158
+ }
159
+ } catch (err) {
160
+ logger.error("[Admin:Register]", "Failed to register asset", err);
161
+ return jsonError("Failed to register asset", 500);
162
+ }
163
+ }