astro-tractstack 2.0.13 → 2.0.15

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 (28) hide show
  1. package/dist/index.js +40 -0
  2. package/package.json +1 -1
  3. package/templates/src/client/view.js +5 -0
  4. package/templates/src/components/compositor/Compositor.tsx +3 -2
  5. package/templates/src/components/compositor/Node.tsx +25 -8
  6. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +105 -0
  7. package/templates/src/components/edit/ToolMode.tsx +7 -0
  8. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +459 -561
  9. package/templates/src/components/edit/pane/AiPaneGenerator.tsx +19 -82
  10. package/templates/src/components/edit/pane/RestylePaneModal.tsx +573 -0
  11. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +140 -0
  12. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +105 -0
  13. package/templates/src/components/edit/pane/steps/DesignLibraryStep.tsx +395 -0
  14. package/templates/src/components/edit/state/SaveToLibraryModal.tsx +205 -0
  15. package/templates/src/constants/prompts.json +3 -1
  16. package/templates/src/stores/selection.ts +4 -0
  17. package/templates/src/types/compositorTypes.ts +51 -1
  18. package/templates/src/types/tractstack.ts +36 -31
  19. package/templates/src/utils/aai/getTitleSlug.ts +1 -1
  20. package/templates/src/utils/api/brandConfig.ts +8 -2
  21. package/templates/src/utils/api/brandHelpers.ts +4 -0
  22. package/templates/src/utils/compositor/aiPaneParser.ts +32 -84
  23. package/templates/src/utils/compositor/designLibraryHelper.ts +416 -0
  24. package/templates/src/utils/compositor/processMarkdown.ts +1 -1
  25. package/utils/inject-files.ts +40 -0
  26. package/templates/src/components/edit/pane/PageGen.tsx +0 -485
  27. package/templates/src/components/edit/pane/PageGenSelector.tsx +0 -245
  28. package/templates/src/components/edit/pane/PageGenSpecial.tsx +0 -339
@@ -1,35 +1,104 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import { useEffect, useState, useMemo } from 'react';
3
- import { Switch } from '@ark-ui/react';
4
- import { Select } from '@ark-ui/react/select';
5
- import { Portal } from '@ark-ui/react/portal';
6
- import { createListCollection } from '@ark-ui/react/collection';
7
- import ChevronUpDownIcon from '@heroicons/react/20/solid/ChevronUpDownIcon';
8
- import CheckIcon from '@heroicons/react/20/solid/CheckIcon';
9
- import { NodesContext } from '@/stores/nodes';
10
- import {
11
- PanesPreviewGenerator,
12
- type PanePreviewRequest,
13
- type PaneFragmentResult,
14
- } from '@/components/compositor/preview/PanesPreviewGenerator';
15
- import {
16
- PaneSnapshotGenerator,
17
- type SnapshotData,
18
- } from '@/components/compositor/preview/PaneSnapshotGenerator';
19
- import { createEmptyStorykeep } from '@/utils/compositor/nodesHelper';
2
+ import { useState, useCallback } from 'react';
3
+ import DocumentPlusIcon from '@heroicons/react/24/outline/DocumentPlusIcon';
4
+ import SparklesIcon from '@heroicons/react/24/outline/SparklesIcon';
5
+ import SwatchIcon from '@heroicons/react/24/outline/SwatchIcon';
6
+ import { NodesContext, getCtx } from '@/stores/nodes';
20
7
  import { cloneDeep } from '@/utils/helpers';
21
- import {
22
- brandColourStore,
23
- preferredThemeStore,
24
- hasAssemblyAIStore,
25
- } from '@/stores/storykeep';
26
- import { templateCategories } from '@/utils/compositor/templateMarkdownStyles';
27
- import { AiPaneGenerator } from './AiPaneGenerator';
28
- import { AddPaneNewCustomCopy } from './AddPanePanel_newCustomCopy';
29
- import { themes, type Theme, type BrandConfig } from '@/types/tractstack';
8
+ import { hasAssemblyAIStore } from '@/stores/storykeep';
9
+ import prompts from '@/constants/prompts.json';
10
+ import type { BrandConfig, DesignLibraryEntry } from '@/types/tractstack';
30
11
  import { PaneAddMode, type TemplatePane } from '@/types/compositorTypes';
31
12
  import { useStore } from '@nanostores/react';
32
13
 
