@morphika/andami 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/app/admin/assets/page.tsx +3 -2
  2. package/app/admin/layout.tsx +4 -0
  3. package/components/admin/nav-builder/NavBuilder.tsx +2 -1
  4. package/components/admin/styles/FontsEditor.tsx +2 -1
  5. package/components/builder/CoverSectionCanvas.tsx +7 -6
  6. package/components/builder/SettingsPanel.tsx +14 -8
  7. package/components/builder/SortableBlock.tsx +4 -0
  8. package/components/builder/SortableRow.tsx +2 -0
  9. package/components/builder/asset-browser/useR2Operations.ts +5 -4
  10. package/components/builder/editors/AudioBlockEditor.tsx +10 -8
  11. package/components/builder/editors/BeforeAfterBlockEditor.tsx +10 -8
  12. package/components/builder/editors/ButtonBlockEditor.tsx +9 -7
  13. package/components/builder/editors/ImageBlockEditor.tsx +10 -8
  14. package/components/builder/editors/ImageGridBlockEditor.tsx +10 -8
  15. package/components/builder/editors/SpacerBlockEditor.tsx +4 -4
  16. package/components/builder/editors/TextBlockEditor.tsx +471 -468
  17. package/components/builder/editors/VideoBlockEditor.tsx +10 -8
  18. package/components/builder/settings-panel/AnimationTab.tsx +11 -8
  19. package/components/builder/settings-panel/BlockLayoutTab.tsx +514 -511
  20. package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +2 -2
  21. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +11 -8
  22. package/components/builder/settings-panel/ColumnV2Settings.tsx +6 -5
  23. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +4 -3
  24. package/components/builder/settings-panel/CoverSectionSettings.tsx +14 -9
  25. package/components/builder/settings-panel/CustomSectionSettings.tsx +9 -7
  26. package/components/builder/settings-panel/PageSettings.tsx +39 -32
  27. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +2 -2
  28. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  29. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +7 -5
  30. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +13 -9
  31. package/components/builder/settings-panel/SectionV2Settings.tsx +7 -6
  32. package/components/builder/settings-panel/TRBLInputs.tsx +2 -2
  33. package/components/builder/settings-panel/useSettingsPanelSelection.ts +16 -13
  34. package/components/ui/NavContentLightbox.tsx +41 -4
  35. package/components/ui/ToastStack.tsx +142 -0
  36. package/lib/auth-token.ts +5 -1
  37. package/lib/bot-guard.ts +6 -0
  38. package/lib/builder/constants.ts +0 -7
  39. package/lib/builder/store-blocks.ts +15 -5
  40. package/lib/builder/store-cover.ts +16 -6
  41. package/lib/builder/store-sections.ts +130 -51
  42. package/lib/toast/index.ts +56 -0
  43. package/lib/toast/store.ts +56 -0
  44. package/lib/version.ts +1 -1
  45. package/package.json +3 -1
@@ -41,6 +41,7 @@ import {
41
41
  moveColumnBetweenSectionsInState,
42
42
  swapColumnsBetweenSectionsInState,
43
43
  } from "./store-helpers";
44
+ import { pushSnapshot } from "./history";
44
45
 
45
46
  type StoreSet = (
46
47
  partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
@@ -60,7 +61,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
60
61
  * the data migration in Session 165.
61
62
  */
62
63
  addSection: (blockType: SectionBlockType, afterRowKey?: string | null): void => {
63
- get()._pushSnapshot();
64
64
  const block = createDefaultBlock(blockType);
65
65
  const gridColumns = 12;
66
66
 
@@ -99,22 +99,31 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
99
99
  } else {
100
100
  rows.push(newSection);
101
101
  }
102
- return { rows, isDirty: true, selectedRowKey: newSection._key };
102
+ return {
103
+ rows,
104
+ isDirty: true,
105
+ selectedRowKey: newSection._key,
106
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
107
+ _future: [],
108
+ };
103
109
  });
104
110
  },
