@morphika/andami 0.5.4 → 0.5.6

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 (46) 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/ColumnDragOverlay.tsx +4 -4
  6. package/components/builder/CoverSectionCanvas.tsx +10 -9
  7. package/components/builder/InsertionLines.tsx +3 -3
  8. package/components/builder/SectionV2Canvas.tsx +3 -3
  9. package/components/builder/SectionV2Column.tsx +20 -20
  10. package/components/builder/SettingsPanel.tsx +14 -8
  11. package/components/builder/SortableBlock.tsx +4 -0
  12. package/components/builder/SortableRow.tsx +2 -0
  13. package/components/builder/asset-browser/useR2Operations.ts +5 -4
  14. package/components/builder/editors/AudioBlockEditor.tsx +10 -8
  15. package/components/builder/editors/BeforeAfterBlockEditor.tsx +10 -8
  16. package/components/builder/editors/ButtonBlockEditor.tsx +9 -7
  17. package/components/builder/editors/ImageBlockEditor.tsx +10 -8
  18. package/components/builder/editors/ImageGridBlockEditor.tsx +10 -8
  19. package/components/builder/editors/SpacerBlockEditor.tsx +4 -4
  20. package/components/builder/editors/TextBlockEditor.tsx +471 -468
  21. package/components/builder/editors/VideoBlockEditor.tsx +10 -8
  22. package/components/builder/live-preview/drag-utils.tsx +5 -3
  23. package/components/builder/settings-panel/AnimationTab.tsx +11 -8
  24. package/components/builder/settings-panel/BlockLayoutTab.tsx +514 -511
  25. package/components/builder/settings-panel/ColumnV2AnimationTab.tsx +2 -2
  26. package/components/builder/settings-panel/ColumnV2LayoutTab.tsx +11 -8
  27. package/components/builder/settings-panel/ColumnV2Settings.tsx +6 -5
  28. package/components/builder/settings-panel/CoverSectionLayoutTab.tsx +4 -3
  29. package/components/builder/settings-panel/CoverSectionSettings.tsx +14 -9
  30. package/components/builder/settings-panel/CustomSectionSettings.tsx +9 -7
  31. package/components/builder/settings-panel/PageSettings.tsx +39 -32
  32. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +2 -2
  33. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +2 -2
  34. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +7 -5
  35. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +13 -9
  36. package/components/builder/settings-panel/SectionV2Settings.tsx +10 -9
  37. package/components/builder/settings-panel/TRBLInputs.tsx +2 -2
  38. package/components/builder/settings-panel/useSettingsPanelSelection.ts +16 -13
  39. package/components/ui/ToastStack.tsx +142 -0
  40. package/lib/auth-token.ts +5 -1
  41. package/lib/bot-guard.ts +6 -0
  42. package/lib/builder/constants.ts +5 -10
  43. package/lib/toast/index.ts +56 -0
  44. package/lib/toast/store.ts +56 -0
  45. package/lib/version.ts +1 -1
  46. package/package.json +3 -1
@@ -142,15 +142,15 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
142
142
  {/* + Add Column button */}
143
143
  <button
144
144
  onClick={handleAddColumn}
145
- className="group/bb relative flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#3580f9] hover:bg-[#3580f9]/5 group"
145
+ className="group/bb relative flex flex-col items-center gap-1 p-2 rounded-lg border border-dashed border-neutral-300 transition-all hover:border-[#d68900] hover:bg-[#d68900]/5 group"
146
146
  aria-label="Add a column (fills first gap, or adds new row below)"
147
147
  >
148
148
  <div className="flex items-center justify-center w-full h-4">