14
+ import { CopyInputStep } from './steps/CopyInputStep';
15
+ import { DesignLibraryStep } from './steps/DesignLibraryStep';
16
+ import { AiDesignStep, type AiDesignConfig } from './steps/AiDesignStep';
17
+ import { parseAiPane, parseAiCopyHtml } from '@/utils/compositor/aiPaneParser';
18
+ import {
19
+ convertStorageToLiveTemplate,
20
+ mergeCopyIntoTemplate,
21
+ convertTemplateToAIShell,
22
+ } from '@/utils/compositor/designLibraryHelper';
23
+
24
+ // --- Types for Workflow State ---
25
+ type Step =
26
+ | 'initial'
27
+ | 'copyInput'
28
+ | 'designLibrary'
29
+ | 'aiDesign'
30
+ | 'loading'
31
+ | 'error';
32
+
33
+ type InitialChoice = 'library' | 'ai' | 'blank';
34
+ type CopyMode = 'prompt' | 'raw';
35
+
36
+ // --- API Call Helper ---
37
+ interface GenerationResponse {
38
+ success: boolean;
39
+ data?: { response: string | object };
40
+ error?: string;
41
+ }
42
+
43
+ const callAskLemurAPI = async (
44
+ prompt: string,
45
+ context: string,
46
+ expectJson: boolean
47
+ ): Promise<string> => {
48
+ const goBackend =
49
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
50
+ const tenantId = import.meta.env.PUBLIC_TENANTID || 'default';
51
+ const requestBody = {
52
+ prompt,
53
+ input_text: context,
54
+ final_model: '',
55
+ temperature: 0.5,
56
+ max_tokens: 2000,
57
+ };
58
+
59
+ const response = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
62
+ credentials: 'include',
63
+ body: JSON.stringify(requestBody),
64
+ });
65
+
66
+ if (!response.ok) {
67
+ const errorText = await response.text();
68
+ let backendError = `API call failed: ${response.status} ${response.statusText}`;
69
+ try {
70
+ const errorJson = JSON.parse(errorText);
71
+ if (errorJson?.error) backendError = errorJson.error;
72
+ } catch (e) {
73
+ /* ignore */
74
+ }
75
+ throw new Error(backendError);
76
+ }
77
+
78
+ const result = (await response.json()) as GenerationResponse;
79
+ if (!result.success || !result.data?.response) {
80
+ throw new Error(
81
+ result.error || 'Generation failed to return valid response.'
82
+ );
83
+ }
84
+
85
+ let rawResponseData = result.data.response;
86
+ if (expectJson && typeof rawResponseData === 'object') {
87
+ return JSON.stringify(rawResponseData);
88
+ }
89
+ if (typeof rawResponseData === 'string') {
90
+ let responseString = rawResponseData;
91
+ if (responseString.startsWith('```json')) {
92
+ responseString = responseString.slice(7, -3).trim();
93
+ } else if (responseString.startsWith('```html')) {
94
+ responseString = responseString.slice(7, -3).trim();
95
+ }
96
+ return responseString;
97
+ }
98
+ throw new Error('Unexpected response format received from API.');
99
+ };
100
+
101
+ // --- Main Component ---
33
102
  interface AddPaneNewPanelProps {
34
103
  nodeId: string;
35
104
  first: boolean;
@@ -40,242 +109,231 @@ interface AddPaneNewPanelProps {
40
109
  config?: BrandConfig;
41
110
  }
42
111
 
43
- interface PreviewPane {
44
- ctx: NodesContext;
45
- snapshot?: SnapshotData;
46
- template: any;
47
- index: number;
48
- htmlFragment?: string;
49
- fragmentError?: string;
50
- }
51
-
52
- interface TemplateCategory {
53
- id: string;
54
- title: string;
55
- getTemplates: (theme: Theme, brand: string, useOdd: boolean) => any[];
56
- }
57
-
58
- const ITEMS_PER_PAGE = 8;
59
-
60
- type Mode = 'template' | 'custom' | 'ai';
61
-
62
112
  const AddPaneNewPanel = ({
63
113
  nodeId,
64
114
  first,
65
- setMode: setParentMode, // Renamed prop to avoid conflict with local state
66
- ctx,
115
+ setMode: setParentMode,
116
+ ctx: providedCtx,
67
117
  isStoryFragment = false,
68
118
  isContextPane = false,
69
119
  config,
70
120
  }: AddPaneNewPanelProps) => {
71
- const brand = useStore(brandColourStore);
121
+ const ctx = providedCtx || getCtx();
72
122
  const hasAssemblyAI = useStore(hasAssemblyAIStore);
73
- const [mode, setMode] = useState<Mode>('template'); // Local mode state
74
- const [customMarkdown, setCustomMarkdown] = useState<string>(`...`);
75
- const [previews, setPreviews] = useState<PreviewPane[]>([]);
76
- const [currentPage, setCurrentPage] = useState(0);
77
- const [renderedPages, setRenderedPages] = useState<Set<number>>(new Set());
78
- const [selectedTheme, setSelectedTheme] = useState<Theme>(
79
- useStore(preferredThemeStore)
80
- );
81
- const [useOddVariant, setUseOddVariant] = useState(false);
82
- const [selectedCategory, setSelectedCategory] = useState<TemplateCategory>(
83
- templateCategories[isContextPane ? 1 : first ? 4 : 0]
123
+
124
+ // --- State Machine and Data Stores ---
125
+ const [step, setStep] = useState<Step>('initial');
126
+ const [initialChoice, setInitialChoice] = useState<InitialChoice | null>(
127
+ null
84
128
  );
85
- const [isInserting, setIsInserting] = useState(false);
86
- const [fragmentsToGenerate, setFragmentsToGenerate] = useState<
87
- PanePreviewRequest[]
88
- >([]);
89
-
90
- const categoryCollection = useMemo(() => {
91
- const categories = isContextPane
92
- ? [templateCategories[1]]
93
- : templateCategories;
94
-
95
- return createListCollection({
96
- items: categories,
97
- itemToValue: (item) => item.id,
98
- itemToString: (item) => item.title,
99
- });
100
- }, [isContextPane]);
101
-
102
- const themesCollection = useMemo(() => {
103
- return createListCollection({
104
- items: themes,
105
- itemToValue: (item) => item,
106
- itemToString: (item) => item.replace(/-/g, ' '),
107
- });
108
- }, []);
109
-
110
- const filteredTemplates = useMemo(() => {
111
- return selectedCategory.getTemplates(selectedTheme, brand, useOddVariant);
112
- }, [selectedTheme, useOddVariant, selectedCategory, brand]);
113
-
114
- useEffect(() => {
115
- if (isContextPane) {
116
- setSelectedCategory(templateCategories[1]);
117
- } else if (first) {
118
- setSelectedCategory(templateCategories[4]);
129
+ const [error, setError] = useState<string | null>(null);
130
+
131
+ // State for CopyInputStep
132
+ const [copyMode, setCopyMode] = useState<CopyMode>('raw');
133
+ const [promptValue, setPromptValue] = useState('');
134
+ const [copyValue, setCopyValue] = useState('');
135
+
136
+ // State for AiDesignStep
137
+ const [aiDesignConfig, setAiDesignConfig] = useState<AiDesignConfig>({
138
+ harmony: 'Analogous',
139
+ baseColor: '',
140
+ accentColor: '',
141
+ theme: 'Light',
142
+ additionalNotes: '',
143
+ });
144
+
145
+ // --- Handlers & Logic ---
146
+
147
+ const handleInitialChoice = (choice: InitialChoice) => {
148
+ setInitialChoice(choice);
149
+ setError(null);
150
+
151
+ if (choice === 'blank') {
152
+ handleBlankSlate();
119
153
  } else {
120
- setSelectedCategory(templateCategories[0]);
154
+ setStep('copyInput');
121
155
  }
122
- }, [isContextPane, first]);
123
-
124
- useEffect(() => {
125
- const newPreviews = filteredTemplates.map((template, index: number) => {
126
- const previewCtx = new NodesContext();
127
- previewCtx.addNode(createEmptyStorykeep('tmp'));
128
- const thisTemplate =
129
- mode === 'custom'
130
- ? {
131
- ...template,
132
- markdown: template.markdown && {
133
- ...template.markdown,
134
- markdownBody: customMarkdown,
135
- },
136
- }
137
- : template;
138
- previewCtx.addTemplatePane('tmp', thisTemplate);
139
- return { ctx: previewCtx, template: thisTemplate, index };
140
- });
141
- setPreviews(newPreviews);
142
- setCurrentPage(0);
143
- setRenderedPages(new Set());
144
- }, [filteredTemplates, customMarkdown, mode]);
145
-
146
- const totalPages = Math.ceil(previews.length / ITEMS_PER_PAGE);
147
-
148
- const visiblePreviews = useMemo(() => {
149
- const startIndex = currentPage * ITEMS_PER_PAGE;
150
- return previews.slice(startIndex, startIndex + ITEMS_PER_PAGE);
151
- }, [previews, currentPage]);
152
-
153
- useEffect(() => {
154
- const pageHasBeenRendered = renderedPages.has(currentPage);
155
- const previewsOnThisPageNeedFetching = visiblePreviews.some(
156
- (p) => !p.htmlFragment && !p.fragmentError
157
- );
156
+ };
158
157
 
159
- if (previewsOnThisPageNeedFetching && !pageHasBeenRendered) {
160
- const newRequests = visiblePreviews
161
- .filter((p) => !p.htmlFragment && !p.fragmentError)
162
- .map((p) => ({
163
- id: `template-${p.index}`,
164
- ctx: p.ctx,
165
- }));
166
- setFragmentsToGenerate(newRequests);
167
- } else {
168
- setFragmentsToGenerate([]);
158
+ const handleBack = () => {
159
+ setError(null);
160
+ if (step === 'copyInput') {
161
+ setStep('initial');
162
+ } else if (step === 'designLibrary' || step === 'aiDesign' || 'error') {
163
+ setStep('copyInput');
169
164
  }
170
- }, [currentPage, visiblePreviews, renderedPages]);
165
+ };
171
166
 
172
- const handlePageChange = (newPage: number) => {
173
- if (newPage >= 0 && newPage < totalPages) {
174
- setCurrentPage(newPage);
167
+ const handleCopyContinue = () => {
168
+ if (initialChoice === 'library') {
169
+ setStep('designLibrary');
170
+ } else if (initialChoice === 'ai') {
171
+ setStep('aiDesign');
175
172
  }
176
173
  };
177
174
 
178
- const handleFragmentsComplete = (results: PaneFragmentResult[]) => {
179
- setPreviews((prevPreviews) => {
180
- const updated = [...prevPreviews];
181
- results.forEach((result) => {
182
- const index = parseInt(result.id.replace('template-', ''));
183
- const previewIndex = updated.findIndex((p) => p.index === index);
184
- if (previewIndex !== -1) {
185
- updated[previewIndex] = {
186
- ...updated[previewIndex],
187
- htmlFragment: result.htmlString,
188
- fragmentError: result.error,
189
- };
190
- }
191
- });
192
- return updated;
193
- });
194
- setRenderedPages((prev) => new Set(prev).add(currentPage));
175
+ const handleBlankSlate = () => {
176
+ const blankTemplate: TemplatePane = {
177
+ id: '', // ctx will assign
178
+ nodeType: 'Pane',
179
+ parentId: '', // ctx will assign
180
+ title: 'New Pane',
181
+ slug: '',
182
+ isDecorative: false,
183
+ markdown: {
184
+ id: '', // ctx will assign
185
+ nodeType: 'Markdown',
186
+ parentId: '', // ctx will assign
187
+ type: 'markdown',
188
+ markdownId: '', // ctx will assign
189
+ defaultClasses: {},
190
+ parentClasses: [],
191
+ nodes: [],
192
+ },
193
+ };
194
+ handleApplyTemplate(blankTemplate);
195
195
  };
196
196
 
197
- const handleSnapshotComplete = (id: string, snapshot: SnapshotData) => {
198
- const index = parseInt(id.replace('template-', ''));
199
- setPreviews((prevPreviews) => {
200
- const updated = [...prevPreviews];
201
- const previewIndex = updated.findIndex((p) => p.index === index);
202
- if (previewIndex !== -1) {
203
- updated[previewIndex] = {
204
- ...updated[previewIndex],
205
- snapshot,
206
- };
197
+ const handleDesignLibrarySelect = async (entry: DesignLibraryEntry) => {
198
+ // This flow is for "Design Library + Provide Copy"
199
+ if (copyMode === 'raw') {
200
+ const liveTemplate = convertStorageToLiveTemplate(
201
+ mergeCopyIntoTemplate(entry.template, []) // Start with blank copy
202
+ );
203
+ if (liveTemplate.markdown) {
204
+ liveTemplate.markdown.markdownBody = copyValue;
207
205
  }
208
- return updated;
209
- });
210
- };
211
-
212
- const handleApplyTemplate = async (template: any) => {
213
- if (isInserting || !ctx) return;
214
- setIsInserting(true);
215
-
216
- try {
217
- const insertTemplate =
218
- mode === 'custom'
219
- ? {
220
- ...cloneDeep(template),
221
- markdown: template.markdown && {
222
- ...template.markdown,
223
- markdownBody: customMarkdown,
224
- },
225
- }
226
- : cloneDeep(template);
227
-
228
- insertTemplate.title = '';
229
- insertTemplate.slug = '';
206
+ handleApplyTemplate(liveTemplate);
207
+ return;
208
+ }
230
209
 
231
- const ownerId =
232
- isStoryFragment || isContextPane
233
- ? nodeId
234
- : ctx.getClosestNodeTypeFromId(nodeId, 'StoryFragment');
210
+ // This flow is for "Design Library + Write a Prompt" (Hybrid AI)
211
+ if (copyMode === 'prompt') {
212
+ setError(null);
213
+ setStep('loading');
214
+ try {
215
+ // 1. Get the full, rich template from the library
216
+ const liveTemplate = convertStorageToLiveTemplate(entry.template);
217
+ if (!liveTemplate.markdown) {
218
+ throw new Error(
219
+ 'The selected design library item is not compatible with this workflow as it has no markdown section.'
220
+ );
221
+ }
235
222
 
236
- if (isContextPane) {
237
- insertTemplate.isContextPane = true;
238
- await ctx.applyAtomicUpdate(async (tmpCtx) => {
239
- tmpCtx.addContextTemplatePane(ownerId, insertTemplate);
240
- });
241
- } else {
242
- await ctx.applyAtomicUpdate(async (tmpCtx) => {
243
- tmpCtx.addTemplatePane(
244
- ownerId,
245
- insertTemplate,
246
- nodeId,
247
- first ? 'before' : 'after'
223
+ // 2. Create the simplified shell for the AI
224
+ const shellJson = convertTemplateToAIShell(liveTemplate);
225
+ if (!shellJson || shellJson === '{}') {
226
+ throw new Error(
227
+ 'Could not generate a valid AI shell from this design.'
248
228
  );
249
- });
250
- ctx.notifyNode(`root`);
229
+ }
230
+
231
+ // 3. Get the AI to write copy based on the shell and prompt
232
+ const copyPromptDetails = prompts.aiPaneCopyPrompt;
233
+ const layout = 'Text Only';
234
+ const formattedCopyPrompt = copyPromptDetails.user_template
235
+ .replace('{{COPY_INPUT}}', promptValue)
236
+ .replace(
237
+ '{{DESIGN_INPUT}}',
238
+ "N/A - Use the provided Shell JSON's design."
239
+ )
240
+ .replace('{{LAYOUT_TYPE}}', layout)
241
+ .replace('{{SHELL_JSON}}', shellJson);
242
+
243
+ const copyResult = await callAskLemurAPI(
244
+ formattedCopyPrompt,
245
+ copyPromptDetails.system || '',
246
+ false
247
+ );
248
+
249
+ // 4. Parse ONLY the AI-generated HTML into content nodes
250
+ const newNodes = parseAiCopyHtml(copyResult, liveTemplate.markdown.id);
251
+
252
+ // 5. Create the final pane by cloning the original rich template
253
+ const finalPane = cloneDeep(liveTemplate);
254
+
255
+ // 6. Inject the new AI content, preserving the original rich design
256
+ finalPane.markdown!.nodes = newNodes;
257
+
258
+ // 7. Apply the complete, correctly merged pane
259
+ handleApplyTemplate(finalPane);
260
+ } catch (err: any) {
261
+ setError(err.message || 'Failed to generate AI copy for this design.');
262
+ setStep('error');
251
263
  }
252
- setParentMode(PaneAddMode.DEFAULT, false);
253
- } catch (error) {
254
- console.error('Error inserting template:', error);
255
- } finally {
256
- setIsInserting(false);
257
264
  }
258
265
  };
259
266
 
260
- const handleApplyGeneratedPane = async (pane: TemplatePane) => {
261
- if (isInserting || !ctx) return;
262
- setIsInserting(true);
267
+ const handleAiDesignGenerate = useCallback(async () => {
268
+ setError(null);
269
+ setStep('loading');
270
+
271
+ let designInput = `Generate a design using a **${aiDesignConfig.harmony.toLowerCase()}** color scheme with a **${aiDesignConfig.theme.toLowerCase()}** theme.`;
272
+ if (aiDesignConfig.baseColor)
273
+ designInput += ` Base the colors around **${aiDesignConfig.baseColor}**.`;
274
+ if (aiDesignConfig.accentColor)
275
+ designInput += ` Use **${aiDesignConfig.accentColor}** as an accent color.`;
276
+ if (aiDesignConfig.additionalNotes)
277
+ designInput += ` Refine with these notes: "${aiDesignConfig.additionalNotes}"`;
278
+
279
+ try {
280
+ const shellPromptDetails = prompts.aiPaneShellPrompt;
281
+ const copyPromptDetails = prompts.aiPaneCopyPrompt;
282
+ const layout = 'Text Only'; // Hardcoded for this simplified AI path
283
+
284
+ const formattedShellPrompt = shellPromptDetails.user_template
285
+ .replace('{{DESIGN_INPUT}}', designInput)
286
+ .replace('{{LAYOUT_TYPE}}', layout);
287
+
288
+ const shellResult = await callAskLemurAPI(
289
+ formattedShellPrompt,
290
+ shellPromptDetails.system || '',
291
+ true
292
+ );
293
+
294
+ const copyInputContent = copyMode === 'prompt' ? promptValue : copyValue;
295
+ const formattedCopyPrompt = copyPromptDetails.user_template
296
+ .replace('{{COPY_INPUT}}', copyInputContent)
297
+ .replace('{{DESIGN_INPUT}}', designInput)
298
+ .replace('{{LAYOUT_TYPE}}', layout)
299
+ .replace('{{SHELL_JSON}}', shellResult);
300
+
301
+ const copyResult = await callAskLemurAPI(
302
+ formattedCopyPrompt,
303
+ copyPromptDetails.system || '',
304
+ false
305
+ );
306
+
307
+ const finalPane = parseAiPane(shellResult, copyResult, layout);
308
+ handleApplyTemplate(finalPane);
309
+ } catch (err: any) {
310
+ setError(err.message || 'Failed to generate AI pane.');
311
+ setStep('error');
312
+ }
313
+ }, [aiDesignConfig, copyMode, promptValue, copyValue]);
314
+
315
+ const handleApplyTemplate = async (template: TemplatePane) => {
316
+ if (!ctx) return;
263
317
  try {
318
+ const insertTemplate = cloneDeep(template);
319
+ insertTemplate.title = insertTemplate.title || 'New Pane';
320
+ insertTemplate.slug = insertTemplate.slug || '';
321
+
264
322
  const ownerId =
265
323
  isStoryFragment || isContextPane
266
324
  ? nodeId
267
325
  : ctx.getClosestNodeTypeFromId(nodeId, 'StoryFragment');
268
326
 
269
327
  if (isContextPane) {
270
- pane.isContextPane = true;
328
+ insertTemplate.isContextPane = true;
271
329
  await ctx.applyAtomicUpdate(async (tmpCtx) => {
272
- tmpCtx.addContextTemplatePane(ownerId, pane);
330
+ tmpCtx.addContextTemplatePane(ownerId, insertTemplate);
273
331
  });
274
332
  } else {
275
333
  await ctx.applyAtomicUpdate(async (tmpCtx) => {
276
334
  tmpCtx.addTemplatePane(
277
335
  ownerId,
278
- pane,
336
+ insertTemplate,
279
337
  nodeId,
280
338
  first ? 'before' : 'after'
281
339
  );
@@ -283,363 +341,203 @@ const AddPaneNewPanel = ({
283
341
  ctx.notifyNode(`root`);
284
342
  }
285
343
  setParentMode(PaneAddMode.DEFAULT, false);
286
- } catch (error) {
287
- console.error('Error applying generated pane:', error);
288
- } finally {
289
- setIsInserting(false);
344
+ } catch (err) {
345
+ console.error('Error inserting template:', err);
346
+ setError(
347
+ err instanceof Error
348
+ ? err.message
349
+ : 'An unexpected error occurred while adding the pane.'
350
+ );
351
+ setStep('error');
290
352
  }
291
353
  };
292
354
 
293
- const handleThemeChange = (details: { value: string[] }) => {
294
- const newTheme = details.value[0] as Theme;
295
- if (newTheme) {
296
- setSelectedTheme(newTheme);
297
- }
298
- };
355
+ // --- Render Logic ---
299
356
 
300
- const handleCategoryChange = (details: { value: string[] }) => {
301
- const id = details.value[0];
302
- if (id) {
303
- const category = templateCategories.find((cat) => cat.id === id);
304
- if (category) setSelectedCategory(category);
305
- }
306
- };
307
-
308
- const customStyles = `
309
- .category-item[data-highlighted] { background-color: #0891b2; color: white; }
310
- .category-item[data-highlighted] .category-indicator { color: white; }
311
- .category-item[data-state="checked"] .category-indicator { display: flex; }
312
- .category-item .category-indicator { display: none; }
313
- .category-item[data-state="checked"] { font-weight: bold; }
314
- .theme-item[data-highlighted] { background-color: #0891b2; color: white; }
315
- .theme-item[data-highlighted] .theme-indicator { color: white; }
316
- .theme-item[data-state="checked"] .theme-indicator { display: flex; }
317
- .theme-item .theme-indicator { display: none; }
318
- .theme-item[data-state="checked"] { font-weight: bold; }
319
- `;
357
+ const renderInitialStep = () => (
358
+ <div className="p-4">
359
+ <h3 className="font-action mb-4 text-center text-xl font-bold text-gray-800">
360
+ How would you like to design your new pane?
361
+ </h3>
362
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
363
+ <button
364
+ onClick={() => handleInitialChoice('library')}
365
+ className="group flex flex-col items-center space-y-3 rounded-lg border bg-white p-6 text-center shadow-sm transition-all hover:border-cyan-600 hover:shadow-lg"
366
+ >
367
+ <SwatchIcon className="h-10 w-10 text-gray-500 transition-colors group-hover:text-cyan-600" />
368
+ <h4 className="font-semibold text-gray-800">Use Design Library</h4>
369
+ <p className="text-sm text-gray-600">
370
+ Start with a pre-made design and add your own content.
371
+ </p>
372
+ </button>
373
+ {hasAssemblyAI && (
374
+ <button
375
+ onClick={() => handleInitialChoice('ai')}
376
+ className="group flex flex-col items-center space-y-3 rounded-lg border bg-white p-6 text-center shadow-sm transition-all hover:border-cyan-600 hover:shadow-lg"
377
+ >
378
+ <SparklesIcon className="h-10 w-10 text-gray-500 transition-colors group-hover:text-cyan-600" />
379
+ <h4 className="font-semibold text-gray-800">Design with AI</h4>
380
+ <p className="text-sm text-gray-600">
381
+ Let AI generate a complete design and copy from your prompt.
382
+ </p>
383
+ </button>
384
+ )}
385
+ <button
386
+ onClick={() => handleInitialChoice('blank')}
387
+ className="group flex flex-col items-center space-y-3 rounded-lg border bg-white p-6 text-center shadow-sm transition-all hover:border-cyan-600 hover:shadow-lg"
388
+ >
389
+ <DocumentPlusIcon className="h-10 w-10 text-gray-500 transition-colors group-hover:text-cyan-600" />
390
+ <h4 className="font-semibold text-gray-800">Blank Slate</h4>
391
+ <p className="text-sm text-gray-600">
392
+ Add a simple, empty pane to build from scratch.
393
+ </p>
394
+ </button>
395
+ </div>
396
+ </div>
397
+ );
320
398
 
321
- return (
322
- <div className="bg-white p-3.5 shadow-inner">
323
- <style>{customStyles}</style>
324
- <div className="group flex w-full gap-1 rounded-md bg-white p-1.5">
399
+ const renderContentStep = () => (
400
+ <div className="space-y-4 p-4">
401
+ <CopyInputStep
402
+ copyMode={copyMode}
403
+ onCopyModeChange={setCopyMode}
404
+ promptValue={promptValue}
405
+ onPromptValueChange={setPromptValue}
406
+ copyValue={copyValue}
407
+ onCopyValueChange={setCopyValue}
408
+ defaultPrompt={
409
+ first
410
+ ? prompts.aiPaneCopyPrompt.heroDefault
411
+ : prompts.aiPaneCopyPrompt.contentDefault
412
+ }
413
+ />
414
+ <div className="flex justify-between">
325
415
  <button
326
- onClick={() => setParentMode(PaneAddMode.DEFAULT, first)}
327
- className="w-fit rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-200 focus:bg-gray-200"
328
- type="button"
416
+ onClick={handleBack}
417
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 shadow-sm hover:bg-gray-50"
329
418
  >
330
- Go Back
419
+ ← Back
331
420
  </button>
421
+ <button
422
+ onClick={handleCopyContinue}
423
+ disabled={
424
+ copyMode === 'prompt' ? !promptValue.trim() : !copyValue.trim()
425
+ }
426
+ className="rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-700 disabled:cursor-not-allowed disabled:bg-gray-400"
427
+ >
428
+ Continue →
429
+ </button>
430
+ </div>
431
+ </div>
432
+ );
332
433
 
333
- <div className="ml-4 flex flex-wrap items-center gap-x-6 gap-y-2 py-2">
334
- <div className="font-action flex-none rounded px-2 py-2.5 text-sm font-bold text-cyan-700 shadow-sm">
335
- + Design New Pane
336
- </div>
337
- <div className="flex items-center space-x-2 rounded-lg bg-gray-100 p-1">
338
- <button
339
- onClick={() => setMode('template')}
340
- className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
341
- mode === 'template'
342
- ? 'bg-white text-cyan-700 shadow'
343
- : 'text-gray-600 hover:text-gray-800'
344
- }`}
345
- type="button"
346
- >
347
- Use Template
348
- </button>
349
- <button
350
- onClick={() => setMode('custom')}
351
- className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
352
- mode === 'custom'
353
- ? 'bg-white text-cyan-700 shadow'
354
- : 'text-gray-600 hover:text-gray-800'
355
- }`}
356
- type="button"
357
- >
358
- Paste Markdown
359
- </button>
360
- {hasAssemblyAI && (
361
- <button
362
- onClick={() => setMode('ai')}
363
- className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
364
- mode === 'ai'
365
- ? 'rounded-md border border-transparent bg-cyan-600 px-3 py-1.5 text-sm font-bold text-white shadow-sm hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
366
- : 'text-gray-600 hover:text-gray-800'
367
- }`}
368
- type="button"
369
- >
370
- ✨ Generate with AI
371
- </button>
372
- )}
373
- </div>
434
+ const renderDesignLibraryStep = () => (
435
+ <div className="space-y-4 p-4">
436
+ <div className="flex justify-start">
437
+ <button
438
+ onClick={handleBack}
439
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 shadow-sm hover:bg-gray-50"
440
+ >
441
+ Back to Content
442
+ </button>
443
+ </div>
444
+ <DesignLibraryStep
445
+ config={config!}
446
+ onSelect={handleDesignLibrarySelect}
447
+ />
448
+ </div>
449
+ );
374
450
 
375
- {mode === 'custom' && (
376
- <div className="mt-4 w-full">
377
- <AddPaneNewCustomCopy
378
- value={customMarkdown}
379
- onChange={setCustomMarkdown}
380
- />
381
- </div>
382
- )}
383
- {mode === 'ai' && (
384
- <div className="mt-4 w-full">
385
- <AiPaneGenerator
386
- ownerId={nodeId}
387
- onComplete={handleApplyGeneratedPane}
388
- onCancel={() => setMode('template')}
389
- config={config!}
390
- />
391
- </div>
392
- )}
393
- </div>
451
+ const renderAiDesignStep = () => (
452
+ <div className="space-y-4 p-4">
453
+ <AiDesignStep
454
+ config={config!}
455
+ designConfig={aiDesignConfig}
456
+ onDesignConfigChange={setAiDesignConfig}
457
+ />
458
+ <div className="flex justify-between">
459
+ <button
460
+ onClick={handleBack}
461
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 shadow-sm hover:bg-gray-50"
462
+ >
463
+ ← Back
464
+ </button>
465
+ <button
466
+ onClick={handleAiDesignGenerate}
467
+ className="rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-700"
468
+ >
469
+ ✨ Generate with AI
470
+ </button>
394
471
  </div>
472
+ </div>
473
+ );
395
474
 
396
- {mode !== 'ai' && (
397
- <>
398
- <h3 className="font-action px-3.5 pb-1.5 pt-4 text-xl font-bold text-black">
399
- 1. Template design settings
400
- </h3>
401
-
402
- <div className="grid grid-cols-1 gap-4 p-2 md:grid-cols-3">
403
- <div className="w-full">
404
- <Select.Root
405
- collection={themesCollection}
406
- value={[selectedTheme]}
407
- onValueChange={handleThemeChange}
408
- >
409
- <Select.Label className="block text-sm font-bold text-gray-700">
410
- Theme
411
- </Select.Label>
412
- <Select.Control className="relative mt-1">
413
- <Select.Trigger className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-cyan-600 focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-cyan-600">
414
- <Select.ValueText className="block truncate capitalize">
415
- {selectedTheme.replace(/-/g, ' ')}
416
- </Select.ValueText>
417
- <Select.Indicator className="absolute inset-y-0 right-0 flex items-center pr-2">
418
- <ChevronUpDownIcon
419
- className="h-5 w-5 text-gray-400"
420
- aria-hidden="true"
421
- />
422
- </Select.Indicator>
423
- </Select.Trigger>
424
- </Select.Control>
425
- <Portal>
426
- <Select.Positioner>
427
- <Select.Content className="z-50 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
428
- {themesCollection.items.map((theme) => (
429
- <Select.Item
430
- key={theme}
431
- item={theme}
432
- className="theme-item relative cursor-default select-none py-2 pl-10 pr-4 text-gray-900"
433
- >
434
- <Select.ItemText className="block truncate capitalize">
435
- {theme.replace(/-/g, ' ')}
436
- </Select.ItemText>
437
- <Select.ItemIndicator className="theme-indicator absolute inset-y-0 left-0 flex items-center pl-3 text-cyan-600">
438
- <CheckIcon className="h-5 w-5" aria-hidden="true" />
439
- </Select.ItemIndicator>
440
- </Select.Item>
441
- ))}
442
- </Select.Content>
443
- </Select.Positioner>
444
- </Portal>
445
- </Select.Root>
446
- </div>
475
+ const renderLoading = () => (
476
+ <div className="flex min-h-[300px] flex-col items-center justify-center space-y-4 p-6">
477
+ <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-cyan-600"></div>
478
+ <p className="text-sm text-gray-600">Generating AI Pane...</p>
479
+ <p className="text-xs text-gray-500">This may take a moment.</p>
480
+ </div>
481
+ );
447
482
 
448
- {!isContextPane && (
449
- <div className="w-full">
450
- <Select.Root
451
- collection={categoryCollection}
452
- value={[selectedCategory.id]}
453
- onValueChange={handleCategoryChange}
454
- >
455
- <Select.Label className="block text-sm font-bold text-gray-700">
456
- Category
457
- </Select.Label>
458
- <Select.Control className="relative mt-1">
459
- <Select.Trigger className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm">
460
- <Select.ValueText className="block truncate">
461
- {selectedCategory.title}
462
- </Select.ValueText>
463
- <Select.Indicator className="absolute inset-y-0 right-0 flex items-center pr-2">
464
- <ChevronUpDownIcon
465
- className="h-5 w-5 text-gray-400"
466
- aria-hidden="true"
467
- />
468
- </Select.Indicator>
469
- </Select.Trigger>
470
- </Select.Control>
471
- <Portal>
472
- <Select.Positioner>
473
- <Select.Content className="z-50 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none sm:text-sm">
474
- {categoryCollection.items.map((category) => (
475
- <Select.Item
476
- key={category.id}
477
- item={category}
478
- className="category-item relative cursor-default select-none py-2 pl-10 pr-4 text-gray-900"
479
- >
480
- <Select.ItemText className="block truncate">
481
- {category.title}
482
- </Select.ItemText>
483
- <Select.ItemIndicator className="category-indicator absolute inset-y-0 left-0 flex items-center pl-3 text-cyan-600">
484
- <CheckIcon
485
- className="h-5 w-5"
486
- aria-hidden="true"
487
- />
488
- </Select.ItemIndicator>
489
- </Select.Item>
490
- ))}
491
- </Select.Content>
492
- </Select.Positioner>
493
- </Portal>
494
- </Select.Root>
495
- </div>
496
- )}
497
-
498
- <div className="flex items-center gap-2">
499
- <Switch.Root
500
- checked={useOddVariant}
501
- onCheckedChange={(details) => setUseOddVariant(details.checked)}
502
- className="inline-flex items-center"
503
- >
504
- <Switch.Control
505
- className={`${
506
- useOddVariant ? 'bg-cyan-600' : 'bg-gray-200'
507
- } relative my-2 inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2`}
508
- >
509
- <Switch.Thumb
510
- className={`${
511
- useOddVariant ? 'translate-x-6' : 'translate-x-1'
512
- } inline-block h-4 w-4 rounded-full bg-white shadow-lg transition-transform duration-200`}
513
- />
514
- </Switch.Control>
515
- <Switch.HiddenInput />
516
- <div className="flex h-6 items-center">
517
- <Switch.Label className="px-4 text-sm text-gray-700">
518
- Toggle subtle variation (great for stacking content)
519
- </Switch.Label>
520
- </div>
521
- </Switch.Root>
522
- </div>
523
- </div>
483
+ const renderError = () => (
484
+ <div className="space-y-4 rounded-lg bg-red-50 p-6 text-center">
485
+ <h4 className="text-lg font-bold text-red-800">Generation Failed</h4>
486
+ <p className="text-sm text-red-700">{error}</p>
487
+ <button
488
+ onClick={handleBack}
489
+ className="mt-2 rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-bold text-red-800 shadow-sm hover:bg-red-50"
490
+ >
491
+ ← Try Again
492
+ </button>
493
+ </div>
494
+ );
524
495
 
525
- <h3 className="font-action px-3.5 pb-1.5 pt-4 text-xl font-bold text-black">
526
- 2. Choose design
527
- </h3>
528
-
529
- {fragmentsToGenerate.length > 0 && (
530
- <PanesPreviewGenerator
531
- requests={fragmentsToGenerate}
532
- onComplete={handleFragmentsComplete}
533
- onError={(error) =>
534
- console.error('Fragment generation error:', error)
535
- }
536
- />
537
- )}
538
-
539
- <div className="grid grid-cols-2 gap-4 p-2 xl:grid-cols-3">
540
- {visiblePreviews.map((preview) => (
541
- <div key={preview.index} className="flex flex-col items-center">
542
- <div
543
- onClick={
544
- isInserting
545
- ? undefined
546
- : () => handleApplyTemplate(preview.template)
547
- }
548
- className={`bg-mywhite group relative w-full rounded-sm shadow-inner ${
549
- isInserting
550
- ? 'cursor-not-allowed opacity-50'
551
- : 'cursor-pointer'
552
- } transition-all duration-200 ${
553
- preview.snapshot
554
- ? 'hover:outline-solid hover:outline hover:outline-4'
555
- : ''
556
- }`}
557
- style={{
558
- ...(!preview.snapshot ? { minHeight: '150px' } : {}),
559
- }}
560
- role="button"
561
- tabIndex={0}
562
- aria-label={preview.template.title}
563
- >
564
- {preview.htmlFragment &&
565
- !preview.snapshot &&
566
- !preview.fragmentError && (
567
- <PaneSnapshotGenerator
568
- id={`template-${preview.index}`}
569
- htmlString={preview.htmlFragment}
570
- onComplete={handleSnapshotComplete}
571
- onError={(id, error) =>
572
- console.error(`Snapshot error for ${id}:`, error)
573
- }
574
- outputWidth={800}
575
- />
576
- )}
577
-
578
- {!preview.htmlFragment && !preview.fragmentError && (
579
- <div className="flex h-48 items-center justify-center">
580
- <div className="text-gray-500">Loading preview...</div>
581
- </div>
582
- )}
583
-
584
- {preview.fragmentError && (
585
- <div className="flex h-48 items-center justify-center">
586
- <div className="text-red-500">Preview error</div>
587
- </div>
588
- )}
589
-
590
- {preview.snapshot && (
591
- <div className="p-0.5">
592
- <img
593
- src={preview.snapshot.imageData}
594
- alt={`Template: ${preview.template.title}`}
595
- className="w-full"
596
- />
597
- </div>
598
- )}
599
- </div>
600
- <p className="bg-mydarkgrey mt-2 w-full break-words p-2 text-center text-sm text-white">
601
- {preview.template.title}
602
- </p>
603
- </div>
604
- ))}
605
- </div>
496
+ const renderStep = () => {
497
+ switch (step) {
498
+ case 'initial':
499
+ return renderInitialStep();
500
+ case 'copyInput':
501
+ return renderContentStep();
502
+ case 'designLibrary':
503
+ return renderDesignLibraryStep();
504
+ case 'aiDesign':
505
+ return renderAiDesignStep();
506
+ case 'loading':
507
+ return renderLoading();
508
+ case 'error':
509
+ return renderError();
510
+ default:
511
+ return renderInitialStep();
512
+ }
513
+ };
606
514
 
607
- <div className="mb-2 mt-4 flex items-center justify-center gap-2">
515
+ return (
516
+ <div className="bg-white p-2 shadow-inner">
517
+ <div className="group mb-2 flex w-full items-center gap-1 rounded-md bg-white p-1.5">
518
+ {first ? (
519
+ <div className="w-full text-center">
520
+ <h2 className="font-action py-1.5 text-lg font-bold text-gray-800">
521
+ Welcome to Tract Stack
522
+ </h2>
523
+ </div>
524
+ ) : (
525
+ <>
608
526
  <button
609
- onClick={() => handlePageChange(currentPage - 1)}
610
- disabled={currentPage === 0}
611
- className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-200 focus:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
612
- type="button"
527
+ onClick={() => setParentMode(PaneAddMode.DEFAULT, first)}
528
+ className="w-fit rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-200"
613
529
  >
614
- Previous
530
+ ← Go Back
615
531
  </button>
616
- <div className="flex gap-1">
617
- {[...Array(totalPages)].map((_, index) => (
618
- <button
619
- key={index}
620
- onClick={() => handlePageChange(index)}
621
- className={`rounded px-3 py-1 text-sm transition-colors ${
622
- currentPage === index
623
- ? 'bg-cyan-700 text-white'
624
- : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
625
- }`}
626
- type="button"
627
- >
628
- {index + 1}
629
- </button>
630
- ))}
532
+ <div className="font-action ml-4 flex-none rounded px-2 py-2.5 text-sm font-bold text-cyan-700 shadow-sm">
533
+ + Design New Pane
631
534
  </div>
632
- <button
633
- onClick={() => handlePageChange(currentPage + 1)}
634
- disabled={currentPage === totalPages - 1}
635
- className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-200 focus:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50"
636
- type="button"
637
- >
638
- Next
639
- </button>
640
- </div>
641
- </>
642
- )}
535
+ </>
536
+ )}
537
+ </div>
538
+ <div className="min-h-[400px] rounded-md border bg-gray-50">
539
+ {renderStep()}
540
+ </div>
643
541
  </div>
644
542
  );
645
543
  };