105
111
 
106
112
  reorderRows: (fromIndex: number, toIndex: number): void => {
107
- get()._pushSnapshot();
108
113
  set((state) => {
109
114
  const rows = [...state.rows];
110
115
  const [moved] = rows.splice(fromIndex, 1);
111
116
  rows.splice(toIndex, 0, moved);
112
- return { rows, isDirty: true };
117
+ return {
118
+ rows,
119
+ isDirty: true,
120
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
121
+ _future: [],
122
+ };
113
123
  });
114
124
  },
115
125
 
116
126
  deleteSection: (sectionKey: string): void => {
117
- get()._pushSnapshot();
118
127
  set((state) => ({
119
128
  rows: state.rows.filter((item) => item._key !== sectionKey),
120
129
  selectedRowKey:
@@ -124,11 +133,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
124
133
  selectedBlockKey:
125
134
  state.selectedRowKey === sectionKey ? null : state.selectedBlockKey,
126
135
  isDirty: true,
136
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
137
+ _future: [],
127
138
  }));
128
139
  },
129
140
 
130
141
  duplicateSection: (sectionKey: string): void => {
131
- get()._pushSnapshot();
132
142
  set((state) => {
133
143
  const idx = state.rows.findIndex((item) => item._key === sectionKey);
134
144
  if (idx === -1) return state;
@@ -173,6 +183,8 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
173
183
  selectedColumnKey: null,
174
184
  selectedBlockKey: null,
175
185
  isDirty: true,
186
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
187
+ _future: [],
176
188
  };
177
189
  });
178
190
  },
@@ -180,15 +192,19 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
180
192
  // ---- V2 Section operations ----
181
193
 
182
194
  addSectionV2: (preset: SectionV2Preset, afterRowKey?: string | null): void => {
183
- get()._pushSnapshot();
184
-
185
- const result = addSectionV2InState(get().rows, preset, afterRowKey);
186
- set({ rows: result.rows, isDirty: true, selectedRowKey: result.newSectionKey });
195
+ set((state) => {
196
+ const result = addSectionV2InState(state.rows, preset, afterRowKey);
197
+ return {
198
+ rows: result.rows,
199
+ isDirty: true,
200
+ selectedRowKey: result.newSectionKey,
201
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
202
+ _future: [],
203
+ };
204
+ });
187
205
  },
188
206
 
189
207
  addColumnV2: (sectionKey: string, gridRow: number, gridColumn: number, span: number): void => {
190
- get()._pushSnapshot();
191
-
192
208
  set((state) => {
193
209
  const path = findSectionPath(state.rows, sectionKey);
194
210
  if (!path) return state;
@@ -216,13 +232,16 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
216
232
  settings: { ...section.settings, preset: newPreset },
217
233
  };
218
234
  });
219
- return { rows, isDirty: true };
235
+ return {
236
+ rows,
237
+ isDirty: true,
238
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
239
+ _future: [],
240
+ };
220
241
  });
221
242
  },
222
243
 
223
244
  deleteColumnV2: (sectionKey: string, columnKey: string): void => {
224
- get()._pushSnapshot();
225
-
226
245
  set((state) => {
227
246
  const path = findSectionPath(state.rows, sectionKey);
228
247
  if (!path) return state;
@@ -251,13 +270,13 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
251
270
  selectedColumnKey:
252
271
  state.selectedColumnKey === columnKey ? null : state.selectedColumnKey,
253
272
  isDirty: true,
273
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
274
+ _future: [],
254
275
  };
255
276
  });
256
277
  },
257
278
 
258
279
  resizeColumnV2: (sectionKey: string, columnKey: string, newSpan: number): void => {
259
- get()._pushSnapshot();
260
-
261
280
  set((state) => {
262
281
  const path = findSectionPath(state.rows, sectionKey);
263
282
  if (!path) return state;
@@ -282,7 +301,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
282
301
  settings: { ...section.settings, preset: newPreset },
283
302
  };
284
303
  });
