@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,218 @@
1
+ import { defineField, defineType } from "sanity";
2
+ import { blockLayoutField, blockAnimationFields } from "./blockLayout";
3
+
4
+ /**
5
+ * projectCarouselBlock — horizontal carousel of projects.
6
+ *
7
+ * Intended use: "keep browsing" row at the end of a project page, but usable
8
+ * on any page. Projects are pulled automatically (latest / random) — no
9
+ * manual selection list. When placed on /work/[slug], the current project
10
+ * can be filtered out automatically via `exclude_current`.
11
+ *
12
+ * Treated as a **section-level block** (same semantics as projectGridBlock):
13
+ * it lives in a full-width column of a pageSectionV2 and is added via the
14
+ * "+ Add Section" modal, not the "+ Add Block" picker.
15
+ *
16
+ * Intentionally independent from projectGridBlock — the two share visual
17
+ * primitives (thumbnail resolution, project data shape) but no coupled
18
+ * code, so changes on one side never break the other.
19
+ */
20
+ export const projectCarouselBlock = defineType({
21
+ name: "projectCarouselBlock",
22
+ title: "Project Carousel",
23
+ type: "object",
24
+ fields: [
25
+ // ─── Source ───
26
+ defineField({
27
+ name: "source_mode",
28
+ title: "Source",
29
+ type: "string",
30
+ description: "How projects are selected for the carousel",
31
+ options: {
32
+ list: [
33
+ { title: "Latest projects", value: "auto_latest" },
34
+ { title: "Random projects", value: "auto_random" },
35
+ ],
36
+ },
37
+ initialValue: "auto_latest",
38
+ }),
39
+ defineField({
40
+ name: "max_projects",
41
+ title: "Number of projects",
42
+ type: "number",
43
+ description: "How many projects to show (2–20)",
44
+ initialValue: 8,
45
+ validation: (Rule) => Rule.min(2).max(20).integer(),
46
+ }),
47
+ defineField({
48
+ name: "exclude_current",
49
+ title: "Exclude current project",
50
+ type: "boolean",
51
+ description:
52
+ "When the carousel is on a /work/[slug] page, automatically filter out the project currently being viewed",
53
+ initialValue: true,
54
+ }),
55
+
56
+ // ─── Layout ───
57
+ defineField({
58
+ name: "cards_per_view_desktop",
59
+ title: "Cards per view (desktop)",
60
+ type: "number",
61
+ description: "Supports fractional values — e.g. 3.5 shows half of the next card to hint 'more content'",
62
+ initialValue: 3.5,
63
+ validation: (Rule) => Rule.min(1).max(6),
64
+ }),
65
+ defineField({
66
+ name: "cards_per_view_tablet",
67
+ title: "Cards per view (tablet)",
68
+ type: "number",
69
+ initialValue: 2.2,
70
+ validation: (Rule) => Rule.min(1).max(4),
71
+ }),
72
+ defineField({
73
+ name: "cards_per_view_phone",
74
+ title: "Cards per view (phone)",
75
+ type: "number",
76
+ initialValue: 1.2,
77
+ validation: (Rule) => Rule.min(1).max(3),
78
+ }),
79
+ defineField({
80
+ name: "gap",
81
+ title: "Gap between cards (px)",
82
+ type: "number",
83
+ initialValue: 16,
84
+ validation: (Rule) => Rule.min(0).max(80),
85
+ }),
86
+
87
+ // ─── Card Display ───
88
+ defineField({
89
+ name: "aspect_ratio",
90
+ title: "Card aspect ratio",
91
+ type: "string",
92
+ options: {
93
+ list: [
94
+ { title: "Landscape (16:9)", value: "16/9" },
95
+ { title: "Standard (4:3)", value: "4/3" },
96
+ { title: "Square (1:1)", value: "1/1" },
97
+ { title: "Portrait (3:4)", value: "3/4" },
98
+ { title: "Tall (9:16)", value: "9/16" },
99
+ ],
100
+ },
101
+ initialValue: "4/3",
102
+ }),
103
+ defineField({
104
+ name: "show_title",
105
+ title: "Show title",
106
+ type: "boolean",
107
+ initialValue: true,
108
+ }),
109
+ defineField({
110
+ name: "show_subtitle",
111
+ title: "Show subtitle",
112
+ type: "boolean",
113
+ initialValue: false,
114
+ }),
115
+ defineField({
116
+ name: "border_radius",
117
+ title: "Border radius (px)",
118
+ type: "number",
119
+ initialValue: 0,
120
+ }),
121
+ defineField({
122
+ name: "hover_effect",
123
+ title: "Hover effect",
124
+ type: "string",
125
+ options: {
126
+ list: [
127
+ { title: "Scale", value: "scale" },
128
+ { title: "None", value: "none" },
129
+ ],
130
+ },
131
+ initialValue: "scale",
132
+ }),
133
+
134
+ // ─── Video ───
135
+ defineField({
136
+ name: "video_mode",
137
+ title: "Video mode",
138
+ type: "string",
139
+ options: {
140
+ list: [
141
+ { title: "Off", value: "off" },
142
+ { title: "Hover", value: "hover" },
143
+ { title: "Autoloop", value: "autoloop" },
144
+ ],
145
+ },
146
+ initialValue: "off",
147
+ }),
148
+
149
+ // ─── Controls ───
150
+ defineField({
151
+ name: "show_arrows",
152
+ title: "Show prev / next arrows",
153
+ type: "boolean",
154
+ initialValue: true,
155
+ }),
156
+ defineField({
157
+ name: "show_dots",
158
+ title: "Show pagination dots",
159
+ type: "boolean",
160
+ initialValue: false,
161
+ }),
162
+ defineField({
163
+ name: "snap_scroll",
164
+ title: "Snap scrolling",
165
+ type: "boolean",
166
+ description: "Cards snap into place as the user scrolls / swipes",
167
+ initialValue: true,
168
+ }),
169
+
170
+ // ─── Card entrance animation ───
171
+ defineField({
172
+ name: "card_entrance",
173
+ title: "Card Entrance Animation",
174
+ type: "object",
175
+ fields: [
176
+ defineField({ name: "enabled", title: "Enabled", type: "boolean", initialValue: false }),
177
+ defineField({
178
+ name: "preset",
179
+ title: "Preset",
180
+ type: "string",
181
+ options: {
182
+ list: [
183
+ { title: "Fade", value: "fade" },
184
+ { title: "Slide Up", value: "slide-up" },
185
+ { title: "Scale", value: "scale" },
186
+ ],
187
+ },
188
+ initialValue: "slide-up",
189
+ }),
190
+ defineField({
191
+ name: "stagger_delay",
192
+ title: "Stagger Delay",
193
+ type: "number",
194
+ description: "Delay between cards (ms)",
195
+ initialValue: 80,
196
+ }),
197
+ defineField({
198
+ name: "duration",
199
+ title: "Duration",
200
+ type: "number",
201
+ description: "Animation duration (ms)",
202
+ initialValue: 500,
203
+ }),
204
+ ],
205
+ }),
206
+
207
+ // ─── Standard block fields ───
208
+ ...blockAnimationFields,
209
+ blockLayoutField,
210
+ ],
211
+ preview: {
212
+ select: { mode: "source_mode", n: "max_projects" },
213
+ prepare({ mode, n }: { mode?: string; n?: number }) {
214
+ const modeLabel = mode === "auto_random" ? "Random" : "Latest";
215
+ return { title: `Project Carousel (${modeLabel} · ${n ?? 8})` };
216
+ },
217
+ },
218
+ });
@@ -19,6 +19,7 @@ import {
19
19
  spacerBlock,
20
20
  buttonBlock,
21
21
  projectGridBlock,
22
+ projectCarouselBlock,
22
23
  } from "./blocks";
