astro-tractstack 2.0.14 → 2.0.16

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 (34) hide show
  1. package/dist/index.js +41 -9
  2. package/package.json +1 -1
  3. package/templates/custom/with-examples/CodeHook.astro +4 -0
  4. package/templates/custom/with-examples/SandboxLauncher.tsx +65 -0
  5. package/templates/env.example +3 -0
  6. package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +75 -0
  7. package/templates/src/components/codehooks/SandboxRegisterForm.tsx +202 -0
  8. package/templates/src/components/compositor/Compositor.tsx +2 -0
  9. package/templates/src/components/compositor/Node.tsx +27 -9
  10. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +13 -11
  11. package/templates/src/components/compositor/nodes/Pane_layout.tsx +16 -14
  12. package/templates/src/components/edit/Header.tsx +8 -2
  13. package/templates/src/components/edit/PanelSwitch.tsx +4 -4
  14. package/templates/src/components/edit/pane/AddPanePanel.tsx +3 -0
  15. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +463 -561
  16. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +140 -0
  17. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +105 -0
  18. package/templates/src/components/edit/pane/steps/DesignLibraryStep.tsx +395 -0
  19. package/templates/src/components/edit/panels/StyleImagePanel.tsx +10 -8
  20. package/templates/src/components/edit/state/SaveModal.tsx +41 -0
  21. package/templates/src/constants/prompts.json +3 -1
  22. package/templates/src/pages/api/sandbox.ts +86 -0
  23. package/templates/src/pages/sandbox.astro +137 -0
  24. package/templates/src/types/nodeProps.ts +1 -0
  25. package/templates/src/utils/compositor/aiPaneParser.ts +32 -84
  26. package/templates/src/utils/compositor/designLibraryHelper.ts +87 -2
  27. package/templates/src/utils/profileStorage.ts +13 -0
  28. package/utils/inject-files.ts +41 -10
  29. package/templates/src/components/edit/pane/AiPaneGenerator.tsx +0 -575
  30. package/templates/src/components/edit/pane/AiPanePreview.tsx +0 -107
  31. package/templates/src/components/edit/pane/PageGen.tsx +0 -485
  32. package/templates/src/components/edit/pane/PageGenSelector.tsx +0 -245
  33. package/templates/src/components/edit/pane/PageGenSpecial.tsx +0 -339
  34. package/templates/src/utils/aai/getTitleSlug.ts +0 -72
@@ -1,34 +1,110 @@
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';
13
+ import { CopyInputStep } from './steps/CopyInputStep';
14
+ import { DesignLibraryStep } from './steps/DesignLibraryStep';
15
+ import { AiDesignStep, type AiDesignConfig } from './steps/AiDesignStep';
16
+ import { parseAiPane, parseAiCopyHtml } from '@/utils/compositor/aiPaneParser';
17
+ import {
18
+ convertStorageToLiveTemplate,
19
+ mergeCopyIntoTemplate,
20
+ convertTemplateToAIShell,
21
+ } from '@/utils/compositor/designLibraryHelper';
22
+
23
+ type Step =
24
+ | 'initial'
25
+ | 'copyInput'
26
+ | 'designLibrary'
27
+ | 'aiDesign'
28
+ | 'loading'
29
+ | 'error';
30
+
31
+ type InitialChoice = 'library' | 'ai' | 'blank';
32
+ type CopyMode = 'prompt' | 'raw';
33
+
34
+ interface GenerationResponse {
35
+ success: boolean;
36
+ data?: { response: string | object };
37
+ error?: string;
38
+ }
39
+
40
+ const callAskLemurAPI = async (
41
+ prompt: string,
42
+ context: string,
43
+ expectJson: boolean,
44
+ isSandboxMode: boolean
45
+ ): Promise<string> => {
46
+ const goBackend =
47
+ import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
48
+ const tenantId = import.meta.env.PUBLIC_TENANTID || 'default';
49
+ const requestBody = {
50
+ prompt,
51
+ input_text: context,
52
+ final_model: '',
53
+ temperature: 0.5,
54
+ max_tokens: 2000,
55
+ };
56
+
57
+ let response: Response;
58
+ if (isSandboxMode) {
59
+ response = await fetch(`/api/sandbox`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
62
+ credentials: 'include',
63
+ body: JSON.stringify({ action: 'askLemur', payload: requestBody }),
64
+ });
65
+ } else {
66
+ response = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
69
+ credentials: 'include',
70
+ body: JSON.stringify(requestBody),
71
+ });
72
+ }
73
+
74
+ if (!response.ok) {
75
+ const errorText = await response.text();
76
+ let backendError = `API call failed: ${response.status} ${response.statusText}`;
77
+ try {
78
+ const errorJson = JSON.parse(errorText);
79
+ if (errorJson?.error) backendError = errorJson.error;
80
+ } catch (e) {
81
+ /* ignore */
82
+ }
83
+ throw new Error(backendError);
84
+ }
85
+
86
+ const result = (await response.json()) as GenerationResponse;
87
+ if (!result.success || !result.data?.response) {
88
+ throw new Error(
89
+ result.error || 'Generation failed to return valid response.'
90
+ );
91
+ }
92
+
93
+ let rawResponseData = result.data.response;
94
+ if (expectJson && typeof rawResponseData === 'object') {
95
+ return JSON.stringify(rawResponseData);
96
+ }
97
+ if (typeof rawResponseData === 'string') {
98
+ let responseString = rawResponseData;
99
+ if (responseString.startsWith('```json')) {
100
+ responseString = responseString.slice(7, -3).trim();
101
+ } else if (responseString.startsWith('```html')) {
102
+ responseString = responseString.slice(7, -3).trim();
103
+ }
104
+ return responseString;
105
+ }
106
+ throw new Error('Unexpected response format received from API.');
107
+ };
32
108
 