285
- return { rows, isDirty: true };
304
+ return {
305
+ rows,
306
+ isDirty: true,
307
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
308
+ _future: [],
309
+ };
286
310
  });
287
311
  },
288
312
 
@@ -295,8 +319,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
295
319
  );
296
320
  if (!result) return; // Blocked by cascade or section not found
297
321
 
298
- get()._pushSnapshot();
299
- set({ rows: result.rows, isDirty: true });
322
+ set((state) => ({
323
+ rows: result.rows,
324
+ isDirty: true,
325
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
326
+ _future: [],
327
+ }));
300
328
  },
301
329
 
302
330
  moveColumnV2: (sectionKey: string, columnKey: string, targetRow: number, targetColumn: number): void => {
@@ -309,8 +337,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
309
337
  );
310
338
  if (!result) return;
311
339
 
312
- get()._pushSnapshot();
313
- set({ rows: result.rows, isDirty: true });
340
+ set((state) => ({
341
+ rows: result.rows,
342
+ isDirty: true,
343
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
344
+ _future: [],
345
+ }));
314
346
  },
315
347
 
316
348
  swapColumnV2: (sectionKey: string, draggedKey: string, targetKey: string): void => {
@@ -322,8 +354,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
322
354
  );
323
355
  if (!result) return;
324
356
 
325
- get()._pushSnapshot();
326
- set({ rows: result.rows, isDirty: true });
357
+ set((state) => ({
358
+ rows: result.rows,
359
+ isDirty: true,
360
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
361
+ _future: [],
362
+ }));
327
363
  },
328
364
 
329
365
  moveColumnToGapV2: (sectionKey: string, columnKey: string, targetRow: number, targetColumn: number, targetSpan: number): void => {
@@ -337,8 +373,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
337
373
  );
338
374
  if (!result) return;
339
375
 
340
- get()._pushSnapshot();
341
- set({ rows: result.rows, isDirty: true });
376
+ set((state) => ({
377
+ rows: result.rows,
378
+ isDirty: true,
379
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
380
+ _future: [],
381
+ }));
342
382
  },
343
383
 
344
384
  moveColumnBetweenSections: (
@@ -360,16 +400,17 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
360
400
  );
361
401
  if (!result) return;
362
402
 
363
- get()._pushSnapshot();
364
403
  // Clear any selection referencing the moved column in its old section —
365
404
  // the column key remains but the "selected section" context has changed.
366
- set({
405
+ set((state) => ({
367
406
  rows: result.rows,
368
407
  isDirty: true,
369
408
  selectedColumnKey: columnKey,
370
409
  selectedRowKey: targetSectionKey,
371
410
  selectedBlockKey: null,
372
- });
411
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
412
+ _future: [],
413
+ }));
373
414
  },
374
415
 
375
416
  swapColumnsBetweenSections: (
@@ -387,23 +428,22 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
387
428
  );
388
429
  if (!result) return;
389
430
 
390
- get()._pushSnapshot();
391
431
  // After swap: the dragged column now lives in the target section.
392
432
  // Select it there (match mental model: "I just put this column here").
393
- set({
433
+ set((state) => ({
394
434
  rows: result.rows,
395
435
  isDirty: true,
396
436
  selectedColumnKey: sourceColumnKey,
397
437
  selectedRowKey: targetSectionKey,
398
438
  selectedBlockKey: null,
399
- });
439
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
440
+ _future: [],
441
+ }));
400
442
  },
401
443
 
402
444
  applyPresetV2: (sectionKey: string, preset: SectionV2Preset): void => {
403
445
  if (preset === "custom") return;
404
446
 
405
- get()._pushSnapshot();
406
-
407
447
  set((state) => {
408
448
  const path = findSectionPath(state.rows, sectionKey);
409
449
  if (!path) return state;
@@ -424,7 +464,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
424
464
  settings: { ...section.settings, preset },
425
465
  };
426
466
  });
