@morphika/andami 0.2.25 → 0.3.1

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 (63) hide show
  1. package/app/admin/pages/[slug]/page.tsx +39 -45
  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 +11 -2
  15. package/components/blocks/CoverSectionRenderer.tsx +76 -4
  16. package/components/blocks/ImageGridBlockRenderer.tsx +17 -11
  17. package/components/blocks/ParallaxGroupRenderer.tsx +45 -10
  18. package/components/blocks/ParallaxSlideRenderer.tsx +1 -1
  19. package/components/blocks/ShaderCanvas.tsx +10 -6
  20. package/components/builder/BlockCardIcons.tsx +227 -0
  21. package/components/builder/BlockTypePicker.tsx +36 -63
  22. package/components/builder/BuilderCanvas.tsx +6 -2
  23. package/components/builder/ColumnDragOverlay.tsx +3 -3
  24. package/components/builder/CoverRowResizeHandle.tsx +5 -2
  25. package/components/builder/CoverSectionCanvas.tsx +45 -52
  26. package/components/builder/DndWrapper.tsx +1 -1
  27. package/components/builder/InsertionLines.tsx +1 -1
  28. package/components/builder/ParallaxGroupCanvas.tsx +13 -72
  29. package/components/builder/ReadOnlyFrame.tsx +16 -2
  30. package/components/builder/SectionCardIcons.tsx +266 -0
  31. package/components/builder/SectionEditorBar.tsx +17 -12
  32. package/components/builder/SectionTypePicker.tsx +33 -137
  33. package/components/builder/SectionV2Canvas.tsx +1 -1
  34. package/components/builder/SectionV2Column.tsx +19 -30
  35. package/components/builder/SettingsPanel.tsx +8 -32
  36. package/components/builder/SortableBlock.tsx +42 -50
  37. package/components/builder/SortableRow.tsx +207 -19
  38. package/components/builder/blockStyles.tsx +53 -180
  39. package/components/builder/iconPrimitives.tsx +78 -0
  40. package/components/builder/live-preview/LiveImagePreview.tsx +16 -2
  41. package/components/builder/live-preview/LiveVideoPreview.tsx +15 -2
  42. package/components/builder/settings-panel/ColumnV2Settings.tsx +5 -5
  43. package/components/builder/settings-panel/CoverSectionSettings.tsx +28 -1
  44. package/components/builder/settings-panel/SectionV2Settings.tsx +14 -14
  45. package/lib/assets.ts +17 -2
  46. package/lib/builder/constants.ts +22 -15
  47. package/lib/builder/format.ts +25 -0
  48. package/lib/builder/history.ts +0 -3
  49. package/lib/builder/layout-styles.ts +1 -1
  50. package/lib/builder/section-visibility.ts +36 -0
  51. package/lib/builder/serializer/normalizers.ts +15 -6
  52. package/lib/builder/serializer/serializers.ts +3 -3
  53. package/lib/builder/store-blocks.ts +16 -9
  54. package/lib/builder/store-cover.ts +76 -8
  55. package/lib/builder/store.ts +0 -2
  56. package/lib/builder/types.ts +1 -2
  57. package/lib/csrf.ts +31 -0
  58. package/lib/sanity/types.ts +4 -1
  59. package/lib/security.ts +50 -0
  60. package/lib/version.ts +1 -1
  61. package/package.json +1 -1
  62. package/sanity/schemas/objects/coverSection.ts +35 -3
  63. package/components/builder/ParallaxSlideHeader.tsx +0 -113
@@ -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();
@@ -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
 
