@morphika/andami 0.2.26 → 0.4.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/app/admin/pages/[slug]/page.tsx +41 -47
- package/app/api/admin/assets/scan/route.ts +40 -13
- package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
- package/app/api/admin/custom-sections/route.ts +4 -1
- package/app/api/admin/pages/[slug]/route.ts +7 -1
- package/app/api/admin/pages/route.ts +4 -1
- package/app/api/admin/r2/connect/route.ts +19 -1
- package/app/api/admin/r2/disconnect/route.ts +3 -0
- package/app/api/admin/r2/rename/route.ts +52 -13
- package/app/api/admin/r2/upload-url/route.ts +8 -1
- package/app/api/admin/settings/route.ts +4 -1
- package/app/api/admin/styles/route.ts +4 -1
- package/components/admin/styles/GridLayoutEditor.tsx +46 -46
- package/components/blocks/BlockRenderer.tsx +15 -2
- package/components/blocks/CoverSectionRenderer.tsx +75 -3
- package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
- package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
- package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
- package/components/blocks/ShaderCanvas.tsx +10 -6
- package/components/builder/BlockCardIcons.tsx +227 -0
- package/components/builder/BlockLivePreview.tsx +5 -0
- package/components/builder/BlockTypePicker.tsx +36 -63
- package/components/builder/BuilderCanvas.tsx +6 -2
- package/components/builder/ColumnDragOverlay.tsx +3 -3
- package/components/builder/CoverRowResizeHandle.tsx +5 -2
- package/components/builder/CoverSectionCanvas.tsx +45 -52
- package/components/builder/DndWrapper.tsx +1 -1
- package/components/builder/InsertionLines.tsx +1 -1
- package/components/builder/ParallaxGroupCanvas.tsx +12 -71
- package/components/builder/ReadOnlyFrame.tsx +4 -23
- package/components/builder/SectionCardIcons.tsx +320 -0
- package/components/builder/SectionEditorBar.tsx +17 -12
- package/components/builder/SectionTypePicker.tsx +34 -138
- package/components/builder/SectionV2Canvas.tsx +1 -1
- package/components/builder/SectionV2Column.tsx +19 -30
- package/components/builder/SettingsPanel.tsx +8 -32
- package/components/builder/SortableBlock.tsx +42 -50
- package/components/builder/SortableRow.tsx +207 -19
- package/components/builder/blockStyles.tsx +59 -180
- package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
- package/components/builder/editors/index.ts +1 -0
- package/components/builder/iconPrimitives.tsx +78 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
- package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
- package/components/builder/live-preview/index.ts +1 -0
- package/components/builder/settings-panel/BlockSettings.tsx +7 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
- package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
- package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
- package/lib/animation/enter-types.ts +1 -0
- package/lib/animation/hover-effect-types.ts +1 -0
- package/lib/assets.ts +17 -2
- package/lib/builder/block-registrations.ts +268 -0
- package/lib/builder/block-registry.ts +195 -0
- package/lib/builder/constants.ts +22 -15
- package/lib/builder/defaults.ts +21 -0
- package/lib/builder/format.ts +25 -0
- package/lib/builder/history.ts +0 -3
- package/lib/builder/index.ts +16 -0
- package/lib/builder/layout-styles.ts +1 -1
- package/lib/builder/registry.ts +44 -0
- package/lib/builder/section-visibility.ts +36 -0
- package/lib/builder/serializer/normalizers.ts +15 -6
- package/lib/builder/serializer/serializers.ts +3 -3
- package/lib/builder/store-blocks.ts +16 -9
- package/lib/builder/store-cover.ts +76 -8
- package/lib/builder/store-sections.ts +1 -1
- package/lib/builder/store.ts +0 -2
- package/lib/builder/types.ts +9 -5
- package/lib/csrf.ts +31 -0
- package/lib/sanity/types.ts +54 -2
- package/lib/security.ts +50 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/index.ts +2 -1
- package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
- package/sanity/schemas/index.ts +4 -1
- package/sanity/schemas/objects/coverSection.ts +35 -3
- package/sanity/schemas/pageSectionV2.ts +1 -0
- package/components/builder/ParallaxSlideHeader.tsx +0 -113
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility helpers for scroll-driven section effects (e.g. the Cover
|
|
3
|
+
* Section's navbar colour override).
|
|
4
|
+
*
|
|
5
|
+
* Kept as pure functions so they're testable without mounting a renderer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fraction (0–1) of the viewport currently occupied by a section,
|
|
10
|
+
* computed from its top/bottom positions relative to the viewport and
|
|
11
|
+
* the viewport height.
|
|
12
|
+
*
|
|
13
|
+
* - 0 when the section is fully above or fully below the viewport
|
|
14
|
+
* - 1 when the section fully covers the viewport (top ≤ 0 and
|
|
15
|
+
* bottom ≥ vh)
|
|
16
|
+
* - fractional for partial overlaps
|
|
17
|
+
*
|
|
18
|
+
* Safe against pathological inputs: returns 0 if `viewportHeight <= 0`.
|
|
19
|
+
*/
|
|
20
|
+
export function sectionVisibilityRatio(
|
|
21
|
+
top: number,
|
|
22
|
+
bottom: number,
|
|
23
|
+
viewportHeight: number
|
|
24
|
+
): number {
|
|
25
|
+
if (viewportHeight <= 0) return 0;
|
|
26
|
+
const visible = Math.min(bottom, viewportHeight) - Math.max(top, 0);
|
|
27
|
+
if (visible <= 0) return 0;
|
|
28
|
+
return Math.min(1, visible / viewportHeight);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Threshold (as a fraction of the viewport) above which a Cover / Parallax
|
|
33
|
+
* section is considered "on screen" for the purposes of taking over the
|
|
34
|
+
* navbar colour.
|
|
35
|
+
*/
|
|
36
|
+
export const NAV_COLOR_OVERRIDE_THRESHOLD = 0.3;
|
|
@@ -13,6 +13,7 @@ import type { EnterAnimationConfig } from "../../../lib/animation/enter-types";
|
|
|
13
13
|
|
|
14
14
|
import { ensureKeys, normalizeBlockResponsive, SECTION_TYPE_MAP } from "./shared";
|
|
15
15
|
import { migrateProjectGridV1ToV2, normalizeBlockAnimationFields } from "./migrations";
|
|
16
|
+
import { normalizeRowHeights } from "../store-cover";
|
|
16
17
|
|
|
17
18
|
// ============================================
|
|
18
19
|
// Section Normalizers
|
|
@@ -178,12 +179,20 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
|
|
|
178
179
|
// CoverSection — normalize with defaults for null fields (Session 176)
|
|
179
180
|
if (item._type === "coverSection") {
|
|
180
181
|
const raw = item as unknown as CoverSection;
|
|
181
|
-
const
|
|
182
|
+
const rawRows = (raw.cover_rows ?? []).map((r) => ({
|
|
182
183
|
...r,
|
|
183
184
|
_key: r._key || generateKey(),
|
|
184
185
|
height_percent: r.height_percent ?? 100,
|
|
185
186
|
vertical_align: r.vertical_align ?? "start",
|
|
186
187
|
}));
|
|
188
|
+
// Defend the sum-to-100 invariant against hand-edited or legacy docs:
|
|
189
|
+
// the schema sum-check is newer than the oldest docs that may be in
|
|
190
|
+
// Sanity, so a page saved before Session 178 could have drift.
|
|
191
|
+
const normalizedPercents = normalizeRowHeights(rawRows.map((r) => r.height_percent));
|
|
192
|
+
const coverRows = rawRows.map((r, i) => ({
|
|
193
|
+
...r,
|
|
194
|
+
height_percent: normalizedPercents[i] ?? r.height_percent,
|
|
195
|
+
}));
|
|
187
196
|
return {
|
|
188
197
|
...raw,
|
|
189
198
|
_key: (raw._key as string) || generateKey(),
|
|
@@ -217,7 +226,7 @@ export function migrateContentItem(item: Record<string, unknown>): ContentItem {
|
|
|
217
226
|
/**
|
|
218
227
|
* Convert a Sanity `page` document into the builder's state shape.
|
|
219
228
|
*/
|
|
220
|
-
export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "
|
|
229
|
+
export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
|
|
221
230
|
const docRecord = doc as unknown as Record<string, unknown>;
|
|
222
231
|
const pageSettings = docRecord.page_settings as Record<string, unknown> | undefined;
|
|
223
232
|
|
|
@@ -254,10 +263,10 @@ export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSa
|
|
|
254
263
|
text_color: (pageSettings?.text_color as string) || DEFAULT_TEXT_COLOR,
|
|
255
264
|
nav_color,
|
|
256
265
|
enter_animation: pageSettings?.enter_animation as PageSettings["enter_animation"],
|
|
257
|
-
nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"])
|
|
258
|
-
nav_entrance_duration: (pageSettings?.nav_entrance_duration as number)
|
|
259
|
-
nav_entrance_delay: (pageSettings?.nav_entrance_delay as number)
|
|
260
|
-
nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean)
|
|
266
|
+
nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"]) ?? undefined,
|
|
267
|
+
nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) ?? undefined,
|
|
268
|
+
nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) ?? undefined,
|
|
269
|
+
nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) ?? undefined,
|
|
261
270
|
},
|
|
262
271
|
// Grid settings are loaded separately via applyGlobalStyles(), not from the page document.
|
|
263
272
|
// Provide defaults here to satisfy the type; they'll be overwritten on init.
|
|
@@ -349,9 +349,9 @@ export function stateToDocument(
|
|
|
349
349
|
nav_color: navColor || undefined,
|
|
350
350
|
enter_animation: hasEnterAnimation ? enterAnim : undefined,
|
|
351
351
|
nav_entrance_animation: navEntranceAnimation || undefined,
|
|
352
|
-
nav_entrance_duration: navEntranceDuration
|
|
353
|
-
nav_entrance_delay: navEntranceDelay
|
|
354
|
-
nav_entrance_disabled: navEntranceDisabled
|
|
352
|
+
nav_entrance_duration: navEntranceDuration ?? undefined,
|
|
353
|
+
nav_entrance_delay: navEntranceDelay ?? undefined,
|
|
354
|
+
nav_entrance_disabled: navEntranceDisabled ?? undefined,
|
|
355
355
|
}
|
|
356
356
|
: undefined,
|
|
357
357
|
draft_mode: state.draftMode,
|
|
@@ -4,6 +4,7 @@ import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sani
|
|
|
4
4
|
import { createDefaultBlock } from "./defaults";
|
|
5
5
|
import { generateKey } from "./utils";
|
|
6
6
|
import { findSectionPath, updateSectionAtPath, moveBlockInState } from "./store-helpers";
|
|
7
|
+
import { pushSnapshot } from "./history";
|
|
7
8
|
|
|
8
9
|
type StoreSet = (
|
|
9
10
|
partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
|
|
@@ -129,15 +130,21 @@ export function createBlockActions(set: StoreSet, get: StoreGet) {
|
|
|
129
130
|
},
|
|
130
131
|
|
|
131
132
|
moveBlock: (blockKey: string, targetSectionKey: string, targetColumnKey: string, toIndex: number): void => {
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
// Do read+validate+snapshot+set atomically inside a single functional
|
|
134
|
+
// update so the snapshot can never drift from the rows the move was
|
|
135
|
+
// computed against. Previously the snapshot was captured via get() AFTER
|
|
136
|
+
// the read, leaving a window where a concurrent mutation could make the
|
|
137
|
+
// undo target inconsistent with the move that just landed.
|
|
138
|
+
set((state) => {
|
|
139
|
+
const result = moveBlockInState(state.rows, blockKey, targetSectionKey, targetColumnKey, toIndex);
|
|
140
|
+
if (!result) return state;
|
|
141
|
+
return {
|
|
142
|
+
rows: result.rows,
|
|
143
|
+
isDirty: true,
|
|
144
|
+
_history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
|
|
145
|
+
_future: [],
|
|
146
|
+
};
|
|
147
|
+
});
|
|
141
148
|
},
|
|
142
149
|
|
|
143
150
|
reorderBlocks: (sectionKey: string, columnKey: string, fromIndex: number, toIndex: number): void => {
|
|
@@ -27,6 +27,45 @@ type StoreGet = () => BuilderState & { _pushSnapshot: () => void };
|
|
|
27
27
|
|
|
28
28
|
const MIN_ROW_PERCENT = 10;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Clamp and normalize an array of cover-row heights so they always respect
|
|
32
|
+
* the sum-to-100 invariant. Used to defend against bad data returned by
|
|
33
|
+
* Sanity (hand-edited documents, migrations, older saves).
|
|
34
|
+
*
|
|
35
|
+
* - Clamps each value into [MIN_ROW_PERCENT, 95] so the UI resizer can always
|
|
36
|
+
* edit every row without hitting an unreachable bound
|
|
37
|
+
* - Scales the set to sum to exactly 100
|
|
38
|
+
* - Falls back to equal distribution if the input is empty or all zero
|
|
39
|
+
*
|
|
40
|
+
* Pure function — no side effects.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeRowHeights(percents: readonly number[]): number[] {
|
|
43
|
+
if (percents.length === 0) return [];
|
|
44
|
+
|
|
45
|
+
const clamped = percents.map((p) => {
|
|
46
|
+
if (!Number.isFinite(p) || p <= 0) return 0;
|
|
47
|
+
return Math.min(95, Math.max(MIN_ROW_PERCENT, p));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const total = clamped.reduce((a, b) => a + b, 0);
|
|
51
|
+
|
|
52
|
+
if (total === 0) {
|
|
53
|
+
// Equal distribution fallback
|
|
54
|
+
const share = 100 / percents.length;
|
|
55
|
+
return percents.map(() => share);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Scale to sum exactly 100
|
|
59
|
+
const scaled = clamped.map((p) => (p / total) * 100);
|
|
60
|
+
|
|
61
|
+
// Round to 3 decimals, then adjust the first row so the sum is exactly 100
|
|
62
|
+
// (guards against floating-point drift like 99.9999…)
|
|
63
|
+
const rounded = scaled.map((p) => Math.round(p * 1000) / 1000);
|
|
64
|
+
const drift = 100 - rounded.reduce((a, b) => a + b, 0);
|
|
65
|
+
if (rounded.length > 0) rounded[0] = Math.round((rounded[0] + drift) * 1000) / 1000;
|
|
66
|
+
return rounded;
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
function updateCoverInRows(
|
|
31
70
|
rows: ContentItem[],
|
|
32
71
|
sectionKey: string,
|
|
@@ -66,14 +105,35 @@ export function createCoverActions(set: StoreSet, get: StoreGet) {
|
|
|
66
105
|
set((state) => ({
|
|
67
106
|
rows: updateCoverInRows(state.rows, sectionKey, (section) => {
|
|
68
107
|
if (section.cover_rows.length >= 5) return section;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
|
|
109
|
+
// Preserve existing proportions. The new row gets a share equal to
|
|
110
|
+
// the average of the current rows, and the existing rows are
|
|
111
|
+
// scaled down so the total still sums to exactly 100.
|
|
112
|
+
//
|
|
113
|
+
// Example:
|
|
114
|
+
// before: [40, 60] (2 rows)
|
|
115
|
+
// new row share = 100 / (2 + 1) = 33.33
|
|
116
|
+
// scale = (100 - 33.33) / 100 = 0.6667
|
|
117
|
+
// after: [40*0.6667, 60*0.6667, 33.33] = [26.67, 40.00, 33.33]
|
|
118
|
+
//
|
|
119
|
+
// This keeps the user's resize work intact instead of resetting
|
|
120
|
+
// every row to an equal share.
|
|
121
|
+
const existing = section.cover_rows;
|
|
122
|
+
const newShare = 100 / (existing.length + 1);
|
|
123
|
+
const scale = (100 - newShare) / 100;
|
|
124
|
+
const scaledRows = existing.map((r) => ({
|
|
125
|
+
...r,
|
|
126
|
+
height_percent: r.height_percent * scale,
|
|
127
|
+
}));
|
|
128
|
+
scaledRows.push(createDefaultCoverRow(newShare));
|
|
129
|
+
|
|
130
|
+
// Normalize to absorb rounding drift and clamp to [MIN_ROW_PERCENT, 95]
|
|
131
|
+
const normalized = normalizeRowHeights(scaledRows.map((r) => r.height_percent));
|
|
132
|
+
const newRows = scaledRows.map((r, i) => ({
|
|
73
133
|
...r,
|
|
74
|
-
height_percent:
|
|
134
|
+
height_percent: normalized[i] ?? r.height_percent,
|
|
75
135
|
}));
|
|
76
|
-
|
|
136
|
+
|
|
77
137
|
return { ...section, cover_rows: newRows };
|
|
78
138
|
}),
|
|
79
139
|
isDirty: true,
|
|
@@ -101,6 +161,13 @@ export function createCoverActions(set: StoreSet, get: StoreGet) {
|
|
|
101
161
|
return r;
|
|
102
162
|
});
|
|
103
163
|
|
|
164
|
+
// Normalize to absorb any floating-point drift and keep the invariant
|
|
165
|
+
const normalized = normalizeRowHeights(redistributed.map((r) => r.height_percent));
|
|
166
|
+
const finalRows = redistributed.map((r, i) => ({
|
|
167
|
+
...r,
|
|
168
|
+
height_percent: normalized[i] ?? r.height_percent,
|
|
169
|
+
}));
|
|
170
|
+
|
|
104
171
|
const filteredColumns = section.columns.filter(
|
|
105
172
|
(c) => c.grid_row !== rowIndex + 1
|
|
106
173
|
);
|
|
@@ -111,7 +178,7 @@ export function createCoverActions(set: StoreSet, get: StoreGet) {
|
|
|
111
178
|
|
|
112
179
|
return {
|
|
113
180
|
...section,
|
|
114
|
-
cover_rows:
|
|
181
|
+
cover_rows: finalRows,
|
|
115
182
|
columns: reindexedColumns,
|
|
116
183
|
};
|
|
117
184
|
}),
|
|
@@ -177,7 +244,8 @@ export function createCoverActions(set: StoreSet, get: StoreGet) {
|
|
|
177
244
|
fields: Partial<Pick<CoverSection,
|
|
178
245
|
"background_type" | "background_color" | "background_image" | "background_video" |
|
|
179
246
|
"background_position" | "background_size" |
|
|
180
|
-
"background_overlay_color" | "background_overlay_opacity"
|
|
247
|
+
"background_overlay_color" | "background_overlay_opacity" |
|
|
248
|
+
"nav_color"
|
|
181
249
|
>>
|
|
182
250
|
): void => {
|
|
183
251
|
get()._pushSnapshot();
|
|
@@ -57,7 +57,7 @@ export function createSectionActions(set: StoreSet, get: StoreGet) {
|
|
|
57
57
|
* Existing V1 pages still work — V1 update/delete actions are kept until
|
|
58
58
|
* the data migration in Session 165.
|
|
59
59
|
*/
|
|
60
|
-
addSection: (blockType: "projectGridBlock", afterRowKey?: string | null): void => {
|
|
60
|
+
addSection: (blockType: "projectGridBlock" | "projectCarouselBlock", afterRowKey?: string | null): void => {
|
|
61
61
|
get()._pushSnapshot();
|
|
62
62
|
const block = createDefaultBlock(blockType);
|
|
63
63
|
const gridColumns = 12;
|
package/lib/builder/store.ts
CHANGED
|
@@ -136,7 +136,6 @@ const initialState: BuilderState = {
|
|
|
136
136
|
// History
|
|
137
137
|
_history: [],
|
|
138
138
|
_future: [],
|
|
139
|
-
_isTimeTraveling: false,
|
|
140
139
|
|
|
141
140
|
// Color picker live preview (Phase 4)
|
|
142
141
|
colorPickerPreview: null,
|
|
@@ -273,7 +272,6 @@ export const useBuilderStore = create<BuilderStore>((set, get) => ({
|
|
|
273
272
|
// Reset history on document load
|
|
274
273
|
_history: [],
|
|
275
274
|
_future: [],
|
|
276
|
-
_isTimeTraveling: false,
|
|
277
275
|
});
|
|
278
276
|
},
|
|
279
277
|
|
package/lib/builder/types.ts
CHANGED
|
@@ -62,6 +62,7 @@ export const ALL_BLOCK_INFO: BlockTypeInfo[] = [
|
|
|
62
62
|
...BLOCK_TYPE_REGISTRY,
|
|
63
63
|
// Section blocks — not in the content picker but still need label/icon lookup
|
|
64
64
|
{ type: "projectGridBlock", label: "Project Grid", description: "Staggered project showcase grid", group: "generic", icon: "⬡", category: "section" },
|
|
65
|
+
{ type: "projectCarouselBlock", label: "Project Carousel", description: "Horizontal carousel of projects — great for end-of-page 'keep browsing'", group: "generic", icon: "▸", category: "section" },
|
|
65
66
|
];
|
|
66
67
|
|
|
67
68
|
// Parallax group info — used by BuilderCanvas/SortableRow for label/icon lookup (not a block)
|
|
@@ -72,10 +73,13 @@ export const PARALLAX_GROUP_INFO = { label: "Parallax Showcase", icon: "▽" };
|
|
|
72
73
|
// ============================================
|
|
73
74
|
|
|
74
75
|
/** Section block types that create a full-width row with a pre-populated block */
|
|
75
|
-
export type SectionBlockType = "projectGridBlock";
|
|
76
|
+
export type SectionBlockType = "projectGridBlock" | "projectCarouselBlock";
|
|
76
77
|
|
|
77
78
|
/** Set for fast lookup — used by SortableBlock, ColumnDropZone, SortableRow to suppress inner chrome */
|
|
78
|
-
const SECTION_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
79
|
+
const SECTION_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
80
|
+
"projectGridBlock",
|
|
81
|
+
"projectCarouselBlock",
|
|
82
|
+
]);
|
|
79
83
|
|
|
80
84
|
/** Check if a block type is a section-level block (should render without block/column chrome) */
|
|
81
85
|
export function isSectionBlockType(type: string): boolean {
|
|
@@ -107,6 +111,7 @@ export const SECTION_TYPE_REGISTRY: SectionTypeInfo[] = [
|
|
|
107
111
|
{ type: "empty-v2", label: "Empty Section", description: "Grid section with flexible columns", icon: "⊞" },
|
|
108
112
|
{ type: "coverSection", label: "Cover Section", description: "Full-viewport section with background and proportional rows", icon: "◆" },
|
|
109
113
|
{ type: "projectGridBlock", label: "Project Grid", description: "Staggered project showcase grid", icon: "⬡", blockType: "projectGridBlock" },
|
|
114
|
+
{ type: "projectCarouselBlock", label: "Project Carousel", description: "Horizontal 'keep browsing' carousel of projects", icon: "▸", blockType: "projectCarouselBlock" },
|
|
110
115
|
{ type: "parallaxGroup", label: "Parallax Section", description: "Full-screen parallax showcase with V2 slides", icon: "▽" },
|
|
111
116
|
];
|
|
112
117
|
|
|
@@ -235,7 +240,6 @@ export interface BuilderState {
|
|
|
235
240
|
// History (Undo/Redo) — BUG-010 fix: snapshots include pageSettings
|
|
236
241
|
_history: import("./history").HistorySnapshot[];
|
|
237
242
|
_future: import("./history").HistorySnapshot[];
|
|
238
|
-
_isTimeTraveling: boolean;
|
|
239
243
|
|
|
240
244
|
/** Cache of fetched custom section base settings, keyed by custom_section_id.
|
|
241
245
|
* Populated by CustomSectionInstanceCard/ReadOnlyCustomSection on fetch.
|
|
@@ -267,7 +271,7 @@ export interface BuilderActions {
|
|
|
267
271
|
|
|
268
272
|
// Section operations
|
|
269
273
|
/** Add a Project Grid section as a V2 section with a full-width column (Session 164) */
|
|
270
|
-
addSection: (blockType: "projectGridBlock", afterRowKey?: string | null) => void;
|
|
274
|
+
addSection: (blockType: "projectGridBlock" | "projectCarouselBlock", afterRowKey?: string | null) => void;
|
|
271
275
|
reorderRows: (fromIndex: number, toIndex: number) => void;
|
|
272
276
|
/** Delete a section by key */
|
|
273
277
|
deleteSection: (sectionKey: string) => void;
|
|
@@ -383,7 +387,7 @@ export interface BuilderActions {
|
|
|
383
387
|
removeCoverRow: (sectionKey: string, rowKey: string) => void;
|
|
384
388
|
resizeCoverRow: (sectionKey: string, handleIndex: number, deltaPercent: number, startAbove: number, startBelow: number) => void;
|
|
385
389
|
updateCoverRowAlign: (sectionKey: string, rowKey: string, align: CoverRow["vertical_align"]) => void;
|
|
386
|
-
updateCoverBackground: (sectionKey: string, fields: Partial<Pick<CoverSection, "background_type" | "background_color" | "background_image" | "background_video" | "background_position" | "background_size" | "background_overlay_color" | "background_overlay_opacity">>) => void;
|
|
390
|
+
updateCoverBackground: (sectionKey: string, fields: Partial<Pick<CoverSection, "background_type" | "background_color" | "background_image" | "background_video" | "background_position" | "background_size" | "background_overlay_color" | "background_overlay_opacity" | "nav_color">>) => void;
|
|
387
391
|
updateCoverSettings: (sectionKey: string, settings: Partial<CoverSectionSettings>) => void;
|
|
388
392
|
updateCoverHeight: (sectionKey: string, height: CoverSection["height"]) => void;
|
|
389
393
|
|
package/lib/csrf.ts
CHANGED
|
@@ -57,6 +57,27 @@ export function validateCsrf(request: NextRequest): boolean {
|
|
|
57
57
|
return cookieToken === headerToken;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Defense-in-depth companion to validateCsrf for JSON endpoints.
|
|
62
|
+
*
|
|
63
|
+
* Our CSRF check pairs a SameSite=strict cookie with a custom X-CSRF-Token
|
|
64
|
+
* header, which is already strong. But some legacy attack vectors (CSRF via
|
|
65
|
+
* <form enctype="text/plain"> / multipart POST) are most easily shut down by
|
|
66
|
+
* also requiring `Content-Type: application/json` on every mutation. A
|
|
67
|
+
* browser form submission cannot set that Content-Type without CORS preflight
|
|
68
|
+
* — which our endpoints do not whitelist — so enforcing it closes off those
|
|
69
|
+
* vectors entirely.
|
|
70
|
+
*
|
|
71
|
+
* Returns true if the request body is (or is plausibly) JSON.
|
|
72
|
+
*/
|
|
73
|
+
export function hasJsonContentType(request: NextRequest): boolean {
|
|
74
|
+
const raw = request.headers.get("content-type");
|
|
75
|
+
if (!raw) return false;
|
|
76
|
+
// Allow parameters (charset, boundary, etc.) — match the media type only.
|
|
77
|
+
const mediaType = raw.split(";")[0].trim().toLowerCase();
|
|
78
|
+
return mediaType === "application/json";
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
/**
|
|
61
82
|
* Return a 403 response for CSRF validation failure.
|
|
62
83
|
*/
|
|
@@ -66,3 +87,13 @@ export function csrfErrorResponse(): NextResponse {
|
|
|
66
87
|
{ status: 403 }
|
|
67
88
|
);
|
|
68
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Standard 415 response for wrong-content-type rejections.
|
|
93
|
+
*/
|
|
94
|
+
export function contentTypeErrorResponse(): NextResponse {
|
|
95
|
+
return NextResponse.json(
|
|
96
|
+
{ error: "Content-Type must be application/json" },
|
|
97
|
+
{ status: 415 }
|
|
98
|
+
);
|
|
99
|
+
}
|
package/lib/sanity/types.ts
CHANGED
|
@@ -240,6 +240,54 @@ export interface CardEntranceConfig {
|
|
|
240
240
|
duration?: number; // ms, default 500
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Project Carousel Block — horizontal "keep browsing" carousel of projects.
|
|
245
|
+
*
|
|
246
|
+
* Section-level block (lives in a full-width column). Unlike `projectGridBlock`
|
|
247
|
+
* there is no manual selection list — projects are pulled automatically (latest
|
|
248
|
+
* or random), optionally excluding the project currently being viewed when the
|
|
249
|
+
* page matches `/work/[slug]`.
|
|
250
|
+
*
|
|
251
|
+
* Intentionally independent from `ProjectGridBlock` — they share visual
|
|
252
|
+
* primitives and the same `/api/projects` endpoint but no coupled code.
|
|
253
|
+
*/
|
|
254
|
+
export interface ProjectCarouselBlock {
|
|
255
|
+
_type: "projectCarouselBlock";
|
|
256
|
+
_key: string;
|
|
257
|
+
|
|
258
|
+
// ─── Source ───
|
|
259
|
+
source_mode?: "auto_latest" | "auto_random";
|
|
260
|
+
max_projects?: number; // 2–20, default 8
|
|
261
|
+
exclude_current?: boolean; // default true
|
|
262
|
+
|
|
263
|
+
// ─── Layout ───
|
|
264
|
+
cards_per_view_desktop?: number; // default 3.5 (fractional → peek next card)
|
|
265
|
+
cards_per_view_tablet?: number; // default 2.2
|
|
266
|
+
cards_per_view_phone?: number; // default 1.2
|
|
267
|
+
gap?: number; // px, default 16
|
|
268
|
+
|
|
269
|
+
// ─── Card display ───
|
|
270
|
+
aspect_ratio?: "16/9" | "4/3" | "1/1" | "3/4" | "9/16";
|
|
271
|
+
show_title?: boolean; // default true
|
|
272
|
+
show_subtitle?: boolean; // default false
|
|
273
|
+
border_radius?: number; // px, default 0
|
|
274
|
+
hover_effect?: "scale" | "none"; // default "scale"
|
|
275
|
+
video_mode?: "off" | "hover" | "autoloop"; // default "off"
|
|
276
|
+
|
|
277
|
+
// ─── Controls ───
|
|
278
|
+
show_arrows?: boolean; // default true
|
|
279
|
+
show_dots?: boolean; // default false
|
|
280
|
+
snap_scroll?: boolean; // default true
|
|
281
|
+
|
|
282
|
+
// ─── Card entrance animation ───
|
|
283
|
+
card_entrance?: CardEntranceConfig;
|
|
284
|
+
|
|
285
|
+
// ─── Standard block fields ───
|
|
286
|
+
enter_animation?: import("../../lib/animation/enter-types").EnterAnimationConfig;
|
|
287
|
+
layout?: BlockLayout;
|
|
288
|
+
responsive?: ResponsiveOverrides<ProjectCarouselBlock>;
|
|
289
|
+
}
|
|
290
|
+
|
|
243
291
|
export interface ProjectGridBlock {
|
|
244
292
|
_type: "projectGridBlock";
|
|
245
293
|
_key: string;
|
|
@@ -443,7 +491,7 @@ export interface CustomSectionInstance {
|
|
|
443
491
|
// Uses the same SectionColumn/block system as V2 but with explicit
|
|
444
492
|
// row heights (percentages) instead of content-driven auto rows.
|
|
445
493
|
|
|
446
|
-
export type CoverSectionHeight = "100vh" | "80vh" | "50vh";
|
|
494
|
+
export type CoverSectionHeight = "100vh" | "80vh" | "50vh" | "20vh";
|
|
447
495
|
|
|
448
496
|
/** A single proportional row within a Cover Section */
|
|
449
497
|
export interface CoverRow {
|
|
@@ -500,6 +548,9 @@ export interface CoverSection {
|
|
|
500
548
|
background_overlay_color?: string;
|
|
501
549
|
background_overlay_opacity?: number; // 0–100
|
|
502
550
|
|
|
551
|
+
// Nav color override — hex applied to the navbar while this cover is on screen
|
|
552
|
+
nav_color?: string;
|
|
553
|
+
|
|
503
554
|
// Height
|
|
504
555
|
height: CoverSectionHeight;
|
|
505
556
|
|
|
@@ -567,7 +618,8 @@ export type ContentBlock =
|
|
|
567
618
|
| VideoBlock
|
|
568
619
|
| SpacerBlock
|
|
569
620
|
| ButtonBlock
|
|
570
|
-
| ProjectGridBlock
|
|
621
|
+
| ProjectGridBlock
|
|
622
|
+
| ProjectCarouselBlock;
|
|
571
623
|
|
|
572
624
|
// ============================================
|
|
573
625
|
// Structural types
|
package/lib/security.ts
CHANGED
|
@@ -228,6 +228,56 @@ export function isValidAssetPath(path: string): boolean {
|
|
|
228
228
|
return true;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/** Top-level prefixes that may not be written to via the presigned upload API.
|
|
232
|
+
* `_thumbs/` is the one allowed underscore-prefixed folder (auto-thumbnail pipeline). */
|
|
233
|
+
const DISALLOWED_UPLOAD_PREFIXES = ["backup/", "backups/", "__system/", "system/"];
|
|
234
|
+
const MAX_UPLOAD_KEY_DEPTH = 10;
|
|
235
|
+
const MAX_UPLOAD_KEY_LENGTH = 1024;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate that a presigned-upload R2 key is safe to sign.
|
|
239
|
+
*
|
|
240
|
+
* Builds on `isValidAssetPath` but adds defense-in-depth for keys that
|
|
241
|
+
* make it into a signed URL:
|
|
242
|
+
* - Max length 1024 bytes (R2 cap is higher, but we keep UI-friendly keys)
|
|
243
|
+
* - Max depth 10 folder levels (prevent pathological nesting)
|
|
244
|
+
* - Reject hidden keys (starting with `.`) except the `.folder` placeholder
|
|
245
|
+
* - Reject leading `_` except the `_thumbs/` sub-tree
|
|
246
|
+
* - Reject reserved prefixes (backup/, system/, etc.)
|
|
247
|
+
*
|
|
248
|
+
* Accepts the full R2 key (e.g. `projects/hero.jpg`, `_thumbs/projects/hero.jpg`).
|
|
249
|
+
*/
|
|
250
|
+
export function isValidUploadKey(key: string): boolean {
|
|
251
|
+
if (!isValidAssetPath(key)) return false;
|
|
252
|
+
if (key.length > MAX_UPLOAD_KEY_LENGTH) return false;
|
|
253
|
+
|
|
254
|
+
const segments = key.split("/");
|
|
255
|
+
if (segments.length > MAX_UPLOAD_KEY_DEPTH) return false;
|
|
256
|
+
|
|
257
|
+
for (const segment of segments) {
|
|
258
|
+
if (!segment) return false; // empty segment = consecutive slashes
|
|
259
|
+
if (segment === "." || segment === "..") return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const firstSegment = segments[0];
|
|
263
|
+
const filename = segments[segments.length - 1];
|
|
264
|
+
|
|
265
|
+
// Hidden filename check — reject `.hidden` anywhere except the final-segment
|
|
266
|
+
// placeholder `.folder` used to create empty folders on R2.
|
|
267
|
+
if (filename.startsWith(".") && filename !== ".folder") return false;
|
|
268
|
+
|
|
269
|
+
// Leading-underscore prefixes are reserved for system folders — only _thumbs allowed
|
|
270
|
+
if (firstSegment.startsWith("_") && firstSegment !== "_thumbs") return false;
|
|
271
|
+
|
|
272
|
+
// Explicit denylist
|
|
273
|
+
const normalizedLead = `${firstSegment}/`.toLowerCase();
|
|
274
|
+
for (const banned of DISALLOWED_UPLOAD_PREFIXES) {
|
|
275
|
+
if (normalizedLead === banned.toLowerCase()) return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
231
281
|
// ─── Font Magic Byte Validation ───────────────────────────────────────────
|
|
232
282
|
|
|
233
283
|
interface FontMagicBytes {
|
package/lib/version.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Block schemas (
|
|
1
|
+
// Block schemas (8)
|
|
2
2
|
export { textBlock } from "./textBlock";
|
|
3
3
|
export { imageBlock } from "./imageBlock";
|
|
4
4
|
export { imageGridBlock } from "./imageGridBlock";
|
|
@@ -6,3 +6,4 @@ export { videoBlock } from "./videoBlock";
|
|
|
6
6
|
export { spacerBlock } from "./spacerBlock";
|
|
7
7
|
export { buttonBlock } from "./buttonBlock";
|
|
8
8
|
export { projectGridBlock } from "./projectGridBlock";
|
|
9
|
+
export { projectCarouselBlock } from "./projectCarouselBlock";
|