427
- return { rows, isDirty: true };
467
+ return {
468
+ rows,
469
+ isDirty: true,
470
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
471
+ _future: [],
472
+ };
428
473
  });
429
474
  },
430
475
 
@@ -492,7 +537,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
492
537
  },
493
538
 
494
539
  addBlockV2: (sectionKey: string, columnKey: string, blockType: BlockType, insertIndex?: number): void => {
495
- get()._pushSnapshot();
496
540
  const newBlock = createDefaultBlock(blockType);
497
541
 
498
542
  set((state) => {
@@ -514,7 +558,13 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
514
558
  return { ...col, blocks: [...col.blocks, newBlock] };
515
559
  }),
516
560
  }));
517
- return { rows, selectedBlockKey: newBlock._key, isDirty: true };
561
+ return {
562
+ rows,
563
+ selectedBlockKey: newBlock._key,
564
+ isDirty: true,
565
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
566
+ _future: [],
567
+ };
518
568
  });
519
569
  },
520
570
 
@@ -570,7 +620,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
570
620
  title: string,
571
621
  afterRowKey?: string | null
572
622
  ) => {
573
- get()._pushSnapshot();
574
623
  const instance: CustomSectionInstance = {
575
624
  _type: "customSectionInstance",
576
625
  _key: generateKey(),
@@ -591,7 +640,13 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
591
640
  } else {
592
641
  rows.push(instance);
593
642
  }
594
- return { rows, isDirty: true, selectedRowKey: instance._key };
643
+ return {
644
+ rows,
645
+ isDirty: true,
646
+ selectedRowKey: instance._key,
647
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
648
+ _future: [],
649
+ };
595
650
  });
596
651
  },
597
652
 
@@ -599,7 +654,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
599
654
  instanceKey: string,
600
655
  sectionData: PageSectionV2
601
656
  ) => {
602
- get()._pushSnapshot();
603
657
  set((state) => ({
604
658
  rows: state.rows.map((item) =>
605
659
  item._key === instanceKey
@@ -607,6 +661,8 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
607
661
  : item
608
662
  ),
609
663
  isDirty: true,
664
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
665
+ _future: [],
610
666
  }));
611
667
  },
612
668
 
@@ -632,7 +688,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
632
688
  instanceKey: string,
633
689
  updates: Partial<SectionV2Settings>
634
690
  ) => {
635
- get()._pushSnapshot();
636
691
  set((state) => ({
637
692
  rows: state.rows.map((item) =>
638
693
  item._key === instanceKey && isCustomSectionInstance(item)
@@ -646,6 +701,8 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
646
701
  : item
647
702
  ),
648
703
  isDirty: true,
704
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
705
+ _future: [],
649
706
  }));
650
707
  },
651
708
 
@@ -661,13 +718,19 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
661
718
  // ---- Parallax Group operations (Session 123) ----
662
719
 
663
720
  addParallaxGroup: (afterRowKey?: string | null): void => {
664
- get()._pushSnapshot();
665
- const result = addParallaxGroupInState(get().rows, afterRowKey);
666
- set({ rows: result.rows, isDirty: true, selectedRowKey: result.newGroupKey });
721
+ set((state) => {
722
+ const result = addParallaxGroupInState(state.rows, afterRowKey);
723
+ return {
724
+ rows: result.rows,
725
+ isDirty: true,
726
+ selectedRowKey: result.newGroupKey,
727
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
728
+ _future: [],
729
+ };
730
+ });
667
731
  },
668
732
 
669
733
  addParallaxSlide: (groupKey: string): void => {
670
- get()._pushSnapshot();
671
734
  set((state) => ({
672
735
  rows: state.rows.map((item) => {
673
736
  if (item._key !== groupKey || !isParallaxGroup(item)) return item;
@@ -676,11 +739,12 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
676
739
  return { ...group, slides: [...group.slides, newSlide] } as ContentItem;
677
740
  }),
678
741
  isDirty: true,
742
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
743
+ _future: [],
679
744
  }));
680
745
  },
