astro-tractstack 2.0.0-rc.8 → 2.0.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 (141) hide show
  1. package/LICENSE +8 -97
  2. package/README.md +7 -5
  3. package/bin/create-tractstack.js +35 -11
  4. package/dist/index.js +106 -29
  5. package/package.json +10 -5
  6. package/templates/css/frontend.css +1 -1
  7. package/templates/custom/minimal/CodeHook.astro +13 -12
  8. package/templates/custom/minimal/CustomRoutes.astro +25 -31
  9. package/templates/custom/with-examples/CodeHook.astro +22 -11
  10. package/templates/custom/with-examples/CustomRoutes.astro +4 -8
  11. package/templates/custom/with-examples/ProductCard.astro +29 -0
  12. package/templates/custom/with-examples/ProductCardWrapper.astro +43 -0
  13. package/templates/custom/with-examples/ProductGrid.astro +64 -0
  14. package/templates/custom/with-examples/pages/Collections.astro +58 -98
  15. package/templates/gitignore +42 -0
  16. package/templates/prettierignore +5 -0
  17. package/templates/prettierrc +19 -0
  18. package/templates/src/client/app.js +127 -0
  19. package/templates/src/client/htmx.min.js +3519 -0
  20. package/templates/src/client/view.js +429 -0
  21. package/templates/src/components/Footer.astro +4 -9
  22. package/templates/src/components/Header.astro +67 -60
  23. package/templates/src/components/Menu.tsx +188 -52
  24. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  25. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +9 -13
  26. package/templates/src/components/codehooks/EpinetTableView.tsx +11 -7
  27. package/templates/src/components/codehooks/EpinetWrapper.tsx +1 -0
  28. package/templates/src/components/codehooks/FeaturedArticle.astro +105 -0
  29. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +318 -0
  30. package/templates/src/components/codehooks/ListContent.astro +32 -162
  31. package/templates/src/components/codehooks/ListContentSetup.tsx +43 -138
  32. package/templates/src/components/codehooks/ProductCardSetup.tsx +152 -0
  33. package/templates/src/components/codehooks/ProductGridSetup.tsx +274 -0
  34. package/templates/src/components/codehooks/SearchWidget.tsx +453 -0
  35. package/templates/src/components/compositor/Node.tsx +3 -6
  36. package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +21 -11
  37. package/templates/src/components/compositor/elements/BunnyVideo.tsx +21 -20
  38. package/templates/src/components/compositor/nodes/Pane.tsx +51 -21
  39. package/templates/src/components/compositor/nodes/RenderChildren.tsx +6 -1
  40. package/templates/src/components/compositor/nodes/Widget.tsx +16 -2
  41. package/templates/src/components/compositor/preview/FeaturedArticlePreview.tsx +155 -0
  42. package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +20 -1
  43. package/templates/src/components/edit/Header.tsx +10 -4
  44. package/templates/src/components/edit/PanelSwitch.tsx +11 -7
  45. package/templates/src/components/edit/SettingsPanel.tsx +29 -18
  46. package/templates/src/components/edit/ToolBar.tsx +1 -28
  47. package/templates/src/components/edit/ToolMode.tsx +45 -32
  48. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +12 -2
  49. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +8 -2
  50. package/templates/src/components/edit/pane/AddPanePanel_newAICopy_modal.tsx +1 -1
  51. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +17 -27
  52. package/templates/src/components/edit/pane/PageGenSelector.tsx +16 -16
  53. package/templates/src/components/edit/pane/PageGenSpecial.tsx +26 -49
  54. package/templates/src/components/edit/pane/PageGen_preview.tsx +17 -2
  55. package/templates/src/components/edit/pane/PanePanel_path.tsx +2 -4
  56. package/templates/src/components/edit/pane/PanePanel_title.tsx +243 -76
  57. package/templates/src/components/edit/panels/StyleBreakPanel.tsx +17 -19
  58. package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +48 -37
  59. package/templates/src/components/edit/panels/StyleElementPanel_add.tsx +60 -55
  60. package/templates/src/components/edit/panels/StyleImagePanel_add.tsx +56 -50
  61. package/templates/src/components/edit/panels/StyleLiElementPanel_add.tsx +54 -47
  62. package/templates/src/components/edit/panels/StyleLinkPanel_add.tsx +54 -44
  63. package/templates/src/components/edit/panels/StyleLinkPanel_config.tsx +113 -138
  64. package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +54 -40
  65. package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +3 -3
  66. package/templates/src/components/edit/panels/StyleWidgetPanel_add.tsx +56 -49
  67. package/templates/src/components/edit/panels/StyleWidgetPanel_config.tsx +14 -5
  68. package/templates/src/components/edit/state/SaveModal.tsx +316 -169
  69. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_og.tsx +1 -1
  70. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_slug.tsx +56 -55
  71. package/templates/src/components/edit/widgets/BunnyWidget.tsx +538 -59
  72. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +656 -0
  73. package/templates/src/components/edit/widgets/ToggleWidget.tsx +9 -16
  74. package/templates/src/components/fields/ArtpackImage.tsx +4 -1
  75. package/templates/src/components/fields/BackgroundImage.tsx +1 -1
  76. package/templates/src/components/fields/BackgroundImageWrapper.tsx +127 -35
  77. package/templates/src/components/fields/ColorPickerCombo.tsx +66 -62
  78. package/templates/src/components/fields/ImageUpload.tsx +1 -1
  79. package/templates/src/components/fields/ViewportComboBox.tsx +59 -42
  80. package/templates/src/components/form/ActionBuilderBeliefSelector.tsx +117 -0
  81. package/templates/src/components/form/ActionBuilderField.tsx +306 -87
  82. package/templates/src/components/search/SearchModal.tsx +420 -0
  83. package/templates/src/components/search/SearchResults.tsx +367 -0
  84. package/templates/src/components/search/SearchWrapper.tsx +46 -0
  85. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -1
  86. package/templates/src/components/storykeep/Dashboard_Analytics.tsx +34 -8
  87. package/templates/src/components/storykeep/Dashboard_Content.tsx +6 -0
  88. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +87 -0
  89. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +37 -33
  90. package/templates/src/components/storykeep/controls/content/MenuForm.tsx +55 -7
  91. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +17 -2
  92. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +5 -8
  93. package/templates/src/components/storykeep/state/FetchAnalytics.tsx +274 -228
  94. package/templates/src/components/storykeep/widgets/Wizard.tsx +14 -7
  95. package/templates/src/components/tenant/RegistrationForm.tsx +1 -1
  96. package/templates/src/components/widgets/ImpressionWrapper.tsx +0 -1
  97. package/templates/src/constants/shapes.ts +9 -0
  98. package/templates/src/constants.ts +2121 -16
  99. package/templates/src/hooks/useSearch.ts +228 -0
  100. package/templates/src/layouts/Layout.astro +213 -104
  101. package/templates/src/lib/storyData.ts +4 -1
  102. package/templates/src/pages/[...slug]/edit.astro +14 -14
  103. package/templates/src/pages/[...slug].astro +82 -21
  104. package/templates/src/pages/api/orphan-analysis.ts +0 -1
  105. package/templates/src/pages/api/tailwind.ts +23 -21
  106. package/templates/src/pages/context/[...contextSlug]/edit.astro +14 -14
  107. package/templates/src/pages/context/[...contextSlug].astro +7 -2
  108. package/templates/src/pages/storykeep/advanced.astro +5 -4
  109. package/templates/src/pages/storykeep/branding.astro +5 -4
  110. package/templates/src/pages/storykeep/content.astro +5 -4
  111. package/templates/src/pages/storykeep/init.astro +40 -1
  112. package/templates/src/pages/storykeep/login.astro +1 -1
  113. package/templates/src/pages/storykeep.astro +5 -4
  114. package/templates/src/stores/nodes.ts +59 -88
  115. package/templates/src/stores/orphanAnalysis.ts +19 -21
  116. package/templates/src/stores/storykeep.ts +7 -0
  117. package/templates/src/types/compositorTypes.ts +6 -0
  118. package/templates/src/types/tractstack.ts +17 -0
  119. package/templates/src/utils/actions/lispLexer.ts +2 -2
  120. package/templates/src/utils/actions/preParse_Action.ts +3 -0
  121. package/templates/src/utils/api/beliefHelpers.ts +12 -36
  122. package/templates/src/utils/api/menuHelpers.ts +2 -2
  123. package/templates/src/utils/api.ts +26 -0
  124. package/templates/src/utils/compositor/TemplateNodes.ts +7 -0
  125. package/templates/src/utils/compositor/allowInsert.ts +5 -3
  126. package/templates/src/utils/compositor/nodesHelper.ts +4 -0
  127. package/templates/src/utils/compositor/processMarkdown.ts +16 -2
  128. package/templates/src/utils/compositor/reduceNodesClassNames.ts +4 -0
  129. package/templates/src/utils/compositor/templateMarkdownStyles.ts +13 -13
  130. package/templates/src/utils/compositor/typeGuards.ts +1 -0
  131. package/templates/src/utils/customHelpers.ts +38 -0
  132. package/templates/src/utils/helpers.ts +2 -2
  133. package/templates/src/utils/layout.ts +65 -144
  134. package/utils/inject-files.ts +95 -18
  135. package/templates/src/client/analytics-events.js +0 -207
  136. package/templates/src/client/belief-events.js +0 -191
  137. package/templates/src/client/sse.js +0 -613
  138. package/templates/src/components/codehooks/FeaturedContent.astro +0 -273
  139. package/templates/src/components/codehooks/FeaturedContentSetup.tsx +0 -738
  140. package/templates/src/components/compositor/preview/FeaturedContentPreview.tsx +0 -128
  141. package/templates/src/components/edit/pane/PanePanel_slug.tsx +0 -219
