@morphika/andami 0.5.3 → 0.5.5
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/app/admin/assets/page.tsx +3 -2
- package/app/admin/layout.tsx +4 -0
- package/components/admin/nav-builder/NavBuilder.tsx +2 -1
- package/components/admin/styles/FontsEditor.tsx +2 -1
- package/components/builder/CoverSectionCanvas.tsx +7 -6
- package/components/builder/SettingsPanel.tsx +14 -8
- package/components/builder/SortableBlock.tsx +4 -0
- package/components/builder/SortableRow.tsx +2 -0
- package/components/builder/asset-browser/useR2Operations.ts +5 -4
- package/components/builder/editors/AudioBlockEditor.tsx +10 -8
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +10 -8
- package/components/builder/editors/ButtonBlockEditor.tsx +9 -7
- package/components/builder/editors/ImageBlockEditor.tsx +10 -8
- package/components/builder/editors/ImageGridBlockEditor.tsx +10 -8
- package/components/builder/editors/SpacerBlockEditor.tsx +4 -4
- package/components/builder/editors/TextBlockEditor.tsx +471 -468
- package/components/builder/editors/VideoBlockEditor.tsx +10 -8
- package/components/builder/settings-panel/AnimationTab.tsx +11 -8
- package/components/builder/settings-panel/BlockLayoutTab.tsx +514 -511
- package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +2 -2
- package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +11 -8
- package/components/builder/settings-panel/ColumnV2Settings.tsx +6 -5
- package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +4 -3
- package/components/builder/settings-panel/CoverSectionSettings.tsx +14 -9
- package/components/builder/settings-panel/CustomSectionSettings.tsx +9 -7
- package/components/builder/settings-panel/PageSettings.tsx +39 -32
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +2 -2
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +7 -5
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +13 -9
- package/components/builder/settings-panel/SectionV2Settings.tsx +7 -6
- package/components/builder/settings-panel/TRBLInputs.tsx +2 -2
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +16 -13
- package/components/ui/NavContentLightbox.tsx +41 -4
- package/components/ui/ToastStack.tsx +142 -0
- package/lib/auth-token.ts +5 -1
- package/lib/bot-guard.ts +6 -0
- package/lib/builder/constants.ts +0 -7
- package/lib/builder/store-blocks.ts +15 -5
- package/lib/builder/store-cover.ts +16 -6
- package/lib/builder/store-sections.ts +130 -51
- package/lib/toast/index.ts +56 -0
- package/lib/toast/store.ts +56 -0
- package/lib/version.ts +1 -1
- package/package.json +3 -1
|
@@ -22,7 +22,7 @@ export function TRBLInputs({
|
|
|
22
22
|
left: string;
|
|
23
23
|
onChange: (field: "top" | "right" | "bottom" | "left", value: string) => void;
|
|
24
24
|
}) {
|
|
25
|
-
const
|
|
25
|
+
const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
|
|
26
26
|
const fields = [
|
|
27
27
|
{ key: "top" as const, label: "TOP", value: top },
|
|
28
28
|
{ key: "right" as const, label: "RIGHT", value: right },
|
|
@@ -40,7 +40,7 @@ export function TRBLInputs({
|
|
|
40
40
|
<input
|
|
41
41
|
type="number"
|
|
42
42
|
value={f.value || "0"}
|
|
43
|
-
onFocus={() =>
|
|
43
|
+
onFocus={() => _pushSnapshot()}
|
|
44
44
|
onChange={(e) => onChange(f.key, e.target.value)}
|
|
45
45
|
className="w-full rounded-lg border border-transparent bg-[#f5f5f5] px-1.5 py-[6px] text-xs text-neutral-900 text-center outline-none transition-all hover:bg-[#efefef] focus:bg-white focus:border-[#3580f9] focus:shadow-[0_0_0_3px_rgba(53, 128, 249,0.06)]"
|
|
46
46
|
/>
|
|
@@ -39,10 +39,13 @@ export interface SelectedParallaxSlideInfo {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function useSettingsPanelSelection() {
|
|
42
|
-
const
|
|
42
|
+
const rows = useBuilderStore((s) => s.rows);
|
|
43
|
+
const selectedRowKey = useBuilderStore((s) => s.selectedRowKey);
|
|
44
|
+
const selectedColumnKey = useBuilderStore((s) => s.selectedColumnKey);
|
|
45
|
+
const selectedBlockKey = useBuilderStore((s) => s.selectedBlockKey);
|
|
43
46
|
|
|
44
47
|
// Find selected elements — handle page sections, V2 sections, and parallax groups/slides
|
|
45
|
-
const selectedItem: ContentItem | undefined =
|
|
48
|
+
const selectedItem: ContentItem | undefined = rows.find((r) => r._key === selectedRowKey);
|
|
46
49
|
const selectedSectionV2: PageSectionV2 | null = selectedItem && isPageSectionV2(selectedItem) ? selectedItem : null;
|
|
47
50
|
const selectedCustomSectionInstance: CustomSectionInstance | null = selectedItem && isCustomSectionInstance(selectedItem) ? selectedItem as CustomSectionInstance : null;
|
|
48
51
|
const selectedCoverSection: CoverSection | null = selectedItem && isCoverSection(selectedItem) ? selectedItem as CoverSection : null;
|
|
@@ -50,11 +53,11 @@ export function useSettingsPanelSelection() {
|
|
|
50
53
|
// Parallax detection: group selected directly, or slide selected (search inside groups)
|
|
51
54
|
const selectedParallaxGroup: ParallaxGroup | null = selectedItem && isParallaxGroup(selectedItem) ? selectedItem as ParallaxGroup : null;
|
|
52
55
|
const selectedParallaxSlide: SelectedParallaxSlideInfo | null = (() => {
|
|
53
|
-
if (!
|
|
54
|
-
for (const item of
|
|
56
|
+
if (!selectedRowKey) return null;
|
|
57
|
+
for (const item of rows) {
|
|
55
58
|
if (!isParallaxGroup(item)) continue;
|
|
56
59
|
const group = item as ParallaxGroup;
|
|
57
|
-
const slide = group.slides.find((s) => s._key ===
|
|
60
|
+
const slide = group.slides.find((s) => s._key === selectedRowKey);
|
|
58
61
|
if (slide) {
|
|
59
62
|
// Create a virtual PageSectionV2 for the slide so we can delegate to SectionV2Settings etc.
|
|
60
63
|
const virtualSection: PageSectionV2 = {
|
|
@@ -86,19 +89,19 @@ export function useSettingsPanelSelection() {
|
|
|
86
89
|
|
|
87
90
|
// V2 column: when a V2 section (or parallax slide or cover section) is selected and a column key is set
|
|
88
91
|
const effectiveSectionV2 = selectedSectionV2 || selectedParallaxSlide?.virtualSection || coverVirtualSection || null;
|
|
89
|
-
const selectedColumnV2: SectionColumn | null = effectiveSectionV2 &&
|
|
90
|
-
? effectiveSectionV2.columns.find((c) => c._key ===
|
|
92
|
+
const selectedColumnV2: SectionColumn | null = effectiveSectionV2 && selectedColumnKey
|
|
93
|
+
? effectiveSectionV2.columns.find((c) => c._key === selectedColumnKey) || null
|
|
91
94
|
: null;
|
|
92
95
|
|
|
93
96
|
const selectedBlock: SelectedBlockInfo | null = (() => {
|
|
94
97
|
// Block search inside V2 sections and parallax slides
|
|
95
|
-
if (!
|
|
96
|
-
for (const item of
|
|
98
|
+
if (!selectedBlockKey) return null;
|
|
99
|
+
for (const item of rows) {
|
|
97
100
|
// V2 sections: search inside columns
|
|
98
101
|
if (isPageSectionV2(item)) {
|
|
99
102
|
for (const col of (item as PageSectionV2).columns || []) {
|
|
100
103
|
const block = (col.blocks || []).find(
|
|
101
|
-
(b) => b._key ===
|
|
104
|
+
(b) => b._key === selectedBlockKey
|
|
102
105
|
);
|
|
103
106
|
if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
|
|
104
107
|
}
|
|
@@ -107,7 +110,7 @@ export function useSettingsPanelSelection() {
|
|
|
107
110
|
if (isCoverSection(item)) {
|
|
108
111
|
for (const col of (item as CoverSection).columns || []) {
|
|
109
112
|
const block = (col.blocks || []).find(
|
|
110
|
-
(b) => b._key ===
|
|
113
|
+
(b) => b._key === selectedBlockKey
|
|
111
114
|
);
|
|
112
115
|
if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
|
|
113
116
|
}
|
|
@@ -118,7 +121,7 @@ export function useSettingsPanelSelection() {
|
|
|
118
121
|
for (const slide of group.slides) {
|
|
119
122
|
for (const col of slide.columns || []) {
|
|
120
123
|
const block = (col.blocks || []).find(
|
|
121
|
-
(b) => b._key ===
|
|
124
|
+
(b) => b._key === selectedBlockKey
|
|
122
125
|
);
|
|
123
126
|
if (block) return { block, rowKey: slide._key, colKey: col._key, isSection: false };
|
|
124
127
|
}
|
|
@@ -167,7 +170,7 @@ export function useSettingsPanelSelection() {
|
|
|
167
170
|
const headerGradient = BLOCK_GRADIENTS[headerStyleKey] || BLOCK_GRADIENTS.page;
|
|
168
171
|
const HeaderIconComponent = BLOCK_ICON_COMPONENTS[headerStyleKey];
|
|
169
172
|
|
|
170
|
-
const hasSelection = !!(
|
|
173
|
+
const hasSelection = !!(selectedRowKey || selectedColumnKey || selectedBlockKey);
|
|
171
174
|
// V2 columns: show Settings + Animation tabs (not Layout) — but NOT when a block inside the column is selected
|
|
172
175
|
const isColumnOnly = !!(selectedColumnV2 && !selectedBlock);
|
|
173
176
|
// Parallax group header: show Settings + Animation (no Layout)
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useCallback } from "react";
|
|
3
|
+
import { useEffect, useCallback, useRef } from "react";
|
|
4
4
|
import { assetUrl } from "../../lib/assets";
|
|
5
5
|
|
|
6
|
+
const FOCUSABLE_SELECTOR =
|
|
7
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
|
|
8
|
+
|
|
6
9
|
// ============================================
|
|
7
10
|
// Helpers
|
|
8
11
|
// ============================================
|
|
@@ -42,22 +45,51 @@ export default function NavContentLightbox({
|
|
|
42
45
|
contentUrl,
|
|
43
46
|
onClose,
|
|
44
47
|
}: NavContentLightboxProps) {
|
|
45
|
-
|
|
48
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
49
|
+
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
50
|
+
|
|
51
|
+
// Focus trap: cycle Tab between focusable children, close on Escape
|
|
46
52
|
const handleKey = useCallback(
|
|
47
53
|
(e: KeyboardEvent) => {
|
|
48
|
-
if (e.key === "Escape")
|
|
54
|
+
if (e.key === "Escape") {
|
|
55
|
+
onClose();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (e.key !== "Tab" || !containerRef.current) return;
|
|
59
|
+
|
|
60
|
+
const focusables = containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
61
|
+
if (focusables.length === 0) return;
|
|
62
|
+
|
|
63
|
+
const first = focusables[0];
|
|
64
|
+
const last = focusables[focusables.length - 1];
|
|
65
|
+
const active = document.activeElement as HTMLElement | null;
|
|
66
|
+
|
|
67
|
+
if (e.shiftKey && active === first) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
last.focus();
|
|
70
|
+
} else if (!e.shiftKey && active === last) {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
first.focus();
|
|
73
|
+
}
|
|
49
74
|
},
|
|
50
75
|
[onClose]
|
|
51
76
|
);
|
|
52
77
|
|
|
53
78
|
useEffect(() => {
|
|
79
|
+
// Remember where focus came from so we can restore it on close
|
|
80
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
81
|
+
|
|
54
82
|
window.addEventListener("keydown", handleKey);
|
|
55
|
-
// Lock body scroll
|
|
56
83
|
const prev = document.body.style.overflow;
|
|
57
84
|
document.body.style.overflow = "hidden";
|
|
85
|
+
|
|
86
|
+
// Move focus into the lightbox (close button is always present)
|
|
87
|
+
closeButtonRef.current?.focus();
|
|
88
|
+
|
|
58
89
|
return () => {
|
|
59
90
|
window.removeEventListener("keydown", handleKey);
|
|
60
91
|
document.body.style.overflow = prev;
|
|
92
|
+
previouslyFocused?.focus?.();
|
|
61
93
|
};
|
|
62
94
|
}, [handleKey]);
|
|
63
95
|
|
|
@@ -78,11 +110,16 @@ export default function NavContentLightbox({
|
|
|
78
110
|
|
|
79
111
|
return (
|
|
80
112
|
<div
|
|
113
|
+
ref={containerRef}
|
|
114
|
+
role="dialog"
|
|
115
|
+
aria-modal="true"
|
|
116
|
+
aria-label="Media lightbox"
|
|
81
117
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
|
82
118
|
onClick={onClose}
|
|
83
119
|
>
|
|
84
120
|
{/* Close button */}
|
|
85
121
|
<button
|
|
122
|
+
ref={closeButtonRef}
|
|
86
123
|
onClick={onClose}
|
|
87
124
|
className="absolute top-5 right-5 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors z-10"
|
|
88
125
|
aria-label="Close lightbox"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useToastStore, type Toast } from "../../lib/toast/store";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ToastStack — renders the global toast queue in a fixed bottom-right stack.
|
|
9
|
+
*
|
|
10
|
+
* Mount once, anywhere in the React tree (the framework's admin layout already
|
|
11
|
+
* includes this). Uses a portal to `document.body` so toast z-index isn't
|
|
12
|
+
* constrained by parent stacking contexts.
|
|
13
|
+
*
|
|
14
|
+
* Each toast auto-dismisses after its `duration`; users can also click the
|
|
15
|
+
* close button or focus + Escape.
|
|
16
|
+
*/
|
|
17
|
+
export default function ToastStack() {
|
|
18
|
+
const toasts = useToastStore((s) => s.toasts);
|
|
19
|
+
const [mounted, setMounted] = useState(false);
|
|
20
|
+
|
|
21
|
+
useEffect(() => setMounted(true), []);
|
|
22
|
+
|
|
23
|
+
if (!mounted || typeof document === "undefined") return null;
|
|
24
|
+
|
|
25
|
+
return createPortal(
|
|
26
|
+
<div
|
|
27
|
+
className="pointer-events-none fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-2"
|
|
28
|
+
role="region"
|
|
29
|
+
aria-label="Notifications"
|
|
30
|
+
>
|
|
31
|
+
{toasts.map((t) => (
|
|
32
|
+
<ToastItem key={t.id} toast={t} />
|
|
33
|
+
))}
|
|
34
|
+
</div>,
|
|
35
|
+
document.body
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ToastItem({ toast }: { toast: Toast }) {
|
|
40
|
+
const dismiss = useToastStore((s) => s.dismiss);
|
|
41
|
+
const [entered, setEntered] = useState(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
// Next tick → trigger enter transition
|
|
45
|
+
const enterFrame = requestAnimationFrame(() => setEntered(true));
|
|
46
|
+
return () => cancelAnimationFrame(enterFrame);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (toast.duration === null) return;
|
|
51
|
+
const timer = setTimeout(() => dismiss(toast.id), toast.duration);
|
|
52
|
+
return () => clearTimeout(timer);
|
|
53
|
+
}, [toast.id, toast.duration, dismiss]);
|
|
54
|
+
|
|
55
|
+
const tone = TONE[toast.kind];
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
role={toast.kind === "error" ? "alert" : "status"}
|
|
60
|
+
aria-live={toast.kind === "error" ? "assertive" : "polite"}
|
|
61
|
+
className={`pointer-events-auto flex items-start gap-3 min-w-[260px] max-w-[400px] px-4 py-3 rounded-lg shadow-lg border transition-all duration-200 ${tone.bg} ${tone.border} ${
|
|
62
|
+
entered ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
|
63
|
+
}`}
|
|
64
|
+
>
|
|
65
|
+
<span className={`mt-0.5 shrink-0 ${tone.icon}`} aria-hidden="true">
|
|
66
|
+
{ICONS[toast.kind]}
|
|
67
|
+
</span>
|
|
68
|
+
<p className={`flex-1 text-[13px] leading-snug ${tone.text}`}>{toast.message}</p>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => dismiss(toast.id)}
|
|
72
|
+
className={`shrink-0 -mt-0.5 -mr-1 p-1 rounded transition-colors ${tone.close}`}
|
|
73
|
+
aria-label="Dismiss notification"
|
|
74
|
+
>
|
|
75
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
|
76
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
77
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
78
|
+
</svg>
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TONE = {
|
|
85
|
+
default: {
|
|
86
|
+
bg: "bg-neutral-900",
|
|
87
|
+
border: "border-neutral-700",
|
|
88
|
+
text: "text-neutral-100",
|
|
89
|
+
icon: "text-neutral-400",
|
|
90
|
+
close: "text-neutral-400 hover:text-neutral-100 hover:bg-white/10",
|
|
91
|
+
},
|
|
92
|
+
success: {
|
|
93
|
+
bg: "bg-emerald-950",
|
|
94
|
+
border: "border-emerald-800",
|
|
95
|
+
text: "text-emerald-50",
|
|
96
|
+
icon: "text-emerald-400",
|
|
97
|
+
close: "text-emerald-400 hover:text-emerald-100 hover:bg-emerald-900/60",
|
|
98
|
+
},
|
|
99
|
+
error: {
|
|
100
|
+
bg: "bg-red-950",
|
|
101
|
+
border: "border-red-800",
|
|
102
|
+
text: "text-red-50",
|
|
103
|
+
icon: "text-red-400",
|
|
104
|
+
close: "text-red-400 hover:text-red-100 hover:bg-red-900/60",
|
|
105
|
+
},
|
|
106
|
+
info: {
|
|
107
|
+
bg: "bg-sky-950",
|
|
108
|
+
border: "border-sky-800",
|
|
109
|
+
text: "text-sky-50",
|
|
110
|
+
icon: "text-sky-400",
|
|
111
|
+
close: "text-sky-400 hover:text-sky-100 hover:bg-sky-900/60",
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const ICONS: Record<string, React.ReactNode> = {
|
|
116
|
+
default: (
|
|
117
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
118
|
+
<circle cx="12" cy="12" r="10" />
|
|
119
|
+
<line x1="12" y1="16" x2="12" y2="12" />
|
|
120
|
+
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
121
|
+
</svg>
|
|
122
|
+
),
|
|
123
|
+
success: (
|
|
124
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
125
|
+
<polyline points="20 6 9 17 4 12" />
|
|
126
|
+
</svg>
|
|
127
|
+
),
|
|
128
|
+
error: (
|
|
129
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
130
|
+
<circle cx="12" cy="12" r="10" />
|
|
131
|
+
<line x1="15" y1="9" x2="9" y2="15" />
|
|
132
|
+
<line x1="9" y1="9" x2="15" y2="15" />
|
|
133
|
+
</svg>
|
|
134
|
+
),
|
|
135
|
+
info: (
|
|
136
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
137
|
+
<circle cx="12" cy="12" r="10" />
|
|
138
|
+
<line x1="12" y1="16" x2="12" y2="12" />
|
|
139
|
+
<line x1="12" y1="8" x2="12.01" y2="8" />
|
|
140
|
+
</svg>
|
|
141
|
+
),
|
|
142
|
+
};
|
package/lib/auth-token.ts
CHANGED
|
@@ -84,7 +84,11 @@ export async function validateAdminToken(token: string): Promise<boolean> {
|
|
|
84
84
|
const key = await getSigningKey(pw);
|
|
85
85
|
const expectedSignature = await hmacSign(key, `admin_session:${timestamp}`);
|
|
86
86
|
|
|
87
|
-
// Constant-time comparison to prevent timing attacks
|
|
87
|
+
// Constant-time comparison to prevent timing attacks.
|
|
88
|
+
// NOTE: we intentionally do NOT use `crypto.timingSafeEqual` here — that's a
|
|
89
|
+
// Node.js-only API and this file runs on the Edge Runtime too (middleware).
|
|
90
|
+
// The manual XOR loop is the portable equivalent; do not "simplify" it to
|
|
91
|
+
// a `===` compare or a Node-only API without breaking Edge compat.
|
|
88
92
|
if (providedSignature.length !== expectedSignature.length) return false;
|
|
89
93
|
let mismatch = 0;
|
|
90
94
|
for (let i = 0; i < expectedSignature.length; i++) {
|
package/lib/bot-guard.ts
CHANGED
|
@@ -14,6 +14,12 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
14
14
|
|
|
15
15
|
// ── Known aggressive bot User-Agents ────────────────────────────────────
|
|
16
16
|
// These bots ignore robots.txt or crawl too aggressively for Hobby-tier hosting.
|
|
17
|
+
//
|
|
18
|
+
// Last reviewed: 2026-04-20 (Session 184 audit). New LLM-scraping bots appear
|
|
19
|
+
// frequently — review this list at least quarterly. Good signals to watch:
|
|
20
|
+
// - Vercel logs: look for User-Agents with high request volume
|
|
21
|
+
// - https://darkvisitors.com/ — community database of AI crawlers
|
|
22
|
+
// - Cloudflare radar / new entries in public robots.txt blocklists
|
|
17
23
|
const BLOCKED_BOT_PATTERNS = [
|
|
18
24
|
"GPTBot",
|
|
19
25
|
"CCBot",
|
package/lib/builder/constants.ts
CHANGED
|
@@ -77,13 +77,6 @@ export const BUILDER_BLOCK = "#3580f9"; // Blocks — same hue as columns
|
|
|
77
77
|
export const BUILDER_VIOLET = "#7500d5"; // Sections (incl. Custom)
|
|
78
78
|
export const BUILDER_GREEN = "#22c55e"; // Success / confirmation cues (e.g. R2 asset check)
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
* @deprecated Use `BUILDER_BLOCK` instead. The legacy name survived multiple
|
|
82
|
-
* colour migrations (orange → emerald → blue) and no longer reflects reality.
|
|
83
|
-
* Kept as an alias so third-party code doesn't break at the import boundary.
|
|
84
|
-
*/
|
|
85
|
-
export const BUILDER_ORANGE = BUILDER_BLOCK;
|
|
86
|
-
|
|
87
80
|
/**
|
|
88
81
|
* Padding map for Row settings (in pixels)
|
|
89
82
|
* Used by builder (SortableRow, ReadOnlyFrame) and public site (RowRenderer)
|
|
@@ -60,7 +60,6 @@ export function createBlockActions(set: StoreSet, get: StoreGet): BlockSliceActi
|
|
|
60
60
|
},
|
|
61
61
|
|
|
62
62
|
deleteBlock: (blockKey: string): void => {
|
|
63
|
-
get()._pushSnapshot();
|
|
64
63
|
const filterBlocks = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
|
|
65
64
|
cols.map((c) => ({ ...c, blocks: c.blocks.filter((b) => b._key !== blockKey) }));
|
|
66
65
|
|
|
@@ -88,12 +87,13 @@ export function createBlockActions(set: StoreSet, get: StoreGet): BlockSliceActi
|
|
|
88
87
|
rows: finalRows,
|
|
89
88
|
selectedBlockKey: state.selectedBlockKey === blockKey ? null : state.selectedBlockKey,
|
|
90
89
|
isDirty: true,
|
|
90
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
91
|
+
_future: [],
|
|
91
92
|
};
|
|
92
93
|
});
|
|
93
94
|
},
|
|
94
95
|
|
|
95
96
|
duplicateBlock: (blockKey: string): void => {
|
|
96
|
-
get()._pushSnapshot();
|
|
97
97
|
set((state) => {
|
|
98
98
|
const newKey = generateKey();
|
|
99
99
|
const dupInColumns = (cols: import("../../lib/sanity/types").SectionColumn[]) =>
|
|
@@ -125,7 +125,13 @@ export function createBlockActions(set: StoreSet, get: StoreGet): BlockSliceActi
|
|
|
125
125
|
}
|
|
126
126
|
return item;
|
|
127
127
|
});
|
|
128
|
-
return {
|
|
128
|
+
return {
|
|
129
|
+
rows,
|
|
130
|
+
selectedBlockKey: newKey,
|
|
131
|
+
isDirty: true,
|
|
132
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
133
|
+
_future: [],
|
|
134
|
+
};
|
|
129
135
|
});
|
|
130
136
|
},
|
|
131
137
|
|
|
@@ -148,7 +154,6 @@ export function createBlockActions(set: StoreSet, get: StoreGet): BlockSliceActi
|
|
|
148
154
|
},
|
|
149
155
|
|
|
150
156
|
reorderBlocks: (sectionKey: string, columnKey: string, fromIndex: number, toIndex: number): void => {
|
|
151
|
-
get()._pushSnapshot();
|
|
152
157
|
set((state) => {
|
|
153
158
|
const path = findSectionPath(state.rows, sectionKey);
|
|
154
159
|
if (!path) return state;
|
|
@@ -162,7 +167,12 @@ export function createBlockActions(set: StoreSet, get: StoreGet): BlockSliceActi
|
|
|
162
167
|
return { ...c, blocks };
|
|
163
168
|
}),
|
|
164
169
|
}));
|
|
165
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
rows,
|
|
172
|
+
isDirty: true,
|
|
173
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
174
|
+
_future: [],
|
|
175
|
+
};
|
|
166
176
|
});
|
|
167
177
|
},
|
|
168
178
|
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
import { isCoverSection } from "../../lib/sanity/types";
|
|
20
20
|
import { generateKey } from "./utils";
|
|
21
21
|
import { createDefaultCoverSection, createDefaultCoverRow } from "./defaults";
|
|
22
|
+
import { pushSnapshot } from "./history";
|
|
22
23
|
|
|
23
24
|
type StoreSet = (
|
|
24
25
|
partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
|
|
@@ -82,7 +83,6 @@ function updateCoverInRows(
|
|
|
82
83
|
export function createCoverActions(set: StoreSet, get: StoreGet): CoverSliceActions {
|
|
83
84
|
return {
|
|
84
85
|
addCoverSection: (afterRowKey?: string | null): void => {
|
|
85
|
-
get()._pushSnapshot();
|
|
86
86
|
const section = createDefaultCoverSection();
|
|
87
87
|
set((state) => {
|
|
88
88
|
const rows = [...state.rows];
|
|
@@ -96,12 +96,17 @@ export function createCoverActions(set: StoreSet, get: StoreGet): CoverSliceActi
|
|
|
96
96
|
} else {
|
|
97
97
|
rows.push(section);
|
|
98
98
|
}
|
|
99
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
rows,
|
|
101
|
+
isDirty: true,
|
|
102
|
+
selectedRowKey: section._key,
|
|
103
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
104
|
+
_future: [],
|
|
105
|
+
};
|
|
100
106
|
});
|
|
101
107
|
},
|
|
102
108
|
|
|
103
109
|
addCoverRow: (sectionKey: string): void => {
|
|
104
|
-
get()._pushSnapshot();
|
|
105
110
|
set((state) => ({
|
|
106
111
|
rows: updateCoverInRows(state.rows, sectionKey, (section) => {
|
|
107
112
|
if (section.cover_rows.length >= 5) return section;
|
|
@@ -137,11 +142,12 @@ export function createCoverActions(set: StoreSet, get: StoreGet): CoverSliceActi
|
|
|
137
142
|
return { ...section, cover_rows: newRows };
|
|
138
143
|
}),
|
|
139
144
|
isDirty: true,
|
|
145
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
146
|
+
_future: [],
|
|
140
147
|
}));
|
|
141
148
|
},
|
|
142
149
|
|
|
143
150
|
removeCoverRow: (sectionKey: string, rowKey: string): void => {
|
|
144
|
-
get()._pushSnapshot();
|
|
145
151
|
set((state) => ({
|
|
146
152
|
rows: updateCoverInRows(state.rows, sectionKey, (section) => {
|
|
147
153
|
if (section.cover_rows.length <= 1) return section;
|
|
@@ -183,6 +189,8 @@ export function createCoverActions(set: StoreSet, get: StoreGet): CoverSliceActi
|
|
|
183
189
|
};
|
|
184
190
|
}),
|
|
185
191
|
isDirty: true,
|
|
192
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
193
|
+
_future: [],
|
|
186
194
|
}));
|
|
187
195
|
},
|
|
188
196
|
|
|
@@ -248,13 +256,14 @@ export function createCoverActions(set: StoreSet, get: StoreGet): CoverSliceActi
|
|
|
248
256
|
"nav_color"
|
|
249
257
|
>>
|
|
250
258
|
): void => {
|
|
251
|
-
get()._pushSnapshot();
|
|
252
259
|
set((state) => ({
|
|
253
260
|
rows: updateCoverInRows(state.rows, sectionKey, (section) => ({
|
|
254
261
|
...section,
|
|
255
262
|
...fields,
|
|
256
263
|
})),
|
|
257
264
|
isDirty: true,
|
|
265
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
266
|
+
_future: [],
|
|
258
267
|
}));
|
|
259
268
|
},
|
|
260
269
|
|
|
@@ -275,13 +284,14 @@ export function createCoverActions(set: StoreSet, get: StoreGet): CoverSliceActi
|
|
|
275
284
|
sectionKey: string,
|
|
276
285
|
height: CoverSection["height"]
|
|
277
286
|
): void => {
|
|
278
|
-
get()._pushSnapshot();
|
|
279
287
|
set((state) => ({
|
|
280
288
|
rows: updateCoverInRows(state.rows, sectionKey, (section) => ({
|
|
281
289
|
...section,
|
|
282
290
|
height,
|
|
283
291
|
})),
|
|
284
292
|
isDirty: true,
|
|
293
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
294
|
+
_future: [],
|
|
285
295
|
}));
|
|
286
296
|
},
|
|
287
297
|
};
|