681
746
 
682
747
  removeParallaxSlide: (groupKey: string, slideKey: string): void => {
683
- get()._pushSnapshot();
684
748
  set((state) => {
685
749
  let changed = false;
686
750
  const rows = state.rows.map((item) => {
@@ -694,7 +758,14 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
694
758
  slides: group.slides.filter((s) => s._key !== slideKey),
695
759
  } as ContentItem;
696
760
  });
697
- return changed ? { rows, isDirty: true } : {};
761
+ return changed
762
+ ? {
763
+ rows,
764
+ isDirty: true,
765
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
766
+ _future: [],
767
+ }
768
+ : {};
698
769
  });
699
770
  },
700
771
 
@@ -703,7 +774,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
703
774
  slideKey: string,
704
775
  direction: "up" | "down"
705
776
  ) => {
706
- get()._pushSnapshot();
707
777
  set((state) => {
708
778
  let changed = false;
709
779
  const rows = state.rows.map((item) => {
@@ -718,7 +788,14 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
718
788
  changed = true;
719
789
  return { ...group, slides } as ContentItem;
720
790
  });
721
- return changed ? { rows, isDirty: true } : {};
791
+ return changed
792
+ ? {
793
+ rows,
794
+ isDirty: true,
795
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
796
+ _future: [],
797
+ }
798
+ : {};
722
799
  });
723
800
  },
724
801
 
@@ -727,7 +804,6 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
727
804
  slideKey: string,
728
805
  fields: Partial<ParallaxSlideV2>
729
806
  ) => {
730
- get()._pushSnapshot();
731
807
  set((state) => ({
732
808
  rows: state.rows.map((item) => {
733
809
  if (item._key !== groupKey || !isParallaxGroup(item)) return item;
@@ -740,6 +816,8 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
740
816
  } as ContentItem;
741
817
  }),
742
818
  isDirty: true,
819
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
820
+ _future: [],
743
821
  }));
744
822
  },
745
823
 
@@ -749,13 +827,14 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
749
827
  Pick<ParallaxGroup, "transition_effect" | "snap_enabled" | "parallax_intensity">
750
828
  >
751
829
  ) => {
752
- get()._pushSnapshot();
753
830
  set((state) => ({
754
831
  rows: state.rows.map((item) => {
755
832
  if (item._key !== groupKey || !isParallaxGroup(item)) return item;
756
833
  return { ...item, ...fields } as ContentItem;
757
834
  }),
758
835
  isDirty: true,
836
+ _history: pushSnapshot(state._history, { rows: state.rows, pageSettings: state.pageSettings }),
837
+ _future: [],
759
838
  }));
760
839
  },
761
840
  };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Public toast API.