@@ -235,7 +235,6 @@ export interface BuilderState {
235
235
  // History (Undo/Redo) — BUG-010 fix: snapshots include pageSettings
236
236
  _history: import("./history").HistorySnapshot[];
237
237
  _future: import("./history").HistorySnapshot[];
238
- _isTimeTraveling: boolean;
239
238
 
240
239
  /** Cache of fetched custom section base settings, keyed by custom_section_id.
241
240
  * Populated by CustomSectionInstanceCard/ReadOnlyCustomSection on fetch.
@@ -383,7 +382,7 @@ export interface BuilderActions {
383
382
  removeCoverRow: (sectionKey: string, rowKey: string) => void;
384
383
  resizeCoverRow: (sectionKey: string, handleIndex: number, deltaPercent: number, startAbove: number, startBelow: number) => void;
385
384
  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;
385
+ 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
386
  updateCoverSettings: (sectionKey: string, settings: Partial<CoverSectionSettings>) => void;
388
387
  updateCoverHeight: (sectionKey: string, height: CoverSection["height"]) => void;
389
388
 
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
+ }
@@ -443,7 +443,7 @@ export interface CustomSectionInstance {
443
443
  // Uses the same SectionColumn/block system as V2 but with explicit
444
444
  // row heights (percentages) instead of content-driven auto rows.
445
445
 
446
- export type CoverSectionHeight = "100vh" | "80vh" | "50vh";
446
+ export type CoverSectionHeight = "100vh" | "80vh" | "50vh" | "20vh";
447
447
 
448
448
  /** A single proportional row within a Cover Section */
449
449
  export interface CoverRow {
@@ -500,6 +500,9 @@ export interface CoverSection {
500
500
  background_overlay_color?: string;
501
501
  background_overlay_opacity?: number; // 0–100
502
502
 
503
+ // Nav color override — hex applied to the navbar while this cover is on screen
504
+ nav_color?: string;
505
+
503
506
  // Height
504
507
  height: CoverSectionHeight;
505
508
 
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.25";
9
+ export const ANDAMI_VERSION = "0.3.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.2.25",
3
+ "version": "0.3.1",
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",
@@ -81,10 +81,31 @@ const responsiveRowOverrideFields = [
81
81
  type: "object",
82
82
  name: "coverRowOverride",
83
83
  fields: [
84
- defineField({ name: "height_percent", title: "Height %", type: "number" }),
84
+ defineField({
85
+ name: "height_percent",
86
+ title: "Height %",
87
+ type: "number",
88
+ // [10, 95] matches the resize handle's MIN_ROW_PERCENT in
89
+ // store-cover.ts. Letting the schema accept values below 10
90
+ // created rows that the UI resizer could not edit without
91
+ // an unexpected normalization jump.
92
+ validation: (Rule) => Rule.min(10).max(95),
93
+ }),
85
94
  ],
86
95
  },
87
96
  ],
97
+ // Sum-to-100 invariant — applied across the array of overrides.
98
+ validation: (Rule) =>
99
+ Rule.custom((rows: unknown) => {
100
+ if (!Array.isArray(rows) || rows.length === 0) return true; // nothing to check
101
+ const total = rows.reduce((acc: number, row: unknown) => {
102
+ const pct = (row as { height_percent?: number } | null)?.height_percent;
103
+ return typeof pct === "number" ? acc + pct : acc;
104
+ }, 0);
105
+ // Allow ±1% drift for rounding artefacts from the resize handle.
106
+ if (Math.abs(total - 100) <= 1) return true;
107
+ return `Row heights must sum to 100% (got ${total.toFixed(1)}%)`;
108
+ }),
88
109
  }),
89
110
  ];
90
111
 
@@ -174,6 +195,16 @@ export default defineType({
174
195
  validation: (Rule) => Rule.min(0).max(100),
175
196
  }),
176
197
 
198
+ // ── Navbar Color Override ──
199
+ defineField({
200
+ name: "nav_color",
201
+ title: "Navbar Color",
202
+ type: "string",
203
+ description:
204
+ "Hex color applied to navbar text while this cover section is on screen. " +
205
+ "Clears when the next section takes over.",
206
+ }),
207
+
177
208
  // ── Section Height ──
