astro-tractstack 2.1.2 → 2.2.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 (131) hide show
  1. package/README.md +54 -266
  2. package/bin/create-tractstack.js +9 -6
  3. package/dist/index.js +109 -71
  4. package/package.json +4 -2
  5. package/templates/css/custom.css +5 -0
  6. package/templates/custom/minimal/CodeHook.astro +1 -0
  7. package/templates/custom/with-examples/CodeHook.astro +1 -0
  8. package/templates/icons/code.svg +18 -0
  9. package/templates/icons/li.svg +4 -0
  10. package/templates/icons/link.svg +22 -0
  11. package/templates/icons/p.svg +3 -0
  12. package/templates/src/client/app.js +80 -1
  13. package/templates/src/components/Footer.astro +1 -1
  14. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +6 -6
  15. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +3 -3
  16. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +1 -1
  17. package/templates/src/components/codehooks/ListContentSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/ProductCardSetup.tsx +1 -1
  19. package/templates/src/components/codehooks/ProductGridSetup.tsx +2 -2
  20. package/templates/src/components/codehooks/SandboxRegisterForm.tsx +3 -3
  21. package/templates/src/components/compositor/Compositor.tsx +25 -9
  22. package/templates/src/components/compositor/Node.tsx +168 -496
  23. package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +1 -0
  24. package/templates/src/components/compositor/elements/SignUp.tsx +1 -1
  25. package/templates/src/components/compositor/elements/YouTubeWrapper.tsx +2 -0
  26. package/templates/src/components/compositor/nodes/CreativePane.tsx +262 -0
  27. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +4 -6
  28. package/templates/src/components/compositor/nodes/GridLayout.tsx +4 -2
  29. package/templates/src/components/compositor/nodes/Markdown.tsx +18 -3
  30. package/templates/src/components/compositor/nodes/Pane.tsx +11 -5
  31. package/templates/src/components/compositor/nodes/RenderChildren.tsx +1 -1
  32. package/templates/src/components/compositor/nodes/tagElements/NodeAnchorComponent.tsx +5 -5
  33. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag.tsx +90 -42
  34. package/templates/src/components/compositor/nodes/tagElements/NodeImg.tsx +2 -0
  35. package/templates/src/components/compositor/nodes/tagElements/NodeText.tsx +27 -1
  36. package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +10 -8
  37. package/templates/src/components/compositor/tools/NodeOverlay.tsx +224 -0
  38. package/templates/src/components/compositor/tools/PaneOverlay.tsx +122 -0
  39. package/templates/src/components/edit/Header.tsx +68 -9
  40. package/templates/src/components/edit/PanelSwitch.tsx +42 -4
  41. package/templates/src/components/edit/SettingsPanel.tsx +2 -3
  42. package/templates/src/components/edit/ToolMode.tsx +1 -31
  43. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +2 -2
  44. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +1 -1
  45. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +193 -659
  46. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +15 -82
  47. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +95 -45
  48. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +137 -49
  49. package/templates/src/components/edit/pane/RestylePaneModal.tsx +1 -1
  50. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +375 -0
  51. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +1 -23
  52. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +327 -0
  53. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +267 -0
  54. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +371 -0
  55. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +201 -76
  56. package/templates/src/components/edit/pane/steps/CreativeInjectStep.tsx +141 -0
  57. package/templates/src/components/edit/panels/CreativeImagePanel.tsx +435 -0
  58. package/templates/src/components/edit/panels/CreativeLinkPanel.tsx +110 -0
  59. package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +1 -1
  60. package/templates/src/components/edit/panels/StyleParentPanel.tsx +118 -126
  61. package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +3 -2
  62. package/templates/src/components/edit/panels/StyleParentPanel_deleteLayer.tsx +1 -0
  63. package/templates/src/components/edit/panels/StyleParentPanel_remove.tsx +3 -1
  64. package/templates/src/components/edit/panels/StyleParentPanel_update.tsx +3 -1
  65. package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +1 -1
  66. package/templates/src/components/edit/state/SaveModal.tsx +19 -787
  67. package/templates/src/components/edit/state/SaveToLibraryModal.tsx +2 -2
  68. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_menu.tsx +1 -1
  69. package/templates/src/components/edit/widgets/BunnyWidget.tsx +5 -5
  70. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +1 -1
  71. package/templates/src/components/edit/widgets/SignupWidget.tsx +1 -1
  72. package/templates/src/components/fields/ActionBuilderTimeSelector.tsx +1 -1
  73. package/templates/src/components/fields/ArtpackImage.tsx +11 -3
  74. package/templates/src/components/fields/BackgroundImage.tsx +8 -0
  75. package/templates/src/components/fields/BackgroundImageWrapper.tsx +15 -9
  76. package/templates/src/components/fields/ImageUpload.tsx +6 -0
  77. package/templates/src/components/form/ActionBuilderField.tsx +15 -5
  78. package/templates/src/components/form/ActionBuilderSlugSelector.tsx +1 -1
  79. package/templates/src/components/form/ColorPicker.tsx +1 -1
  80. package/templates/src/components/form/EnumSelect.tsx +1 -1
  81. package/templates/src/components/form/NumberInput.tsx +1 -1
  82. package/templates/src/components/form/StringArrayInput.tsx +1 -1
  83. package/templates/src/components/form/StringInput.tsx +1 -1
  84. package/templates/src/components/form/UnsavedChangesBar.tsx +1 -1
  85. package/templates/src/components/form/advanced/APIConfigSection.tsx +2 -2
  86. package/templates/src/components/form/advanced/AuthConfigSection.tsx +2 -2
  87. package/templates/src/components/profile/ProfileCreate.tsx +1 -1
  88. package/templates/src/components/profile/ProfileEdit.tsx +1 -1
  89. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +2 -2
  90. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +1 -1
  91. package/templates/src/components/storykeep/controls/content/ContentSummary.tsx +2 -2
  92. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +1 -1
  93. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +6 -6
  94. package/templates/src/components/storykeep/controls/content/MenuForm.tsx +1 -1
  95. package/templates/src/components/storykeep/controls/content/PaneTable.tsx +358 -0
  96. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -1
  97. package/templates/src/constants/prompts.json +18 -10
  98. package/templates/src/constants.ts +3 -0
  99. package/templates/src/hooks/usePaneFragments.ts +60 -0
  100. package/templates/src/lib/session.ts +71 -16
  101. package/templates/src/pages/[...slug].astro +5 -46
  102. package/templates/src/pages/api/css.ts +149 -0
  103. package/templates/src/pages/context/[...contextSlug].astro +1 -0
  104. package/templates/src/pages/maint.astro +1 -1
  105. package/templates/src/pages/storykeep/login.astro +2 -2
  106. package/templates/src/stores/nodes.ts +162 -49
  107. package/templates/src/stores/orphanAnalysis.ts +6 -30
  108. package/templates/src/stores/previews.ts +7 -0
  109. package/templates/src/stores/storykeep.ts +0 -8
  110. package/templates/src/types/compositorTypes.ts +53 -10
  111. package/templates/src/utils/compositor/aiGeneration.ts +93 -0
  112. package/templates/src/utils/compositor/allowInsert.ts +2 -0
  113. package/templates/src/utils/compositor/htmlAst.ts +704 -0
  114. package/templates/src/utils/compositor/nodesHelper.ts +281 -102
  115. package/templates/src/utils/compositor/savePipeline.ts +893 -0
  116. package/templates/src/utils/etl/index.ts +3 -0
  117. package/templates/src/utils/etl/transformer.ts +10 -0
  118. package/templates/src/utils/helpers.ts +101 -0
  119. package/utils/inject-files.ts +100 -62
  120. package/templates/icons/text.svg +0 -6
  121. package/templates/src/components/compositor/NodeWithGuid.tsx +0 -69
  122. package/templates/src/components/compositor/nodes/GridLayout_eraser.tsx +0 -33
  123. package/templates/src/components/compositor/nodes/Markdown_eraser.tsx +0 -56
  124. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +0 -269
  125. package/templates/src/components/compositor/nodes/Pane_eraser.tsx +0 -186
  126. package/templates/src/components/compositor/nodes/Pane_layout.tsx +0 -79
  127. package/templates/src/components/compositor/nodes/tagElements/NodeA_eraser.tsx +0 -26
  128. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_eraser.tsx +0 -61
  129. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_insert.tsx +0 -120
  130. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_settings.tsx +0 -62
  131. package/templates/src/components/compositor/nodes/tagElements/NodeButton_eraser.tsx +0 -26
