astro-tractstack 2.0.12 → 2.0.14

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 (26) hide show
  1. package/dist/index.js +22 -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 +18 -2
  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.tsx +5 -1
  9. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +4 -1
  10. package/templates/src/components/edit/pane/AiPaneGenerator.tsx +264 -94
  11. package/templates/src/components/edit/pane/AiPanePreview.tsx +60 -210
  12. package/templates/src/components/edit/pane/PageGen.tsx +1 -1
  13. package/templates/src/components/edit/pane/PageGenSelector.tsx +4 -0
  14. package/templates/src/components/edit/pane/RestylePaneModal.tsx +573 -0
  15. package/templates/src/components/edit/state/SaveToLibraryModal.tsx +205 -0
  16. package/templates/src/constants/prompts.json +3 -3
  17. package/templates/src/stores/selection.ts +4 -0
  18. package/templates/src/types/compositorTypes.ts +51 -1
  19. package/templates/src/types/tractstack.ts +36 -31
  20. package/templates/src/utils/aai/getTitleSlug.ts +1 -1
  21. package/templates/src/utils/api/brandConfig.ts +8 -2
  22. package/templates/src/utils/api/brandHelpers.ts +4 -0
  23. package/templates/src/utils/compositor/aiPaneParser.ts +39 -13
  24. package/templates/src/utils/compositor/designLibraryHelper.ts +331 -0
  25. package/templates/src/utils/compositor/processMarkdown.ts +1 -1
  26. package/utils/inject-files.ts +22 -0
@@ -2,15 +2,19 @@ import { useState, useCallback } from 'react';
2
2
  import { AiPanePreview } from './AiPanePreview';
3
3
  import type { TemplatePane } from '@/types/compositorTypes';
4
4
  import prompts from '@/constants/prompts.json';
5
- import StringInput from '@/components/form/StringInput';
5
+ import ColorPickerCombo from '@/components/fields/ColorPickerCombo';
6
+ import { parseAiPane } from '@/utils/compositor/aiPaneParser';
7
+ import { classNames } from '@/utils/helpers';
8
+ import type { BrandConfig } from '@/types/tractstack';
6
9
 
7
10
  interface AiPaneGeneratorProps {
8
11
  ownerId: string;
9
12
  onComplete: (pane: TemplatePane) => void;
10
13
  onCancel: () => void;
14
+ config?: BrandConfig;
11
15
  }
12
16
 
13
- type GenerationStep = 'layout' | 'input' | 'preview' | 'loading';
17
+ type GenerationStep = 'input' | 'preview' | 'loading';
14
18
  type CopyMode = 'prompt' | 'raw';
15
19
 
