@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.
Files changed (81) hide show
  1. package/app/admin/pages/[slug]/page.tsx +41 -47
  2. package/app/api/admin/assets/scan/route.ts +40 -13
  3. package/app/api/admin/custom-sections/[slug]/route.ts +4 -1
  4. package/app/api/admin/custom-sections/route.ts +4 -1
  5. package/app/api/admin/pages/[slug]/route.ts +7 -1
  6. package/app/api/admin/pages/route.ts +4 -1
  7. package/app/api/admin/r2/connect/route.ts +19 -1
  8. package/app/api/admin/r2/disconnect/route.ts +3 -0
  9. package/app/api/admin/r2/rename/route.ts +52 -13
  10. package/app/api/admin/r2/upload-url/route.ts +8 -1
  11. package/app/api/admin/settings/route.ts +4 -1
  12. package/app/api/admin/styles/route.ts +4 -1
  13. package/components/admin/styles/GridLayoutEditor.tsx +46 -46
  14. package/components/blocks/BlockRenderer.tsx +15 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +75 -3
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ProjectCarouselBlockRenderer.tsx +527 -0
  19. package/components/blocks/ShaderCanvas.tsx +10 -6
  20. package/components/builder/BlockCardIcons.tsx +227 -0
  21. package/components/builder/BlockLivePreview.tsx +5 -0
  22. package/components/builder/BlockTypePicker.tsx +36 -63
  23. package/components/builder/BuilderCanvas.tsx +6 -2
  24. package/components/builder/ColumnDragOverlay.tsx +3 -3
  25. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  26. package/components/builder/CoverSectionCanvas.tsx +45 -52
  27. package/components/builder/DndWrapper.tsx +1 -1
  28. package/components/builder/InsertionLines.tsx +1 -1
  29. package/components/builder/ParallaxGroupCanvas.tsx +12 -71
  30. package/components/builder/ReadOnlyFrame.tsx +4 -23
  31. package/components/builder/SectionCardIcons.tsx +320 -0
  32. package/components/builder/SectionEditorBar.tsx +17 -12
  33. package/components/builder/SectionTypePicker.tsx +34 -138
  34. package/components/builder/SectionV2Canvas.tsx +1 -1
  35. package/components/builder/SectionV2Column.tsx +19 -30
  36. package/components/builder/SettingsPanel.tsx +8 -32
  37. package/components/builder/SortableBlock.tsx +42 -50
  38. package/components/builder/SortableRow.tsx +207 -19
  39. package/components/builder/blockStyles.tsx +59 -180
  40. package/components/builder/editors/ProjectCarouselBlockEditor.tsx +443 -0
  41. package/components/builder/editors/index.ts +1 -0
  42. package/components/builder/iconPrimitives.tsx +78 -0
  43. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  44. package/components/builder/live-preview/LiveProjectCarouselPreview.tsx +227 -0
  45. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  46. package/components/builder/live-preview/index.ts +1 -0
  47. package/components/builder/settings-panel/BlockSettings.tsx +7 -0
  48. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  49. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  50. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  51. package/lib/animation/enter-types.ts +1 -0
  52. package/lib/animation/hover-effect-types.ts +1 -0
  53. package/lib/assets.ts +17 -2
  54. package/lib/builder/block-registrations.ts +268 -0
  55. package/lib/builder/block-registry.ts +195 -0
  56. package/lib/builder/constants.ts +22 -15
  57. package/lib/builder/defaults.ts +21 -0
  58. package/lib/builder/format.ts +25 -0
  59. package/lib/builder/history.ts +0 -3
  60. package/lib/builder/index.ts +16 -0
  61. package/lib/builder/layout-styles.ts +1 -1
  62. package/lib/builder/registry.ts +44 -0
  63. package/lib/builder/section-visibility.ts +36 -0
  64. package/lib/builder/serializer/normalizers.ts +15 -6
  65. package/lib/builder/serializer/serializers.ts +3 -3
  66. package/lib/builder/store-blocks.ts +16 -9
  67. package/lib/builder/store-cover.ts +76 -8
  68. package/lib/builder/store-sections.ts +1 -1
  69. package/lib/builder/store.ts +0 -2
  70. package/lib/builder/types.ts +9 -5
  71. package/lib/csrf.ts +31 -0
  72. package/lib/sanity/types.ts +54 -2
  73. package/lib/security.ts +50 -0
  74. package/lib/version.ts +1 -1
  75. package/package.json +1 -1
  76. package/sanity/schemas/blocks/index.ts +2 -1
  77. package/sanity/schemas/blocks/projectCarouselBlock.ts +218 -0
  78. package/sanity/schemas/index.ts +4 -1
  79. package/sanity/schemas/objects/coverSection.ts +35 -3
  80. package/sanity/schemas/pageSectionV2.ts +1 -0
  81. 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 coverRows = (raw.cover_rows ?? []).map((r) => ({
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" | "_isTimeTraveling" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
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"]) || undefined,
258
- nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) || undefined,
259
- nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) || undefined,
260
- nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) || undefined,
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 || undefined,
353
- nav_entrance_delay: navEntranceDelay || undefined,
354
- nav_entrance_disabled: navEntranceDisabled || undefined,
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
- // RC-001 fix: Validate first without side effects, then push snapshot
133
- // BEFORE applying mutation. This ensures undo state captures the
134
- // pre-mutation rows, even if another mutation races in between.
135
- const currentRows = get().rows;
136
- const result = moveBlockInState(currentRows, blockKey, targetSectionKey, targetColumnKey, toIndex);
137
- if (!result) return;
138
-
139
- get()._pushSnapshot();
140
- set({ rows: result.rows, isDirty: true });
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
- const totalRows = section.cover_rows.length + 1;
70
- const equalPercent = Math.floor(100 / totalRows);
71
- const remainder = 100 - equalPercent * totalRows;
72
- const newRows = section.cover_rows.map((r, i) => ({
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: equalPercent + (i === 0 ? remainder : 0),
134
+ height_percent: normalized[i] ?? r.height_percent,
75
135
  }));
76
- newRows.push(createDefaultCoverRow(equalPercent));
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: redistributed,
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;
@@ -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
 
@@ -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>(["projectGridBlock"]);
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
+ }
@@ -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
@@ -6,4 +6,4 @@
6
6
  * Exposed as a plain constant so it can be imported without reading
7
7
  * package.json at runtime.
8
8
  */
9
- export const ANDAMI_VERSION = "0.2.26";
9
+ export const ANDAMI_VERSION = "0.4.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.2.26",
3
+ "version": "0.4.0",
4
4
  "description": "Visual Page Builder — core library. A reusable website builder with visual editing, CMS integration, and asset management.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,4 @@
1
- // Block schemas (7)
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";