@@ -0,0 +1,893 @@
1
+ import {
2
+ getPendingImageOperation,
3
+ clearPendingImageOperation,
4
+ pendingHomePageSlugStore,
5
+ } from '@/stores/storykeep';
6
+ import { cloneDeep } from '@/utils/helpers';
7
+ import {
8
+ transformLivePaneForSave,
9
+ transformStoryFragmentForSave,
10
+ } from '@/utils/etl/index';
11
+ import { processClassesForViewports } from '@/utils/compositor/reduceNodesClassNames';
12
+ import { scanAndReplaceBase64 } from '@/utils/compositor/htmlAst';
13
+ import type { NodesContext } from '@/stores/nodes';
14
+ import type {
15
+ BaseNode,
16
+ PaneNode,
17
+ StoryFragmentNode,
18
+ FlatNode,
19
+ MarkdownPaneFragmentNode,
20
+ GridLayoutNode,
21
+ } from '@/types/compositorTypes';
22
+
23
+ type PendingFileNode = BaseNode & {
24
+ base64Data?: string;
25
+ fileId?: string;
26
+ src?: string;
27
+ srcSet?: string;
28
+ };
29
+
30
+ export type SaveStage =
31
+ | 'PREPARING'
32
+ | 'SAVING_PENDING_FILES'
33
+ | 'PROCESSING_OG_IMAGES'
34
+ | 'COOKING_NODES'
35
+ | 'PROCESSING_STYLES'
36
+ | 'SAVING_PANES'
37
+ | 'SAVING_STORY_FRAGMENTS'
38
+ | 'LINKING_FILES'
39
+ | 'UPDATING_HOME_PAGE'
40
+ | 'COMPLETED'
41
+ | 'ERROR';
42
+
43
+ export interface SaveStageProgress {
44
+ currentStep: number;
45
+ totalSteps: number;
46
+ currentFileName?: string;
47
+ isUploading?: boolean;
48
+ }
49
+
50
+ export interface SavePipelineConfig {
51
+ slug: string;
52
+ isContext: boolean;
53
+ isCreateMode: boolean;
54
+ hydrate: boolean;
55
+ tenantId: string;
56
+ backendUrl: string;
57
+ pendingHomePageSlug: string | null;
58
+ }
59
+
60
+ export interface SavePipelineCallbacks {
61
+ setStage: (stage: SaveStage) => void;
62
+ setProgress: (percentage: number) => void;
63
+ setStageProgress: (
64
+ update: SaveStageProgress | ((prev: SaveStageProgress) => SaveStageProgress)
65
+ ) => void;
66
+ logDebug: (message: string) => void;
67
+ setIsIndeterminateStage: (isIndeterminate: boolean) => void;
68
+ }
69
+
70
+ const PROGRESS_PHASES = {
71
+ PREPARATION: 5,
72
+ UPLOADS: 60,
73
+ PROCESSING: 25,
74
+ FINALIZATION: 10,
75
+ };
76
+
77
+ export async function executeSavePipeline(
78
+ ctx: NodesContext,
79
+ config: SavePipelineConfig,
80
+ callbacks: SavePipelineCallbacks
81
+ ): Promise<void> {
82
+ const {
83
+ slug,
84
+ isContext,
85
+ isCreateMode,
86
+ hydrate,
87
+ tenantId,
88
+ backendUrl,
89
+ pendingHomePageSlug,
90
+ } = config;
91
+ const {
92
+ setStage,
93
+ setProgress,
94
+ setStageProgress,
95
+ logDebug,
96
+ setIsIndeterminateStage,
97
+ } = callbacks;
98
+
99
+ const allDirtyNodes = ctx.getDirtyNodes();
100
+
101
+ setStage('PREPARING');
102
+ setProgress(PROGRESS_PHASES.PREPARATION);
103
+ logDebug(
104
+ `Starting save process... (${isContext ? 'Context' : 'StoryFragment'} mode, ${isCreateMode ? 'CREATE' : 'UPDATE'})`
105
+ );
106
+ logDebug(
107
+ `Config: slug=${slug}, tenantId=${tenantId}, backendUrl=${backendUrl}`
108
+ );
109
+
110
+ let dirtyPanes = allDirtyNodes.filter(
111
+ (node) => node.nodeType === 'Pane'
112
+ ) as PaneNode[];
113
+ let dirtyStoryFragments = allDirtyNodes.filter(
114
+ (node) => node.nodeType === 'StoryFragment'
115
+ ) as StoryFragmentNode[];
116
+
117
+ if (isContext) {
118
+ dirtyStoryFragments = [];
119
+ logDebug('Context mode: Ignoring StoryFragment nodes');
120
+ }
121
+
122
+ const nodesWithPendingFiles = allDirtyNodes.filter(
123
+ (node): node is BaseNode & { base64Data?: string } =>
124
+ 'base64Data' in node && !!node.base64Data
125
+ );
126
+
127
+ const storyFragmentsWithPendingImages = dirtyStoryFragments.filter(
128
+ (fragment) => {
129
+ const pendingOp = getPendingImageOperation(fragment.id);
130
+ return pendingOp && pendingOp.type === 'upload';
131
+ }
132
+ );
133
+
134
+ const totalFileBytes = nodesWithPendingFiles.reduce(
135
+ (sum, node) => sum + (node.base64Data?.length || 0),
136
+ 0
137
+ );
138
+ const totalOgBytes = storyFragmentsWithPendingImages.reduce(
139
+ (sum, fragment) => {
140
+ const pendingOp = getPendingImageOperation(fragment.id);
141
+ return sum + (pendingOp?.data?.length || 0);
142
+ },
143
+ 0
144
+ );
145
+ const totalUploadBytes = totalFileBytes + totalOgBytes;
146
+ let completedUploadBytes = 0;
147
+
148
+ const relevantNodeCount = dirtyPanes.length + dirtyStoryFragments.length;
149
+ logDebug(
150
+ `Found ${relevantNodeCount} relevant dirty nodes to save (${dirtyPanes.length} Panes, ${dirtyStoryFragments.length} StoryFragments)`
151
+ );
152
+ logDebug(
153
+ `Pending uploads: ${nodesWithPendingFiles.length} standard files, ${storyFragmentsWithPendingImages.length} OG images`
154
+ );
155
+
156
+ if (
157
+ relevantNodeCount === 0 &&
158
+ nodesWithPendingFiles.length === 0 &&
159
+ storyFragmentsWithPendingImages.length === 0 &&
160
+ !pendingHomePageSlug
161
+ ) {
162
+ logDebug('No changes to save');
163
+ setStage('COMPLETED');
164
+ setProgress(100);
165
+ return;
166
+ }
167
+
168
+ const uploadedOGPaths: Record<string, string> = {};
169
+
170
+ if (nodesWithPendingFiles.length > 0) {
171
+ setStage('SAVING_PENDING_FILES');
172
+ logDebug(
173
+ `Starting processing of ${nodesWithPendingFiles.length} pending files...`
174
+ );
175
+
176
+ for (let i = 0; i < nodesWithPendingFiles.length; i++) {
177
+ const fileNode = nodesWithPendingFiles[i];
178
+ const fileBytes = fileNode.base64Data?.length || 0;
179
+ const endpoint = `${backendUrl}/api/v1/nodes/files/create`;
180
+
181
+ setStageProgress({
182
+ currentStep: i + 1,
183
+ totalSteps: nodesWithPendingFiles.length,
184
+ currentFileName: `${fileNode.id}.jpg`,
185
+ isUploading: true,
186
+ });
187
+ logDebug(
188
+ `Processing file ${i + 1}/${nodesWithPendingFiles.length}: ${fileNode.id} -> POST ${endpoint}`
189
+ );
190
+
191
+ try {
192
+ const response = await fetch(endpoint, {
193
+ method: 'POST',
194
+ headers: {
195
+ 'Content-Type': 'application/json',
196
+ 'X-Tenant-ID': tenantId,
197
+ },
198
+ credentials: 'include',
199
+ body: JSON.stringify({ base64Data: fileNode.base64Data }),
200
+ });
201
+
202
+ if (!response.ok) {
203
+ throw new Error(`HTTP error! status: ${response.status}`);
204
+ }
205
+
206
+ const result = await response.json();
207
+
208
+ const updatedNode = cloneDeep(fileNode) as PendingFileNode;
209
+ delete updatedNode.base64Data;
210
+
211
+ updatedNode.fileId = result.fileId;
212
+ updatedNode.src = result.src;
213
+ if (result.srcSet) updatedNode.srcSet = result.srcSet;
214
+ updatedNode.isChanged = true;
215
+ ctx.modifyNodes([updatedNode]);
216
+
217
+ const localRef = fileNode as PendingFileNode;
218
+ delete localRef.base64Data;
219
+ localRef.fileId = result.fileId;
220
+ localRef.src = result.src;
221
+ if (result.srcSet) localRef.srcSet = result.srcSet;
222
+ logDebug(
223
+ `File ${fileNode.id} uploaded successfully - got fileId: ${result.fileId}`
224
+ );
225
+ } catch (error) {
226
+ const errorMsg =
227
+ error instanceof Error ? error.message : 'Unknown error';
228
+ logDebug(`File ${fileNode.id} upload failed: ${errorMsg}`);
229
+ throw new Error(`Failed to upload file ${fileNode.id}: ${errorMsg}`);
230
+ }
231
+
232
+ completedUploadBytes += fileBytes;
233
+ const uploadProgress =
234
+ totalUploadBytes > 0
235
+ ? (completedUploadBytes / totalUploadBytes) * PROGRESS_PHASES.UPLOADS
236
+ : 0;
237
+ setProgress(PROGRESS_PHASES.PREPARATION + uploadProgress);
238
+ setStageProgress((prev: SaveStageProgress) => ({
239
+ ...prev,
240
+ isUploading: false,
241
+ }));
242
+ }
243
+ }
244
+
245
+ const creativePanes = dirtyPanes.filter(
246
+ (p) => p.htmlAst && p.htmlAst.editableElements
247
+ );
248
+ if (creativePanes.length > 0) {
249
+ logDebug(
250
+ `Scanning ${creativePanes.length} Creative Panes for embedded uploads...`
251
+ );
252
+ setIsIndeterminateStage(true);
253
+
254
+ for (const pane of creativePanes) {
255
+ if (!pane.htmlAst) continue;
256
+
257
+ try {
258
+ logDebug(`Scanning pane ${pane.id} for base64 assets...`);
259
+ const cleanAst = await scanAndReplaceBase64(
260
+ pane.htmlAst,
261
+ async (base64Data) => {
262
+ logDebug(
263
+ `Uploading embedded asset for pane ${pane.id} (${base64Data.length} bytes)...`
264
+ );
265
+ const endpoint = `${backendUrl}/api/v1/nodes/files/create`;
266
+
267
+ const response = await fetch(endpoint, {
268
+ method: 'POST',
269
+ headers: {
270
+ 'Content-Type': 'application/json',
271
+ 'X-Tenant-ID': tenantId,
272
+ },
273
+ credentials: 'include',
274
+ body: JSON.stringify({ base64Data }),
275
+ });
276
+
277
+ if (!response.ok) {
278
+ const txt = await response.text();
279
+ throw new Error(
280
+ `Creative upload failed: ${response.status} - ${txt}`
281
+ );
282
+ }
283
+ const res = await response.json();
284
+ logDebug(`Asset uploaded. New fileId: ${res.fileId}`);
285
+ return res;
286
+ }
287
+ );
288
+
289
+ ctx.modifyNodes([
290
+ { ...pane, htmlAst: cleanAst, isChanged: true } as PaneNode,
291
+ ]);
292
+ logDebug(`Creative Pane ${pane.id} assets processed and node updated.`);
293
+ } catch (err) {
294
+ console.error('Creative Pane Asset Upload Error:', err);
295
+ const errMsg = err instanceof Error ? err.message : String(err);
296
+ logDebug(
297
+ `Failed to process assets for Creative Pane ${pane.id}: ${errMsg}`
298
+ );
299
+ }
300
+ }
301
+ setIsIndeterminateStage(false);
302
+ }
303
+
304
+ if (storyFragmentsWithPendingImages.length > 0) {
305
+ setStage('PROCESSING_OG_IMAGES');
306
+ logDebug(
307
+ `Processing ${storyFragmentsWithPendingImages.length} OG images...`
308
+ );
309
+
310
+ for (let i = 0; i < storyFragmentsWithPendingImages.length; i++) {
311
+ const fragment = storyFragmentsWithPendingImages[i];
312
+ const pendingOp = getPendingImageOperation(fragment.id);
313
+ const imageBytes = pendingOp?.data?.length || 0;
314
+
315
+ if (pendingOp && pendingOp.type === 'upload' && pendingOp.data) {
316
+ const ogUploadEndpoint = `${backendUrl}/api/v1/nodes/images/og`;
317
+ logDebug(
318
+ `Processing OG image ${i + 1}/${storyFragmentsWithPendingImages.length}: ${fragment.id} -> POST ${ogUploadEndpoint}`
319
+ );
320
+
321
+ setStageProgress({
322
+ currentStep: i + 1,
323
+ totalSteps: storyFragmentsWithPendingImages.length,
324
+ currentFileName: pendingOp?.filename || `${fragment.id}-og.png`,
325
+ isUploading: true,
326
+ });
327
+
328
+ const uploadPayload = {
329
+ data: pendingOp.data,
330
+ filename: pendingOp.filename || `${fragment.id}-${Date.now()}.png`,
331
+ };
332
+
333
+ try {
334
+ const response = await fetch(ogUploadEndpoint, {
335
+ method: 'POST',
336
+ headers: {
337
+ 'Content-Type': 'application/json',
338
+ 'X-Tenant-ID': tenantId,
339
+ },
340
+ credentials: 'include',
341
+ body: JSON.stringify(uploadPayload),
342
+ });
343
+
344
+ if (!response.ok) {
345
+ throw new Error(`HTTP error! status: ${response.status}`);
346
+ }
347
+
348
+ const result = await response.json();
349
+ uploadedOGPaths[fragment.id] = result.path;
350
+ logDebug(`OG image uploaded successfully: ${result.path}`);
351
+ } catch (error) {
352
+ const errorMsg =
353
+ error instanceof Error ? error.message : 'Unknown error';
354
+ logDebug(`OG image upload failed for ${fragment.id}: ${errorMsg}`);
355
+ throw new Error(
356
+ `Failed to upload OG image for ${fragment.id}: ${errorMsg}`
357
+ );
358
+ }
359
+ completedUploadBytes += imageBytes;
360
+ const uploadProgress =
361
+ totalUploadBytes > 0
362
+ ? (completedUploadBytes / totalUploadBytes) *
363
+ PROGRESS_PHASES.UPLOADS
364
+ : 0;
365
+ setProgress(PROGRESS_PHASES.PREPARATION + uploadProgress);
366
+ setStageProgress((prev: SaveStageProgress) => ({
367
+ ...prev,
368
+ isUploading: false,
369
+ }));
370
+ }
371
+ }
372
+ }
373
+
374
+ if (totalUploadBytes > 0) {
375
+ setProgress(PROGRESS_PHASES.PREPARATION + PROGRESS_PHASES.UPLOADS);
376
+ }
377
+
378
+ const totalProcessingSteps = dirtyPanes.length + dirtyStoryFragments.length;
379
+ let completedProcessingSteps = 0;
380
+
381
+ if (allDirtyNodes.length > 0) {
382
+ setStage('COOKING_NODES');
383
+ setIsIndeterminateStage(true);
384
+ logDebug(
385
+ `Cooking ${allDirtyNodes.length} nodes for whitelist extraction...`
386
+ );
387
+
388
+ const cookingUpdates: BaseNode[] = [];
389
+ const nodesToCookMap = new Map<string, BaseNode>();
390
+
391
+ dirtyPanes.forEach((pane) => {
392
+ const subtree = ctx.getNodesRecursively(pane);
393
+ subtree.forEach((node) => {
394
+ nodesToCookMap.set(node.id, node);
395
+ });
396
+ });
397
+
398
+ nodesToCookMap.forEach((liveNode) => {
399
+ try {
400
+ let updatedNode: BaseNode | null = null;
401
+
402
+ if (liveNode.nodeType === 'TagElement') {
403
+ const flatNode = liveNode as FlatNode;
404
+ const computedCSS = ctx.getNodeClasses(flatNode.id, 'auto', 0);
405
+ if (flatNode.elementCss !== computedCSS) {
406
+ updatedNode = {
407
+ ...liveNode,
408
+ elementCss: computedCSS,
409
+ } as FlatNode;
410
+ }
411
+ } else if (liveNode.nodeType === 'Markdown') {
412
+ const markdownNode = liveNode as MarkdownPaneFragmentNode;
413
+ let needsUpdate = false;
414
+ const nextNode = { ...markdownNode };
415
+
416
+ if (markdownNode.parentClasses) {
417
+ const computedParentCss = markdownNode.parentClasses.map(
418
+ (_: any, index: number) =>
419
+ ctx.getNodeClasses(liveNode.id, 'auto', index)
420
+ );
421
+ if (
422
+ JSON.stringify(markdownNode.parentCss) !==
423
+ JSON.stringify(computedParentCss)
424
+ ) {
425
+ nextNode.parentCss = computedParentCss;
426
+ needsUpdate = true;
427
+ }
428
+ }
429
+
430
+ if (markdownNode.gridClasses) {
431
+ const [allClasses] = processClassesForViewports(
432
+ markdownNode.gridClasses,
433
+ {},
434
+ 1
435
+ );
436
+ if (allClasses && allClasses.length > 0) {
437
+ const computedGridCss = allClasses[0];
438
+ if (markdownNode.gridCss !== computedGridCss) {
439
+ nextNode.gridCss = computedGridCss;
440
+ needsUpdate = true;
441
+ }
442
+ }
443
+ }
444
+
445
+ if (needsUpdate) updatedNode = nextNode;
446
+ } else if (liveNode.nodeType === 'GridLayoutNode') {
447
+ const gridNode = liveNode as GridLayoutNode;
448
+ let needsUpdate = false;
449
+ const nextNode = { ...gridNode };
450
+
451
+ if (gridNode.parentClasses) {
452
+ const computedParentCss = gridNode.parentClasses.map(
453
+ (_: any, index: number) =>
454
+ ctx.getNodeClasses(liveNode.id, 'auto', index)
455
+ );
456
+ if (
457
+ JSON.stringify(gridNode.parentCss) !==
458
+ JSON.stringify(computedParentCss)
459
+ ) {
460
+ nextNode.parentCss = computedParentCss.join(` `);
461
+ needsUpdate = true;
462
+ }
463
+ }
464
+
465
+ if (gridNode.gridColumns) {
466
+ const { mobile, tablet, desktop } = gridNode.gridColumns;
467
+ let computedGridCss = `grid grid-cols-${mobile}`;
468
+ if (tablet !== mobile) computedGridCss += ` md:grid-cols-${tablet}`;
469
+ if (desktop !== tablet)
470
+ computedGridCss += ` xl:grid-cols-${desktop}`;
471
+
472
+ if (gridNode.gridCss !== computedGridCss) {
473
+ nextNode.gridCss = computedGridCss;
474
+ needsUpdate = true;
475
+ }
476
+ }
477
+
478
+ if (needsUpdate) updatedNode = nextNode;
479
+ }
480
+
481
+ if (updatedNode) {
482
+ cookingUpdates.push(updatedNode);
483
+ }
484
+ } catch (e) {
485
+ console.warn(`Failed to cook node ${liveNode.id}`, e);
486
+ logDebug(`Failed to cook node ${liveNode.id}: ${e}`);
487
+ }
488
+ });
489
+
490
+ if (cookingUpdates.length > 0) {
491
+ ctx.modifyNodes(cookingUpdates, {
492
+ notify: false,
493
+ recordHistory: false,
494
+ });
495
+ logDebug(`Cooked ${cookingUpdates.length} nodes successfully.`);
496
+ } else {
497
+ logDebug('No nodes needed cooking.');
498
+ }
499
+ setIsIndeterminateStage(false);
500
+ }
501
+
502
+ setStage('PROCESSING_STYLES');
503
+ setIsIndeterminateStage(true);
504
+ const baseFinalizationProgress =
505
+ PROGRESS_PHASES.PREPARATION +
506
+ PROGRESS_PHASES.UPLOADS +
507
+ PROGRESS_PHASES.PROCESSING;
508
+ setProgress(baseFinalizationProgress + PROGRESS_PHASES.FINALIZATION / 2);
509
+ logDebug(`Processing styles... gathering dirty classes.`);
510
+
511
+ try {
512
+ const { dirtyPaneIds, classes: dirtyClasses } =
513
+ ctx.getDirtyNodesClassData();
514
+ logDebug(
515
+ `Found ${dirtyClasses.length} distinct classes across ${dirtyPaneIds.length} panes.`
516
+ );
517
+
518
+ const astroEndpoint = `/api/tailwind`;
519
+ const astroPayload = { dirtyPaneIds, dirtyClasses };
520
+ logDebug(
521
+ `POST ${astroEndpoint} - payload size: ${JSON.stringify(astroPayload).length}`
522
+ );
523
+
524
+ const astroResponse = await fetch(astroEndpoint, {
525
+ method: 'POST',
526
+ headers: {
527
+ 'Content-Type': 'application/json',
528
+ 'X-Tenant-ID': tenantId,
529
+ },
530
+ credentials: 'include',
531
+ body: JSON.stringify(astroPayload),
532
+ });
533
+
534
+ if (!astroResponse.ok) {
535
+ throw new Error(`CSS generation failed! status: ${astroResponse.status}`);
536
+ }
537
+
538
+ const astroResult = await astroResponse.json();
539
+
540
+ if (!astroResult.success || !astroResult.generatedCss) {
541
+ throw new Error('CSS generation failed: no CSS returned');
542
+ }
543
+
544
+ logDebug(`CSS generated: ${astroResult.generatedCss.length} bytes.`);
545
+
546
+ const goEndpoint = `${backendUrl}/api/v1/tailwind/update`;
547
+ const goPayload = { frontendCss: astroResult.generatedCss };
548
+ logDebug(`POST ${goEndpoint} - saving frontend CSS`);
549
+
550
+ const goResponse = await fetch(goEndpoint, {
551
+ method: 'POST',
552
+ headers: {
553
+ 'Content-Type': 'application/json',
554
+ 'X-Tenant-ID': tenantId,
555
+ },
556
+ credentials: 'include',
557
+ body: JSON.stringify(goPayload),
558
+ });
559
+
560
+ if (!goResponse.ok) {
561
+ throw new Error(`CSS save failed! status: ${goResponse.status}`);
562
+ }
563
+
564
+ const goResult = await goResponse.json();
565
+ logDebug(`CSS saved successfully: stylesVer ${goResult.stylesVer}`);
566
+ } catch (error) {
567
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
568
+ logDebug(`Styles processing failed: ${errorMsg}`);
569
+ throw new Error(`Failed to process styles: ${errorMsg}`);
570
+ } finally {
571
+ setIsIndeterminateStage(false);
572
+ }
573
+
574
+ if (dirtyPanes.length > 0) {
575
+ setStage('SAVING_PANES');
576
+ setIsIndeterminateStage(true);
577
+ setStageProgress({
578
+ currentStep: 0,
579
+ totalSteps: dirtyPanes.length,
580
+ });
581
+ logDebug(`Preparing to save ${dirtyPanes.length} panes...`);
582
+
583
+ const bulkPayload = dirtyPanes.map((paneNode) =>
584
+ transformLivePaneForSave(ctx, paneNode.id, isContext)
585
+ );
586
+ logDebug(`Bulk payload constructed. Count: ${bulkPayload.length}`);
587
+
588
+ bulkPayload.forEach((payload) => {
589
+ payload.optionsPayload.nodes.forEach((transformedNode) => {
590
+ const liveNode = ctx.allNodes.get().get(transformedNode.id);
591
+ if (!liveNode) return;
592
+
593
+ let needsUpdate = false;
594
+ let updatedNode: BaseNode = { ...liveNode };
595
+
596
+ if (
597
+ transformedNode.nodeType === 'TagElement' &&
598
+ transformedNode.elementCss
599
+ ) {
600
+ const flatNode = liveNode as FlatNode;
601
+ if (flatNode.elementCss !== transformedNode.elementCss) {
602
+ (updatedNode as FlatNode).elementCss = transformedNode.elementCss;
603
+ needsUpdate = true;
604
+ }
605
+ }
606
+
607
+ if (
608
+ transformedNode.nodeType === 'Markdown' &&
609
+ transformedNode.parentCss
610
+ ) {
611
+ const markdownNode = liveNode as MarkdownPaneFragmentNode;
612
+ const currentParentCss = markdownNode.parentCss;
613
+ const newParentCss = transformedNode.parentCss as string[];
614
+
615
+ const isDifferent =
616
+ !currentParentCss ||
617
+ currentParentCss.length !== newParentCss.length ||
618
+ currentParentCss.some((css, index) => css !== newParentCss[index]);
619
+
620
+ if (isDifferent) {
621
+ (updatedNode as MarkdownPaneFragmentNode).parentCss = newParentCss;
622
+ needsUpdate = true;
623
+ }
624
+ }
625
+
626
+ if (needsUpdate) {
627
+ ctx.allNodes.get().set(transformedNode.id, updatedNode);
628
+ }
629
+ });
630
+ });
631
+
632
+ const endpoint = `${backendUrl}/api/v1/nodes/panes/bulk`;
633
+ logDebug(`Processing ${dirtyPanes.length} panes via -> POST ${endpoint}`);
634
+
635
+ try {
636
+ const response = await fetch(endpoint, {
637
+ method: 'POST',
638
+ headers: {
639
+ 'Content-Type': 'application/json',
640
+ 'X-Tenant-ID': tenantId,
641
+ },
642
+ credentials: 'include',
643
+ body: JSON.stringify({ panes: bulkPayload }),
644
+ });
645
+
646
+ if (!response.ok) {
647
+ const errorText = await response.text();
648
+ throw new Error(
649
+ `HTTP error! status: ${response.status} - ${errorText}`
650
+ );
651
+ }
652
+
653
+ await response.json();
654
+ logDebug(
655
+ `${dirtyPanes.length} panes saved successfully via bulk endpoint.`
656
+ );
657
+ } catch (bulkError) {
658
+ const errorMsg =
659
+ bulkError instanceof Error
660
+ ? bulkError.message
661
+ : 'Unknown bulk save error';
662
+ logDebug(`Bulk pane save failed: ${errorMsg}`);
663
+ throw new Error(`Failed to save panes in bulk: ${errorMsg}`);
664
+ } finally {
665
+ setIsIndeterminateStage(false);
666
+ }
667
+
668
+ setStageProgress({
669
+ currentStep: dirtyPanes.length,
670
+ totalSteps: dirtyPanes.length,
671
+ });
672
+ completedProcessingSteps += dirtyPanes.length;
673
+ const processingProgress =
674
+ (completedProcessingSteps / totalProcessingSteps) *
675
+ PROGRESS_PHASES.PROCESSING;
676
+ setProgress(
677
+ PROGRESS_PHASES.PREPARATION + PROGRESS_PHASES.UPLOADS + processingProgress
678
+ );
679
+ }
680
+
681
+ if (!isContext && dirtyStoryFragments.length > 0) {
682
+ setStage('SAVING_STORY_FRAGMENTS');
683
+ setStageProgress({
684
+ currentStep: 0,
685
+ totalSteps: dirtyStoryFragments.length,
686
+ });
687
+ logDebug(`Saving ${dirtyStoryFragments.length} story fragments...`);
688
+
689
+ for (let i = 0; i < dirtyStoryFragments.length; i++) {
690
+ const fragment = dirtyStoryFragments[i];
691
+
692
+ try {
693
+ const payload = await transformStoryFragmentForSave(
694
+ ctx,
695
+ fragment.id,
696
+ tenantId
697
+ );
698
+
699
+ if (uploadedOGPaths[fragment.id]) {
700
+ payload.socialImagePath = uploadedOGPaths[fragment.id];
701
+ }
702
+
703
+ const endpoint = isCreateMode
704
+ ? `${backendUrl}/api/v1/nodes/storyfragments/create`
705
+ : `${backendUrl}/api/v1/nodes/storyfragments/${payload.id}/complete`;
706
+ const method = isCreateMode ? 'POST' : 'PUT';
707
+
708
+ logDebug(
709
+ `Processing story fragment ${i + 1}/${dirtyStoryFragments.length}: ${fragment.id} -> ${method} ${endpoint}`
710
+ );
711
+
712
+ const response = await fetch(endpoint, {
713
+ method,
714
+ headers: {
715
+ 'Content-Type': 'application/json',
716
+ 'X-Tenant-ID': tenantId,
717
+ },
718
+ credentials: 'include',
719
+ body: JSON.stringify(payload),
720
+ });
721
+
722
+ if (!response.ok) {
723
+ throw new Error(`HTTP error! status: ${response.status}`);
724
+ }
725
+
726
+ await response.json();
727
+ logDebug(`StoryFragment ${fragment.id} saved successfully`);
728
+
729
+ if (uploadedOGPaths[fragment.id]) {
730
+ clearPendingImageOperation(fragment.id);
731
+ logDebug(`Cleared pending image operation for ${fragment.id}`);
732
+ }
733
+ } catch (etlError) {
734
+ const errorMsg =
735
+ etlError instanceof Error ? etlError.message : 'Unknown error';
736
+ logDebug(`StoryFragment ${fragment.id} ETL failed: ${errorMsg}`);
737
+ throw new Error(
738
+ `Failed to save story fragment ${fragment.id}: ${errorMsg}`
739
+ );
740
+ }
741
+
742
+ setStageProgress((prev: SaveStageProgress) => ({
743
+ ...prev,
744
+ currentStep: i + 1,
745
+ }));
746
+ completedProcessingSteps++;
747
+ const processingProgress =
748
+ (completedProcessingSteps / totalProcessingSteps) *
749
+ PROGRESS_PHASES.PROCESSING;
750
+ setProgress(
751
+ PROGRESS_PHASES.PREPARATION +
752
+ PROGRESS_PHASES.UPLOADS +
753
+ processingProgress
754
+ );
755
+ }
756
+ }
757
+
758
+ if (dirtyPanes.length > 0) {
759
+ setStage('LINKING_FILES');
760
+ setIsIndeterminateStage(true);
761
+ setProgress(baseFinalizationProgress);
762
+ logDebug('Starting file-pane relationship linking...');
763
+
764
+ const relationships = [];
765
+ for (const paneNode of dirtyPanes) {
766
+ const fileIds = ctx.getPaneImageFileIds(paneNode.id);
767
+ if (fileIds.length > 0) {
768
+ logDebug(`Pane ${paneNode.id} has files: ${fileIds.join(', ')}`);
769
+ }
770
+ relationships.push({
771
+ paneId: paneNode.id,
772
+ fileIds: fileIds,
773
+ });
774
+ }
775
+
776
+ if (relationships.some((rel) => rel.fileIds.length > 0)) {
777
+ try {
778
+ const bulkEndpoint = `${backendUrl}/api/v1/nodes/panes/files/bulk`;
779
+ const activeRels = relationships.filter((r) => r.fileIds.length > 0);
780
+ logDebug(`Linking relationships: ${JSON.stringify(activeRels)}`);
781
+
782
+ const response = await fetch(bulkEndpoint, {
783
+ method: 'POST',
784
+ headers: {
785
+ 'Content-Type': 'application/json',
786
+ 'X-Tenant-ID': tenantId,
787
+ },
788
+ credentials: 'include',
789
+ body: JSON.stringify({ relationships }),
790
+ });
791
+
792
+ if (!response.ok) {
793
+ const txt = await response.text();
794
+ throw new Error(`HTTP error! status: ${response.status} - ${txt}`);
795
+ }
796
+
797
+ const result = await response.json();
798
+ logDebug(
799
+ `File-pane relationships linked successfully: ${result.message}`
800
+ );
801
+ } catch (error) {
802
+ const errorMsg =
803
+ error instanceof Error ? error.message : 'Unknown error';
804
+ logDebug(`Failed to link file-pane relationships: ${errorMsg}`);
805
+ throw new Error(`Failed to link file-pane relationships: ${errorMsg}`);
806
+ }
807
+ } else {
808
+ logDebug('No file relationships to link');
809
+ }
810
+ setIsIndeterminateStage(false);
811
+ }
812
+
813
+ if (pendingHomePageSlug) {
814
+ setStage('UPDATING_HOME_PAGE');
815
+ setIsIndeterminateStage(true);
816
+ setProgress(baseFinalizationProgress + (PROGRESS_PHASES.FINALIZATION - 2));
817
+ logDebug(`Updating home page to: ${pendingHomePageSlug}`);
818
+
819
+ try {
820
+ const response = await fetch(`${backendUrl}/api/v1/config/brand`, {
821
+ method: 'GET',
822
+ headers: {
823
+ 'Content-Type': 'application/json',
824
+ 'X-Tenant-ID': tenantId,
825
+ },
826
+ credentials: 'include',
827
+ });
828
+
829
+ if (!response.ok) {
830
+ throw new Error(
831
+ `Failed to get current brand config: ${response.status}`
832
+ );
833
+ }
834
+
835
+ const currentBrandConfig = await response.json();
836
+
837
+ const updatedBrandConfig = {
838
+ ...currentBrandConfig,
839
+ HOME_SLUG: pendingHomePageSlug,
840
+ };
841
+
842
+ const updateResponse = await fetch(`${backendUrl}/api/v1/config/brand`, {
843
+ method: 'PUT',
844
+ headers: {
845
+ 'Content-Type': 'application/json',
846
+ 'X-Tenant-ID': tenantId,
847
+ },
848
+ credentials: 'include',
849
+ body: JSON.stringify(updatedBrandConfig),
850
+ });
851
+
852
+ if (!updateResponse.ok) {
853
+ throw new Error(`Failed to update home page: ${updateResponse.status}`);
854
+ }
855
+
856
+ pendingHomePageSlugStore.set(null);
857
+ logDebug('Home page updated successfully');
858
+ } catch (error) {
859
+ const errorMsg = error instanceof Error ? error.message : 'Unknown error';
860
+ logDebug(`Home page update failed: ${errorMsg}`);
861
+ throw new Error(`Failed to update home page: ${errorMsg}`);
862
+ } finally {
863
+ setIsIndeterminateStage(false);
864
+ }
865
+ }
866
+
867
+ if (hydrate) {
868
+ logDebug('Finalizing setup (Kill Switch)...');
869
+ try {
870
+ const response = await fetch(`${backendUrl}/api/v1/setup/complete`, {
871
+ method: 'POST',
872
+ headers: {
873
+ 'Content-Type': 'application/json',
874
+ 'X-Tenant-ID': tenantId,
875
+ },
876
+ credentials: 'include',
877
+ body: JSON.stringify({}),
878
+ });
879
+
880
+ if (!response.ok) {
881
+ throw new Error(`Kill Switch failed: ${response.status}`);
882
+ }
883
+ logDebug('Hydration token cleared.');
884
+ } catch (e) {
885
+ console.error('Kill switch error:', e);
886
+ logDebug('Warning: Failed to clear hydration token.');
887
+ }
888
+ }
889
+
890
+ setStage('COMPLETED');
891
+ setProgress(100);
892
+ logDebug('Save process completed successfully!');
893
+ }