33
109
  interface AddPaneNewPanelProps {
34
110
  nodeId: string;
@@ -38,244 +114,230 @@ interface AddPaneNewPanelProps {
38
114
  isStoryFragment?: boolean;
39
115
  isContextPane?: boolean;
40
116
  config?: BrandConfig;
117
+ isSandboxMode?: boolean;
41
118
  }
42
119
 
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
120
  const AddPaneNewPanel = ({
63
121
  nodeId,
64
122
  first,
65
- setMode: setParentMode, // Renamed prop to avoid conflict with local state
66
- ctx,
123
+ setMode: setParentMode,
124
+ ctx: providedCtx,
67
125
  isStoryFragment = false,
68
126
  isContextPane = false,
69
127
  config,
128
+ isSandboxMode = false,
70
129
  }: AddPaneNewPanelProps) => {
71
- const brand = useStore(brandColourStore);
130
+ const ctx = providedCtx || getCtx();
72
131
  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)
132
+ const [step, setStep] = useState<Step>('initial');
133
+ const [initialChoice, setInitialChoice] = useState<InitialChoice | null>(
134
+ null
80
135
  );
81
- const [useOddVariant, setUseOddVariant] = useState(false);
82
- const [selectedCategory, setSelectedCategory] = useState<TemplateCategory>(
83
- templateCategories[isContextPane ? 1 : first ? 4 : 0]
84
- );
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]);
136
+ const [error, setError] = useState<string | null>(null);
137
+ const [copyMode, setCopyMode] = useState<CopyMode>('prompt');
138
+ const [promptValue, setPromptValue] = useState('');
139
+ const [copyValue, setCopyValue] = useState('');
140
+ const [aiDesignConfig, setAiDesignConfig] = useState<AiDesignConfig>({
141
+ harmony: 'Analogous',
142
+ baseColor: '',
143
+ accentColor: '',
144
+ theme: 'Light',
145
+ additionalNotes: '',
146
+ });
147
+
148
+ const handleInitialChoice = (choice: InitialChoice) => {
149
+ setInitialChoice(choice);
150
+ setError(null);
151
+
152
+ if (choice === 'blank') {
153
+ handleBlankSlate();
119
154
  } else {
120
- setSelectedCategory(templateCategories[0]);
155
+ setStep('copyInput');
121
156
  }
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
- );
157
+ };
158
158
 
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([]);
159
+ const handleBack = () => {
160
+ setError(null);
161
+ if (step === 'copyInput') {
162
+ setStep('initial');
163
+ } else if (step === 'designLibrary' || step === 'aiDesign' || 'error') {
164
+ setStep('copyInput');
169
165
  }
170
- }, [currentPage, visiblePreviews, renderedPages]);
166
+ };
171
167
 