178
209
  defineField({
179
210
  name: "height",
@@ -184,6 +215,7 @@ export default defineType({
184
215
  { title: "Full Viewport (100vh)", value: "100vh" },
185
216
  { title: "80% Viewport (80vh)", value: "80vh" },
186
217
  { title: "50% Viewport (50vh)", value: "50vh" },
218
+ { title: "20% Viewport (20vh)", value: "20vh" },
187
219
  ],
188
220
  },
189
221
  initialValue: "100vh",
@@ -204,8 +236,8 @@ export default defineType({
204
236
  name: "height_percent",
205
237
  title: "Height (%)",
206
238
  type: "number",
207
- description: "Row height as percentage of section (5–95)",
208
- validation: (Rule) => Rule.required().min(5).max(95),
239
+ description: "Row height as percentage of section (10–95)",
240
+ validation: (Rule) => Rule.required().min(10).max(95),
209
241
  }),
210
242
  defineField({
211
243
  name: "vertical_align",
@@ -1,113 +0,0 @@
1
- "use client";
2
-
3
- import { useBuilderStore } from "../../lib/builder/store";
4
- import type { ParallaxSlideV2 } from "../../lib/sanity/types";
5
- import { BUILDER_GREEN } from "../../lib/builder/constants";
6
-
7
- /**
8
- * ParallaxSlideHeader — thin header bar for each slide in a parallax group.
9
- * Shows slide index, background preview thumbnail, settings icon,
10
- * reorder arrows, and delete button.
11
- *
12
- * Session 123: Parallax V2 Phase 2
13
- */
14
-
15
- interface ParallaxSlideHeaderProps {
16
- slide: ParallaxSlideV2;
17
- slideIndex: number;
18
- totalSlides: number;
19
- groupKey: string;
20
- isSelected: boolean;
21
- onSelect: () => void;
22
- }
23
-
24
- export default function ParallaxSlideHeader({
25
- slide,
26
- slideIndex,
27
- totalSlides,
28
- groupKey,
29
- isSelected,
30
- onSelect,
31
- }: ParallaxSlideHeaderProps) {
32
- const store = useBuilderStore();
33
- const hasBg = slide.background_type === "image"
34
- ? !!slide.background_image
35
- : !!slide.background_video;
36
-
37
- return (
38
- <div
39
- className="flex items-center gap-2 px-3 py-1.5 rounded-t-lg cursor-pointer select-none transition-colors"
40
- style={{
41
- background: isSelected
42
- ? "linear-gradient(135deg, #c8a8ff 0%, #d8b8ff 100%)"
43
- : "#f5f0ff",
44
- borderBottom: "1px solid rgba(139, 92, 246, 0.15)",
45
- }}
46
- onClick={(e) => {
47
- e.stopPropagation();
48
- onSelect();
49
- }}
50
- >
51
- {/* Slide number */}
52
- <span
53
- className="flex items-center justify-center rounded-md text-[10px] font-bold text-white min-w-[22px] h-[22px] px-1"
54
- style={{ background: "#8b5cf6" }}
55
- >
56
- {slideIndex + 1}
57
- </span>
58
-
59
- {/* Background type indicator */}
60
- <span className="text-[10px] text-neutral-500 uppercase tracking-wider font-medium">
61
- {slide.background_type === "video" ? "Video BG" : "Image BG"}
62
- {hasBg && <span className="ml-1 text-green-500">●</span>}
63
- </span>
64
-
65
- {/* Spacer */}
66
- <div className="flex-1" />
67
-
68
- {/* Reorder arrows */}
69
- <button
70
- className="p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-30 transition-colors"
71
- disabled={slideIndex === 0}
72
- onClick={(e) => {
73
- e.stopPropagation();
74
- store.moveParallaxSlide(groupKey, slide._key, "up");
75
- }}
76
- title="Move slide up"
77
- >
78
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
79
- <path d="M6 3L9 7H3L6 3Z" fill="currentColor" />
80
- </svg>
81
- </button>
82
- <button
83
- className="p-0.5 text-neutral-400 hover:text-neutral-700 disabled:opacity-30 transition-colors"
84
- disabled={slideIndex === totalSlides - 1}
85
- onClick={(e) => {
86
- e.stopPropagation();
87
- store.moveParallaxSlide(groupKey, slide._key, "down");
88
- }}
89
- title="Move slide down"
90
- >
91
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
92
- <path d="M6 9L3 5H9L6 9Z" fill="currentColor" />
93
- </svg>
94
- </button>
95
-
96
- {/* Delete slide */}
97
- {totalSlides > 1 && (
98
- <button
99
- className="p-0.5 text-neutral-400 hover:text-red-500 transition-colors"
100
- onClick={(e) => {
101
- e.stopPropagation();
102
- store.removeParallaxSlide(groupKey, slide._key);
103
- }}
104
- title="Delete slide"
105
- >
106
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
107
- <path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
108
- </svg>
109
- </button>
110
- )}
111
- </div>
112
- );
113
- }