149
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#3580f9] transition-colors">
149
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" className="text-neutral-400 group-hover:text-[#d68900] transition-colors">
150
150
  <path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
151
151
  </svg>
152
152
  </div>
153
- <span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#3580f9] transition-colors">
153
+ <span className="text-[9px] font-medium text-neutral-400 group-hover:text-[#d68900] transition-colors">
154
154
  Add Col
155
155
  </span>
156
156
  <BubbleTooltip>Add a column (fills first gap, or adds new row below)</BubbleTooltip>
@@ -164,9 +164,10 @@ function PresetGrid({ section }: { section: PageSectionV2 }) {
164
164
  // ============================================
165
165
 
166
166
  export default function SectionV2Settings({ section }: { section: PageSectionV2 }) {
167
- const store = useBuilderStore();
167
+ const activeViewport = useBuilderStore((s) => s.activeViewport);
168
+ const updateSectionV2Settings = useBuilderStore((s) => s.updateSectionV2Settings);
169
+ const updateSectionV2Responsive = useBuilderStore((s) => s.updateSectionV2Responsive);
168
170
  const settings = section.settings;
169
- const activeViewport = store.activeViewport;
170
171
  const isResponsive = activeViewport !== "desktop";
171
172
 
172
173
  const hasColOverrides = hasAnyColumnV2Overrides(section, activeViewport);
@@ -176,10 +177,10 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
176
177
  /** Viewport-aware update: desktop writes to settings, tablet/phone to responsive */
177
178
  const updateSettingResponsive = (property: keyof SectionV2SettingsOverridable, value: unknown) => {
178
179
  if (activeViewport === "desktop") {
179
- store.updateSectionV2Settings(section._key, { [property]: value } as Partial<SectionV2SettingsType>);
180
+ updateSectionV2Settings(section._key, { [property]: value } as Partial<SectionV2SettingsType>);
180
181
  } else {
181
182
  const responsive = buildSectionV2SettingOverride(section, activeViewport, property, value);
182
- store.updateSectionV2Responsive(section._key, responsive ?? undefined);
183
+ updateSectionV2Responsive(section._key, responsive ?? undefined);
183
184
  }
184
185
  };
185
186
 
@@ -190,7 +191,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
190
191
  const handleStack = () => {
191
192
  const colOverrides = buildStackOverride(section);
192
193
  const responsive = buildColumnV2Overrides(section, activeViewport, colOverrides);
193
- store.updateSectionV2Responsive(section._key, responsive ?? undefined);
194
+ updateSectionV2Responsive(section._key, responsive ?? undefined);
194
195
  };
195
196
 
196
197
  const handleReset = () => {
@@ -199,7 +200,7 @@ export default function SectionV2Settings({ section }: { section: PageSectionV2
199
200
  const vp = activeViewport as "tablet" | "phone";
200
201
  const responsive = { ...existing };
201
202
  delete responsive[vp];
202
- store.updateSectionV2Responsive(section._key, Object.keys(responsive).length ? responsive : undefined);
203
+ updateSectionV2Responsive(section._key, Object.keys(responsive).length ? responsive : undefined);
203
204
  };
204
205
 
205
206
  return (
@@ -22,7 +22,7 @@ export function TRBLInputs({
22
22
  left: string;
23
23
  onChange: (field: "top" | "right" | "bottom" | "left", value: string) => void;
24
24
  }) {
25
- const store = useBuilderStore();
25
+ const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
26
26
  const fields = [
27
27
  { key: "top" as const, label: "TOP", value: top },
28
28
  { key: "right" as const, label: "RIGHT", value: right },
@@ -40,7 +40,7 @@ export function TRBLInputs({
40
40
  <input
41
41
  type="number"
42
42
  value={f.value || "0"}
43
- onFocus={() => store._pushSnapshot()}
43
+ onFocus={() => _pushSnapshot()}
44
44
  onChange={(e) => onChange(f.key, e.target.value)}
45
45
  className="w-full rounded-lg border border-transparent bg-[#f5f5f5] px-1.5 py-[6px] text-xs text-neutral-900 text-center outline-none transition-all hover:bg-[#efefef] focus:bg-white focus:border-[#3580f9] focus:shadow-[0_0_0_3px_rgba(53, 128, 249,0.06)]"
46
46
  />
@@ -39,10 +39,13 @@ export interface SelectedParallaxSlideInfo {
39
39
  }
40
40
 
41
41
  export function useSettingsPanelSelection() {
42
- const store = useBuilderStore();
42
+ const rows = useBuilderStore((s) => s.rows);
43
+ const selectedRowKey = useBuilderStore((s) => s.selectedRowKey);
44
+ const selectedColumnKey = useBuilderStore((s) => s.selectedColumnKey);
45
+ const selectedBlockKey = useBuilderStore((s) => s.selectedBlockKey);
43
46
 
44
47
  // Find selected elements — handle page sections, V2 sections, and parallax groups/slides
45
- const selectedItem: ContentItem | undefined = store.rows.find((r) => r._key === store.selectedRowKey);
48
+ const selectedItem: ContentItem | undefined = rows.find((r) => r._key === selectedRowKey);
46
49
  const selectedSectionV2: PageSectionV2 | null = selectedItem && isPageSectionV2(selectedItem) ? selectedItem : null;
47
50
  const selectedCustomSectionInstance: CustomSectionInstance | null = selectedItem && isCustomSectionInstance(selectedItem) ? selectedItem as CustomSectionInstance : null;
48
51
  const selectedCoverSection: CoverSection | null = selectedItem && isCoverSection(selectedItem) ? selectedItem as CoverSection : null;
@@ -50,11 +53,11 @@ export function useSettingsPanelSelection() {
50
53
  // Parallax detection: group selected directly, or slide selected (search inside groups)
51
54
  const selectedParallaxGroup: ParallaxGroup | null = selectedItem && isParallaxGroup(selectedItem) ? selectedItem as ParallaxGroup : null;
52
55
  const selectedParallaxSlide: SelectedParallaxSlideInfo | null = (() => {
53
- if (!store.selectedRowKey) return null;
54
- for (const item of store.rows) {
56
+ if (!selectedRowKey) return null;
57
+ for (const item of rows) {
55
58
  if (!isParallaxGroup(item)) continue;
56
59
  const group = item as ParallaxGroup;
57
- const slide = group.slides.find((s) => s._key === store.selectedRowKey);
60
+ const slide = group.slides.find((s) => s._key === selectedRowKey);
58
61
  if (slide) {
59
62
  // Create a virtual PageSectionV2 for the slide so we can delegate to SectionV2Settings etc.
60
63
  const virtualSection: PageSectionV2 = {
@@ -86,19 +89,19 @@ export function useSettingsPanelSelection() {
86
89
 
87
90
  // V2 column: when a V2 section (or parallax slide or cover section) is selected and a column key is set
88
91
  const effectiveSectionV2 = selectedSectionV2 || selectedParallaxSlide?.virtualSection || coverVirtualSection || null;
89
- const selectedColumnV2: SectionColumn | null = effectiveSectionV2 && store.selectedColumnKey
90
- ? effectiveSectionV2.columns.find((c) => c._key === store.selectedColumnKey) || null
92
+ const selectedColumnV2: SectionColumn | null = effectiveSectionV2 && selectedColumnKey
93
+ ? effectiveSectionV2.columns.find((c) => c._key === selectedColumnKey) || null
91
94
  : null;
92
95
 
93
96
  const selectedBlock: SelectedBlockInfo | null = (() => {
94
97
  // Block search inside V2 sections and parallax slides
95
- if (!store.selectedBlockKey) return null;
96
- for (const item of store.rows) {
98
+ if (!selectedBlockKey) return null;
99
+ for (const item of rows) {
97
100
  // V2 sections: search inside columns
98
101
  if (isPageSectionV2(item)) {
99
102
  for (const col of (item as PageSectionV2).columns || []) {
100
103
  const block = (col.blocks || []).find(
101
- (b) => b._key === store.selectedBlockKey
104
+ (b) => b._key === selectedBlockKey
102
105
  );
103
106
  if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
104
107
  }
@@ -107,7 +110,7 @@ export function useSettingsPanelSelection() {
107
110
  if (isCoverSection(item)) {
108
111
  for (const col of (item as CoverSection).columns || []) {
109
112
  const block = (col.blocks || []).find(
110
- (b) => b._key === store.selectedBlockKey
113
+ (b) => b._key === selectedBlockKey
111
114
  );
112
115
  if (block) return { block, rowKey: item._key, colKey: col._key, isSection: false };
113
116
  }
@@ -118,7 +121,7 @@ export function useSettingsPanelSelection() {
118
121
  for (const slide of group.slides) {
119
122
  for (const col of slide.columns || []) {
120
123
  const block = (col.blocks || []).find(
121
- (b) => b._key === store.selectedBlockKey
124
+ (b) => b._key === selectedBlockKey
122
125
  );
123
126
  if (block) return { block, rowKey: slide._key, colKey: col._key, isSection: false };
124
127
  }
@@ -167,7 +170,7 @@ export function useSettingsPanelSelection() {
167
170
  const headerGradient = BLOCK_GRADIENTS[headerStyleKey] || BLOCK_GRADIENTS.page;
168
171
  const HeaderIconComponent = BLOCK_ICON_COMPONENTS[headerStyleKey];
169
172
 
170
- const hasSelection = !!(store.selectedRowKey || store.selectedColumnKey || store.selectedBlockKey);
173
+ const hasSelection = !!(selectedRowKey || selectedColumnKey || selectedBlockKey);
171
174
  // V2 columns: show Settings + Animation tabs (not Layout) — but NOT when a block inside the column is selected
172
175
  const isColumnOnly = !!(selectedColumnV2 && !selectedBlock);
173
176
  // Parallax group header: show Settings + Animation (no Layout)
@@ -0,0 +1,142 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { useToastStore, type Toast } from "../../lib/toast/store";
6
+
7
+ /**
8
+ * ToastStack — renders the global toast queue in a fixed bottom-right stack.
9
+ *
10
+ * Mount once, anywhere in the React tree (the framework's admin layout already
11
+ * includes this). Uses a portal to `document.body` so toast z-index isn't
12
+ * constrained by parent stacking contexts.
13
+ *
14
+ * Each toast auto-dismisses after its `duration`; users can also click the
15
+ * close button or focus + Escape.
16
+ */
17
+ export default function ToastStack() {
18
+ const toasts = useToastStore((s) => s.toasts);
19
+ const [mounted, setMounted] = useState(false);
20
+
21
+ useEffect(() => setMounted(true), []);
22
+
23
+ if (!mounted || typeof document === "undefined") return null;
24
+
25
+ return createPortal(
26
+ <div
27
+ className="pointer-events-none fixed bottom-4 right-4 z-[9999] flex flex-col items-end gap-2"
28
+ role="region"
29
+ aria-label="Notifications"
30
+ >
31
+ {toasts.map((t) => (
32
+ <ToastItem key={t.id} toast={t} />
33
+ ))}
34
+ </div>,
35
+ document.body
36
+ );
37
+ }
38
+
39
+ function ToastItem({ toast }: { toast: Toast }) {
40
+ const dismiss = useToastStore((s) => s.dismiss);
41
+ const [entered, setEntered] = useState(false);
42
+
43
+ useEffect(() => {
44
+ // Next tick → trigger enter transition
45
+ const enterFrame = requestAnimationFrame(() => setEntered(true));
46
+ return () => cancelAnimationFrame(enterFrame);
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ if (toast.duration === null) return;
51
+ const timer = setTimeout(() => dismiss(toast.id), toast.duration);
52
+ return () => clearTimeout(timer);
53
+ }, [toast.id, toast.duration, dismiss]);
54
+
55
+ const tone = TONE[toast.kind];
56
+
57
+ return (
58
+ <div
59
+ role={toast.kind === "error" ? "alert" : "status"}
60
+ aria-live={toast.kind === "error" ? "assertive" : "polite"}
61
+ className={`pointer-events-auto flex items-start gap-3 min-w-[260px] max-w-[400px] px-4 py-3 rounded-lg shadow-lg border transition-all duration-200 ${tone.bg} ${tone.border} ${
62
+ entered ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
63
+ }`}
64
+ >
65
+ <span className={`mt-0.5 shrink-0 ${tone.icon}`} aria-hidden="true">
66
+ {ICONS[toast.kind]}
67
+ </span>
68
+ <p className={`flex-1 text-[13px] leading-snug ${tone.text}`}>{toast.message}</p>
69
+ <button
70
+ type="button"
71
+ onClick={() => dismiss(toast.id)}
72
+ className={`shrink-0 -mt-0.5 -mr-1 p-1 rounded transition-colors ${tone.close}`}
73
+ aria-label="Dismiss notification"
74
+ >
75
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
76
+ <line x1="18" y1="6" x2="6" y2="18" />
77
+ <line x1="6" y1="6" x2="18" y2="18" />
78
+ </svg>
79
+ </button>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ const TONE = {
85
+ default: {
86
+ bg: "bg-neutral-900",
87
+ border: "border-neutral-700",
88
+ text: "text-neutral-100",
89
+ icon: "text-neutral-400",
90
+ close: "text-neutral-400 hover:text-neutral-100 hover:bg-white/10",
91
+ },
92
+ success: {
93
+ bg: "bg-emerald-950",
94
+ border: "border-emerald-800",
95
+ text: "text-emerald-50",
96
+ icon: "text-emerald-400",
97
+ close: "text-emerald-400 hover:text-emerald-100 hover:bg-emerald-900/60",
98
+ },
99
+ error: {
100
+ bg: "bg-red-950",
101
+ border: "border-red-800",
102
+ text: "text-red-50",
103
+ icon: "text-red-400",
104
+ close: "text-red-400 hover:text-red-100 hover:bg-red-900/60",
105
+ },
106
+ info: {
107
+ bg: "bg-sky-950",
108
+ border: "border-sky-800",
109
+ text: "text-sky-50",
110
+ icon: "text-sky-400",
111
+ close: "text-sky-400 hover:text-sky-100 hover:bg-sky-900/60",
112
+ },
113
+ };
114
+
115
+ const ICONS: Record<string, React.ReactNode> = {
116
+ default: (
117
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
118
+ <circle cx="12" cy="12" r="10" />
119
+ <line x1="12" y1="16" x2="12" y2="12" />
120
+ <line x1="12" y1="8" x2="12.01" y2="8" />
121
+ </svg>
122
+ ),
123
+ success: (
124
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
125
+ <polyline points="20 6 9 17 4 12" />
126
+ </svg>
127
+ ),
128
+ error: (
129
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
130
+ <circle cx="12" cy="12" r="10" />
131
+ <line x1="15" y1="9" x2="9" y2="15" />
132
+ <line x1="9" y1="9" x2="15" y2="15" />
133
+ </svg>
134
+ ),
135
+ info: (
136
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
137
+ <circle cx="12" cy="12" r="10" />
138
+ <line x1="12" y1="16" x2="12" y2="12" />
139
+ <line x1="12" y1="8" x2="12.01" y2="8" />
140
+ </svg>
141
+ ),
142
+ };
package/lib/auth-token.ts CHANGED
@@ -84,7 +84,11 @@ export async function validateAdminToken(token: string): Promise<boolean> {
84
84
  const key = await getSigningKey(pw);
85
85
  const expectedSignature = await hmacSign(key, `admin_session:${timestamp}`);
86
86
 
87
- // Constant-time comparison to prevent timing attacks
87
+ // Constant-time comparison to prevent timing attacks.
88
+ // NOTE: we intentionally do NOT use `crypto.timingSafeEqual` here — that's a
89
+ // Node.js-only API and this file runs on the Edge Runtime too (middleware).
90
+ // The manual XOR loop is the portable equivalent; do not "simplify" it to
91
+ // a `===` compare or a Node-only API without breaking Edge compat.
88
92
  if (providedSignature.length !== expectedSignature.length) return false;
89
93
  let mismatch = 0;
90
94
  for (let i = 0; i < expectedSignature.length; i++) {
package/lib/bot-guard.ts CHANGED
@@ -14,6 +14,12 @@ import { NextRequest, NextResponse } from "next/server";
14
14
 
15
15
  // ── Known aggressive bot User-Agents ────────────────────────────────────
16
16
  // These bots ignore robots.txt or crawl too aggressively for Hobby-tier hosting.
17
+ //
18
+ // Last reviewed: 2026-04-20 (Session 184 audit). New LLM-scraping bots appear
19
+ // frequently — review this list at least quarterly. Good signals to watch:
20
+ // - Vercel logs: look for User-Agents with high request volume
21
+ // - https://darkvisitors.com/ — community database of AI crawlers
22
+ // - Cloudflare radar / new entries in public robots.txt blocklists
17
23
  const BLOCKED_BOT_PATTERNS = [
18
24
  "GPTBot",
19
25
  "CCBot",
@@ -72,18 +72,13 @@ export const ADMIN_ERROR_DARK = "#d42f1a";
72
72
  // while the delete inside a block toolbar is BLUE too (inside the block
73
73
  // pill, on hover it flashes red as a destructive cue).
74
74
 
75
- export const BUILDER_BLUE = "#3580f9"; // Columns (unified strong blue)
76
- export const BUILDER_BLOCK = "#3580f9"; // Blockssame hue as columns
77
- export const BUILDER_VIOLET = "#7500d5"; // Sections (incl. Custom)
75
+ export const BUILDER_BLUE = "#3580f9"; // Admin accent / generic blue (admin chrome, inputs, focus rings)
76
+ export const BUILDER_YELLOW = "#d68900"; // Columns (dark strokes, borders, pill text)
77
+ export const BUILDER_YELLOW_LIGHT = "#fffcc2"; // Columns (light — pill background)
78
+ export const BUILDER_BLOCK = "#3580f9"; // Blocks (shares the blue hue; distinct semantic from columns)
79
+ export const BUILDER_VIOLET = "#7500d5"; // Sections (incl. Custom, Cover, Parallax)
78
80
  export const BUILDER_GREEN = "#22c55e"; // Success / confirmation cues (e.g. R2 asset check)
79
81
 
80
- /**
81
- * @deprecated Use `BUILDER_BLOCK` instead. The legacy name survived multiple
82
- * colour migrations (orange → emerald → blue) and no longer reflects reality.
83
- * Kept as an alias so third-party code doesn't break at the import boundary.
84
- */
85
- export const BUILDER_ORANGE = BUILDER_BLOCK;
86
-
87
82
  /**
88
83
  * Padding map for Row settings (in pixels)
89
84
  * Used by builder (SortableRow, ReadOnlyFrame) and public site (RowRenderer)
@@ -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.4";
9
+ export const ANDAMI_VERSION = "0.5.6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morphika/andami",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",