@morphika/andami 0.1.7 → 0.1.9
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/README.md +3 -0
- package/app/(site)/[slug]/page.tsx +3 -2
- package/app/(site)/page.tsx +3 -2
- package/app/(site)/work/[slug]/page.tsx +3 -3
- package/app/robots.ts +38 -1
- package/components/builder/SettingsPanel.tsx +29 -543
- package/components/builder/live-preview/GhostCard.tsx +84 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +294 -1010
- package/components/builder/live-preview/ProjectCardWrapper.tsx +291 -0
- package/components/builder/live-preview/drag-utils.tsx +89 -0
- package/components/builder/live-preview/useDragReorder.ts +370 -0
- package/components/builder/settings-panel/AnimationTab.tsx +152 -0
- package/components/builder/settings-panel/CardEntranceSection.tsx +114 -0
- package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +32 -0
- package/components/builder/settings-panel/CustomSectionSettings.tsx +150 -0
- package/components/builder/settings-panel/index.ts +6 -0
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +184 -0
- package/lib/bot-guard.ts +138 -0
- package/lib/builder/serializer/migrations.ts +107 -0
- package/lib/builder/serializer/normalizers.ts +278 -0
- package/lib/builder/serializer/serializers.ts +393 -0
- package/lib/builder/serializer/shared.ts +102 -0
- package/lib/builder/serializer.ts +11 -846
- package/package.json +10 -9
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CustomSectionSettings — Edit & Detach actions for custom section instances.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from SettingsPanel.tsx in Session C (refactor split).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useEffect, useCallback } from "react";
|
|
10
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
11
|
+
import { BUILDER_VIOLET } from "../../../lib/builder/constants";
|
|
12
|
+
import type { CustomSectionInstance, PageSectionV2 } from "../../../lib/sanity/types";
|
|
13
|
+
|
|
14
|
+
export function CustomSectionSettings({ instance }: { instance: CustomSectionInstance }) {
|
|
15
|
+
const store = useBuilderStore();
|
|
16
|
+
const [showDetachConfirm, setShowDetachConfirm] = useState(false);
|
|
17
|
+
const [sectionData, setSectionData] = useState<PageSectionV2 | null>(null);
|
|
18
|
+
const [loadingEdit, setLoadingEdit] = useState(false);
|
|
19
|
+
|
|
20
|
+
// Fetch section data for detach
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
fetch(`/api/custom-sections/${instance.custom_section_id}`)
|
|
24
|
+
.then((res) => res.ok ? res.json() : null)
|
|
25
|
+
.then((data) => {
|
|
26
|
+
if (!cancelled && data?.section) setSectionData(data.section);
|
|
27
|
+
})
|
|
28
|
+
.catch(() => {});
|
|
29
|
+
return () => { cancelled = true; };
|
|
30
|
+
}, [instance.custom_section_id]);
|
|
31
|
+
|
|
32
|
+
const handleEdit = useCallback(async () => {
|
|
33
|
+
setLoadingEdit(true);
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`/api/admin/custom-sections/${instance.custom_section_slug}`);
|
|
36
|
+
if (!res.ok) throw new Error("Failed to load section");
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
|
|
39
|
+
const remoteTitle = data.section.title;
|
|
40
|
+
if (remoteTitle && remoteTitle !== instance.custom_section_title) {
|
|
41
|
+
store.updateCustomSectionInstanceTitle(instance._key, remoteTitle);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
store.enterSectionEditor(
|
|
45
|
+
instance.custom_section_slug,
|
|
46
|
+
remoteTitle,
|
|
47
|
+
data.section.section
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
if (sectionData) {
|
|
51
|
+
store.enterSectionEditor(
|
|
52
|
+
instance.custom_section_slug,
|
|
53
|
+
instance.custom_section_title,
|
|
54
|
+
sectionData
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
setLoadingEdit(false);
|
|
59
|
+
}
|
|
60
|
+
}, [instance, sectionData, store]);
|
|
61
|
+
|
|
62
|
+
const handleDetach = useCallback(() => {
|
|
63
|
+
if (!sectionData) return;
|
|
64
|
+
store.detachCustomSectionInstance(instance._key, sectionData);
|
|
65
|
+
setShowDetachConfirm(false);
|
|
66
|
+
}, [instance._key, sectionData, store]);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="px-4 py-3 space-y-3">
|
|
70
|
+
{/* Linked badge */}
|
|
71
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[#f3f0ff] border border-[#8b5cf6]/20">
|
|
72
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" strokeWidth="2" strokeLinecap="round" className="shrink-0">
|
|
73
|
+
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
74
|
+
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
75
|
+
</svg>
|
|
76
|
+
<span className="text-[11px] text-[#8b5cf6] font-medium truncate">
|
|
77
|
+
Linked Section
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Edit button */}
|
|
82
|
+
<button
|
|
83
|
+
onClick={handleEdit}
|
|
84
|
+
disabled={loadingEdit}
|
|
85
|
+
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium text-white transition-colors disabled:opacity-50"
|
|
86
|
+
style={{ backgroundColor: BUILDER_VIOLET }}
|
|
87
|
+
>
|
|
88
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
89
|
+
<path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
|
90
|
+
<path d="m15 5 4 4" />
|
|
91
|
+
</svg>
|
|
92
|
+
{loadingEdit ? "Loading..." : "Edit Section"}
|
|
93
|
+
</button>
|
|
94
|
+
<p className="text-[10px] text-neutral-400 -mt-1">
|
|
95
|
+
Changes apply to all pages using this section.
|
|
96
|
+
</p>
|
|
97
|
+
|
|
98
|
+
{/* Detach button */}
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => setShowDetachConfirm(true)}
|
|
101
|
+
disabled={!sectionData}
|
|
102
|
+
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg text-xs font-medium text-neutral-600 bg-[#f5f5f5] hover:bg-[#ebebeb] transition-colors disabled:opacity-30"
|
|
103
|
+
>
|
|
104
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
105
|
+
<path d="M18.84 12.25l1.72-1.71a5 5 0 0 0-7.07-7.07l-3 3a5 5 0 0 0 .54 7.54" />
|
|
106
|
+
<path d="M5.16 11.75l-1.72 1.71a5 5 0 0 0 7.07 7.07l3-3a5 5 0 0 0-.54-7.54" />
|
|
107
|
+
<line x1="2" y1="2" x2="22" y2="22" />
|
|
108
|
+
</svg>
|
|
109
|
+
Detach
|
|
110
|
+
</button>
|
|
111
|
+
<p className="text-[10px] text-neutral-400 -mt-1">
|
|
112
|
+
Convert to an independent inline section on this page.
|
|
113
|
+
</p>
|
|
114
|
+
|
|
115
|
+
{/* Detach confirmation dialog */}
|
|
116
|
+
{showDetachConfirm && (
|
|
117
|
+
<div
|
|
118
|
+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60"
|
|
119
|
+
onClick={(e) => { e.stopPropagation(); setShowDetachConfirm(false); }}
|
|
120
|
+
>
|
|
121
|
+
<div
|
|
122
|
+
className="bg-white rounded-lg border border-[#e5e5e5] p-6 max-w-sm shadow-xl"
|
|
123
|
+
onClick={(e) => e.stopPropagation()}
|
|
124
|
+
>
|
|
125
|
+
<h3 className="text-neutral-900 text-sm font-medium mb-2">Detach section?</h3>
|
|
126
|
+
<p className="text-neutral-500 text-xs mb-4">
|
|
127
|
+
This will create an independent copy of “{instance.custom_section_title}”.
|
|
128
|
+
Future changes to the saved section won't affect this page.
|
|
129
|
+
</p>
|
|
130
|
+
<div className="flex justify-end gap-2">
|
|
131
|
+
<button
|
|
132
|
+
onClick={() => setShowDetachConfirm(false)}
|
|
133
|
+
className="px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-900 rounded border border-[#e5e5e5] hover:border-[#ccc] transition-colors"
|
|
134
|
+
>
|
|
135
|
+
Cancel
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
onClick={handleDetach}
|
|
139
|
+
className="px-3 py-1.5 text-sm text-white rounded font-medium transition-colors"
|
|
140
|
+
style={{ backgroundColor: BUILDER_VIOLET }}
|
|
141
|
+
>
|
|
142
|
+
Detach
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -17,3 +17,9 @@ export { SectionV2AnimationTab } from "./SectionV2AnimationTab";
|
|
|
17
17
|
export { default as ColumnV2Settings } from "./ColumnV2Settings";
|
|
18
18
|
export { default as ParallaxSlideSettings } from "./ParallaxSlideSettings";
|
|
19
19
|
export { default as ParallaxGroupSettings } from "./ParallaxGroupSettings";
|
|
20
|
+
export { useSettingsPanelSelection } from "./useSettingsPanelSelection";
|
|
21
|
+
export type { SelectedBlockInfo, SelectedParallaxSlideInfo } from "./useSettingsPanelSelection";
|
|
22
|
+
export { AnimationTab, getBlockHoverEffect } from "./AnimationTab";
|
|
23
|
+
export { ColumnV2AnimationTab } from "./ColumnV2AnimationTab";
|
|
24
|
+
export { CardEntranceSection, ENTRANCE_PRESETS, CARD_ENTRANCE_SELECT_CLASS, CARD_ENTRANCE_SLIDER_CLASS } from "./CardEntranceSection";
|
|
25
|
+
export { CustomSectionSettings } from "./CustomSectionSettings";
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSettingsPanelSelection — Custom hook that resolves the current builder
|
|
3
|
+
* selection into strongly-typed values for SettingsPanel routing.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from SettingsPanel.tsx in Session C (refactor split).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
9
|
+
import { ALL_BLOCK_INFO } from "../../../lib/builder/types";
|
|
10
|
+
import { BLOCK_GRADIENTS, BLOCK_ICON_COMPONENTS } from "../blockStyles";
|
|
11
|
+
import type {
|
|
12
|
+
ContentBlock,
|
|
13
|
+
ContentItem,
|
|
14
|
+
PageSection,
|
|
15
|
+
PageSectionV2,
|
|
16
|
+
CustomSectionInstance,
|
|
17
|
+
ParallaxGroup,
|
|
18
|
+
ParallaxSlideV2,
|
|
19
|
+
SectionColumn,
|
|
20
|
+
} from "../../../lib/sanity/types";
|
|
21
|
+
import {
|
|
22
|
+
isPageSection,
|
|
23
|
+
isPageSectionV2,
|
|
24
|
+
isCustomSectionInstance,
|
|
25
|
+
isParallaxGroup,
|
|
26
|
+
} from "../../../lib/sanity/types";
|
|
27
|
+
|
|
28
|
+
export interface SelectedBlockInfo {
|
|
29
|
+
block: ContentBlock;
|
|
30
|
+
rowKey: string;
|
|
31
|
+
colKey: string;
|
|
32
|
+
isSection: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SelectedParallaxSlideInfo {
|
|
36
|
+
group: ParallaxGroup;
|
|
37
|
+
slide: ParallaxSlideV2;
|
|
38
|
+
virtualSection: PageSectionV2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useSettingsPanelSelection() {
|
|
42
|
+
const store = useBuilderStore();
|
|
43
|
+
|
|
44
|
+
// Find selected elements — handle page sections, V2 sections, and parallax groups/slides
|
|
45
|
+
const selectedItem: ContentItem | undefined = store.rows.find((r) => r._key === store.selectedRowKey);
|
|
46
|
+
const selectedSection: PageSection | null = selectedItem && isPageSection(selectedItem) ? selectedItem : null;
|
|
47
|
+
const selectedSectionV2: PageSectionV2 | null = selectedItem && isPageSectionV2(selectedItem) ? selectedItem : null;
|
|
48
|
+
const selectedCustomSectionInstance: CustomSectionInstance | null = selectedItem && isCustomSectionInstance(selectedItem) ? selectedItem as CustomSectionInstance : null;
|
|
49
|
+
|
|
50
|
+
// Parallax detection: group selected directly, or slide selected (search inside groups)
|
|
51
|
+
const selectedParallaxGroup: ParallaxGroup | null = selectedItem && isParallaxGroup(selectedItem) ? selectedItem as ParallaxGroup : null;
|
|
52
|
+
const selectedParallaxSlide: SelectedParallaxSlideInfo | null = (() => {
|
|
53
|
+
if (!store.selectedRowKey) return null;
|
|
54
|
+
for (const item of store.rows) {
|
|
55
|
+
if (!isParallaxGroup(item)) continue;
|
|
56
|
+
const group = item as ParallaxGroup;
|
|
57
|
+
const slide = group.slides.find((s) => s._key === store.selectedRowKey);
|
|
58
|
+
if (slide) {
|
|
59
|
+
// Create a virtual PageSectionV2 for the slide so we can delegate to SectionV2Settings etc.
|
|
60
|
+
const virtualSection: PageSectionV2 = {
|
|
61
|
+
_type: "pageSectionV2",
|
|
62
|
+
_key: slide._key,
|
|
63
|
+
section_type: "empty-v2",
|
|
64
|
+
columns: slide.columns,
|
|
65
|
+
settings: slide.section_settings,
|
|
66
|
+
};
|
|
67
|
+
return { group, slide, virtualSection };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
// V2 column: when a V2 section (or parallax slide) is selected and a column key is set
|
|
74
|
+
const effectiveSectionV2 = selectedSectionV2 || selectedParallaxSlide?.virtualSection || null;
|
|
75
|
+
const selectedColumnV2: SectionColumn | null = effectiveSectionV2 && store.selectedColumnKey
|
|
76
|
+
? effectiveSectionV2.columns.find((c) => c._key === store.selectedColumnKey) || null
|
|
77
|
+
: null;
|
|
78
|
+
|
|
79
|
+
// For PageSections, the "block" is section.block[0] — selected automatically
|
|
80
|
+
const selectedBlock: SelectedBlockInfo | null = (() => {
|
|
81
|
+
// If a PageSection is selected, its block is the section block
|
|
82
|
+
if (selectedSection) {
|
|
83
|
+
const block = selectedSection.block[0];
|
|
84
|
+
if (block) return { block, rowKey: selectedSection._key, colKey: "", isSection: true };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
// Regular block search inside rows, V2 sections, and parallax slides
|
|
88
|
+
if (!store.selectedBlockKey) return null;
|
|
89
|
+
for (const item of store.rows) {
|
|
90
|
+
// V2 sections: search inside columns
|
|
91
|
+
if (isPageSectionV2(item)) {
|
|
92
|
+
for (const col of (item as PageSectionV2).columns || []) {
|
|
93
|
+
const block = (col.blocks || []).find(
|
|
94
|
+
(b) => b._key === store.selectedBlockKey
|
|
95
|
+
);
|
|
96
|
+
if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Parallax groups: search inside slide columns
|
|
100
|
+
if (isParallaxGroup(item)) {
|
|
101
|
+
const group = item as ParallaxGroup;
|
|
102
|
+
for (const slide of group.slides) {
|
|
103
|
+
for (const col of slide.columns || []) {
|
|
104
|
+
const block = (col.blocks || []).find(
|
|
105
|
+
(b) => b._key === store.selectedBlockKey
|
|
106
|
+
);
|
|
107
|
+
if (block) return { block, rowKey: slide._key, colKey: col._key, isSection: false };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
})();
|
|
114
|
+
|
|
115
|
+
// Derive the panel title + icon from what's selected
|
|
116
|
+
const blockInfo = selectedBlock
|
|
117
|
+
? ALL_BLOCK_INFO.find((b) => b.type === selectedBlock.block._type)
|
|
118
|
+
: null;
|
|
119
|
+
|
|
120
|
+
// BUG-V2-003 fix: Block selection takes priority over V2 column/section
|
|
121
|
+
const panelTitle = selectedBlock
|
|
122
|
+
? blockInfo?.label || selectedBlock.block._type
|
|
123
|
+
: selectedColumnV2
|
|
124
|
+
? "Column"
|
|
125
|
+
: selectedParallaxSlide
|
|
126
|
+
? `Slide ${selectedParallaxSlide.group.slides.findIndex((s) => s._key === selectedParallaxSlide.slide._key) + 1}`
|
|
127
|
+
: selectedParallaxGroup
|
|
128
|
+
? "Parallax Showcase"
|
|
129
|
+
: selectedCustomSectionInstance
|
|
130
|
+
? (selectedCustomSectionInstance.custom_section_title || "Saved Section")
|
|
131
|
+
: selectedSectionV2
|
|
132
|
+
? "Section"
|
|
133
|
+
: selectedSection
|
|
134
|
+
? (selectedSection.section_type === "projectGrid" ? "Project Grid" : "Parallax Section")
|
|
135
|
+
: "Page";
|
|
136
|
+
|
|
137
|
+
// Resolve gradient + icon component for the header
|
|
138
|
+
const headerStyleKey = selectedBlock
|
|
139
|
+
? selectedBlock.block._type
|
|
140
|
+
: selectedColumnV2
|
|
141
|
+
? "column"
|
|
142
|
+
: (selectedParallaxSlide || selectedParallaxGroup)
|
|
143
|
+
? "parallaxGroup"
|
|
144
|
+
: selectedCustomSectionInstance
|
|
145
|
+
? "customSectionInstance"
|
|
146
|
+
: selectedSectionV2
|
|
147
|
+
? "row"
|
|
148
|
+
: selectedSection
|
|
149
|
+
? (selectedSection.block[0]?._type || "row")
|
|
150
|
+
: "page";
|
|
151
|
+
const headerGradient = BLOCK_GRADIENTS[headerStyleKey] || BLOCK_GRADIENTS.page;
|
|
152
|
+
const HeaderIconComponent = BLOCK_ICON_COMPONENTS[headerStyleKey];
|
|
153
|
+
|
|
154
|
+
const hasSelection = !!(store.selectedRowKey || store.selectedColumnKey || store.selectedBlockKey);
|
|
155
|
+
// V2 columns: show Settings + Animation tabs (not Layout) — but NOT when a block inside the column is selected
|
|
156
|
+
const isColumnOnly = !!(selectedColumnV2 && !selectedBlock);
|
|
157
|
+
// Parallax group header: show Settings + Animation (no Layout)
|
|
158
|
+
const isParallaxGroupOnly = !!(selectedParallaxGroup && !selectedParallaxSlide && !selectedBlock);
|
|
159
|
+
// Custom section instance: show all 3 tabs (Settings with Edit/Detach, Layout, Animation)
|
|
160
|
+
const isCustomSectionOnly = !!(selectedCustomSectionInstance && !selectedBlock);
|
|
161
|
+
// Page level: nothing selected — show Settings + SEO + Animation (no Layout)
|
|
162
|
+
const isPageLevel = !hasSelection;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
selectedItem,
|
|
166
|
+
selectedSection,
|
|
167
|
+
selectedSectionV2,
|
|
168
|
+
selectedCustomSectionInstance,
|
|
169
|
+
selectedParallaxGroup,
|
|
170
|
+
selectedParallaxSlide,
|
|
171
|
+
effectiveSectionV2,
|
|
172
|
+
selectedColumnV2,
|
|
173
|
+
selectedBlock,
|
|
174
|
+
panelTitle,
|
|
175
|
+
headerStyleKey,
|
|
176
|
+
headerGradient,
|
|
177
|
+
HeaderIconComponent,
|
|
178
|
+
hasSelection,
|
|
179
|
+
isColumnOnly,
|
|
180
|
+
isParallaxGroupOnly,
|
|
181
|
+
isCustomSectionOnly,
|
|
182
|
+
isPageLevel,
|
|
183
|
+
};
|
|
184
|
+
}
|
package/lib/bot-guard.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot detection & rate-limiting for Edge Middleware.
|
|
3
|
+
*
|
|
4
|
+
* Runs on Vercel's Edge Runtime (cheap) to block aggressive crawlers
|
|
5
|
+
* BEFORE they invoke expensive serverless functions (Fluid Active CPU).
|
|
6
|
+
*
|
|
7
|
+
* Strategy:
|
|
8
|
+
* 1. Block known AI scrapers / aggressive bots by User-Agent
|
|
9
|
+
* 2. Detect bot-like rapid crawling patterns (many unique paths from same IP)
|
|
10
|
+
* 3. Return 429 for rate-limited requests
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
14
|
+
|
|
15
|
+
// ── Known aggressive bot User-Agents ────────────────────────────────────
|
|
16
|
+
// These bots ignore robots.txt or crawl too aggressively for Hobby-tier hosting.
|
|
17
|
+
const BLOCKED_BOT_PATTERNS = [
|
|
18
|
+
"GPTBot",
|
|
19
|
+
"CCBot",
|
|
20
|
+
"anthropic-ai",
|
|
21
|
+
"ClaudeBot",
|
|
22
|
+
"Bytespider",
|
|
23
|
+
"PetalBot",
|
|
24
|
+
"Sogou",
|
|
25
|
+
"AhrefsBot",
|
|
26
|
+
"SemrushBot",
|
|
27
|
+
"DotBot",
|
|
28
|
+
"MJ12bot",
|
|
29
|
+
"BLEXBot",
|
|
30
|
+
"DataForSeoBot",
|
|
31
|
+
"serpstatbot",
|
|
32
|
+
"Amazonbot",
|
|
33
|
+
"Barkrowler",
|
|
34
|
+
"YandexBot",
|
|
35
|
+
"MegaIndex",
|
|
36
|
+
"Applebot", // Apple's crawler — not needed for most portfolio sites
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ── Simple in-memory rate limiter for Edge ─────────────────────────────
|
|
40
|
+
// Edge functions are short-lived, so this map resets frequently.
|
|
41
|
+
// It won't catch all abuse but will throttle burst patterns within
|
|
42
|
+
// a single Edge instance lifetime (typically several minutes).
|
|
43
|
+
interface RateEntry {
|
|
44
|
+
count: number;
|
|
45
|
+
windowStart: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ipHits = new Map<string, RateEntry>();
|
|
49
|
+
|
|
50
|
+
// Max public page requests per IP per window (generous for humans, tight for bots)
|
|
51
|
+
const PUBLIC_PAGE_LIMIT = 30;
|
|
52
|
+
const WINDOW_MS = 60_000; // 1 minute
|
|
53
|
+
|
|
54
|
+
// Garbage-collect stale entries every 100 checks
|
|
55
|
+
let gcCounter = 0;
|
|
56
|
+
|
|
57
|
+
function isRateLimited(ip: string): boolean {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
|
|
60
|
+
// Periodic cleanup
|
|
61
|
+
if (++gcCounter >= 100) {
|
|
62
|
+
gcCounter = 0;
|
|
63
|
+
for (const [key, entry] of ipHits) {
|
|
64
|
+
if (now - entry.windowStart > WINDOW_MS * 2) {
|
|
65
|
+
ipHits.delete(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entry = ipHits.get(ip);
|
|
71
|
+
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
|
72
|
+
ipHits.set(ip, { count: 1, windowStart: now });
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
entry.count++;
|
|
77
|
+
return entry.count > PUBLIC_PAGE_LIMIT;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Main guard function ────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Call this at the TOP of your middleware, before any other logic.
|
|
84
|
+
* Returns a Response if the request should be blocked, or null to continue.
|
|
85
|
+
*
|
|
86
|
+
* Only applies to public (non-admin) GET requests for pages.
|
|
87
|
+
*/
|
|
88
|
+
export function guardAgainstBots(request: NextRequest): NextResponse | null {
|
|
89
|
+
const { pathname } = request.nextUrl;
|
|
90
|
+
|
|
91
|
+
// Skip admin routes, API routes (except public ones), and static assets
|
|
92
|
+
if (
|
|
93
|
+
pathname.startsWith("/admin") ||
|
|
94
|
+
pathname.startsWith("/api/admin") ||
|
|
95
|
+
pathname.startsWith("/studio") ||
|
|
96
|
+
pathname.startsWith("/_next") ||
|
|
97
|
+
pathname.startsWith("/fonts")
|
|
98
|
+
) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Only guard GET requests (mutations already have their own rate limiter)
|
|
103
|
+
if (request.method !== "GET") {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ua = request.headers.get("user-agent") || "";
|
|
108
|
+
|
|
109
|
+
// 1. Block known aggressive bots
|
|
110
|
+
const isBlockedBot = BLOCKED_BOT_PATTERNS.some(
|
|
111
|
+
(pattern) => ua.includes(pattern)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (isBlockedBot) {
|
|
115
|
+
return new NextResponse("Forbidden", {
|
|
116
|
+
status: 403,
|
|
117
|
+
headers: { "X-Robots-Tag": "noindex, nofollow" },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. Rate-limit public page requests per IP
|
|
122
|
+
const ip =
|
|
123
|
+
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
124
|
+
request.headers.get("x-real-ip") ||
|
|
125
|
+
"unknown";
|
|
126
|
+
|
|
127
|
+
if (isRateLimited(ip)) {
|
|
128
|
+
return new NextResponse("Too Many Requests", {
|
|
129
|
+
status: 429,
|
|
130
|
+
headers: {
|
|
131
|
+
"Retry-After": "60",
|
|
132
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V1 → V2 migration utilities for content blocks.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from serializer.ts in Session 162.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ============================================
|
|
8
|
+
// Project Grid v1 → v2 Migration (Session 105)
|
|
9
|
+
// ============================================
|
|
10
|
+
|
|
11
|
+
/** Map v1 string gap values to pixel numbers */
|
|
12
|
+
export const GAP_V1_MAP: Record<string, number> = {
|
|
13
|
+
small: 8,
|
|
14
|
+
medium: 16,
|
|
15
|
+
large: 32,
|
|
16
|
+
xlarge: 48,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect and migrate a v1 projectGridBlock to v2 format.
|
|
21
|
+
* v1 is identified by the presence of `grid_layout` (string field).
|
|
22
|
+
* v2 is identified by the presence of `columns` (number field).
|
|
23
|
+
* If already v2 or not a projectGridBlock, returns as-is.
|
|
24
|
+
*/
|
|
25
|
+
export function migrateProjectGridV1ToV2(block: Record<string, unknown>): void {
|
|
26
|
+
if (block._type !== "projectGridBlock") return;
|
|
27
|
+
|
|
28
|
+
// Already v2: has `columns` as a number
|
|
29
|
+
if (typeof block.columns === "number") return;
|
|
30
|
+
|
|
31
|
+
// Not v1 either (fresh block without grid_layout) — apply defaults
|
|
32
|
+
if (!block.grid_layout && typeof block.columns !== "number") {
|
|
33
|
+
block.columns = 3;
|
|
34
|
+
block.aspect_ratios = ["16/9"];
|
|
35
|
+
block.gap_v = 16;
|
|
36
|
+
block.gap_h = 16;
|
|
37
|
+
block.hover_effect = block.hover_effect || "scale";
|
|
38
|
+
block.show_subtitle = block.show_subtitle ?? true;
|
|
39
|
+
block.border_radius = 0;
|
|
40
|
+
block.video_mode = "off";
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── v1 → v2 migration ───
|
|
45
|
+
|
|
46
|
+
// columns: columns_desktop → columns (clamp 1–6)
|
|
47
|
+
const colDesktop = typeof block.columns_desktop === "number" ? block.columns_desktop : 2;
|
|
48
|
+
block.columns = Math.max(1, Math.min(6, colDesktop));
|
|
49
|
+
|
|
50
|
+
// gap: string → gap_v + gap_h (both same value)
|
|
51
|
+
const gapStr = typeof block.gap === "string" ? block.gap : "large";
|
|
52
|
+
const gapPx = GAP_V1_MAP[gapStr] ?? 32;
|
|
53
|
+
block.gap_v = gapPx;
|
|
54
|
+
block.gap_h = gapPx;
|
|
55
|
+
|
|
56
|
+
// card_aspect_ratio → aspect_ratios
|
|
57
|
+
const oldRatio = typeof block.card_aspect_ratio === "string" ? block.card_aspect_ratio : "16/9";
|
|
58
|
+
if (oldRatio === "random") {
|
|
59
|
+
block.aspect_ratios = ["16/9", "1/1", "9/16"];
|
|
60
|
+
} else {
|
|
61
|
+
block.aspect_ratios = [oldRatio];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// hover_effect: "overlay" → "scale", rest 1:1
|
|
65
|
+
const oldHover = typeof block.hover_effect === "string" ? block.hover_effect : "overlay";
|
|
66
|
+
if (oldHover === "overlay") {
|
|
67
|
+
block.hover_effect = "scale";
|
|
68
|
+
}
|
|
69
|
+
// "scale" and "none" pass through
|
|
70
|
+
|
|
71
|
+
// border_radius: string → number
|
|
72
|
+
const oldRadius = block.card_border_radius;
|
|
73
|
+
block.border_radius = typeof oldRadius === "string" ? (parseInt(oldRadius, 10) || 0) : 0;
|
|
74
|
+
|
|
75
|
+
// video: video_autoloop + video_hover → video_mode
|
|
76
|
+
if (block.video_autoloop === true) {
|
|
77
|
+
block.video_mode = "autoloop";
|
|
78
|
+
} else if (block.video_hover === true) {
|
|
79
|
+
block.video_mode = "hover";
|
|
80
|
+
} else {
|
|
81
|
+
block.video_mode = "off";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// show_subtitle: direct copy (default true)
|
|
85
|
+
block.show_subtitle = block.show_subtitle ?? true;
|
|
86
|
+
|
|
87
|
+
// ─── Clean up v1 fields (not needed in builder state) ───
|
|
88
|
+
delete block.grid_layout;
|
|
89
|
+
delete block.gap;
|
|
90
|
+
delete block.auto_columns;
|
|
91
|
+
delete block.card_size;
|
|
92
|
+
delete block.columns_desktop;
|
|
93
|
+
delete block.card_aspect_ratio;
|
|
94
|
+
delete block.card_border_radius;
|
|
95
|
+
delete block.video_hover;
|
|
96
|
+
delete block.video_autoloop;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Normalize animation fields on a block during read.
|
|
101
|
+
* Post-migration (Session 122): only new fields exist in Sanity.
|
|
102
|
+
* This is a no-op now but kept as a hook point for future needs.
|
|
103
|
+
*/
|
|
104
|
+
export function normalizeBlockAnimationFields(_block: Record<string, unknown>): void {
|
|
105
|
+
// No-op: migration completed in Session 122.
|
|
106
|
+
// All documents now use enter_animation / hover_effect directly.
|
|
107
|
+
}
|