16
20
  interface GenerationResponse {
@@ -21,25 +25,41 @@ interface GenerationResponse {
21
25
  error?: string;
22
26
  }
23
27
 
24
- const layoutOptions = ['Text Only', 'Text + Image Left', 'Text + Image Right'];
28
+ const harmonyOptions = [
29
+ 'Analogous',
30
+ 'Monochromatic',
31
+ 'Complementary',
32
+ 'Triadic',
33
+ ];
34
+ const themeOptions = ['Light', 'Dark', 'Bright', 'Muted', 'Pastel', 'Earthy'];
25
35
 
26
36
  export function AiPaneGenerator({
27
37
  ownerId,
28
38
  onComplete,
29
39
  onCancel,
40
+ config,
30
41
  }: AiPaneGeneratorProps) {
31
- const [currentStep, setCurrentStep] = useState<GenerationStep>('layout');
32
- const [selectedLayout, setSelectedLayout] = useState<string>(
33
- layoutOptions[0]
34
- );
42
+ const [currentStep, setCurrentStep] = useState<GenerationStep>('input');
43
+ const [selectedLayout] = useState<string>('Text Only');
35
44
  const [copyMode, setCopyMode] = useState<CopyMode>('prompt');
36
45
  const [copyPrompt, setCopyPrompt] = useState('');
37
46
  const [rawCopy, setRawCopy] = useState('');
38
- const [designPrompt, setDesignPrompt] = useState('');
39
47
  const [generatedShell, setGeneratedShell] = useState<string | null>(null);
40
48
  const [generatedCopy, setGeneratedCopy] = useState<string | null>(null);
41
49
  const [error, setError] = useState<string | null>(null);
42
50
 
51
+ const [selectedHarmony, setSelectedHarmony] = useState<string>(
52
+ harmonyOptions[0]
53
+ );
54
+ const [baseColor, setBaseColor] = useState<string>('');
55
+ const [accentColor, setAccentColor] = useState<string>('');
56
+ const [selectedTheme, setSelectedTheme] = useState<string>(themeOptions[0]);
57
+ const [additionalNotes, setAdditionalNotes] = useState<string>('');
58
+
59
+ const [isInjectMode, setIsInjectMode] = useState(false);
60
+ const [injectShell, setInjectShell] = useState('');
61
+ const [injectCopy, setInjectCopy] = useState('');
62
+
43
63
  const callAskLemurAPI = useCallback(
44
64
  async (
45
65
  prompt: string,
@@ -53,7 +73,7 @@ export function AiPaneGenerator({
53
73
  const requestBody = {
54
74
  prompt: prompt,
55
75
  input_text: context,
56
- final_model: 'anthropic/claude-3-5-sonnet',
76
+ final_model: '',
57
77
  temperature: 0.5,
58
78
  max_tokens: 2000,
59
79
  };
@@ -93,12 +113,10 @@ export function AiPaneGenerator({
93
113
 
94
114
  let rawResponseData = result.data.response;
95
115
 
96
- // Handle case where API returns JSON object for shell
97
116
  if (expectJson && typeof rawResponseData === 'object') {
98
- return JSON.stringify(rawResponseData); // Return as string
117
+ return JSON.stringify(rawResponseData);
99
118
  }
100
119
 
101
- // Handle case where API returns string (potentially wrapped)
102
120
  if (typeof rawResponseData === 'string') {
103
121
  let responseString = rawResponseData;
104
122
  try {
@@ -116,10 +134,9 @@ export function AiPaneGenerator({
116
134
  } catch (e) {
117
135
  /* Ignore stripping errors */
118
136
  }
119
- return responseString; // Return string directly
137
+ return responseString;
120
138
  }
121
139
 
122
- // Fallback if response is neither expected string nor object
123
140
  throw new Error('Unexpected response format received from API.');
124
141
  },
125
142
  []
@@ -131,6 +148,17 @@ export function AiPaneGenerator({
131
148
  setGeneratedShell(null);
132
149
  setGeneratedCopy(null);
133
150
 
151
+ let designInput = `Generate a design using a **${selectedHarmony.toLowerCase()}** color scheme with a **${selectedTheme.toLowerCase()}** theme.`;
152
+ if (baseColor) {
153
+ designInput += ` Base the colors around **${baseColor}**.`;
154
+ }
155
+ if (accentColor) {
156
+ designInput += ` Use **${accentColor}** as an accent color.`;
157
+ }
158
+ if (additionalNotes) {
159
+ designInput += ` Refine the design with these additional notes: "${additionalNotes}"`;
160
+ }
161
+
134
162
  try {
135
163
  const shellPromptDetails = prompts.aiPaneShellPrompt;
136
164
  const copyPromptDetails = prompts.aiPaneCopyPrompt;
@@ -142,11 +170,8 @@ export function AiPaneGenerator({
142
170
  throw new Error('AI prompts not found or incomplete in prompts.json');
143
171
  }
144
172
 
145
- // --- This is the updated (sequential) logic ---
146
-
147
- // 1. Prepare and call the Shell API first
148
173
  const formattedShellPrompt = shellPromptDetails.user_template
149
- .replace('{{DESIGN_INPUT}}', designPrompt)
174
+ .replace('{{DESIGN_INPUT}}', designInput)
150
175
  .replace('{{LAYOUT_TYPE}}', selectedLayout);
151
176
 
152
177
  const shellResult = await callAskLemurAPI(
@@ -154,35 +179,34 @@ export function AiPaneGenerator({
154
179
  shellPromptDetails.system || '',
155
180
  true
156
181
  );
157
- setGeneratedShell(shellResult); // Set this for the previewer
182
+ setGeneratedShell(shellResult);
158
183
 
159
- // 2. NOW, create the copy prompt, injecting the shellResult
160
184
  const copyInputContent = copyMode === 'prompt' ? copyPrompt : rawCopy;
161
- // Note: Assumes prompts.json's aiPaneCopyPrompt.user_template now includes {{SHELL_JSON}}
162
185
  const formattedCopyPrompt = copyPromptDetails.user_template
163
186
  .replace('{{COPY_INPUT}}', copyInputContent)
164
- .replace('{{DESIGN_INPUT}}', designPrompt)
187
+ .replace('{{DESIGN_INPUT}}', designInput)
165
188
  .replace('{{LAYOUT_TYPE}}', selectedLayout)
166
- .replace('{{SHELL_JSON}}', shellResult); // <-- This is the new, critical part
189
+ .replace('{{SHELL_JSON}}', shellResult);
167
190
 
168
- // 3. Call Copy API second, using the fully-formed prompt
169
191
  const copyResult = await callAskLemurAPI(
170
192
  formattedCopyPrompt,
171
193
  copyPromptDetails.system || '',
172
194
  false
173
195
  );
174
- setGeneratedCopy(copyResult); // Should be an HTML string
196
+ setGeneratedCopy(copyResult);
175
197
 
176
198
  setCurrentStep('preview');
177
-
178
- // --- End of updated logic ---
179
199
  } catch (err: any) {
180
200
  console.error('AI Pane Generation Error:', err);
181
201
  setError(err.message || 'Failed to generate AI pane.');
182
202
  setCurrentStep('input');
183
203
  }
184
204
  }, [
185
- designPrompt,
205
+ selectedHarmony,
206
+ baseColor,
207
+ accentColor,
208
+ selectedTheme,
209
+ additionalNotes,
186
210
  selectedLayout,
187
211
  copyMode,
188
212
  copyPrompt,
@@ -190,12 +214,51 @@ export function AiPaneGenerator({
190
214
  callAskLemurAPI,
191
215
  ]);
192
216
 
217
+ const handleInject = useCallback(() => {
218
+ setError(null);
219
+ if (!injectShell || !injectCopy) {
220
+ setError('Both Shell JSON and Copy HTML must be provided.');
221
+ return;
222
+ }
223
+ try {
224
+ const shellResponse = JSON.parse(injectShell);
225
+ const copyResponse = JSON.parse(injectCopy);
226
+
227
+ const shellPayloadString = JSON.stringify(shellResponse?.data?.response);
228
+ const copyPayloadString = copyResponse?.data?.response;
229
+
230
+ if (
231
+ !shellPayloadString ||
232
+ shellPayloadString === 'null' ||
233
+ typeof copyPayloadString !== 'string'
234
+ ) {
235
+ throw new Error(
236
+ 'Payloads are in an unexpected format. Could not find "data.response".'
237
+ );
238
+ }
239
+
240
+ const pane = parseAiPane(
241
+ shellPayloadString,
242
+ copyPayloadString,
243
+ selectedLayout
244
+ );
245
+ onComplete(pane);
246
+ } catch (err: any) {
247
+ console.error('Payload Injection Error:', err);
248
+ setError(err.message || 'Failed to parse payloads. Check JSON format.');
249
+ }
250
+ }, [injectShell, injectCopy, selectedLayout, onComplete]);
251
+
193
252
  const handleBack = () => {
194
253
  setError(null);
195
254
  if (currentStep === 'preview') {
196
255
  setCurrentStep('input');
197
256
  } else if (currentStep === 'input') {
198
- setCurrentStep('layout');
257
+ if (isInjectMode) {
258
+ setIsInjectMode(false);
259
+ } else {
260
+ onCancel();
261
+ }
199
262
  } else if (currentStep === 'loading') {
200
263
  setCurrentStep('input');
201
264
  }
@@ -230,72 +293,166 @@ export function AiPaneGenerator({
230
293
  );
231
294
  }
232
295
 
233
- if (currentStep === 'layout') {
234
- return (
235
- <div className="space-y-4 p-4">
236
- <label className="mb-2 block text-lg font-semibold text-gray-800">
237
- Choose a Layout
238
- </label>
239
- <div className="space-y-2">
240
- {layoutOptions.map((layout) => (
241
- <div key={layout} className="flex items-center space-x-2">
242
- <input
243
- type="radio"
244
- id={`layout-${layout}`}
245
- name="layoutOptions"
246
- value={layout}
247
- checked={selectedLayout === layout}
248
- onChange={(e) => setSelectedLayout(e.target.value)}
249
- className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
250
- />
251
- <label
252
- htmlFor={`layout-${layout}`}
253
- className="text-sm font-medium text-gray-700"
254
- >
255
- {layout}
256
- </label>
257
- </div>
258
- ))}
259
- </div>
260
- <div className="flex justify-end space-x-2 pt-4">
261
- <button
262
- type="button"
263
- onClick={onCancel}
264
- className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
265
- >
266
- Cancel
267
- </button>
268
- <button
269
- type="button"
270
- onClick={() => setCurrentStep('input')}
271
- className="rounded-md border border-transparent bg-cyan-600 px-4 py-2 text-sm font-bold text-white hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
272
- >
273
- Next
274
- </button>
296
+ if (currentStep === 'input') {
297
+ if (isInjectMode) {
298
+ return (
299
+ <div className="space-y-6 p-4">
300
+ <div>
301
+ <label
302
+ htmlFor="shell-json"
303
+ className="block text-lg font-semibold text-gray-800"
304
+ >
305
+ Shell JSON Payload
306
+ </label>
307
+ <textarea
308
+ id="shell-json"
309
+ value={injectShell}
310
+ onChange={(e) => setInjectShell(e.target.value)}
311
+ placeholder="Paste raw API response for ShellJson here..."
312
+ rows={8}
313
+ className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
314
+ />
315
+ </div>
316
+
317
+ <div>
318
+ <label
319
+ htmlFor="copy-html"
320
+ className="block text-lg font-semibold text-gray-800"
321
+ >
322
+ Copy HTML Payload
323
+ </label>
324
+ <textarea
325
+ id="copy-html"
326
+ value={injectCopy}
327
+ onChange={(e) => setInjectCopy(e.target.value)}
328
+ placeholder="Paste raw API response for copyHtml here..."
329
+ rows={8}
330
+ className="mt-2 block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
331
+ />
332
+ </div>
333
+
334
+ {error && <p className="text-sm text-red-600">{error}</p>}
335
+
336
+ <div className="flex justify-between pt-4">
337
+ <button
338
+ type="button"
339
+ onClick={handleBack}
340
+ className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
341
+ >
342
+ Back to Generator
343
+ </button>
344
+ <button
345
+ type="button"
346
+ onClick={handleInject}
347
+ disabled={!injectShell || !injectCopy}
348
+ className={`rounded-md border border-transparent px-4 py-2 text-sm font-bold text-white shadow-sm ${
349
+ !injectShell || !injectCopy
350
+ ? 'cursor-not-allowed bg-gray-400'
351
+ : 'bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
352
+ }`}
353
+ >
354
+ Create from Payloads
355
+ </button>
356
+ </div>
275
357
  </div>
276
- </div>
277
- );
278
- }
358
+ );
359
+ }
279
360
 
280
- if (currentStep === 'input') {
281
361
  return (
282
362
  <div className="space-y-6 p-4">
363
+ <div>
364
+ <label className="block text-lg font-semibold text-gray-800">
365
+ Color Harmony
366
+ </label>
367
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
368
+ {harmonyOptions.map((option) => (
369
+ <div key={option} className="flex items-center space-x-2">
370
+ <input
371
+ type="radio"
372
+ id={`harmony-${option}`}
373
+ name="harmonyOptions"
374
+ value={option}
375
+ checked={selectedHarmony === option}
376
+ onChange={(e) => setSelectedHarmony(e.target.value)}
377
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
378
+ />
379
+ <label
380
+ htmlFor={`harmony-${option}`}
381
+ className="text-sm font-medium text-gray-700"
382
+ >
383
+ {option}
384
+ </label>
385
+ </div>
386
+ ))}
387
+ </div>
388
+ </div>
389
+
390
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
391
+ <div>
392
+ <ColorPickerCombo
393
+ title="Base Color (Optional)"
394
+ config={config!}
395
+ defaultColor={baseColor}
396
+ onColorChange={setBaseColor}
397
+ allowNull={true}
398
+ />
399
+ </div>
400
+ <div>
401
+ <ColorPickerCombo
402
+ title="Accent Color (Optional)"
403
+ config={config!}
404
+ defaultColor={accentColor}
405
+ onColorChange={setAccentColor}
406
+ allowNull={true}
407
+ />
408
+ </div>
409
+ </div>
410
+
411
+ <div>
412
+ <label className="block text-lg font-semibold text-gray-800">
413
+ Theme / Mood
414
+ </label>
415
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
416
+ {themeOptions.map((option) => (
417
+ <div key={option} className="flex items-center space-x-2">
418
+ <input
419
+ type="radio"
420
+ id={`theme-${option}`}
421
+ name="themeOptions"
422
+ value={option}
423
+ checked={selectedTheme === option}
424
+ onChange={(e) => setSelectedTheme(e.target.value)}
425
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
426
+ />
427
+ <label
428
+ htmlFor={`theme-${option}`}
429
+ className="text-sm font-medium text-gray-700"
430
+ >
431
+ {option}
432
+ </label>
433
+ </div>
434
+ ))}
435
+ </div>
436
+ </div>
437
+
283
438
  <div>
284
439
  <label
285
- htmlFor="design-prompt"
440
+ htmlFor="additional-notes"
286
441
  className="block text-lg font-semibold text-gray-800"
287
442
  >
288
- Describe the Design
443
+ Additional Design Notes (Optional)
289
444
  </label>
290
445
  <p className="mb-2 mt-1 text-sm text-gray-500">
291
- Example: "dark, minimalist hero", "bright, playful feature box"
446
+ Add specific requests like "use rounded corners", "add subtle
447
+ texture".
292
448
  </p>
293
- <StringInput
294
- id="design-prompt"
295
- value={designPrompt}
296
- onChange={setDesignPrompt}
297
- placeholder="Enter design prompt..."
298
- className="block w-full rounded-md border border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
449
+ <textarea
450
+ id="additional-notes"
451
+ value={additionalNotes}
452
+ onChange={(e) => setAdditionalNotes(e.target.value)}
453
+ placeholder="Enter additional notes..."
454
+ rows={3}
455
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
299
456
  />
300
457
  </div>
301
458
 
@@ -380,21 +537,34 @@ export function AiPaneGenerator({
380
537
  onClick={handleBack}
381
538
  className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-bold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2"
382
539
  >
383
- Back
540
+ Cancel
384
541
  </button>
385
542
  <button
386
543
  type="button"
387
544
  onClick={handleGenerate}
388
- disabled={
389
- !designPrompt || (copyMode === 'prompt' ? !copyPrompt : !rawCopy)
390
- }
391
- className={`rounded-md border border-transparent px-4 py-2 text-sm font-bold text-white shadow-sm ${
392
- !designPrompt || (copyMode === 'prompt' ? !copyPrompt : !rawCopy)
393
- ? 'cursor-not-allowed bg-gray-400'
394
- : 'bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2'
395
- }`}
545
+ disabled={copyMode === 'prompt' ? !copyPrompt : !rawCopy}
546
+ className={classNames(
547
+ `rounded-md border border-transparent px-4 py-2 text-sm font-bold shadow-sm transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2`,
548
+ (copyMode === 'prompt' && !copyPrompt) ||
549
+ (copyMode === `raw` && !rawCopy)
550
+ ? 'cursor-not-allowed bg-gray-300 text-gray-500'
551
+ : 'bg-cyan-600 text-white hover:bg-cyan-700'
552
+ )}
553
+ >
554
+ Generate Pane
555
+ </button>
556
+ </div>
557
+
558
+ <div className="border-t border-gray-200 pt-4 text-center">
559
+ <button
560
+ type="button"
561
+ onClick={() => {
562
+ setError(null);
563
+ setIsInjectMode(true);
564
+ }}
565
+ className="text-sm text-cyan-600 hover:text-cyan-800 hover:underline"
396
566
  >
397
- Generate Preview
567
+ Direct Inject Payload
398
568
  </button>
399
569
  </div>
400
570
  </div>