172
- const handlePageChange = (newPage: number) => {
173
- if (newPage >= 0 && newPage < totalPages) {
174
- setCurrentPage(newPage);
168
+ const handleCopyContinue = () => {
169
+ if (initialChoice === 'library') {
170
+ setStep('designLibrary');
171
+ } else if (initialChoice === 'ai') {
172
+ setStep('aiDesign');
175
173
  }
176
174
  };
177
175
 
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));
176
+ const handleBlankSlate = () => {
177
+ const blankTemplate: TemplatePane = {
178
+ id: '', // ctx will assign
179
+ nodeType: 'Pane',
180
+ parentId: '', // ctx will assign
181
+ title: 'New Pane',
182
+ slug: '',
183
+ isDecorative: false,
184
+ markdown: {
185
+ id: '', // ctx will assign
186
+ nodeType: 'Markdown',
187
+ parentId: '', // ctx will assign
188
+ type: 'markdown',
189
+ markdownId: '', // ctx will assign
190
+ defaultClasses: {},
191
+ parentClasses: [],
192
+ nodes: [],
193
+ },
194
+ };
195
+ handleApplyTemplate(blankTemplate);
195
196
  };
196
197
 
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
- };
198
+ const handleDesignLibrarySelect = async (entry: DesignLibraryEntry) => {
199
+ // This flow is for "Design Library + Provide Copy"
200
+ if (copyMode === 'raw') {
201
+ const liveTemplate = convertStorageToLiveTemplate(
202
+ mergeCopyIntoTemplate(entry.template, []) // Start with blank copy
203
+ );
204
+ if (liveTemplate.markdown) {
205
+ liveTemplate.markdown.markdownBody = copyValue;
207
206
  }
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 = '';
207
+ handleApplyTemplate(liveTemplate);
208
+ return;
209
+ }
230
210
 
231
- const ownerId =
232
- isStoryFragment || isContextPane
233
- ? nodeId
234
- : ctx.getClosestNodeTypeFromId(nodeId, 'StoryFragment');
211
+ // This flow is for "Design Library + Write a Prompt" (Hybrid AI)
212
+ if (copyMode === 'prompt') {
213
+ setError(null);
214
+ setStep('loading');
215
+ try {
216
+ // 1. Get the full, rich template from the library
217
+ const liveTemplate = convertStorageToLiveTemplate(entry.template);
218
+ if (!liveTemplate.markdown) {
219
+ throw new Error(
220
+ 'The selected design library item is not compatible with this workflow as it has no markdown section.'
221
+ );
222
+ }
235
223
 
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'
224
+ // 2. Create the simplified shell for the AI
225
+ const shellJson = convertTemplateToAIShell(liveTemplate);
226
+ if (!shellJson || shellJson === '{}') {
227
+ throw new Error(
228
+ 'Could not generate a valid AI shell from this design.'
248
229
  );
249
- });
250
- ctx.notifyNode(`root`);
230
+ }
231
+
232
+ // 3. Get the AI to write copy based on the shell and prompt
233
+ const copyPromptDetails = prompts.aiPaneCopyPrompt;
234
+ const layout = 'Text Only';
235
+ const formattedCopyPrompt = copyPromptDetails.user_template
236
+ .replace('{{COPY_INPUT}}', promptValue)
237
+ .replace(
238
+ '{{DESIGN_INPUT}}',
239
+ "N/A - Use the provided Shell JSON's design."
240
+ )
241
+ .replace('{{LAYOUT_TYPE}}', layout)
242
+ .replace('{{SHELL_JSON}}', shellJson);
243
+
244
+ const copyResult = await callAskLemurAPI(
245
+ formattedCopyPrompt,
246
+ copyPromptDetails.system || '',
247
+ false,
248
+ isSandboxMode
249
+ );
250
+
251
+ // 4. Parse ONLY the AI-generated HTML into content nodes
252
+ const newNodes = parseAiCopyHtml(copyResult, liveTemplate.markdown.id);
253
+
254
+ // 5. Create the final pane by cloning the original rich template
255
+ const finalPane = cloneDeep(liveTemplate);
256
+
257
+ // 6. Inject the new AI content, preserving the original rich design
258
+ finalPane.markdown!.nodes = newNodes;
259
+
260
+ // 7. Apply the complete, correctly merged pane
261
+ handleApplyTemplate(finalPane);
262
+ } catch (err: any) {
263
+ setError(err.message || 'Failed to generate AI copy for this design.');
264
+ setStep('error');
251
265
  }