23
24
 
24
25
  // Re-export individual schemas for granular use by instances
@@ -69,6 +70,7 @@ export {
69
70
  spacerBlock,
70
71
  buttonBlock,
71
72
  projectGridBlock,
73
+ projectCarouselBlock,
72
74
  } from "./blocks";
73
75
 
74
76
  export const schemaTypes = [
@@ -91,7 +93,7 @@ export const schemaTypes = [
91
93
  parallaxGroup, // Parallax V2 group (Session 123)
92
94
  coverSection, // Cover Section — proportional rows (Session 176)
93
95
 
94
- // Blocks (8)
96
+ // Blocks (9)
95
97
  textBlock,
96
98
  imageBlock,
97
99
  imageGridBlock,
@@ -99,6 +101,7 @@ export const schemaTypes = [
99
101
  spacerBlock,
100
102
  buttonBlock,
101
103
  projectGridBlock,
104
+ projectCarouselBlock,
102
105
  ];
103
106
 
104
107
  /**
@@ -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",
@@ -67,6 +67,7 @@ export default defineType({
67
67
  { type: "spacerBlock" },
68
68
  { type: "buttonBlock" },
69
69
  { type: "projectGridBlock" },
70
+ { type: "projectCarouselBlock" },
70
71
  ],
71
72
  }),
72
73
  // Column-level enter animation (Session 117)
@@ -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
- }