3
+ *
4
+ * Usage:
5
+ * import { toast } from "@morphika/andami/lib/toast";
6
+ * toast.success("Saved");
7
+ * toast.error("Upload failed");
8
+ * toast.info("Processing…");
9
+ * toast("Plain message");
10
+ *
11
+ * The `<ToastStack />` component (exported from `components/ui/ToastStack.tsx`)
12
+ * must be mounted somewhere in the tree — it's already included in the admin
13
+ * layout provided by the framework.
14
+ */
15
+
16
+ import { useToastStore, type ToastKind } from "./store";
17
+
18
+ /** Default auto-dismiss durations per kind (ms). */
19
+ const DEFAULT_DURATIONS: Record<ToastKind, number> = {
20
+ default: 4000,
21
+ success: 4000,
22
+ info: 4000,
23
+ error: 6000,
24
+ };
25
+
26
+ interface ToastOptions {
27
+ /** Auto-dismiss after N ms. Pass `null` to keep the toast until dismissed manually. */
28
+ duration?: number | null;
29
+ }
30
+
31
+ function emit(message: string, kind: ToastKind, options?: ToastOptions): string {
32
+ const duration =
33
+ options?.duration === undefined ? DEFAULT_DURATIONS[kind] : options.duration;
34
+ return useToastStore.getState().push(message, kind, duration);
35
+ }
36
+
37
+ interface ToastFn {
38
+ (message: string, options?: ToastOptions): string;
39
+ success: (message: string, options?: ToastOptions) => string;
40
+ error: (message: string, options?: ToastOptions) => string;
41
+ info: (message: string, options?: ToastOptions) => string;
42
+ dismiss: (id: string) => void;
43
+ clear: () => void;
44
+ }
45
+
46
+ const toastFn = ((message: string, options?: ToastOptions) => emit(message, "default", options)) as ToastFn;
47
+ toastFn.success = (message, options) => emit(message, "success", options);
48
+ toastFn.error = (message, options) => emit(message, "error", options);
49
+ toastFn.info = (message, options) => emit(message, "info", options);
50
+ toastFn.dismiss = (id) => useToastStore.getState().dismiss(id);
51
+ toastFn.clear = () => useToastStore.getState().clear();
52
+
53
+ export const toast = toastFn;
54
+
55
+ export { useToastStore } from "./store";
56
+ export type { Toast, ToastKind } from "./store";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Toast notification store — transient messages shown bottom-right of the admin.
3
+ *
4
+ * Independent Zustand store (not merged with the builder store) because toasts
5
+ * are cross-cutting: anything that mutates state or calls an API might want to
6
+ * surface feedback, and pulling that into the builder store would bloat it.
7
+ *
8
+ * Imperative API (see `index.ts`) mirrors conventions from `sonner` / `react-hot-toast`:
9
+ * toast.success("Saved"); toast.error("Failed"); toast.info("Hello");
10
+ *
11
+ * The `<ToastStack />` component subscribes to `toasts` and renders them.
12
+ */
13
+ "use client";
14
+
15
+ import { create } from "zustand";
16
+
17
+ export type ToastKind = "default" | "success" | "error" | "info";
18
+
19
+ export interface Toast {
20
+ id: string;
21
+ message: string;
22
+ kind: ToastKind;
23
+ /** ms before auto-dismiss. `null` = never auto-dismiss. */
24
+ duration: number | null;
25
+ createdAt: number;
26
+ }
27
+
28
+ interface ToastStore {
29
+ toasts: Toast[];
30
+ push: (message: string, kind: ToastKind, duration: number | null) => string;
31
+ dismiss: (id: string) => void;
32
+ clear: () => void;
33
+ }
34
+
35
+ /** Max simultaneous toasts — oldest is dropped when exceeded. */
36
+ const MAX_VISIBLE = 5;
37
+
38
+ export const useToastStore = create<ToastStore>((set) => ({
39
+ toasts: [],
40
+ push: (message, kind, duration) => {
41
+ const id =
42
+ typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
43
+ ? crypto.randomUUID()
44
+ : `t${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
45
+ set((state) => {
46
+ const next = [...state.toasts, { id, message, kind, duration, createdAt: Date.now() }];
47
+ // Cap queue — drop oldest when exceeded
48
+ return { toasts: next.slice(-MAX_VISIBLE) };
49
+ });
50
+ return id;
51
+ },
52
+ dismiss: (id) => {
53
+ set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }));
54
+ },
55
+ clear: () => set({ toasts: [] }),
56
+ }));
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.5.3";
9
+ export const ANDAMI_VERSION = "0.5.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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",
@@ -103,6 +103,8 @@
103
103
  "./lib/logger": "./lib/logger.ts",
104
104
  "./lib/audit": "./lib/audit.ts",
105
105
  "./lib/revalidate": "./lib/revalidate.ts",
106
+ "./lib/toast": "./lib/toast/index.ts",
107
+ "./lib/toast/store": "./lib/toast/store.ts",
106
108
  "./lib/color-utils": "./lib/color-utils.ts",
107
109
  "./lib/format-utils": "./lib/format-utils.ts",
108
110
  "./lib/utils": "./lib/utils.ts",