252
- setParentMode(PaneAddMode.DEFAULT, false);
253
- } catch (error) {
254
- console.error('Error inserting template:', error);
255
- } finally {
256
- setIsInserting(false);
257
266
  }
258
267
  };
259
268
 
260
- const handleApplyGeneratedPane = async (pane: TemplatePane) => {
261
- if (isInserting || !ctx) return;
262
- setIsInserting(true);
269
+ const handleAiDesignGenerate = useCallback(async () => {
270
+ setError(null);
271
+ setStep('loading');
272
+
273
+ let designInput = `Generate a design using a **${aiDesignConfig.harmony.toLowerCase()}** color scheme with a **${aiDesignConfig.theme.toLowerCase()}** theme.`;
274
+ if (aiDesignConfig.baseColor)
275
+ designInput += ` Base the colors around **${aiDesignConfig.baseColor}**.`;
276
+ if (aiDesignConfig.accentColor)
277
+ designInput += ` Use **${aiDesignConfig.accentColor}** as an accent color.`;
278
+ if (aiDesignConfig.additionalNotes)
279
+ designInput += ` Refine with these notes: "${aiDesignConfig.additionalNotes}"`;
280
+
281
+ try {
282
+ const shellPromptDetails = prompts.aiPaneShellPrompt;
283
+ const copyPromptDetails = prompts.aiPaneCopyPrompt;
284
+ const layout = 'Text Only'; // Hardcoded for this simplified AI path
285
+
286
+ const formattedShellPrompt = shellPromptDetails.user_template
287
+ .replace('{{DESIGN_INPUT}}', designInput)
288
+ .replace('{{LAYOUT_TYPE}}', layout);
289
+
290
+ const shellResult = await callAskLemurAPI(
291
+ formattedShellPrompt,
292
+ shellPromptDetails.system || '',
293
+ true,
294
+ isSandboxMode
295
+ );
296
+
297
+ const copyInputContent = copyMode === 'prompt' ? promptValue : copyValue;
298
+ const formattedCopyPrompt = copyPromptDetails.user_template
299
+ .replace('{{COPY_INPUT}}', copyInputContent)
300
+ .replace('{{DESIGN_INPUT}}', designInput)
301
+ .replace('{{LAYOUT_TYPE}}', layout)
302
+ .replace('{{SHELL_JSON}}', shellResult);
303
+
304
+ const copyResult = await callAskLemurAPI(
305
+ formattedCopyPrompt,
306
+ copyPromptDetails.system || '',
307
+ false,
308
+ isSandboxMode
309
+ );
310
+
311
+ const finalPane = parseAiPane(shellResult, copyResult, layout);
312
+ handleApplyTemplate(finalPane);
313
+ } catch (err: any) {
314
+ setError(err.message || 'Failed to generate AI pane.');
315
+ setStep('error');
316
+ }
317
+ }, [aiDesignConfig, copyMode, promptValue, copyValue, isSandboxMode]);
318
+
319
+ const handleApplyTemplate = async (template: TemplatePane) => {
320
+ if (!ctx) return;
263
321
  try {
322
+ const insertTemplate = cloneDeep(template);
323
+ insertTemplate.title = insertTemplate.title || 'New Pane';
324
+ insertTemplate.slug = insertTemplate.slug || '';
325
+
264
326
  const ownerId =
265
327
  isStoryFragment || isContextPane
266
328
  ? nodeId
267
329
  : ctx.getClosestNodeTypeFromId(nodeId, 'StoryFragment');
268
330
 
269
331
  if (isContextPane) {
270
- pane.isContextPane = true;
332
+ insertTemplate.isContextPane = true;
271
333
  await ctx.applyAtomicUpdate(async (tmpCtx) => {
272
- tmpCtx.addContextTemplatePane(ownerId, pane);
334
+ tmpCtx.addContextTemplatePane(ownerId, insertTemplate);
273
335
  });
274
336
  } else {
275
337
  await ctx.applyAtomicUpdate(async (tmpCtx) => {
276
338
  tmpCtx.addTemplatePane(
277
339
  ownerId,
278
- pane,
340
+ insertTemplate,
279
341
  nodeId,
280
342
  first ? 'before' : 'after'
281
343
  );
@@ -283,363 +345,203 @@ const AddPaneNewPanel = ({
283
345
  ctx.notifyNode(`root`);
284
346
  }
285
347
  setParentMode(PaneAddMode.DEFAULT, false);
286
- } catch (error) {
287
- console.error('Error applying generated pane:', error);
288
- } finally {
289
- setIsInserting(false);
348
+ } catch (err) {
349
+ console.error('Error inserting template:', err);
350
+ setError(
351
+ err instanceof Error
352
+ ? err.message
353
+ : 'An unexpected error occurred while adding the pane.'
354
+ );
355
+ setStep('error');
290
356
  }
291
357
  };
292
358
 
293
- const handleThemeChange = (details: { value: string[] }) => {
294
- const newTheme = details.value[0] as Theme;
295
- if (newTheme) {
296
- setSelectedTheme(newTheme);
297
- }
298
- };
299
-
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
- };
359
+ // --- Render Logic ---
307
360
 
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
- `;
361
+ const renderInitialStep = () => (
362
+ <div className="p-4">
363
+ <h3 className="font-action mb-4 text-center text-xl font-bold text-gray-800">
364
+ How would you like to design your new pane?
365
+ </h3>
366
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
367
+ <button
368
+ onClick={() => handleInitialChoice('library')}
369
+ 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"
370
+ >
371
+ <SwatchIcon className="h-10 w-10 text-gray-500 transition-colors group-hover:text-cyan-600" />
372
+ <h4 className="font-semibold text-gray-800">Use Design Library</h4>
373
+ <p className="text-sm text-gray-600">
374
+ Start with a pre-made design and add your own content.
375
+ </p>
376
+ </button>
377
+ {hasAssemblyAI && (
378
+ <button
379
+ onClick={() => handleInitialChoice('ai')}
380
+ 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"
381
+ >
382
+ <SparklesIcon className="h-10 w-10 text-gray-500 transition-colors group-hover:text-cyan-600" />
383
+ <h4 className="font-semibold text-gray-800">Design with AI</h4>
384
+ <p className="text-sm text-gray-600">
385
+ Let AI generate a complete design and copy from your prompt.
386
+ </p>
387
+ </button>
388
+ )}
389
+ <button
390
+ onClick={() => handleInitialChoice('blank')}
391
+ 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"
392
+ >
393
+ <DocumentPlusIcon className="h-10 w-10 text-gray-500 transition-colors group-hover:text-cyan-600" />
394
+ <h4 className="font-semibold text-gray-800">Blank Slate</h4>
395
+ <p className="text-sm text-gray-600">
396
+ Add a simple, empty pane to build from scratch.
397
+ </p>
398
+ </button>
399
+ </div>
400
+ </div>
401
+ );
320
402
 
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">
403
+ const renderContentStep = () => (
404
+ <div className="space-y-4 p-4">
405
+ <CopyInputStep
406
+ copyMode={copyMode}
407
+ onCopyModeChange={setCopyMode}
408
+ promptValue={promptValue}
409
+ onPromptValueChange={setPromptValue}
410
+ copyValue={copyValue}
411
+ onCopyValueChange={setCopyValue}
412
+ defaultPrompt={
413
+ first
414
+ ? prompts.aiPaneCopyPrompt.heroDefault
415
+ : prompts.aiPaneCopyPrompt.contentDefault
416
+ }
417
+ />
418
+ <div className="flex justify-between">
325
419
  <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"
420
+ onClick={handleBack}
421
+ 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
422
  >
330
- Go Back
423
+ ← Back
331
424
  </button>
425
+ <button
426
+ onClick={handleCopyContinue}
427
+ disabled={
428
+ copyMode === 'prompt' ? !promptValue.trim() : !copyValue.trim()
429
+ }
430
+ 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"
431
+ >
432
+ Continue →
433
+ </button>
434
+ </div>
435
+ </div>
436
+ );
332
437
 
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>
438
+ const renderDesignLibraryStep = () => (
439
+ <div className="space-y-4 p-4">
440
+ <div className="flex justify-start">
441
+ <button
442
+ onClick={handleBack}
443
+ 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"
444
+ >
445
+ Back to Content
446
+ </button>
447
+ </div>
448
+ <DesignLibraryStep
449
+ config={config!}
450
+ onSelect={handleDesignLibrarySelect}
451
+ />
452
+ </div>
453
+ );
374
454
 
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>
455
+ const renderAiDesignStep = () => (
456
+ <div className="space-y-4 p-4">
457
+ <AiDesignStep
458
+ config={config!}
459
+ designConfig={aiDesignConfig}
460
+ onDesignConfigChange={setAiDesignConfig}
461
+ />
462
+ <div className="flex justify-between">
463
+ <button
464
+ onClick={handleBack}
465
+ 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"
466
+ >
467
+ ← Back
468
+ </button>
469
+ <button
470
+ onClick={handleAiDesignGenerate}
471
+ className="rounded-md bg-cyan-600 px-4 py-2 text-sm font-bold text-white shadow-sm hover:bg-cyan-700"
472
+ >
473
+ ✨ Generate with AI
474
+ </button>
394
475
  </div>
476
+ </div>
477
+ );
395
478
 
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>
479
+ const renderLoading = () => (
480
+ <div className="flex min-h-[300px] flex-col items-center justify-center space-y-4 p-6">
481
+ <div className="h-8 w-8 animate-spin rounded-full border-b-2 border-cyan-600"></div>
482
+ <p className="text-sm text-gray-600">Generating AI Pane...</p>
483
+ <p className="text-xs text-gray-500">This may take a moment.</p>
484
+ </div>
485
+ );
447
486
 
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>
487
+ const renderError = () => (
488
+ <div className="space-y-4 rounded-lg bg-red-50 p-6 text-center">
489
+ <h4 className="text-lg font-bold text-red-800">Generation Failed</h4>
490
+ <p className="text-sm text-red-700">{error}</p>
491
+ <button
492
+ onClick={handleBack}
493
+ 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"
494
+ >
495
+ ← Try Again
496
+ </button>
497
+ </div>
498
+ );
524
499
 
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>
500
+ const renderStep = () => {
501
+ switch (step) {
502
+ case 'initial':
503
+ return renderInitialStep();
504
+ case 'copyInput':
505
+ return renderContentStep();
506
+ case 'designLibrary':
507
+ return renderDesignLibraryStep();
508
+ case 'aiDesign':
509
+ return renderAiDesignStep();
510
+ case 'loading':
511
+ return renderLoading();
512
+ case 'error':
513
+ return renderError();
514
+ default:
515
+ return renderInitialStep();
516
+ }
517
+ };
606
518
 
607
- <div className="mb-2 mt-4 flex items-center justify-center gap-2">
519
+ return (
520
+ <div className="bg-white p-2 shadow-inner">
521
+ <div className="group mb-2 flex w-full items-center gap-1 rounded-md bg-white p-1.5">
522
+ {first ? (
523
+ <div className="w-full text-center">
524
+ <h2 className="font-action py-1.5 text-lg font-bold text-gray-800">
525
+ Welcome to Tract Stack
526
+ </h2>
527
+ </div>
528
+ ) : (
529
+ <>
608
530
  <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"
531
+ onClick={() => setParentMode(PaneAddMode.DEFAULT, first)}
532
+ className="w-fit rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 transition-colors hover:bg-gray-200"
613
533
  >
614
- Previous
534
+ ← Go Back
615
535
  </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
- ))}
536
+ <div className="font-action ml-4 flex-none rounded px-2 py-2.5 text-sm font-bold text-cyan-700 shadow-sm">
537
+ + Design New Pane
631
538
  </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
- )}
539
+ </>
540
+ )}
541
+ </div>
542
+ <div className="min-h-[400px] rounded-md border bg-gray-50">
543
+ {renderStep()}
544
+ </div>
643
545
  </div>
644
546
  );
645
547
  };