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