@@ -11,12 +11,15 @@ import {
11
11
  fullContentMapStore,
12
12
  getPendingImageOperation,
13
13
  clearPendingImageOperation,
14
+ pendingHomePageSlugStore,
14
15
  } from '@/stores/storykeep';
15
16
  import { startLoadingAnimation } from '@/utils/helpers';
16
17
  import type {
18
+ FlatNode,
17
19
  BaseNode,
18
20
  PaneNode,
19
21
  StoryFragmentNode,
22
+ MarkdownPaneFragmentNode,
20
23
  } from '@/types/compositorTypes';
21
24
 
22
25
  type SaveStage =
@@ -27,12 +30,15 @@ type SaveStage =
27
30
  | 'SAVING_STORY_FRAGMENTS'
28
31
  | 'LINKING_FILES'
29
32
  | 'PROCESSING_STYLES'
33
+ | 'UPDATING_HOME_PAGE'
30
34
  | 'COMPLETED'
31
35
  | 'ERROR';
32
36
 
33
37
  interface SaveStageProgress {
34
38
  currentStep: number;
35
39
  totalSteps: number;
40
+ currentFileName?: string;
41
+ isUploading?: boolean;
36
42
  }
37
43
 
38
44
  interface SaveModalProps {
@@ -42,6 +48,13 @@ interface SaveModalProps {
42
48
  onClose: () => void;
43
49
  }
44
50
 
51
+ const PROGRESS_PHASES = {
52
+ PREPARATION: 5,
53
+ UPLOADS: 60,
54
+ PROCESSING: 25,
55
+ FINALIZATION: 10,
56
+ };
57
+
45
58
  export default function SaveModal({
46
59
  show,
47
60
  slug,
@@ -59,13 +72,8 @@ export default function SaveModal({
59
72
  const [debugMessages, setDebugMessages] = useState<string[]>([]);
60
73
  const isSaving = useRef(false);
61
74
  const [isNavigating, setIsNavigating] = useState(false);
62
-
63
- // Determine if we're in create mode
64
75
  const isCreateMode = slug === 'create';
65
-
66
- const contentMap = fullContentMapStore.get();
67
-
68
- // Get backend URL
76
+ const pendingHomePageSlug = pendingHomePageSlugStore.get();
69
77
  const goBackend =
70
78
  import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
71
79
  const tenantId = import.meta.env.PUBLIC_TENANTID || 'default';
@@ -75,9 +83,7 @@ export default function SaveModal({
75
83
  setDebugMessages((prev) => [...prev, `${timestamp}: ${message}`]);
76
84
  };
77
85
 
78
- // Main save process
79
86
  useEffect(() => {
80
- // Reset state when modal is hidden or if save is already running
81
87
  if (!show) {
82
88
  setStage('PREPARING');
83
89
  setProgress(0);
@@ -96,12 +102,13 @@ export default function SaveModal({
96
102
 
97
103
  try {
98
104
  setStage('PREPARING');
99
- setProgress(5);
105
+ setProgress(PROGRESS_PHASES.PREPARATION);
100
106
  addDebugMessage(
101
- `Starting save process... (${isContext ? 'Context' : 'StoryFragment'} mode, ${isCreateMode ? 'CREATE' : 'UPDATE'})`
107
+ `Starting save process... (${
108
+ isContext ? 'Context' : 'StoryFragment'
109
+ } mode, ${isCreateMode ? 'CREATE' : 'UPDATE'})`
102
110
  );
103
111
 
104
- // Filter nodes based on context mode
105
112
  let dirtyPanes = allDirtyNodes.filter(
106
113
  (node) => node.nodeType === 'Pane'
107
114
  );
@@ -109,7 +116,6 @@ export default function SaveModal({
109
116
  (node) => node.nodeType === 'StoryFragment'
110
117
  );
111
118
 
112
- // In context mode, we only care about panes, not story fragments
113
119
  if (isContext) {
114
120
  dirtyStoryFragments = [];
115
121
  addDebugMessage('Context mode: Ignoring StoryFragment nodes');
@@ -119,8 +125,6 @@ export default function SaveModal({
119
125
  (node): node is BaseNode & { base64Data?: string } =>
120
126
  'base64Data' in node && !!node.base64Data
121
127
  );
122
-
123
- // Check for story fragments with pending OG image operations
124
128
  const storyFragmentsWithPendingImages = dirtyStoryFragments.filter(
125
129
  (fragment) => {
126
130
  const pendingOp = getPendingImageOperation(fragment.id);
@@ -128,6 +132,20 @@ export default function SaveModal({
128
132
  }
129
133
  );
130
134
 
135
+ const totalFileBytes = nodesWithPendingFiles.reduce(
136
+ (sum, node) => sum + (node.base64Data?.length || 0),
137
+ 0
138
+ );
139
+ const totalOgBytes = storyFragmentsWithPendingImages.reduce(
140
+ (sum, fragment) => {
141
+ const pendingOp = getPendingImageOperation(fragment.id);
142
+ return sum + (pendingOp?.data?.length || 0);
143
+ },
144
+ 0
145
+ );
146
+ const totalUploadBytes = totalFileBytes + totalOgBytes;
147
+ let completedUploadBytes = 0;
148
+
131
149
  const relevantNodeCount =
132
150
  dirtyPanes.length + dirtyStoryFragments.length;
133
151
  addDebugMessage(
@@ -143,7 +161,8 @@ export default function SaveModal({
143
161
  if (
144
162
  relevantNodeCount === 0 &&
145
163
  nodesWithPendingFiles.length === 0 &&
146
- storyFragmentsWithPendingImages.length === 0
164
+ storyFragmentsWithPendingImages.length === 0 &&
165
+ !pendingHomePageSlug
147
166
  ) {
148
167
  addDebugMessage('No changes to save');
149
168
  setStage('COMPLETED');
@@ -151,35 +170,25 @@ export default function SaveModal({
151
170
  return;
152
171
  }
153
172
 
154
- const totalSteps =
155
- nodesWithPendingFiles.length +
156
- storyFragmentsWithPendingImages.length +
157
- dirtyPanes.length +
158
- dirtyStoryFragments.length +
159
- 2; // +1 for file linking, +1 for styles
160
-
161
- addDebugMessage(
162
- `Save plan: ${nodesWithPendingFiles.length} files, ${storyFragmentsWithPendingImages.length} og images, ${dirtyPanes.length} panes, ${dirtyStoryFragments.length} story fragments, 1 file linking, 1 styles = ${totalSteps} total steps`
163
- );
164
-
165
- let completedSteps = 1;
166
-
167
- // PHASE 1: Upload all pending files and OG images first
168
173
  const uploadedOGPaths: Record<string, string> = {};
169
174
 
170
- // Handle pending files
171
175
  if (nodesWithPendingFiles.length > 0) {
172
176
  setStage('SAVING_PENDING_FILES');
173
- setStageProgress({
174
- currentStep: 0,
175
- totalSteps: nodesWithPendingFiles.length,
176
- });
177
-
178
177
  for (let i = 0; i < nodesWithPendingFiles.length; i++) {
179
178
  const fileNode = nodesWithPendingFiles[i];
179
+ const fileBytes = fileNode.base64Data?.length || 0;
180
180
  const endpoint = `${goBackend}/api/v1/nodes/files/create`;
181
+
182
+ setStageProgress({
183
+ currentStep: i + 1,
184
+ totalSteps: nodesWithPendingFiles.length,
185
+ currentFileName: `${fileNode.id}.jpg`,
186
+ isUploading: true,
187
+ });
181
188
  addDebugMessage(
182
- `Processing file ${i + 1}/${nodesWithPendingFiles.length}: ${fileNode.id} -> POST ${endpoint}`
189
+ `Processing file ${i + 1}/${nodesWithPendingFiles.length}: ${
190
+ fileNode.id
191
+ } -> POST ${endpoint}`
183
192
  );
184
193
 
185
194
  try {
@@ -190,7 +199,7 @@ export default function SaveModal({
190
199
  'X-Tenant-ID': tenantId,
191
200
  },
192
201
  credentials: 'include',
193
- body: JSON.stringify({ base64Data: fileNode.base64Data }), // FIXED: only send base64Data
202
+ body: JSON.stringify({ base64Data: fileNode.base64Data }),
194
203
  });
195
204
 
196
205
  if (!response.ok) {
@@ -198,16 +207,12 @@ export default function SaveModal({
198
207
  }
199
208
 
200
209
  const result = await response.json();
201
-
202
- // Update tree with response data - handle different node types properly
203
210
  const updatedNode = { ...fileNode, isChanged: true };
204
211
 
205
- // Remove base64Data and add file properties
206
212
  if ('base64Data' in updatedNode) {
207
213
  delete updatedNode.base64Data;
208
214
  }
209
215
 
210
- // Add file properties - these properties already exist in FlatNode and BgImageNode types
211
216
  if ('fileId' in updatedNode) {
212
217
  updatedNode.fileId = result.fileId;
213
218
  }
@@ -232,29 +237,39 @@ export default function SaveModal({
232
237
  );
233
238
  }
234
239
 
235
- setStageProgress((prev) => ({ ...prev, currentStep: i + 1 }));
236
- completedSteps++;
237
- setProgress((completedSteps / totalSteps) * 80);
240
+ completedUploadBytes += fileBytes;
241
+ const uploadProgress =
242
+ totalUploadBytes > 0
243
+ ? (completedUploadBytes / totalUploadBytes) *
244
+ PROGRESS_PHASES.UPLOADS
245
+ : 0;
246
+ setProgress(PROGRESS_PHASES.PREPARATION + uploadProgress);
247
+ setStageProgress((prev) => ({ ...prev, isUploading: false }));
238
248
  }
239
249
  }
240
250
 
241
- // Handle OG image uploads
242
251
  if (storyFragmentsWithPendingImages.length > 0) {
243
252
  setStage('PROCESSING_OG_IMAGES');
244
- setStageProgress({
245
- currentStep: 0,
246
- totalSteps: storyFragmentsWithPendingImages.length,
247
- });
248
253
  for (let i = 0; i < storyFragmentsWithPendingImages.length; i++) {
249
254
  const fragment = storyFragmentsWithPendingImages[i];
250
255
  const pendingOp = getPendingImageOperation(fragment.id);
256
+ const imageBytes = pendingOp?.data?.length || 0;
251
257
 
252
258
  if (pendingOp && pendingOp.type === 'upload' && pendingOp.data) {
253
259
  const ogUploadEndpoint = `${goBackend}/api/v1/nodes/images/og`;
254
260
  addDebugMessage(
255
- `Processing OG image ${i + 1}/${storyFragmentsWithPendingImages.length}: ${fragment.id} -> POST ${ogUploadEndpoint}`
261
+ `Processing OG image ${i + 1}/${
262
+ storyFragmentsWithPendingImages.length
263
+ }: ${fragment.id} -> POST ${ogUploadEndpoint}`
256
264
  );
257
265
 
266
+ setStageProgress({
267
+ currentStep: i + 1,
268
+ totalSteps: storyFragmentsWithPendingImages.length,
269
+ currentFileName: pendingOp?.filename || `${fragment.id}-og.png`,
270
+ isUploading: true,
271
+ });
272
+
258
273
  const uploadPayload = {
259
274
  data: pendingOp.data,
260
275
  filename:
@@ -292,78 +307,136 @@ export default function SaveModal({
292
307
  `Failed to upload OG image for ${fragment.id}: ${errorMsg}`
293
308
  );
294
309
  }
310
+ completedUploadBytes += imageBytes;
311
+ const uploadProgress =
312
+ totalUploadBytes > 0
313
+ ? (completedUploadBytes / totalUploadBytes) *
314
+ PROGRESS_PHASES.UPLOADS
315
+ : 0;
316
+ setProgress(PROGRESS_PHASES.PREPARATION + uploadProgress);
317
+ setStageProgress((prev) => ({ ...prev, isUploading: false }));
295
318
  }
296
-
297
- setStageProgress((prev) => ({ ...prev, currentStep: i + 1 }));
298
- completedSteps++;
299
- setProgress((completedSteps / totalSteps) * 80);
300
319
  }
301
320
  }
302
321
 
303
- // Handle panes
322
+ if (totalUploadBytes > 0) {
323
+ setProgress(PROGRESS_PHASES.PREPARATION + PROGRESS_PHASES.UPLOADS);
324
+ }
325
+
326
+ const totalProcessingSteps =
327
+ dirtyPanes.length + dirtyStoryFragments.length;
328
+ let completedProcessingSteps = 0;
329
+
304
330
  if (dirtyPanes.length > 0) {
305
331
  setStage('SAVING_PANES');
306
332
  setStageProgress({
307
333
  currentStep: 0,
308
334
  totalSteps: dirtyPanes.length,
309
335
  });
310
- for (let i = 0; i < dirtyPanes.length; i++) {
311
- const paneNode = dirtyPanes[i];
312
-
313
- try {
314
- const payload = transformLivePaneForSave(
315
- ctx,
316
- paneNode.id,
317
- isContext
318
- );
319
336
 
320
- // Check if this pane exists or is new
321
- const paneExistsInBackend = contentMap.some(
322
- (item) => item.type === 'Pane' && item.id === paneNode.id
323
- );
324
- const isCreatePaneMode = !paneExistsInBackend;
325
- const endpoint = isCreatePaneMode
326
- ? `${goBackend}/api/v1/nodes/panes/create`
327
- : `${goBackend}/api/v1/nodes/panes/${payload.id}`;
328
- const method = isCreatePaneMode ? 'POST' : 'PUT';
337
+ const bulkPayload = dirtyPanes.map((paneNode) =>
338
+ transformLivePaneForSave(ctx, paneNode.id, isContext)
339
+ );
329
340
 
330
- addDebugMessage(
331
- `Processing pane ${i + 1}/${dirtyPanes.length}: ${paneNode.id} -> ${method} ${endpoint}`
332
- );
341
+ bulkPayload.forEach((payload) => {
342
+ payload.optionsPayload.nodes.forEach((transformedNode) => {
343
+ const liveNode = ctx.allNodes.get().get(transformedNode.id);
344
+ if (!liveNode) return;
345
+
346
+ let needsUpdate = false;
347
+ let updatedNode: BaseNode = { ...liveNode };
348
+
349
+ if (
350
+ transformedNode.nodeType === 'TagElement' &&
351
+ transformedNode.elementCss
352
+ ) {
353
+ const flatNode = liveNode as FlatNode;
354
+ if (flatNode.elementCss !== transformedNode.elementCss) {
355
+ (updatedNode as FlatNode).elementCss =
356
+ transformedNode.elementCss;
357
+ needsUpdate = true;
358
+ }
359
+ }
333
360
 
334
- const response = await fetch(endpoint, {
335
- method,
336
- headers: {
337
- 'Content-Type': 'application/json',
338
- 'X-Tenant-ID': tenantId,
339
- },
340
- credentials: 'include',
341
- body: JSON.stringify(payload),
342
- });
361
+ if (
362
+ transformedNode.nodeType === 'Markdown' &&
363
+ transformedNode.parentCss
364
+ ) {
365
+ const markdownNode = liveNode as MarkdownPaneFragmentNode;
366
+ const currentParentCss = markdownNode.parentCss;
367
+ const newParentCss = transformedNode.parentCss as string[];
368
+
369
+ const isDifferent =
370
+ !currentParentCss ||
371
+ currentParentCss.length !== newParentCss.length ||
372
+ currentParentCss.some(
373
+ (css, index) => css !== newParentCss[index]
374
+ );
375
+
376
+ if (isDifferent) {
377
+ (updatedNode as MarkdownPaneFragmentNode).parentCss =
378
+ newParentCss;
379
+ needsUpdate = true;
380
+ }
381
+ }
343
382
 
344
- if (!response.ok) {
345
- throw new Error(`HTTP error! status: ${response.status}`);
383
+ if (needsUpdate) {
384
+ ctx.allNodes.get().set(transformedNode.id, updatedNode);
346
385
  }
386
+ });
387
+ });
347
388
 
348
- //const result =
349
- await response.json();
350
- addDebugMessage(`Pane ${paneNode.id} saved successfully`);
351
- } catch (etlError) {
352
- const errorMsg =
353
- etlError instanceof Error ? etlError.message : 'Unknown error';
354
- addDebugMessage(`Pane ${paneNode.id} ETL failed: ${errorMsg}`);
389
+ const endpoint = `${goBackend}/api/v1/nodes/panes/bulk`;
390
+ addDebugMessage(
391
+ `Processing ${dirtyPanes.length} panes via -> POST ${endpoint}`
392
+ );
393
+
394
+ try {
395
+ const response = await fetch(endpoint, {
396
+ method: 'POST',
397
+ headers: {
398
+ 'Content-Type': 'application/json',
399
+ 'X-Tenant-ID': tenantId,
400
+ },
401
+ credentials: 'include',
402
+ body: JSON.stringify({ panes: bulkPayload }),
403
+ });
404
+
405
+ if (!response.ok) {
406
+ const errorText = await response.text();
355
407
  throw new Error(
356
- `Failed to save pane ${paneNode.id}: ${errorMsg}`
408
+ `HTTP error! status: ${response.status} - ${errorText}`
357
409
  );
358
410
  }
359
411
 
360
- setStageProgress((prev) => ({ ...prev, currentStep: i + 1 }));
361
- completedSteps++;
362
- setProgress((completedSteps / totalSteps) * 80);
412
+ await response.json();
413
+ addDebugMessage(
414
+ `${dirtyPanes.length} panes saved successfully via bulk endpoint.`
415
+ );
416
+ } catch (bulkError) {
417
+ const errorMsg =
418
+ bulkError instanceof Error
419
+ ? bulkError.message
420
+ : 'Unknown bulk save error';
421
+ addDebugMessage(`Bulk pane save failed: ${errorMsg}`);
422
+ throw new Error(`Failed to save panes in bulk: ${errorMsg}`);
363
423
  }
424
+
425
+ setStageProgress({
426
+ currentStep: dirtyPanes.length,
427
+ totalSteps: dirtyPanes.length,
428
+ });
429
+ completedProcessingSteps += dirtyPanes.length;
430
+ const processingProgress =
431
+ (completedProcessingSteps / totalProcessingSteps) *
432
+ PROGRESS_PHASES.PROCESSING;
433
+ setProgress(
434
+ PROGRESS_PHASES.PREPARATION +
435
+ PROGRESS_PHASES.UPLOADS +
436
+ processingProgress
437
+ );
364
438
  }
365
439
 
366
- // Handle story fragments
367
440
  if (!isContext && dirtyStoryFragments.length > 0) {
368
441
  setStage('SAVING_STORY_FRAGMENTS');
369
442
  setStageProgress({
@@ -380,7 +453,6 @@ export default function SaveModal({
380
453
  window.TRACTSTACK_CONFIG?.tenantId || 'default'
381
454
  );
382
455
 
383
- // If we uploaded an OG image for this fragment, use that path
384
456
  if (uploadedOGPaths[fragment.id]) {
385
457
  payload.socialImagePath = uploadedOGPaths[fragment.id];
386
458
  }
@@ -391,7 +463,9 @@ export default function SaveModal({
391
463
  const method = isCreateMode ? 'POST' : 'PUT';
392
464
 
393
465
  addDebugMessage(
394
- `Processing story fragment ${i + 1}/${dirtyStoryFragments.length}: ${fragment.id} -> ${method} ${endpoint}`
466
+ `Processing story fragment ${i + 1}/${
467
+ dirtyStoryFragments.length
468
+ }: ${fragment.id} -> ${method} ${endpoint}`
395
469
  );
396
470
 
397
471
  const response = await fetch(endpoint, {
@@ -408,13 +482,11 @@ export default function SaveModal({
408
482
  throw new Error(`HTTP error! status: ${response.status}`);
409
483
  }
410
484
 
411
- //const result =
412
485
  await response.json();
413
486
  addDebugMessage(
414
487
  `StoryFragment ${fragment.id} saved successfully`
415
488
  );
416
489
 
417
- // Clear pending image operation after successful save
418
490
  if (uploadedOGPaths[fragment.id]) {
419
491
  clearPendingImageOperation(fragment.id);
420
492
  addDebugMessage(
@@ -433,17 +505,28 @@ export default function SaveModal({
433
505
  }
434
506
 
435
507
  setStageProgress((prev) => ({ ...prev, currentStep: i + 1 }));
436
- completedSteps++;
437
- setProgress((completedSteps / totalSteps) * 80);
508
+ completedProcessingSteps++;
509
+ const processingProgress =
510
+ (completedProcessingSteps / totalProcessingSteps) *
511
+ PROGRESS_PHASES.PROCESSING;
512
+ setProgress(
513
+ PROGRESS_PHASES.PREPARATION +
514
+ PROGRESS_PHASES.UPLOADS +
515
+ processingProgress
516
+ );
438
517
  }
439
518
  }
440
519
 
441
- // PHASE 3: Link file-pane relationships
520
+ const baseFinalizationProgress =
521
+ PROGRESS_PHASES.PREPARATION +
522
+ PROGRESS_PHASES.UPLOADS +
523
+ PROGRESS_PHASES.PROCESSING;
524
+
442
525
  if (dirtyPanes.length > 0) {
443
526
  setStage('LINKING_FILES');
527
+ setProgress(baseFinalizationProgress);
444
528
  addDebugMessage('Starting file-pane relationship linking...');
445
529
 
446
- // Extract pane<>file relationships from saved panes
447
530
  const relationships = [];
448
531
  for (const paneNode of dirtyPanes) {
449
532
  const fileIds = ctx.getPaneImageFileIds(paneNode.id);
@@ -491,84 +574,132 @@ export default function SaveModal({
491
574
  } else {
492
575
  addDebugMessage('No file relationships to link');
493
576
  }
494
-
495
- completedSteps++;
496
- setProgress((completedSteps / totalSteps) * 90);
497
577
  }
498
578
 
499
- // PHASE 4: Styles processing (2-step process)
500
579
  setStage('PROCESSING_STYLES');
501
- setProgress(95);
580
+ setProgress(
581
+ baseFinalizationProgress + PROGRESS_PHASES.FINALIZATION / 2
582
+ );
502
583
  addDebugMessage(`Processing styles...`);
503
584
 
504
585
  try {
505
586
  const { dirtyPaneIds, classes: dirtyClasses } =
506
587
  ctx.getDirtyNodesClassData();
507
588
 
508
- if (dirtyClasses.length === 0) {
509
- addDebugMessage(
510
- 'No dirty classes to process, skipping Tailwind update'
589
+ const astroEndpoint = `/api/tailwind`;
590
+ const astroPayload = { dirtyPaneIds, dirtyClasses };
591
+ const astroResponse = await fetch(astroEndpoint, {
592
+ method: 'POST',
593
+ headers: {
594
+ 'Content-Type': 'application/json',
595
+ 'X-Tenant-ID': tenantId,
596
+ },
597
+ credentials: 'include',
598
+ body: JSON.stringify(astroPayload),
599
+ });
600
+
601
+ if (!astroResponse.ok) {
602
+ throw new Error(
603
+ `CSS generation failed! status: ${astroResponse.status}`
511
604
  );
512
- } else {
513
- // STEP 1: Generate CSS using Astro API
514
- const astroEndpoint = `/api/tailwind`;
515
- const astroPayload = { dirtyPaneIds, dirtyClasses };
516
- const astroResponse = await fetch(astroEndpoint, {
517
- method: 'POST',
605
+ }
606
+
607
+ const astroResult = await astroResponse.json();
608
+
609
+ if (!astroResult.success || !astroResult.generatedCss) {
610
+ throw new Error('CSS generation failed: no CSS returned');
611
+ }
612
+
613
+ addDebugMessage(
614
+ `CSS generated: ${astroResult.generatedCss.length} bytes for ${dirtyClasses.length} classes`
615
+ );
616
+
617
+ const goEndpoint = `${goBackend}/api/v1/tailwind/update`;
618
+ const goPayload = { frontendCss: astroResult.generatedCss };
619
+ const goResponse = await fetch(goEndpoint, {
620
+ method: 'POST',
621
+ headers: {
622
+ 'Content-Type': 'application/json',
623
+ 'X-Tenant-ID': tenantId,
624
+ },
625
+ credentials: 'include',
626
+ body: JSON.stringify(goPayload),
627
+ });
628
+
629
+ if (!goResponse.ok) {
630
+ throw new Error(`CSS save failed! status: ${goResponse.status}`);
631
+ }
632
+
633
+ const goResult = await goResponse.json();
634
+ addDebugMessage(
635
+ `CSS saved successfully: stylesVer ${goResult.stylesVer}`
636
+ );
637
+ } catch (error) {
638
+ const errorMsg =
639
+ error instanceof Error ? error.message : 'Unknown error';
640
+ addDebugMessage(`Styles processing failed: ${errorMsg}`);
641
+ throw new Error(`Failed to process styles: ${errorMsg}`);
642
+ }
643
+
644
+ if (pendingHomePageSlug) {
645
+ setStage('UPDATING_HOME_PAGE');
646
+ setProgress(
647
+ baseFinalizationProgress + (PROGRESS_PHASES.FINALIZATION - 2)
648
+ );
649
+ addDebugMessage(`Updating home page to: ${pendingHomePageSlug}`);
650
+
651
+ try {
652
+ const response = await fetch(`${goBackend}/api/v1/config/brand`, {
653
+ method: 'GET',
518
654
  headers: {
519
655
  'Content-Type': 'application/json',
520
656
  'X-Tenant-ID': tenantId,
521
657
  },
522
658
  credentials: 'include',
523
- body: JSON.stringify(astroPayload),
524
659
  });
525
660
 
526
- if (!astroResponse.ok) {
661
+ if (!response.ok) {
527
662
  throw new Error(
528
- `CSS generation failed! status: ${astroResponse.status}`
663
+ `Failed to get current brand config: ${response.status}`
529
664
  );
530
665
  }
531
666
 
532
- const astroResult = await astroResponse.json();
667
+ const currentBrandConfig = await response.json();
533
668
 
534
- if (!astroResult.success || !astroResult.generatedCss) {
535
- throw new Error('CSS generation failed: no CSS returned');
536
- }
669
+ const updatedBrandConfig = {
670
+ ...currentBrandConfig,
671
+ HOME_SLUG: pendingHomePageSlug,
672
+ };
537
673
 
538
- addDebugMessage(
539
- `CSS generated: ${astroResult.generatedCss.length} bytes for ${dirtyClasses.length} classes`
674
+ const updateResponse = await fetch(
675
+ `${goBackend}/api/v1/config/brand`,
676
+ {
677
+ method: 'PUT',
678
+ headers: {
679
+ 'Content-Type': 'application/json',
680
+ 'X-Tenant-ID': tenantId,
681
+ },
682
+ credentials: 'include',
683
+ body: JSON.stringify(updatedBrandConfig),
684
+ }
540
685
  );
541
686
 
542
- // STEP 2: Save CSS to Go backend
543
- const goEndpoint = `${goBackend}/api/v1/tailwind/update`;
544
- const goPayload = { frontendCss: astroResult.generatedCss };
545
- const goResponse = await fetch(goEndpoint, {
546
- method: 'POST',
547
- headers: {
548
- 'Content-Type': 'application/json',
549
- 'X-Tenant-ID': tenantId,
550
- },
551
- credentials: 'include',
552
- body: JSON.stringify(goPayload),
553
- });
554
-
555
- if (!goResponse.ok) {
556
- throw new Error(`CSS save failed! status: ${goResponse.status}`);
687
+ if (!updateResponse.ok) {
688
+ throw new Error(
689
+ `Failed to update home page: ${updateResponse.status}`
690
+ );
557
691
  }
558
692
 
559
- const goResult = await goResponse.json();
560
- addDebugMessage(
561
- `CSS saved successfully: stylesVer ${goResult.stylesVer}`
562
- );
693
+ pendingHomePageSlugStore.set(null);
694
+ addDebugMessage('Home page updated successfully');
695
+ } catch (error) {
696
+ const errorMsg =
697
+ error instanceof Error ? error.message : 'Unknown error';
698
+ addDebugMessage(`Home page update failed: ${errorMsg}`);
699
+ throw new Error(`Failed to update home page: ${errorMsg}`);
563
700
  }
564
- } catch (error) {
565
- const errorMsg =
566
- error instanceof Error ? error.message : 'Unknown error';
567
- addDebugMessage(`Styles processing failed: ${errorMsg}`);
568
- throw new Error(`Failed to process styles: ${errorMsg}`);
569
701
  }
570
702
 
571
- // Success!
572
703
  setStage('COMPLETED');
573
704
  setProgress(100);
574
705
  addDebugMessage('Save process completed successfully!');
@@ -589,10 +720,18 @@ export default function SaveModal({
589
720
  }, [show, slug, isContext, isCreateMode, goBackend, tenantId]);
590
721
 
591
722
  const getStageDescription = () => {
592
- const getProgressText = () =>
593
- stageProgress.totalSteps > 0
594
- ? ` (${stageProgress.currentStep}/${stageProgress.totalSteps})`
595
- : '';
723
+ const { currentStep, totalSteps, currentFileName, isUploading } =
724
+ stageProgress;
725
+
726
+ const getProgressText = () => {
727
+ if (currentFileName && isUploading) {
728
+ return ` - Uploading ${currentFileName}...`;
729
+ }
730
+ if (currentFileName && !isUploading) {
731
+ return ` - Completed ${currentFileName}`;
732
+ }
733
+ return totalSteps > 0 ? ` (${currentStep}/${totalSteps})` : '';
734
+ };
596
735
 
597
736
  const modeText = isContext ? 'Context Pane' : 'Story Fragment';
598
737
  const actionText = isCreateMode ? 'Creating' : 'Updating';
@@ -601,9 +740,9 @@ export default function SaveModal({
601
740
  case 'PREPARING':
602
741
  return `Preparing ${actionText.toLowerCase()} ${modeText.toLowerCase()}...`;
603
742
  case 'SAVING_PENDING_FILES':
604
- return `Uploading files...${getProgressText()}`;
743
+ return `Uploading files${getProgressText()}`;
605
744
  case 'PROCESSING_OG_IMAGES':
606
- return `Processing OG images...${getProgressText()}`;
745
+ return `Processing social images${getProgressText()}`;
607
746
  case 'SAVING_PANES':
608
747
  return `${actionText} pane content...${getProgressText()}`;
609
748
  case 'SAVING_STORY_FRAGMENTS':
@@ -612,6 +751,8 @@ export default function SaveModal({
612
751
  return 'Linking file relationships...';
613
752
  case 'PROCESSING_STYLES':
614
753
  return 'Processing styles...';
754
+ case 'UPDATING_HOME_PAGE':
755
+ return 'Updating home page...';
615
756
  case 'COMPLETED':
616
757
  return `${actionText} ${modeText.toLowerCase()} completed successfully!`;
617
758
  case 'ERROR':
@@ -634,19 +775,15 @@ export default function SaveModal({
634
775
 
635
776
  if (isCreateMode) {
636
777
  let actualSlug: string;
778
+ const ctx = getCtx();
779
+ const allDirtyNodes = ctx.getDirtyNodes();
637
780
 
638
781
  if (isContext) {
639
- // For context mode, get slug from the saved pane
640
- const ctx = getCtx();
641
- const allDirtyNodes = ctx.getDirtyNodes();
642
782
  const dirtyPanes = allDirtyNodes.filter(
643
783
  (node): node is PaneNode => node.nodeType === 'Pane'
644
784
  );
645
785
  actualSlug = dirtyPanes[0].slug;
646
786
  } else {
647
- // For storyfragment mode, get slug from the saved storyfragment
648
- const ctx = getCtx();
649
- const allDirtyNodes = ctx.getDirtyNodes();
650
787
  const dirtyStoryFragments = allDirtyNodes.filter(
651
788
  (node): node is StoryFragmentNode =>
652
789
  node.nodeType === 'StoryFragment'
@@ -725,7 +862,11 @@ export default function SaveModal({
725
862
  <div className="p-6">
726
863
  <div className="mb-4">
727
864
  <div className="mb-2 flex items-center justify-between">
728
- <span className="text-sm text-gray-700">
865
+ <span
866
+ className={`text-sm text-gray-700 ${
867
+ stageProgress.isUploading ? 'animate-pulse' : ''
868
+ }`}
869
+ >
729
870
  {getStageDescription()}
730
871
  </span>
731
872
  {stage !== 'ERROR' && (
@@ -764,7 +905,7 @@ export default function SaveModal({
764
905
 
765
906
  {stage === 'ERROR' && (
766
907
  <div className="mb-4 rounded bg-red-50 p-3 text-red-800">
767
- <div className="font-medium">Save failed</div>
908
+ <div className="font-bold">Save failed</div>
768
909
  <div className="mt-1 text-sm">{error}</div>
769
910
  </div>
770
911
  )}
@@ -790,6 +931,12 @@ export default function SaveModal({
790
931
  >
791
932
  Keep Editing
792
933
  </button>
934
+ <a
935
+ href="/storykeep/content"
936
+ className={`rounded bg-black px-4 py-2 text-white transition-colors hover:bg-white hover:text-black`}
937
+ >
938
+ Dashboard
939
+ </a>
793
940
  </>
794
941
  )}
795
942
  {stage === 'ERROR' && (