@morphika/webframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -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 +210 -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,271 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { client } from "../../../../../lib/sanity/client";
|
|
3
|
+
import { writeClient } from "../../../../../lib/sanity/writeClient";
|
|
4
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
5
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
6
|
+
import { isBodyTooLarge, MAX_JSON_BODY_SIZE } from "../../../../../lib/security";
|
|
7
|
+
import { getStorageAdapter, getActiveProvider } from "../../../../../lib/storage";
|
|
8
|
+
import { logger } from "../../../../../lib/logger";
|
|
9
|
+
import type { ScannedFile } from "../../../../../lib/storage/types";
|
|
10
|
+
import type { RegisteredAsset } from "../../../../../lib/sanity/types";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* POST /api/admin/assets/scan — Scan asset storage and update registry
|
|
14
|
+
*
|
|
15
|
+
* Provider-aware: reads the active storage provider from Sanity and delegates
|
|
16
|
+
* to the appropriate adapter for file listing.
|
|
17
|
+
*
|
|
18
|
+
* Modes:
|
|
19
|
+
* 1. Active provider (default): Uses the storage adapter to list files
|
|
20
|
+
* 2. Manual manifest: Accepts a JSON array of files in the request body
|
|
21
|
+
*
|
|
22
|
+
* Body (optional):
|
|
23
|
+
* { files?: ScannedFile[] } — For manual manifest import
|
|
24
|
+
*/
|
|
25
|
+
export async function POST(request: NextRequest) {
|
|
26
|
+
if (!(await isAdminAuthenticated())) {
|
|
27
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
28
|
+
}
|
|
29
|
+
if (!validateCsrf(request)) {
|
|
30
|
+
return csrfErrorResponse();
|
|
31
|
+
}
|
|
32
|
+
if (isBodyTooLarge(request, MAX_JSON_BODY_SIZE)) {
|
|
33
|
+
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Ensure registry exists
|
|
38
|
+
await writeClient.createIfNotExists({
|
|
39
|
+
_id: "assetRegistry",
|
|
40
|
+
_type: "assetRegistry",
|
|
41
|
+
scan_status: "ready",
|
|
42
|
+
assets: [],
|
|
43
|
+
relink_log: [],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Mark as scanning
|
|
47
|
+
await writeClient
|
|
48
|
+
.patch("assetRegistry")
|
|
49
|
+
.set({ scan_status: "scanning", scan_error: "" })
|
|
50
|
+
.commit();
|
|
51
|
+
|
|
52
|
+
let scannedFiles: ScannedFile[];
|
|
53
|
+
|
|
54
|
+
// Check if manual manifest was provided
|
|
55
|
+
const body = await request.json().catch(() => ({}));
|
|
56
|
+
|
|
57
|
+
if (body.files && Array.isArray(body.files)) {
|
|
58
|
+
// Manual manifest mode
|
|
59
|
+
scannedFiles = body.files;
|
|
60
|
+
} else {
|
|
61
|
+
// Provider-aware scanning — delegates to the active storage adapter
|
|
62
|
+
const activeProvider = await getActiveProvider();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const adapter = await getStorageAdapter(activeProvider);
|
|
66
|
+
scannedFiles = await adapter.listFiles();
|
|
67
|
+
} catch (adapterErr) {
|
|
68
|
+
const errMsg = adapterErr instanceof Error ? adapterErr.message : "Storage provider error";
|
|
69
|
+
const contextualError = `[${activeProvider}] ${errMsg}`;
|
|
70
|
+
await writeClient
|
|
71
|
+
.patch("assetRegistry")
|
|
72
|
+
.set({
|
|
73
|
+
scan_status: "error",
|
|
74
|
+
scan_error: contextualError,
|
|
75
|
+
})
|
|
76
|
+
.commit();
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{ error: contextualError },
|
|
79
|
+
{ status: 400 }
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Separate _thumbs/ files from real assets ──
|
|
85
|
+
// Thumbnails are internal files, not user assets. We extract them
|
|
86
|
+
// into a Set for quick lookup, then filter them out of the main list.
|
|
87
|
+
const thumbPaths = new Set<string>();
|
|
88
|
+
const realFiles: ScannedFile[] = [];
|
|
89
|
+
|
|
90
|
+
for (const file of scannedFiles) {
|
|
91
|
+
if (file.path.startsWith("_thumbs/")) {
|
|
92
|
+
// Store the original path this thumbnail corresponds to
|
|
93
|
+
// _thumbs/projects/hero.jpg → projects/hero (without ext, thumb is always .jpg)
|
|
94
|
+
const originalRelPath = file.path.replace(/^_thumbs\//, "");
|
|
95
|
+
// Store with the .jpg extension stripped so we can match against any source ext
|
|
96
|
+
const withoutExt = originalRelPath.replace(/\.[^.]+$/, "");
|
|
97
|
+
thumbPaths.add(withoutExt);
|
|
98
|
+
} else {
|
|
99
|
+
realFiles.push(file);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Replace scannedFiles with filtered list (no _thumbs/ entries in registry)
|
|
104
|
+
scannedFiles = realFiles;
|
|
105
|
+
|
|
106
|
+
// Get existing assets from registry for merging
|
|
107
|
+
const existingRegistry = await client.fetch(
|
|
108
|
+
`*[_type == "assetRegistry"][0]{ assets }`
|
|
109
|
+
);
|
|
110
|
+
const existingAssets: RegisteredAsset[] = existingRegistry?.assets || [];
|
|
111
|
+
|
|
112
|
+
// Build maps for fast lookup
|
|
113
|
+
const existingByPath = new Map<string, RegisteredAsset>();
|
|
114
|
+
const existingByFilename = new Map<string, RegisteredAsset[]>();
|
|
115
|
+
for (const asset of existingAssets) {
|
|
116
|
+
existingByPath.set(asset.path, asset);
|
|
117
|
+
// Group by filename for auto-relink (moved files keep the same name)
|
|
118
|
+
const list = existingByFilename.get(asset.filename) || [];
|
|
119
|
+
list.push(asset);
|
|
120
|
+
existingByFilename.set(asset.filename, list);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
let newCount = 0;
|
|
125
|
+
let updatedCount = 0;
|
|
126
|
+
let relinkedCount = 0;
|
|
127
|
+
let thumbnailsFound = 0;
|
|
128
|
+
let thumbnailsMissing = 0;
|
|
129
|
+
const scannedPaths = new Set<string>();
|
|
130
|
+
|
|
131
|
+
// Raster image extensions that support thumbnails
|
|
132
|
+
const rasterExts = new Set(["jpg", "jpeg", "png", "webp", "gif"]);
|
|
133
|
+
|
|
134
|
+
/** Check if a file has a corresponding thumbnail in _thumbs/ */
|
|
135
|
+
function hasThumbnail(filePath: string, ext: string): boolean {
|
|
136
|
+
if (!rasterExts.has(ext.toLowerCase())) return false;
|
|
137
|
+
const withoutExt = filePath.replace(/\.[^.]+$/, "");
|
|
138
|
+
return thumbPaths.has(withoutExt);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Build merged list: only assets found in scan exist in the result ──
|
|
142
|
+
const mergedAssets: RegisteredAsset[] = scannedFiles.map((file) => {
|
|
143
|
+
scannedPaths.add(file.path);
|
|
144
|
+
const existing = existingByPath.get(file.path);
|
|
145
|
+
const hasThumb = hasThumbnail(file.path, file.extension);
|
|
146
|
+
|
|
147
|
+
// Track thumbnail stats for raster images
|
|
148
|
+
if (rasterExts.has(file.extension.toLowerCase())) {
|
|
149
|
+
if (hasThumb) thumbnailsFound++;
|
|
150
|
+
else thumbnailsMissing++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (existing) {
|
|
154
|
+
// Same path — update metadata
|
|
155
|
+
updatedCount++;
|
|
156
|
+
return {
|
|
157
|
+
...existing,
|
|
158
|
+
_key: existing._key,
|
|
159
|
+
filename: file.filename,
|
|
160
|
+
extension: file.extension,
|
|
161
|
+
file_size: file.file_size,
|
|
162
|
+
mime_type: file.mime_type,
|
|
163
|
+
file_hash: file.file_hash || existing.file_hash,
|
|
164
|
+
has_thumbnail: hasThumb,
|
|
165
|
+
status: "active" as const,
|
|
166
|
+
last_checked_at: now,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Auto-relink: file is new at this path, but maybe it moved from another path ──
|
|
171
|
+
const candidates = existingByFilename.get(file.filename) || [];
|
|
172
|
+
const movedFrom = candidates.find((c) => c.path !== file.path && !scannedPaths.has(c.path));
|
|
173
|
+
|
|
174
|
+
if (movedFrom) {
|
|
175
|
+
// Relink: preserve _key, used_in, and track old path
|
|
176
|
+
relinkedCount++;
|
|
177
|
+
const previousPaths = [...(movedFrom.previous_paths || [])];
|
|
178
|
+
if (!previousPaths.includes(movedFrom.path)) {
|
|
179
|
+
previousPaths.push(movedFrom.path);
|
|
180
|
+
}
|
|
181
|
+
// Remove old entry from the path map so it won't match again
|
|
182
|
+
existingByPath.delete(movedFrom.path);
|
|
183
|
+
return {
|
|
184
|
+
...movedFrom,
|
|
185
|
+
_key: movedFrom._key,
|
|
186
|
+
path: file.path,
|
|
187
|
+
filename: file.filename,
|
|
188
|
+
extension: file.extension,
|
|
189
|
+
file_size: file.file_size,
|
|
190
|
+
mime_type: file.mime_type,
|
|
191
|
+
file_hash: file.file_hash || movedFrom.file_hash,
|
|
192
|
+
has_thumbnail: hasThumb,
|
|
193
|
+
status: "active" as const,
|
|
194
|
+
last_checked_at: now,
|
|
195
|
+
previous_paths: previousPaths,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Truly new file
|
|
200
|
+
newCount++;
|
|
201
|
+
return {
|
|
202
|
+
_key: crypto.randomUUID().replace(/-/g, "").slice(0, 12),
|
|
203
|
+
path: file.path,
|
|
204
|
+
filename: file.filename,
|
|
205
|
+
extension: file.extension,
|
|
206
|
+
file_size: file.file_size,
|
|
207
|
+
mime_type: file.mime_type,
|
|
208
|
+
file_hash: file.file_hash,
|
|
209
|
+
has_thumbnail: hasThumb,
|
|
210
|
+
status: "new" as const,
|
|
211
|
+
last_checked_at: now,
|
|
212
|
+
used_in: [],
|
|
213
|
+
previous_paths: [],
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── Track missing assets (kept in registry for relink, hidden in browser) ──
|
|
218
|
+
let missingCount = 0;
|
|
219
|
+
for (const existing of existingAssets) {
|
|
220
|
+
if (!scannedPaths.has(existing.path) && existingByPath.has(existing.path)) {
|
|
221
|
+
// Not found in scan AND not already relinked to a new path
|
|
222
|
+
missingCount++;
|
|
223
|
+
mergedAssets.push({
|
|
224
|
+
...existing,
|
|
225
|
+
status: "missing",
|
|
226
|
+
last_checked_at: now,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Update the registry
|
|
232
|
+
await writeClient
|
|
233
|
+
.patch("assetRegistry")
|
|
234
|
+
.set({
|
|
235
|
+
assets: mergedAssets,
|
|
236
|
+
scan_status: "ready",
|
|
237
|
+
scan_error: "",
|
|
238
|
+
last_scanned_at: now,
|
|
239
|
+
})
|
|
240
|
+
.commit();
|
|
241
|
+
|
|
242
|
+
return NextResponse.json({
|
|
243
|
+
success: true,
|
|
244
|
+
scanned_count: scannedFiles.length,
|
|
245
|
+
new_assets: newCount,
|
|
246
|
+
updated_assets: updatedCount,
|
|
247
|
+
relinked_assets: relinkedCount,
|
|
248
|
+
missing_assets: missingCount,
|
|
249
|
+
total_assets: mergedAssets.filter((a) => a.status !== "missing").length,
|
|
250
|
+
thumbnails_found: thumbnailsFound,
|
|
251
|
+
thumbnails_missing: thumbnailsMissing,
|
|
252
|
+
});
|
|
253
|
+
} catch (err) {
|
|
254
|
+
logger.error("[Admin:Scan]", "Scan failed", err);
|
|
255
|
+
|
|
256
|
+
// Record error
|
|
257
|
+
await writeClient
|
|
258
|
+
.patch("assetRegistry")
|
|
259
|
+
.set({
|
|
260
|
+
scan_status: "error",
|
|
261
|
+
scan_error: err instanceof Error ? err.message : "Scan failed",
|
|
262
|
+
})
|
|
263
|
+
.commit()
|
|
264
|
+
.catch(() => {}); // Don't throw if patch fails
|
|
265
|
+
|
|
266
|
+
return NextResponse.json(
|
|
267
|
+
{ error: "Scan failed" },
|
|
268
|
+
{ status: 500 }
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { generateAdminToken } from "../../../../lib/auth-token";
|
|
3
|
+
import { generateCsrfToken, setCsrfCookie } from "../../../../lib/csrf";
|
|
4
|
+
import { auditLog } from "../../../../lib/audit";
|
|
5
|
+
|
|
6
|
+
// ─── In-memory rate limiter ───────────────────────────────────────────────
|
|
7
|
+
// Tracks failed login attempts per IP. Resets after the window expires.
|
|
8
|
+
// Note: For multi-instance deployments (e.g. Vercel), consider upgrading
|
|
9
|
+
// to @upstash/ratelimit for distributed rate limiting.
|
|
10
|
+
const RATE_LIMIT_MAX_ATTEMPTS = 5;
|
|
11
|
+
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
|
12
|
+
|
|
13
|
+
interface RateLimitEntry {
|
|
14
|
+
attempts: number;
|
|
15
|
+
firstAttempt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const rateLimitMap = new Map<string, RateLimitEntry>();
|
|
19
|
+
|
|
20
|
+
// Periodic cleanup to prevent memory leaks (every 5 minutes)
|
|
21
|
+
let lastCleanup = Date.now();
|
|
22
|
+
function cleanupRateLimitMap() {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (now - lastCleanup < 5 * 60 * 1000) return;
|
|
25
|
+
lastCleanup = now;
|
|
26
|
+
for (const [ip, entry] of rateLimitMap) {
|
|
27
|
+
if (now - entry.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
28
|
+
rateLimitMap.delete(ip);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getClientIp(request: NextRequest): string {
|
|
34
|
+
return (
|
|
35
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
36
|
+
request.headers.get("x-real-ip") ||
|
|
37
|
+
"unknown"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRateLimited(ip: string): { limited: boolean; retryAfterSeconds: number } {
|
|
42
|
+
cleanupRateLimitMap();
|
|
43
|
+
const entry = rateLimitMap.get(ip);
|
|
44
|
+
if (!entry) return { limited: false, retryAfterSeconds: 0 };
|
|
45
|
+
|
|
46
|
+
// Reset window if expired
|
|
47
|
+
if (Date.now() - entry.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
48
|
+
rateLimitMap.delete(ip);
|
|
49
|
+
return { limited: false, retryAfterSeconds: 0 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (entry.attempts >= RATE_LIMIT_MAX_ATTEMPTS) {
|
|
53
|
+
const retryAfter = Math.ceil(
|
|
54
|
+
(RATE_LIMIT_WINDOW_MS - (Date.now() - entry.firstAttempt)) / 1000
|
|
55
|
+
);
|
|
56
|
+
return { limited: true, retryAfterSeconds: Math.max(retryAfter, 1) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { limited: false, retryAfterSeconds: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function recordFailedAttempt(ip: string): void {
|
|
63
|
+
const entry = rateLimitMap.get(ip);
|
|
64
|
+
if (!entry || Date.now() - entry.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
65
|
+
rateLimitMap.set(ip, { attempts: 1, firstAttempt: Date.now() });
|
|
66
|
+
} else {
|
|
67
|
+
entry.attempts++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearFailedAttempts(ip: string): void {
|
|
72
|
+
rateLimitMap.delete(ip);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Route handlers ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export async function POST(request: NextRequest) {
|
|
78
|
+
const ip = getClientIp(request);
|
|
79
|
+
|
|
80
|
+
// Check rate limit before processing
|
|
81
|
+
const { limited, retryAfterSeconds } = isRateLimited(ip);
|
|
82
|
+
if (limited) {
|
|
83
|
+
return NextResponse.json(
|
|
84
|
+
{ error: "Too many login attempts. Please try again later." },
|
|
85
|
+
{
|
|
86
|
+
status: 429,
|
|
87
|
+
headers: { "Retry-After": retryAfterSeconds.toString() },
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const { password } = await request.json();
|
|
94
|
+
|
|
95
|
+
if (!process.env.ADMIN_PASSWORD) {
|
|
96
|
+
return NextResponse.json(
|
|
97
|
+
{ error: "Server configuration error" },
|
|
98
|
+
{ status: 500 }
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate password input
|
|
103
|
+
if (typeof password !== "string" || password.length < 12) {
|
|
104
|
+
recordFailedAttempt(ip);
|
|
105
|
+
auditLog("auth.failed", { ip, reason: "password_too_short" });
|
|
106
|
+
return NextResponse.json(
|
|
107
|
+
{ error: "Invalid password" },
|
|
108
|
+
{ status: 401 }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (password !== process.env.ADMIN_PASSWORD) {
|
|
113
|
+
recordFailedAttempt(ip);
|
|
114
|
+
auditLog("auth.failed", { ip });
|
|
115
|
+
return NextResponse.json(
|
|
116
|
+
{ error: "Invalid password" },
|
|
117
|
+
{ status: 401 }
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Success — clear rate limit history for this IP
|
|
122
|
+
clearFailedAttempts(ip);
|
|
123
|
+
auditLog("auth.login", { ip });
|
|
124
|
+
|
|
125
|
+
const token = await generateAdminToken(password);
|
|
126
|
+
const response = NextResponse.json({ success: true });
|
|
127
|
+
|
|
128
|
+
response.cookies.set("admin_token", token, {
|
|
129
|
+
httpOnly: true,
|
|
130
|
+
secure: process.env.NODE_ENV === "production",
|
|
131
|
+
sameSite: "strict",
|
|
132
|
+
path: "/",
|
|
133
|
+
maxAge: 60 * 60 * 24, // 24 hours (matches token expiration)
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Set CSRF token cookie for double-submit pattern
|
|
137
|
+
setCsrfCookie(response, generateCsrfToken());
|
|
138
|
+
|
|
139
|
+
return response;
|
|
140
|
+
} catch {
|
|
141
|
+
return NextResponse.json(
|
|
142
|
+
{ error: "Invalid request" },
|
|
143
|
+
{ status: 400 }
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function DELETE() {
|
|
149
|
+
const response = NextResponse.json({ success: true });
|
|
150
|
+
|
|
151
|
+
response.cookies.set("admin_token", "", {
|
|
152
|
+
httpOnly: true,
|
|
153
|
+
secure: process.env.NODE_ENV === "production",
|
|
154
|
+
sameSite: "lax",
|
|
155
|
+
path: "/",
|
|
156
|
+
maxAge: 0,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return response;
|
|
160
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
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 { customSectionBySlugQuery, pagesUsingCustomSectionQuery } from "../../../../../lib/sanity/queries";
|
|
6
|
+
import { isAdminAuthenticated } from "../../../../../lib/auth";
|
|
7
|
+
import { validateCsrf, csrfErrorResponse } from "../../../../../lib/csrf";
|
|
8
|
+
import { isBodyTooLarge, MAX_PAGE_BODY_SIZE } from "../../../../../lib/security";
|
|
9
|
+
import { auditLog } from "../../../../../lib/audit";
|
|
10
|
+
import { logger } from "../../../../../lib/logger";
|
|
11
|
+
|
|
12
|
+
type RouteContext = { params: Promise<{ slug: string }> };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GET /api/admin/custom-sections/[slug] — Get full custom section data
|
|
16
|
+
*/
|
|
17
|
+
export async function GET(_request: NextRequest, context: RouteContext) {
|
|
18
|
+
if (!(await isAdminAuthenticated())) {
|
|
19
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const { slug } = await context.params;
|
|
24
|
+
const section = await client.fetch(customSectionBySlugQuery, { slug });
|
|
25
|
+
if (!section) {
|
|
26
|
+
return NextResponse.json({ error: "Custom section not found" }, { status: 404 });
|
|
27
|
+
}
|
|
28
|
+
return NextResponse.json({ section });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
logger.error("[Admin:CustomSections]", "Failed to fetch custom section", err);
|
|
31
|
+
return NextResponse.json(
|
|
32
|
+
{ error: "Failed to fetch custom section" },
|
|
33
|
+
{ status: 500 }
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* PATCH /api/admin/custom-sections/[slug] — Update custom section
|
|
40
|
+
* Body: { title?, description?, section?, thumbnail_path? }
|
|
41
|
+
*/
|
|
42
|
+
export async function PATCH(request: NextRequest, context: RouteContext) {
|
|
43
|
+
if (!(await isAdminAuthenticated())) {
|
|
44
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
45
|
+
}
|
|
46
|
+
if (!validateCsrf(request)) {
|
|
47
|
+
return csrfErrorResponse();
|
|
48
|
+
}
|
|
49
|
+
if (isBodyTooLarge(request, MAX_PAGE_BODY_SIZE)) {
|
|
50
|
+
return NextResponse.json({ error: "Request body too large" }, { status: 413 });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const { slug } = await context.params;
|
|
55
|
+
|
|
56
|
+
// Find the document
|
|
57
|
+
const existing = await client.fetch(
|
|
58
|
+
`*[_type == "customSection" && slug.current == $slug][0] { _id }`,
|
|
59
|
+
{ slug }
|
|
60
|
+
);
|
|
61
|
+
if (!existing) {
|
|
62
|
+
return NextResponse.json({ error: "Custom section not found" }, { status: 404 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const body = await request.json();
|
|
66
|
+
const { title, description, section, thumbnail_path } = body;
|
|
67
|
+
|
|
68
|
+
// Build patch
|
|
69
|
+
const patch: Record<string, unknown> = {
|
|
70
|
+
updated_at: new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
if (title !== undefined) patch.title = title;
|
|
73
|
+
if (description !== undefined) patch.description = description;
|
|
74
|
+
if (section !== undefined) patch.section = section;
|
|
75
|
+
if (thumbnail_path !== undefined) patch.thumbnail_path = thumbnail_path;
|
|
76
|
+
|
|
77
|
+
await writeClient.patch(existing._id).set(patch).commit();
|
|
78
|
+
|
|
79
|
+
auditLog("customSection.update", { slug, fields: Object.keys(patch) });
|
|
80
|
+
|
|
81
|
+
// Revalidate pages using this custom section
|
|
82
|
+
try {
|
|
83
|
+
// Purge the CDN-cached public endpoint so instances render fresh data (M3 fix)
|
|
84
|
+
revalidatePath(`/api/custom-sections/${existing._id}`);
|
|
85
|
+
|
|
86
|
+
const referencingPages = await client.fetch(pagesUsingCustomSectionQuery, { id: existing._id });
|
|
87
|
+
if (Array.isArray(referencingPages)) {
|
|
88
|
+
for (const page of referencingPages) {
|
|
89
|
+
const pageSlug = page.slug?.current;
|
|
90
|
+
if (pageSlug) {
|
|
91
|
+
revalidatePath(`/${pageSlug}`);
|
|
92
|
+
revalidatePath(`/work/${pageSlug}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch (revalErr) {
|
|
97
|
+
logger.error("[Admin:CustomSections]", "Revalidation failed (non-fatal)", revalErr);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return NextResponse.json({ success: true });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.error("[Admin:CustomSections]", "Failed to update custom section", err);
|
|
103
|
+
return NextResponse.json(
|
|
104
|
+
{ error: "Failed to update custom section" },
|
|
105
|
+
{ status: 500 }
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* DELETE /api/admin/custom-sections/[slug] — Delete custom section
|
|
112
|
+
* Safety checks: cannot delete footer, cannot delete if referenced by pages
|
|
113
|
+
*/
|
|
114
|
+
export async function DELETE(request: NextRequest, context: RouteContext) {
|
|
115
|
+
if (!(await isAdminAuthenticated())) {
|
|
116
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
117
|
+
}
|
|
118
|
+
if (!validateCsrf(request)) {
|
|
119
|
+
return csrfErrorResponse();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const { slug } = await context.params;
|
|
124
|
+
|
|
125
|
+
// Find the document
|
|
126
|
+
const existing = await client.fetch(
|
|
127
|
+
`*[_type == "customSection" && slug.current == $slug][0] { _id, title }`,
|
|
128
|
+
{ slug }
|
|
129
|
+
);
|
|
130
|
+
if (!existing) {
|
|
131
|
+
return NextResponse.json({ error: "Custom section not found" }, { status: 404 });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check for referencing pages
|
|
135
|
+
const referencingPages = await client.fetch(pagesUsingCustomSectionQuery, { id: existing._id });
|
|
136
|
+
if (Array.isArray(referencingPages) && referencingPages.length > 0) {
|
|
137
|
+
const pageSlugs = referencingPages.map((p: { slug?: { current?: string } }) => p.slug?.current).filter(Boolean);
|
|
138
|
+
return NextResponse.json(
|
|
139
|
+
{
|
|
140
|
+
error: `Section is used by ${referencingPages.length} page(s). Remove or embed instances first.`,
|
|
141
|
+
pages: pageSlugs,
|
|
142
|
+
},
|
|
143
|
+
{ status: 409 }
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await writeClient.delete(existing._id);
|
|
148
|
+
|
|
149
|
+
auditLog("customSection.delete", { slug, title: existing.title });
|
|
150
|
+
|
|
151
|
+
return NextResponse.json({ success: true });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
logger.error("[Admin:CustomSections]", "Failed to delete custom section", err);
|
|
154
|
+
return NextResponse.json(
|
|
155
|
+
{ error: "Failed to delete custom section" },
|
|
156
|
+
{ status: 500 }
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|