@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,279 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../lib/auth";
3
+ import { client } from "../../../../lib/sanity/client";
4
+ import { writeClient } from "../../../../lib/sanity/writeClient";
5
+ import { siteSettingsQuery } from "../../../../lib/sanity/queries";
6
+ import { isSafeUrl, isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../lib/security";
7
+ import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
8
+ import { auditLog } from "../../../../lib/audit";
9
+ import { getSiteConfig } from "../../../../lib/config";
10
+ import { logger } from "../../../../lib/logger";
11
+
12
+ /**
13
+ * GET /api/admin/settings — fetch siteSettings document
14
+ * POST /api/admin/settings — update siteSettings (authenticated)
15
+ */
16
+
17
+ const SETTINGS_ID = "siteSettings";
18
+ const cfg = getSiteConfig();
19
+
20
+ export async function GET() {
21
+ const authenticated = await isAdminAuthenticated();
22
+ if (!authenticated) {
23
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
24
+ }
25
+
26
+ try {
27
+ let settings = await client.fetch(siteSettingsQuery);
28
+
29
+ // Auto-create if missing
30
+ if (!settings) {
31
+ await writeClient.createIfNotExists({
32
+ _id: SETTINGS_ID,
33
+ _type: "siteSettings",
34
+ nav_items: [],
35
+ default_title: cfg.defaults.metaTitle,
36
+ });
37
+ settings = await client.fetch(siteSettingsQuery);
38
+ }
39
+
40
+ return NextResponse.json({ settings });
41
+ } catch (error) {
42
+ logger.error("[Admin:Settings]", "Failed to fetch settings:", error);
43
+ return NextResponse.json(
44
+ { error: "Failed to fetch settings" },
45
+ { status: 500 }
46
+ );
47
+ }
48
+ }
49
+
50
+ export async function POST(request: NextRequest) {
51
+ const authenticated = await isAdminAuthenticated();
52
+ if (!authenticated) {
53
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
54
+ }
55
+ if (!validateCsrf(request)) {
56
+ return csrfErrorResponse();
57
+ }
58
+ if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
59
+ return NextResponse.json({ error: "Request body too large" }, { status: 413 });
60
+ }
61
+
62
+ try {
63
+ const body = await request.json();
64
+ const { section, data } = body;
65
+
66
+ if (!section || !data) {
67
+ return NextResponse.json(
68
+ { error: "Missing section or data" },
69
+ { status: 400 }
70
+ );
71
+ }
72
+
73
+ // Validate section name
74
+ const validSections = ["navigation", "nav_design", "metadata", "assets"];
75
+ if (!validSections.includes(section)) {
76
+ return NextResponse.json(
77
+ { error: "Invalid section" },
78
+ { status: 400 }
79
+ );
80
+ }
81
+
82
+ // Build patch based on section
83
+ let patch: Record<string, unknown> = {};
84
+
85
+ switch (section) {
86
+ case "navigation": {
87
+ // Validate nav_items structure
88
+ if (!Array.isArray(data.nav_items)) {
89
+ return NextResponse.json(
90
+ { error: "nav_items must be an array" },
91
+ { status: 400 }
92
+ );
93
+ }
94
+ // Check nav_items array length
95
+ if (data.nav_items.length > 50) {
96
+ return NextResponse.json(
97
+ { error: "nav_items exceeds maximum of 50 items" },
98
+ { status: 400 }
99
+ );
100
+ }
101
+ for (const item of data.nav_items) {
102
+ if (!item.label || typeof item.label !== "string") {
103
+ return NextResponse.json(
104
+ { error: "Each nav item must have a label" },
105
+ { status: 400 }
106
+ );
107
+ }
108
+ if (item.label.length > 1000) {
109
+ return NextResponse.json(
110
+ { error: "Nav item label exceeds maximum length of 1000 characters" },
111
+ { status: 400 }
112
+ );
113
+ }
114
+ if (!["internal", "external", "content"].includes(item.link_type)) {
115
+ return NextResponse.json(
116
+ { error: "link_type must be 'internal', 'external', or 'content'" },
117
+ { status: 400 }
118
+ );
119
+ }
120
+ // Validate external URLs — reject javascript:, data:, vbscript: etc.
121
+ if (item.link_type === "external" && item.external_url) {
122
+ if (!isSafeUrl(item.external_url)) {
123
+ return NextResponse.json(
124
+ { error: "External URL uses a disallowed protocol" },
125
+ { status: 400 }
126
+ );
127
+ }
128
+ }
129
+ // Validate content embed URLs
130
+ if (item.link_type === "content" && item.content_type === "video-embed" && item.content_url) {
131
+ if (!isSafeUrl(item.content_url)) {
132
+ return NextResponse.json(
133
+ { error: "Content embed URL uses a disallowed protocol" },
134
+ { status: 400 }
135
+ );
136
+ }
137
+ }
138
+ }
139
+ patch = {
140
+ nav_items: data.nav_items.map(
141
+ (item: {
142
+ _key?: string;
143
+ type?: string;
144
+ label: string;
145
+ logo_image?: string;
146
+ link_type: string;
147
+ internal_page?: { _ref: string };
148
+ external_url?: string;
149
+ content_type?: string;
150
+ content_asset?: string;
151
+ content_url?: string;
152
+ visible?: boolean;
153
+ grid_column?: number;
154
+ column_span?: number;
155
+ style_overrides?: Record<string, unknown>;
156
+ }) => ({
157
+ _key: item._key || crypto.randomUUID().slice(0, 8),
158
+ _type: "object",
159
+ type: item.type || "menu-item",
160
+ label: item.label,
161
+ ...(item.logo_image ? { logo_image: item.logo_image } : {}),
162
+ link_type: item.link_type,
163
+ ...(item.link_type === "internal" && item.internal_page
164
+ ? { internal_page: { _type: "reference", _ref: item.internal_page._ref } }
165
+ : {}),
166
+ ...(item.link_type === "external" && item.external_url
167
+ ? { external_url: item.external_url }
168
+ : {}),
169
+ ...(item.link_type === "content" ? {
170
+ ...(item.content_type ? { content_type: item.content_type } : {}),
171
+ ...(item.content_asset ? { content_asset: item.content_asset } : {}),
172
+ ...(item.content_url ? { content_url: item.content_url } : {}),
173
+ } : {}),
174
+ visible: item.visible !== false,
175
+ ...(typeof item.grid_column === "number" &&
176
+ item.grid_column >= 1 &&
177
+ item.grid_column <= 12
178
+ ? { grid_column: item.grid_column }
179
+ : {}),
180
+ column_span: typeof item.column_span === "number" && item.column_span >= 1
181
+ ? Math.min(item.column_span, 12)
182
+ : 1,
183
+ ...(item.style_overrides && Object.keys(item.style_overrides).length > 0
184
+ ? { style_overrides: item.style_overrides }
185
+ : {}),
186
+ })
187
+ ),
188
+ };
189
+ break;
190
+ }
191
+
192
+ case "nav_design": {
193
+ const nd = data.nav_design || {};
194
+ patch = {
195
+ nav_design: {
196
+ _type: "object",
197
+ logo_text: typeof nd.logo_text === "string" ? nd.logo_text.slice(0, 200) : cfg.defaults.logoText,
198
+ color: ["yellow-lime", "yellow", "red-coral", "blue", "green", "white"].includes(nd.color) ? nd.color : "yellow-lime",
199
+ position: ["fixed", "sticky", "static"].includes(nd.position) ? nd.position : "fixed",
200
+ hide_on_scroll: nd.hide_on_scroll !== false,
201
+ font_size: typeof nd.font_size === "number" ? Math.max(8, Math.min(48, nd.font_size)) : 14,
202
+ font_family: typeof nd.font_family === "string" ? nd.font_family.slice(0, 200) : "",
203
+ text_align: ["left", "center", "right"].includes(nd.text_align) ? nd.text_align : "left",
204
+ text_transform: ["none", "uppercase", "lowercase", "capitalize"].includes(nd.text_transform) ? nd.text_transform : "uppercase",
205
+ padding_h: typeof nd.padding_h === "number" ? Math.max(0, Math.min(200, nd.padding_h)) : 24,
206
+ padding_v: typeof nd.padding_v === "number" ? Math.max(0, Math.min(200, nd.padding_v)) : 27,
207
+ margin_h: typeof nd.margin_h === "number" ? Math.max(0, Math.min(200, nd.margin_h)) : 0,
208
+ margin_v: typeof nd.margin_v === "number" ? Math.max(0, Math.min(200, nd.margin_v)) : 0,
209
+ background_color: typeof nd.background_color === "string" ? nd.background_color.slice(0, 50) : "",
210
+ background_opacity: typeof nd.background_opacity === "number" ? Math.max(0, Math.min(100, nd.background_opacity)) : 0,
211
+ backdrop_blur: !!nd.backdrop_blur,
212
+ items_gap: typeof nd.items_gap === "number" ? Math.max(0, Math.min(200, nd.items_gap)) : 32,
213
+ logo_columns: typeof nd.logo_columns === "number" ? Math.max(1, Math.min(6, Math.round(nd.logo_columns))) : 3,
214
+ vertical_align: ["top", "middle", "bottom"].includes(nd.vertical_align) ? nd.vertical_align : "top",
215
+ font_weight: typeof nd.font_weight === "string" ? nd.font_weight.slice(0, 10) : "400",
216
+ // ── Entrance animation ──
217
+ entrance_animation: ["", "fade-in", "slide-down", "blur-in"].includes(nd.entrance_animation) ? nd.entrance_animation : "",
218
+ entrance_duration: typeof nd.entrance_duration === "number" ? Math.max(200, Math.min(5000, nd.entrance_duration)) : 600,
219
+ entrance_delay: typeof nd.entrance_delay === "number" ? Math.max(0, Math.min(5000, nd.entrance_delay)) : 0,
220
+ entrance_stagger: !!nd.entrance_stagger,
221
+ entrance_stagger_delay: typeof nd.entrance_stagger_delay === "number" ? Math.max(20, Math.min(500, nd.entrance_stagger_delay)) : 80,
222
+ },
223
+ };
224
+ break;
225
+ }
226
+
227
+ case "metadata": {
228
+ // Validate text field lengths
229
+ if (data.default_title && typeof data.default_title === "string" && data.default_title.length > 1000) {
230
+ return NextResponse.json(
231
+ { error: "Title field exceeds maximum length of 1000 characters" },
232
+ { status: 400 }
233
+ );
234
+ }
235
+ if (data.default_description && typeof data.default_description === "string" && data.default_description.length > 1000) {
236
+ return NextResponse.json(
237
+ { error: "Description field exceeds maximum length of 1000 characters" },
238
+ { status: 400 }
239
+ );
240
+ }
241
+ patch = {
242
+ default_title: data.default_title || "",
243
+ default_description: data.default_description || "",
244
+ default_og_image: data.default_og_image || "",
245
+ favicon_path: data.favicon_path || "",
246
+ analytics_id: data.analytics_id || "",
247
+ };
248
+ break;
249
+ }
250
+
251
+ case "assets": {
252
+ patch = {};
253
+ if (data.asset_base_url !== undefined) {
254
+ patch.asset_base_url = data.asset_base_url;
255
+ }
256
+ break;
257
+ }
258
+ }
259
+
260
+ // Ensure the document exists before patching
261
+ await writeClient.createIfNotExists({
262
+ _id: SETTINGS_ID,
263
+ _type: "siteSettings",
264
+ });
265
+
266
+ // Apply the patch
267
+ await writeClient.patch(SETTINGS_ID).set(patch).commit();
268
+
269
+ auditLog("settings.update", { section });
270
+
271
+ return NextResponse.json({ success: true, section });
272
+ } catch (error) {
273
+ logger.error("[Admin:Settings]", "Failed to save settings:", error);
274
+ return NextResponse.json(
275
+ { error: "Failed to save settings" },
276
+ { status: 500 }
277
+ );
278
+ }
279
+ }
@@ -0,0 +1,51 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { writeClient } from "../../../../../lib/sanity/writeClient";
4
+ import { client } from "../../../../../lib/sanity/client";
5
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
6
+ import { auditLog } from "../../../../../lib/audit";
7
+ import { logger } from "../../../../../lib/logger";
8
+
9
+ /**
10
+ * POST /api/admin/setup/complete — Mark setup wizard as complete.
11
+ *
12
+ * Sets `setup_complete: true` on the siteSettings document.
13
+ * Called when the user clicks "Start Building" on the final wizard step.
14
+ * This prevents the wizard from re-triggering on subsequent admin visits.
15
+ */
16
+ export async function POST(request: NextRequest) {
17
+ const authenticated = await isAdminAuthenticated();
18
+ if (!authenticated) {
19
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
20
+ }
21
+
22
+ if (!validateCsrf(request)) {
23
+ return csrfErrorResponse();
24
+ }
25
+
26
+ try {
27
+ // Find the siteSettings document
28
+ const doc = await client.fetch<{ _id: string } | null>(
29
+ `*[_type == "siteSettings"][0]{ _id }`
30
+ );
31
+
32
+ if (!doc) {
33
+ return NextResponse.json(
34
+ { error: "siteSettings document not found. Run database setup first." },
35
+ { status: 400 }
36
+ );
37
+ }
38
+
39
+ // Set the completion flag
40
+ await writeClient.patch(doc._id).set({ setup_complete: true }).commit();
41
+
42
+ auditLog("setup.complete", {});
43
+ logger.info("[Setup]", "Setup wizard marked as complete");
44
+
45
+ return NextResponse.json({ success: true });
46
+ } catch (err) {
47
+ const message = err instanceof Error ? err.message : "Failed to complete setup";
48
+ logger.error("[Setup]", "Failed to mark setup complete:", err);
49
+ return NextResponse.json({ error: message }, { status: 500 });
50
+ }
51
+ }
@@ -0,0 +1,118 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../lib/auth";
3
+ import { getSetupStatus } from "../../../../lib/setup/detect";
4
+ import { client } from "../../../../lib/sanity/client";
5
+ import { writeClient } from "../../../../lib/sanity/writeClient";
6
+ import { validateCsrf, csrfErrorResponse } from "../../../../lib/csrf";
7
+
8
+ /**
9
+ * GET /api/admin/setup — Returns current setup/onboarding status.
10
+ * Used by the admin layout to decide whether to show the wizard,
11
+ * and by the wizard itself to know which steps are already done.
12
+ */
13
+ export async function GET() {
14
+ const authenticated = await isAdminAuthenticated();
15
+ if (!authenticated) {
16
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
17
+ }
18
+
19
+ try {
20
+ const status = await getSetupStatus();
21
+ return NextResponse.json(status);
22
+ } catch {
23
+ return NextResponse.json(
24
+ { error: "Failed to check setup status" },
25
+ { status: 500 }
26
+ );
27
+ }
28
+ }
29
+
30
+ /**
31
+ * POST /api/admin/setup — Seed initial Sanity documents.
32
+ * Called by the wizard's Database step after a successful connection test.
33
+ * Creates siteSettings, siteStyles, and assetRegistry if they don't exist.
34
+ */
35
+ export async function POST(request: NextRequest) {
36
+ const authenticated = await isAdminAuthenticated();
37
+ if (!authenticated) {
38
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
39
+ }
40
+
41
+ if (!validateCsrf(request)) {
42
+ return csrfErrorResponse();
43
+ }
44
+
45
+ try {
46
+ // Check which documents already exist
47
+ const existing = await client.fetch<{
48
+ siteSettings: number;
49
+ siteStyles: number;
50
+ assetRegistry: number;
51
+ }>(`{
52
+ "siteSettings": count(*[_type == "siteSettings"]),
53
+ "siteStyles": count(*[_type == "siteStyles"]),
54
+ "assetRegistry": count(*[_type == "assetRegistry"])
55
+ }`);
56
+
57
+ const seeded: string[] = [];
58
+ const skipped: string[] = [];
59
+
60
+ // Seed siteSettings
61
+ if (existing.siteSettings === 0) {
62
+ await writeClient.create({
63
+ _type: "siteSettings",
64
+ site_title: "",
65
+ site_description: "",
66
+ nav_items: [],
67
+ nav_design: {
68
+ position: "fixed",
69
+ hide_on_scroll: true,
70
+ font_size: 14,
71
+ padding_h: 24,
72
+ padding_v: 27,
73
+ margin_h: 0,
74
+ margin_v: 0,
75
+ background_opacity: 100,
76
+ backdrop_blur: false,
77
+ items_gap: 32,
78
+ },
79
+ });
80
+ seeded.push("siteSettings");
81
+ } else {
82
+ skipped.push("siteSettings");
83
+ }
84
+
85
+ // Seed siteStyles
86
+ if (existing.siteStyles === 0) {
87
+ await writeClient.create({
88
+ _type: "siteStyles",
89
+ grid_columns: 12,
90
+ grid_width: 1200,
91
+ grid_gutter: 24,
92
+ fonts: [],
93
+ color_palette: [],
94
+ typography: {},
95
+ });
96
+ seeded.push("siteStyles");
97
+ } else {
98
+ skipped.push("siteStyles");
99
+ }
100
+
101
+ // Seed assetRegistry
102
+ if (existing.assetRegistry === 0) {
103
+ await writeClient.create({
104
+ _type: "assetRegistry",
105
+ assets: [],
106
+ storage_provider: "",
107
+ });
108
+ seeded.push("assetRegistry");
109
+ } else {
110
+ skipped.push("assetRegistry");
111
+ }
112
+
113
+ return NextResponse.json({ seeded, skipped });
114
+ } catch (err) {
115
+ const message = err instanceof Error ? err.message : "Seeding failed";
116
+ return NextResponse.json({ error: message }, { status: 500 });
117
+ }
118
+ }
@@ -0,0 +1,117 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { revalidatePath } from "next/cache";
3
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
4
+ import { writeClient } from "../../../../../lib/sanity/writeClient";
5
+ import { client } from "../../../../../lib/sanity/client";
6
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
7
+ import { isBodyTooLarge, MAX_JSON_BODY_SIZE, jsonError } from "../../../../../lib/security";
8
+ import { invalidateProviderConfigCache } from "../../../../../lib/storage";
9
+ import { auditLog } from "../../../../../lib/audit";
10
+ import type { StorageProvider } from "../../../../../lib/storage/types";
11
+ import { logger } from "../../../../../lib/logger";
12
+
13
+ const VALID_PROVIDERS: StorageProvider[] = ["r2"];
14
+
15
+ /**
16
+ * POST /api/admin/storage/switch — Switch the active storage provider.
17
+ *
18
+ * Body: { provider: "r2" }
19
+ *
20
+ * 1. Validates the target provider is connected (has credentials).
21
+ * 2. Updates `assetRegistry.storage_provider` in Sanity.
22
+ * 3. Invalidates the in-memory provider config cache.
23
+ * 4. Triggers ISR revalidation of the entire site layout so all
24
+ * server-rendered pages pick up the new asset URLs.
25
+ *
26
+ * The switch assumes providers have the same files at the same
27
+ * relative paths. No automatic data migration occurs.
28
+ *
29
+ * Currently only R2 is supported. The route is preserved for the
30
+ * multi-provider architecture — new providers can be added here.
31
+ */
32
+ export async function POST(request: NextRequest) {
33
+ if (!(await isAdminAuthenticated())) {
34
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
35
+ }
36
+ if (!validateCsrf(request)) {
37
+ return csrfErrorResponse();
38
+ }
39
+ if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
40
+ return jsonError("Request body too large", 413);
41
+ }
42
+
43
+ try {
44
+ const body = await request.json();
45
+ const { provider } = body;
46
+
47
+ // ── Validate provider value ──
48
+ if (!provider || !VALID_PROVIDERS.includes(provider)) {
49
+ return jsonError(
50
+ `Invalid provider. Must be one of: ${VALID_PROVIDERS.join(", ")}`,
51
+ 400
52
+ );
53
+ }
54
+
55
+ // ── Check current provider (avoid no-op) ──
56
+ const registry = await client.fetch(
57
+ `*[_type == "assetRegistry"][0]{
58
+ storage_provider,
59
+ r2_bucket_url,
60
+ r2_access_key_id,
61
+ r2_endpoint,
62
+ r2_bucket_name,
63
+ r2_connected_at
64
+ }`
65
+ );
66
+
67
+ const currentProvider = registry?.storage_provider || "r2";
68
+ if (currentProvider === provider) {
69
+ return NextResponse.json({
70
+ success: true,
71
+ provider,
72
+ message: `Already using ${provider}`,
73
+ revalidated: false,
74
+ });
75
+ }
76
+
77
+ // ── Validate target provider is connected ──
78
+ if (provider === "r2") {
79
+ if (!registry?.r2_access_key_id || !registry?.r2_bucket_url || !registry?.r2_endpoint || !registry?.r2_bucket_name) {
80
+ return jsonError(
81
+ "Cannot switch to R2: not connected. Please connect R2 first via the Storage page.",
82
+ 400
83
+ );
84
+ }
85
+ }
86
+
87
+ // ── Update active provider in Sanity ──
88
+ await writeClient
89
+ .patch("assetRegistry")
90
+ .set({ storage_provider: provider })
91
+ .commit();
92
+
93
+ // ── Invalidate server-side provider cache ──
94
+ invalidateProviderConfigCache();
95
+
96
+ // ── Revalidate the entire site so pages pick up new asset URLs ──
97
+ revalidatePath("/", "layout");
98
+
99
+ auditLog("storage.switch", {
100
+ from: currentProvider,
101
+ to: provider,
102
+ });
103
+
104
+ return NextResponse.json({
105
+ success: true,
106
+ provider,
107
+ previousProvider: currentProvider,
108
+ revalidated: true,
109
+ });
110
+ } catch (err) {
111
+ logger.error("[Admin:Storage]", "Failed to switch storage provider:", err);
112
+ return NextResponse.json(
113
+ { error: "Failed to switch storage provider" },
114
+ { status: 500 }
115
+ );
116
+ }
117
+ }
@@ -0,0 +1,97 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { isAdminAuthenticated } from "../../../../../lib/auth";
3
+ import { writeClient } from "../../../../../lib/sanity/writeClient";
4
+ import { validateFontMagicBytes } from "../../../../../lib/security";
5
+ import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
6
+ import { logger } from "../../../../../lib/logger";
7
+
8
+ /**
9
+ * POST /api/admin/styles/fonts — Upload a font file to Sanity CDN
10
+ *
11
+ * Accepts multipart/form-data with a single font file (.woff2, .woff, .ttf, .otf).
12
+ * Validates both file extension AND magic bytes to prevent malicious uploads.
13
+ * Returns the Sanity CDN URL and asset ID for storing in the siteStyles document.
14
+ */
15
+
16
+ const ALLOWED_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf"];
17
+ const MIME_MAP: Record<string, string> = {
18
+ ".woff2": "font/woff2",
19
+ ".woff": "font/woff",
20
+ ".ttf": "font/ttf",
21
+ ".otf": "font/otf",
22
+ };
23
+ const MAX_FONT_SIZE = 5 * 1024 * 1024; // 5MB
24
+
25
+ export async function POST(request: NextRequest) {
26
+ const authenticated = await isAdminAuthenticated();
27
+ if (!authenticated) {
28
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
29
+ }
30
+ if (!validateCsrf(request)) {
31
+ return csrfErrorResponse();
32
+ }
33
+
34
+ try {
35
+ const formData = await request.formData();
36
+ const file = formData.get("font") as File | null;
37
+
38
+ if (!file) {
39
+ return NextResponse.json(
40
+ { error: "No font file provided" },
41
+ { status: 400 }
42
+ );
43
+ }
44
+
45
+ // Validate extension
46
+ const filename = file.name.toLowerCase();
47
+ const ext = filename.substring(filename.lastIndexOf("."));
48
+ if (!ALLOWED_EXTENSIONS.includes(ext)) {
49
+ return NextResponse.json(
50
+ { error: "Invalid file type. Allowed: .woff2, .woff, .ttf, .otf" },
51
+ { status: 400 }
52
+ );
53
+ }
54
+
55
+ // Validate file size (max 5MB)
56
+ if (file.size > MAX_FONT_SIZE) {
57
+ return NextResponse.json(
58
+ { error: "Font file too large. Maximum 5MB." },
59
+ { status: 400 }
60
+ );
61
+ }
62
+
63
+ // Read file content
64
+ const arrayBuffer = await file.arrayBuffer();
65
+
66
+ // Validate magic bytes — ensure file content matches the claimed extension
67
+ if (!validateFontMagicBytes(arrayBuffer, ext)) {
68
+ return NextResponse.json(
69
+ { error: "File content does not match the expected font format" },
70
+ { status: 400 }
71
+ );
72
+ }
73
+
74
+ // Convert to Buffer for Sanity upload
75
+ const buffer = Buffer.from(arrayBuffer);
76
+
77
+ // Upload to Sanity CDN with correct Content-Type
78
+ const asset = await writeClient.assets.upload("file", buffer, {
79
+ filename: file.name,
80
+ contentType: MIME_MAP[ext] || "application/octet-stream",
81
+ });
82
+
83
+ return NextResponse.json({
84
+ success: true,
85
+ file_url: asset.url,
86
+ file_id: asset._id,
87
+ original_filename: file.name,
88
+ size: file.size,
89
+ });
90
+ } catch (error) {
91
+ logger.error("[Admin:Styles]", "Failed to upload font:", error);
92
+ return NextResponse.json(
93
+ { error: "Failed to upload font file" },
94
+ { status: 500 }
95
+ );
96
+ }
97
+ }