@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,617 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { revalidatePath } from "next/cache";
3
+ import { client } from "../../../../../lib/sanity/client";
4
+ import { writeClient } from "../../../../../lib/sanity/writeClient";
5
+ import { pageBySlugQuery } from "../../../../../lib/sanity/queries";
6
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
7
+ import { isSafeUrl, isValidAssetPath, isBodyTooLarge, MAX_PAGE_BODY_SIZE, MAX_JSON_BODY_SIZE } from "../../../../../lib/security";
8
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
9
+ import { auditLog } from "../../../../../lib/audit";
10
+ import { logger } from "../../../../../lib/logger";
11
+ /** Raw nav_items from Sanity before GROQ projection resolves references */
12
+ interface RawNavItem {
13
+ _key: string;
14
+ internal_page?: { _ref: string };
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ // ─── Helpers ──────────────────────────────────────────────────────────────
19
+
20
+ /** Validate basic row→column→block structure before deeper sanitization.
21
+ * Supports both Row (columns→blocks) and PageSection (_type: "pageSection", block array).
22
+ */
23
+ function validateBlockStructure(rows: unknown[]): { valid: boolean; error?: string } {
24
+ if (!Array.isArray(rows)) return { valid: false, error: "content_rows must be an array" };
25
+ for (let i = 0; i < rows.length; i++) {
26
+ const row = rows[i];
27
+ if (!row || typeof row !== "object") return { valid: false, error: `Item ${i}: must be an object` };
28
+ const r = row as Record<string, unknown>;
29
+ if (typeof r._key !== "string" || !r._key) return { valid: false, error: `Item ${i}: missing _key` };
30
+
31
+ // CustomSectionInstance: validate reference fields + strip unknown fields (Sessions 107, 110)
32
+ if (r._type === "customSectionInstance") {
33
+ if (typeof r.custom_section_id !== "string" || !r.custom_section_id) {
34
+ return { valid: false, error: `Item ${i}: customSectionInstance missing custom_section_id` };
35
+ }
36
+ // m4 fix: allowlist — only keep known fields, strip anything injected (Session 110)
37
+ const allowedKeys = new Set(["_type", "_key", "custom_section_id", "custom_section_slug", "custom_section_title", "settings_overrides", "responsive_overrides"]);
38
+ for (const key of Object.keys(r)) {
39
+ if (!allowedKeys.has(key)) {
40
+ delete r[key];
41
+ }
42
+ }
43
+ continue;
44
+ }
45
+
46
+ // PageSection: validate block array instead of columns
47
+ if (r._type === "pageSection") {
48
+ if (!Array.isArray(r.block)) return { valid: false, error: `Section ${i}: block must be an array` };
49
+ const blocks = r.block as unknown[];
50
+ for (let k = 0; k < blocks.length; k++) {
51
+ const block = blocks[k];
52
+ if (!block || typeof block !== "object") return { valid: false, error: `Section ${i}, block ${k}: must be an object` };
53
+ const b = block as Record<string, unknown>;
54
+ if (typeof b._key !== "string" || !b._key) return { valid: false, error: `Section ${i}, block ${k}: missing _key` };
55
+ if (typeof b._type !== "string" || !b._type) return { valid: false, error: `Section ${i}, block ${k}: missing _type` };
56
+ }
57
+ continue;
58
+ }
59
+
60
+ // ParallaxGroup: validate slides array with columns→blocks inside each slide (Session 127)
61
+ if (r._type === "parallaxGroup") {
62
+ if (!Array.isArray(r.slides)) return { valid: false, error: `ParallaxGroup ${i}: slides must be an array` };
63
+ const slides = r.slides as unknown[];
64
+ for (let s = 0; s < slides.length; s++) {
65
+ const slide = slides[s];
66
+ if (!slide || typeof slide !== "object") return { valid: false, error: `ParallaxGroup ${i}, slide ${s}: must be an object` };
67
+ const sl = slide as Record<string, unknown>;
68
+ if (typeof sl._key !== "string" || !sl._key) return { valid: false, error: `ParallaxGroup ${i}, slide ${s}: missing _key` };
69
+ if (!Array.isArray(sl.columns)) return { valid: false, error: `ParallaxGroup ${i}, slide ${s}: columns must be an array` };
70
+ for (let j = 0; j < (sl.columns as unknown[]).length; j++) {
71
+ const col = (sl.columns as unknown[])[j];
72
+ if (!col || typeof col !== "object") return { valid: false, error: `ParallaxGroup ${i}, slide ${s}, col ${j}: must be an object` };
73
+ const c = col as Record<string, unknown>;
74
+ if (typeof c._key !== "string" || !c._key) return { valid: false, error: `ParallaxGroup ${i}, slide ${s}, col ${j}: missing _key` };
75
+ if (c.blocks !== undefined && !Array.isArray(c.blocks)) return { valid: false, error: `ParallaxGroup ${i}, slide ${s}, col ${j}: blocks must be an array` };
76
+ const blocks = (c.blocks || []) as unknown[];
77
+ for (let k = 0; k < blocks.length; k++) {
78
+ const block = blocks[k];
79
+ if (!block || typeof block !== "object") return { valid: false, error: `ParallaxGroup ${i}, slide ${s}, col ${j}, block ${k}: must be an object` };
80
+ const b = block as Record<string, unknown>;
81
+ if (typeof b._key !== "string" || !b._key) return { valid: false, error: `ParallaxGroup ${i}, slide ${s}, col ${j}, block ${k}: missing _key` };
82
+ if (typeof b._type !== "string" || !b._type) return { valid: false, error: `ParallaxGroup ${i}, slide ${s}, col ${j}, block ${k}: missing _type` };
83
+ }
84
+ }
85
+ }
86
+ continue;
87
+ }
88
+
89
+ // Regular Row (pageSectionV2): validate columns→blocks
90
+ if (!Array.isArray(r.columns)) return { valid: false, error: `Row ${i}: columns must be an array` };
91
+ for (let j = 0; j < (r.columns as unknown[]).length; j++) {
92
+ const col = (r.columns as unknown[])[j];
93
+ if (!col || typeof col !== "object") return { valid: false, error: `Row ${i}, col ${j}: must be an object` };
94
+ const c = col as Record<string, unknown>;
95
+ if (typeof c._key !== "string" || !c._key) return { valid: false, error: `Row ${i}, col ${j}: missing _key` };
96
+ if (c.blocks !== undefined && !Array.isArray(c.blocks)) return { valid: false, error: `Row ${i}, col ${j}: blocks must be an array` };
97
+ const blocks = (c.blocks || []) as unknown[];
98
+ for (let k = 0; k < blocks.length; k++) {
99
+ const block = blocks[k];
100
+ if (!block || typeof block !== "object") return { valid: false, error: `Row ${i}, col ${j}, block ${k}: must be an object` };
101
+ const b = block as Record<string, unknown>;
102
+ if (typeof b._key !== "string" || !b._key) return { valid: false, error: `Row ${i}, col ${j}, block ${k}: missing _key` };
103
+ if (typeof b._type !== "string" || !b._type) return { valid: false, error: `Row ${i}, col ${j}, block ${k}: missing _type` };
104
+ }
105
+ }
106
+ }
107
+ return { valid: true };
108
+ }
109
+
110
+ /** Validate a single block's URLs and asset paths */
111
+ function sanitizeSingleBlock(blockRecord: Record<string, unknown>): { valid: boolean; error?: string } {
112
+ // BUG-017 fix: check correct Sanity type name "buttonBlock" (not "button")
113
+ if (blockRecord._type === "buttonBlock") {
114
+ const url = blockRecord.url;
115
+ if (typeof url === "string" && !isSafeUrl(url)) {
116
+ return { valid: false, error: "Invalid URL protocol in button" };
117
+ }
118
+ }
119
+
120
+ // Validate image/video asset paths
121
+ const assetPath = blockRecord.asset_path;
122
+ if (typeof assetPath === "string" && !isValidAssetPath(assetPath)) {
123
+ return { valid: false, error: "Invalid asset path" };
124
+ }
125
+
126
+ // Validate cover block URLs
127
+ const linkUrl = blockRecord.link_url;
128
+ if (typeof linkUrl === "string" && !isSafeUrl(linkUrl)) {
129
+ return { valid: false, error: "Invalid URL protocol in link" };
130
+ }
131
+
132
+ // Check nested items (e.g. image grid items)
133
+ const items = blockRecord.items;
134
+ if (Array.isArray(items)) {
135
+ for (const item of items) {
136
+ if (!item || typeof item !== "object") continue;
137
+ const itemRecord = item as Record<string, unknown>;
138
+ const itemAssetPath = itemRecord.asset_path;
139
+ if (typeof itemAssetPath === "string" && !isValidAssetPath(itemAssetPath)) {
140
+ return { valid: false, error: "Invalid asset path in grid item" };
141
+ }
142
+ const itemLinkUrl = itemRecord.link_url;
143
+ if (typeof itemLinkUrl === "string" && !isSafeUrl(itemLinkUrl)) {
144
+ return { valid: false, error: "Invalid URL in grid item" };
145
+ }
146
+ }
147
+ }
148
+
149
+ return { valid: true };
150
+ }
151
+
152
+ /** Sanitize all blocks inside a columns array (shared by V2 sections and parallax slides) */
153
+ function sanitizeColumnsBlocks(columns: unknown[]): { valid: boolean; error?: string } {
154
+ for (const col of columns) {
155
+ if (!col || typeof col !== "object") continue;
156
+ const colRecord = col as Record<string, unknown>;
157
+ const blocks = colRecord.blocks;
158
+ if (!Array.isArray(blocks)) continue;
159
+
160
+ for (const block of blocks) {
161
+ if (!block || typeof block !== "object") continue;
162
+ const result = sanitizeSingleBlock(block as Record<string, unknown>);
163
+ if (!result.valid) return result;
164
+ }
165
+ }
166
+ return { valid: true };
167
+ }
168
+
169
+ /** Recursively sanitize URLs and asset paths in block content.
170
+ * Supports Row (columns→blocks), PageSection (block array), and ParallaxGroup (slides→columns→blocks).
171
+ */
172
+ function sanitizeBlockContent(rows: unknown[]): { valid: boolean; error?: string } {
173
+ if (!Array.isArray(rows)) return { valid: true };
174
+
175
+ for (const row of rows) {
176
+ if (!row || typeof row !== "object") continue;
177
+ const rowRecord = row as Record<string, unknown>;
178
+
179
+ // PageSection: sanitize block array directly
180
+ if (rowRecord._type === "pageSection") {
181
+ const sectionBlocks = rowRecord.block;
182
+ if (!Array.isArray(sectionBlocks)) continue;
183
+ for (const block of sectionBlocks) {
184
+ if (!block || typeof block !== "object") continue;
185
+ const result = sanitizeSingleBlock(block as Record<string, unknown>);
186
+ if (!result.valid) return result;
187
+ }
188
+ continue;
189
+ }
190
+
191
+ // ParallaxGroup: sanitize blocks inside each slide's columns (Session 127)
192
+ if (rowRecord._type === "parallaxGroup") {
193
+ const slides = rowRecord.slides;
194
+ if (!Array.isArray(slides)) continue;
195
+ for (const slide of slides) {
196
+ if (!slide || typeof slide !== "object") continue;
197
+ const slideRecord = slide as Record<string, unknown>;
198
+ // Validate asset paths in slide background fields
199
+ const bgImage = slideRecord.background_image;
200
+ if (typeof bgImage === "string" && !isValidAssetPath(bgImage)) {
201
+ return { valid: false, error: "Invalid asset path in parallax slide background" };
202
+ }
203
+ const bgVideo = slideRecord.background_video;
204
+ if (typeof bgVideo === "string" && !isValidAssetPath(bgVideo)) {
205
+ return { valid: false, error: "Invalid asset path in parallax slide video" };
206
+ }
207
+ // Sanitize blocks inside slide columns
208
+ const columns = slideRecord.columns;
209
+ if (Array.isArray(columns)) {
210
+ const result = sanitizeColumnsBlocks(columns);
211
+ if (!result.valid) return result;
212
+ }
213
+ }
214
+ continue;
215
+ }
216
+
217
+ // CustomSectionInstance: no blocks to sanitize
218
+ if (rowRecord._type === "customSectionInstance") {
219
+ continue;
220
+ }
221
+
222
+ // Regular Row (pageSectionV2): sanitize columns→blocks
223
+ const columns = rowRecord.columns;
224
+ if (!Array.isArray(columns)) continue;
225
+ const result = sanitizeColumnsBlocks(columns);
226
+ if (!result.valid) return result;
227
+ }
228
+ return { valid: true };
229
+ }
230
+
231
+ // ─── Route handlers ───────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * GET /api/admin/pages/[slug] — Fetch a page by slug for editing
235
+ */
236
+ export async function GET(
237
+ _request: NextRequest,
238
+ { params }: { params: Promise<{ slug: string }> }
239
+ ) {
240
+ if (!(await isAdminAuthenticated())) {
241
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
242
+ }
243
+
244
+ try {
245
+ const { slug } = await params;
246
+ const page = await client.fetch(pageBySlugQuery, { slug });
247
+
248
+ if (!page) {
249
+ return NextResponse.json(
250
+ { error: "Resource not found" },
251
+ { status: 404 }
252
+ );
253
+ }
254
+
255
+ return NextResponse.json({ page });
256
+ } catch (err) {
257
+ logger.error("[Admin:Pages]", "Failed to fetch page", err);
258
+ return NextResponse.json(
259
+ { error: "Failed to fetch page" },
260
+ { status: 500 }
261
+ );
262
+ }
263
+ }
264
+
265
+ /**
266
+ * POST /api/admin/pages/[slug] — Save/update a page from the builder
267
+ * Body: Sanity document payload from stateToDocument()
268
+ */
269
+ export async function POST(
270
+ request: NextRequest,
271
+ { params }: { params: Promise<{ slug: string }> }
272
+ ) {
273
+ if (!(await isAdminAuthenticated())) {
274
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
275
+ }
276
+ if (!validateCsrf(request)) {
277
+ return csrfErrorResponse();
278
+ }
279
+ if (isBodyTooLarge(request, MAX_PAGE_BODY_SIZE)) {
280
+ return NextResponse.json({ error: "Request body too large" }, { status: 413 });
281
+ }
282
+
283
+ try {
284
+ const { slug } = await params;
285
+ const body = await request.json();
286
+
287
+ // Validate content_rows structure and sanitize URLs/asset paths before saving
288
+ if (body.content_rows) {
289
+ const structureCheck = validateBlockStructure(body.content_rows);
290
+ if (!structureCheck.valid) {
291
+ return NextResponse.json(
292
+ { error: structureCheck.error || "Invalid content structure" },
293
+ { status: 400 }
294
+ );
295
+ }
296
+ const sanitizeCheck = sanitizeBlockContent(body.content_rows);
297
+ if (!sanitizeCheck.valid) {
298
+ return NextResponse.json(
299
+ { error: sanitizeCheck.error || "Invalid content" },
300
+ { status: 400 }
301
+ );
302
+ }
303
+ }
304
+
305
+ // Find existing document by slug
306
+ const existing = await client.fetch(
307
+ `*[_type == "page" && slug.current == $slug][0]._id`,
308
+ { slug }
309
+ );
310
+
311
+ if (!existing) {
312
+ return NextResponse.json(
313
+ { error: "Resource not found" },
314
+ { status: 404 }
315
+ );
316
+ }
317
+
318
+ // If setting this page as home, atomically unset previous + set new using transaction
319
+ if (body.is_home === true) {
320
+ const currentHomeId = await client.fetch(
321
+ `*[_type == "page" && is_home == true && _id != $id][0]._id`,
322
+ { id: existing }
323
+ );
324
+ if (currentHomeId) {
325
+ await writeClient
326
+ .transaction()
327
+ .patch(currentHomeId, (p) => p.set({ is_home: false }))
328
+ .patch(existing, (p) => p.set({ is_home: true }))
329
+ .commit();
330
+ // Skip setting is_home again below since transaction already handled it
331
+ delete body.is_home;
332
+ }
333
+ }
334
+
335
+ // Build patch — update mutable fields
336
+ const patch = writeClient.patch(existing);
337
+
338
+ if (body.title !== undefined) patch.set({ title: body.title });
339
+ if (body.slug !== undefined) patch.set({ slug: body.slug });
340
+ if (body.page_type !== undefined) patch.set({ page_type: body.page_type });
341
+ if (body.is_home !== undefined) patch.set({ is_home: body.is_home });
342
+ if (body.content_rows !== undefined) patch.set({ content_rows: body.content_rows });
343
+ if (body.metadata !== undefined) patch.set({ metadata: body.metadata });
344
+ if (body.page_settings !== undefined) patch.set({ page_settings: body.page_settings });
345
+ if (body.draft_mode !== undefined) patch.set({ draft_mode: body.draft_mode });
346
+ if (body.published_at !== undefined) patch.set({ published_at: body.published_at });
347
+ if (body.thumbnail_path !== undefined) patch.set({ thumbnail_path: body.thumbnail_path });
348
+ if (body.cover_video !== undefined) patch.set({ cover_video: body.cover_video });
349
+
350
+ const updated = await patch.commit();
351
+
352
+ auditLog("page.update", { slug });
353
+
354
+ // Fetch page type to determine revalidation paths
355
+ const pageDoc = await client.fetch<{ page_type?: string; slug?: { current?: string } } | null>(
356
+ `*[_type == "page" && _id == $id][0]{ page_type, slug }`,
357
+ { id: existing }
358
+ );
359
+ const pType = pageDoc?.page_type;
360
+ const currentSlug = pageDoc?.slug?.current || slug;
361
+
362
+ // If draft_mode changed, revalidate the public path so the page appears/disappears
363
+ if (body.draft_mode !== undefined) {
364
+ const publicPath = pType === "project" ? `/work/${currentSlug}` : `/${currentSlug}`;
365
+ revalidatePath(publicPath);
366
+ revalidatePath("/", "layout");
367
+ }
368
+
369
+ // If this is a project, revalidate the /api/projects endpoint so ProjectGrid
370
+ // and ParallaxShowcase blocks show the latest data without waiting for ISR expiry
371
+ if (pType === "project") {
372
+ revalidatePath("/api/projects");
373
+ }
374
+
375
+ return NextResponse.json({ page: updated });
376
+ } catch (err) {
377
+ logger.error("[Admin:Pages]", "Failed to save page", err);
378
+ return NextResponse.json(
379
+ { error: "Failed to save page" },
380
+ { status: 500 }
381
+ );
382
+ }
383
+ }
384
+
385
+ /**
386
+ * DELETE /api/admin/pages/[slug] — Delete a page
387
+ */
388
+ // ─── PATCH — Update page settings (title, slug) ─────────────────────────────
389
+
390
+ export async function PATCH(
391
+ request: NextRequest,
392
+ { params }: { params: Promise<{ slug: string }> }
393
+ ) {
394
+ if (!(await isAdminAuthenticated())) {
395
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
396
+ }
397
+ if (!validateCsrf(request)) {
398
+ return csrfErrorResponse();
399
+ }
400
+
401
+ if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
402
+ return NextResponse.json({ error: "Request body too large" }, { status: 413 });
403
+ }
404
+
405
+ try {
406
+ const { slug } = await params;
407
+ const body = await request.json();
408
+
409
+ // Validate input
410
+ const { title, newSlug } = body as { title?: string; newSlug?: string };
411
+ if (!title && !newSlug) {
412
+ return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
413
+ }
414
+ if (title && (typeof title !== "string" || title.length > 200)) {
415
+ return NextResponse.json({ error: "Invalid title" }, { status: 400 });
416
+ }
417
+ if (newSlug) {
418
+ if (typeof newSlug !== "string" || newSlug.length > 100) {
419
+ return NextResponse.json({ error: "Invalid slug" }, { status: 400 });
420
+ }
421
+ // Only allow lowercase alphanumeric + hyphens
422
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(newSlug)) {
423
+ return NextResponse.json(
424
+ { error: "Slug must be lowercase alphanumeric with hyphens only" },
425
+ { status: 400 }
426
+ );
427
+ }
428
+ }
429
+
430
+ // Find existing document
431
+ const existing = await client.fetch(
432
+ `*[_type == "page" && slug.current == $slug][0]._id`,
433
+ { slug }
434
+ );
435
+ if (!existing) {
436
+ return NextResponse.json({ error: "Page not found" }, { status: 404 });
437
+ }
438
+
439
+ // If changing slug, check for conflicts
440
+ if (newSlug && newSlug !== slug) {
441
+ const conflict = await client.fetch(
442
+ `*[_type == "page" && slug.current == $newSlug][0]._id`,
443
+ { newSlug }
444
+ );
445
+ if (conflict) {
446
+ return NextResponse.json(
447
+ { error: "A page with that slug already exists" },
448
+ { status: 409 }
449
+ );
450
+ }
451
+ }
452
+
453
+ // Build patch
454
+ const patch: Record<string, unknown> = {};
455
+ if (title) patch.title = title.trim();
456
+ if (newSlug && newSlug !== slug) patch["slug.current"] = newSlug;
457
+
458
+ await writeClient.patch(existing).set(patch).commit();
459
+
460
+ // ── Slug cascade: update references in ProjectGrid / ParallaxShowcase blocks ──
461
+ // When a project slug changes, find all pages whose content_rows contain
462
+ // blocks that reference the old slug and rewrite them to the new slug.
463
+ if (newSlug && newSlug !== slug) {
464
+ try {
465
+ // Find pages that contain the old slug anywhere in content_rows
466
+ const referencingPages = await client.fetch<
467
+ { _id: string; content_rows: unknown[] }[]
468
+ >(
469
+ `*[_type == "page" && content_rows[].columns[].blocks[].project_slug == $oldSlug]{
470
+ _id, content_rows
471
+ }`,
472
+ { oldSlug: slug }
473
+ );
474
+
475
+ for (const page of referencingPages) {
476
+ let changed = false;
477
+ const rows = JSON.parse(JSON.stringify(page.content_rows || []));
478
+
479
+ for (const row of rows) {
480
+ if (!row?.columns) continue;
481
+ for (const col of row.columns) {
482
+ if (!col?.blocks) continue;
483
+ for (const block of col.blocks) {
484
+ const btype = block?._type;
485
+ if (btype !== "projectGridBlock") continue;
486
+ if (!Array.isArray(block.projects)) continue;
487
+ for (const item of block.projects) {
488
+ if (item.project_slug === slug) {
489
+ item.project_slug = newSlug;
490
+ changed = true;
491
+ }
492
+ }
493
+ }
494
+ }
495
+ }
496
+
497
+ if (changed) {
498
+ await writeClient.patch(page._id).set({ content_rows: rows }).commit();
499
+ auditLog("page.slug_cascade", { page_id: page._id, oldSlug: slug, newSlug });
500
+ }
501
+ }
502
+ } catch (cascadeErr) {
503
+ // Non-fatal: log but don't fail the rename
504
+ logger.error("[Admin:Pages]", "Slug cascade failed (non-fatal)", cascadeErr);
505
+ }
506
+ }
507
+
508
+ // Revalidate ISR cache for affected paths
509
+ // Determine page type to build the correct public path
510
+ const pageDoc = await client.fetch<{ page_type?: string } | null>(
511
+ `*[_type == "page" && _id == $id][0]{ page_type }`,
512
+ { id: existing }
513
+ );
514
+ const pType = pageDoc?.page_type;
515
+ const oldPublicPath = pType === "project" ? `/work/${slug}` : `/${slug}`;
516
+ revalidatePath(oldPublicPath);
517
+ if (newSlug && newSlug !== slug) {
518
+ const newPublicPath = pType === "project" ? `/work/${newSlug}` : `/${newSlug}`;
519
+ revalidatePath(newPublicPath);
520
+ }
521
+ // Revalidate layout to update navs, project grids, etc.
522
+ revalidatePath("/", "layout");
523
+ // If this is a project, purge /api/projects so grids reflect slug changes
524
+ if (pType === "project") {
525
+ revalidatePath("/api/projects");
526
+ }
527
+
528
+ auditLog("page.update_settings", { slug, ...patch });
529
+
530
+ return NextResponse.json({ success: true, slug: newSlug || slug });
531
+ } catch (err) {
532
+ logger.error("[Admin:Pages]", "Failed to update page settings", err);
533
+ return NextResponse.json(
534
+ { error: "Failed to update page settings" },
535
+ { status: 500 }
536
+ );
537
+ }
538
+ }
539
+
540
+ // ─── DELETE ──────────────────────────────────────────────────────────────────
541
+
542
+ export async function DELETE(
543
+ request: NextRequest,
544
+ { params }: { params: Promise<{ slug: string }> }
545
+ ) {
546
+ if (!(await isAdminAuthenticated())) {
547
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
548
+ }
549
+ if (!validateCsrf(request)) {
550
+ return csrfErrorResponse();
551
+ }
552
+
553
+ try {
554
+ const { slug } = await params;
555
+
556
+ // Find existing document by slug (fetch page_type before deletion for revalidation)
557
+ const existingDoc = await client.fetch<{ _id: string; page_type?: string } | null>(
558
+ `*[_type == "page" && slug.current == $slug][0]{ _id, page_type }`,
559
+ { slug }
560
+ );
561
+
562
+ if (!existingDoc) {
563
+ return NextResponse.json(
564
+ { error: "Resource not found" },
565
+ { status: 404 }
566
+ );
567
+ }
568
+
569
+ const existing = existingDoc._id;
570
+
571
+ // Check for references (e.g. navigation links pointing to this page)
572
+ const referencing = await client.fetch(
573
+ `count(*[references($id)])`,
574
+ { id: existing }
575
+ );
576
+
577
+ if (referencing > 0) {
578
+ // Remove navigation references to this page before deleting
579
+ const siteSettings = await client.fetch(
580
+ `*[_type == "siteSettings"][0]{ _id, nav_items }`
581
+ );
582
+ if (siteSettings?.nav_items?.length) {
583
+ const filtered = siteSettings.nav_items.filter((item: RawNavItem) =>
584
+ item.internal_page?._ref !== existing
585
+ );
586
+ if (filtered.length !== siteSettings.nav_items.length) {
587
+ await writeClient
588
+ .patch(siteSettings._id)
589
+ .set({ nav_items: filtered })
590
+ .commit();
591
+ }
592
+ }
593
+ }
594
+
595
+ await writeClient.delete(existing);
596
+
597
+ // Revalidate ISR cache so the deleted page returns 404 immediately
598
+ const deletedPublicPath = existingDoc.page_type === "project" ? `/work/${slug}` : `/${slug}`;
599
+ revalidatePath(deletedPublicPath);
600
+ // Also revalidate home and layout to update any project grids/lists
601
+ revalidatePath("/", "layout");
602
+ // If deleted page was a project, purge /api/projects so grids update immediately
603
+ if (existingDoc.page_type === "project") {
604
+ revalidatePath("/api/projects");
605
+ }
606
+
607
+ auditLog("page.delete", { slug });
608
+
609
+ return NextResponse.json({ success: true });
610
+ } catch (err) {
611
+ logger.error("[Admin:Pages]", "Failed to delete page", err);
612
+ return NextResponse.json(
613
+ { error: "Failed to delete page" },
614
+ { status: 500 }
615
+ );
616
+ }
617
+ }
@@ -0,0 +1,76 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { revalidatePath } from "next/cache";
3
+ import { client } from "../../../../../../lib/sanity/client";
4
+ import { writeClient } from "../../../../../../lib/sanity/writeClient";
5
+ import { isAdminAuthenticated } from "../../../../../../lib/auth";
6
+ import { validateCsrf, csrfErrorResponse } from "../../../../../../lib/csrf";
7
+ import { auditLog } from "../../../../../../lib/audit";
8
+ import { logger } from "../../../../../../lib/logger";
9
+
10
+ /**
11
+ * POST /api/admin/pages/[slug]/set-home — Set a page as the home page.
12
+ * Atomically unsets is_home on the previous home page and sets it on this one.
13
+ * Only non-project pages can be set as home.
14
+ */
15
+ export async function POST(
16
+ request: NextRequest,
17
+ { params }: { params: Promise<{ slug: string }> }
18
+ ) {
19
+ if (!(await isAdminAuthenticated())) {
20
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
21
+ }
22
+ if (!validateCsrf(request)) {
23
+ return csrfErrorResponse();
24
+ }
25
+
26
+ try {
27
+ const { slug } = await params;
28
+
29
+ // Find the target page
30
+ const targetPage = await client.fetch(
31
+ `*[_type == "page" && slug.current == $slug][0]{ _id, page_type, title }`,
32
+ { slug }
33
+ );
34
+
35
+ if (!targetPage) {
36
+ return NextResponse.json({ error: "Page not found" }, { status: 404 });
37
+ }
38
+
39
+ // Projects cannot be set as home
40
+ if (targetPage.page_type === "project") {
41
+ return NextResponse.json(
42
+ { error: "Projects cannot be set as the home page" },
43
+ { status: 400 }
44
+ );
45
+ }
46
+
47
+ // Atomically unset is_home on the current home page and set it on the target.
48
+ // Using a Sanity transaction ensures both patches succeed or neither does,
49
+ // preventing concurrent requests from creating multiple home pages.
50
+ const currentHomeId = await client.fetch(
51
+ `*[_type == "page" && is_home == true && _id != $id][0]._id`,
52
+ { id: targetPage._id }
53
+ );
54
+
55
+ const tx = writeClient.transaction();
56
+ if (currentHomeId) {
57
+ tx.patch(currentHomeId, (p) => p.set({ is_home: false }));
58
+ }
59
+ tx.patch(targetPage._id, (p) => p.set({ is_home: true }));
60
+ await tx.commit();
61
+
62
+ // Revalidate home page and layout
63
+ revalidatePath("/");
64
+ revalidatePath("/", "layout");
65
+
66
+ auditLog("page.set-home", { slug, title: targetPage.title });
67
+
68
+ return NextResponse.json({ success: true });
69
+ } catch (err) {
70
+ logger.error("[Admin:Pages]", "Failed to set home page", err);
71
+ return NextResponse.json(
72
+ { error: "Failed to set home page" },
73
+ { status: 500 }
74
+ );
75
+ }
76
+ }