@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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +212 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- 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
|
+
}
|