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.
- package/dist/index.js +41 -9
- package/package.json +1 -1
- package/templates/custom/with-examples/CodeHook.astro +4 -0
- package/templates/custom/with-examples/SandboxLauncher.tsx +65 -0
- package/templates/env.example +3 -0
- package/templates/src/components/codehooks/SandboxAuthWrapper.tsx +75 -0
- package/templates/src/components/codehooks/SandboxRegisterForm.tsx +202 -0
- package/templates/src/components/compositor/Compositor.tsx +2 -0
- package/templates/src/components/compositor/Node.tsx +27 -9
- package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +13 -11
- package/templates/src/components/compositor/nodes/Pane_layout.tsx +16 -14
- package/templates/src/components/edit/Header.tsx +8 -2
- package/templates/src/components/edit/PanelSwitch.tsx +4 -4
- package/templates/src/components/edit/pane/AddPanePanel.tsx +3 -0
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +463 -561
- package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +140 -0
- package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +105 -0
- package/templates/src/components/edit/pane/steps/DesignLibraryStep.tsx +395 -0
- package/templates/src/components/edit/panels/StyleImagePanel.tsx +10 -8
- package/templates/src/components/edit/state/SaveModal.tsx +41 -0
- package/templates/src/constants/prompts.json +3 -1
- package/templates/src/pages/api/sandbox.ts +86 -0
- package/templates/src/pages/sandbox.astro +137 -0
- package/templates/src/types/nodeProps.ts +1 -0
- package/templates/src/utils/compositor/aiPaneParser.ts +32 -84
- package/templates/src/utils/compositor/designLibraryHelper.ts +87 -2
- package/templates/src/utils/profileStorage.ts +13 -0
- package/utils/inject-files.ts +41 -10
- package/templates/src/components/edit/pane/AiPaneGenerator.tsx +0 -575
- package/templates/src/components/edit/pane/AiPanePreview.tsx +0 -107
- package/templates/src/components/edit/pane/PageGen.tsx +0 -485
- package/templates/src/components/edit/pane/PageGenSelector.tsx +0 -245
- package/templates/src/components/edit/pane/PageGenSpecial.tsx +0 -339
- package/templates/src/utils/aai/getTitleSlug.ts +0 -72
|
@@ -45,6 +45,7 @@ interface SaveModalProps {
|
|
|
45
45
|
slug: string;
|
|
46
46
|
isContext: boolean;
|
|
47
47
|
onClose: () => void;
|
|
48
|
+
isSandboxMode?: boolean;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
const PROGRESS_PHASES = {
|
|
@@ -61,11 +62,47 @@ const INDETERMINATE_STAGES: SaveStage[] = [
|
|
|
61
62
|
'UPDATING_HOME_PAGE',
|
|
62
63
|
];
|
|
63
64
|
|
|
65
|
+
const SandboxUpgradeNotice = ({ onClose }: { onClose: () => void }) => (
|
|
66
|
+
<Dialog.Root open={true} onOpenChange={() => onClose()} modal={true}>
|
|
67
|
+
<Portal>
|
|
68
|
+
<Dialog.Backdrop className="fixed inset-0 z-[9005] bg-black bg-opacity-75" />
|
|
69
|
+
<Dialog.Positioner className="fixed inset-0 z-[9005] flex items-center justify-center p-4">
|
|
70
|
+
<Dialog.Content className="w-full max-w-md overflow-hidden rounded-lg bg-white shadow-xl">
|
|
71
|
+
<div className="p-6 text-center">
|
|
72
|
+
<Dialog.Title className="text-xl font-bold text-gray-900">
|
|
73
|
+
Save Your Work
|
|
74
|
+
</Dialog.Title>
|
|
75
|
+
<Dialog.Description className="mt-2 text-gray-600">
|
|
76
|
+
To save your changes and get a shareable link, please sign up for
|
|
77
|
+
a full account.
|
|
78
|
+
</Dialog.Description>
|
|
79
|
+
<div className="mt-6 flex justify-center gap-3">
|
|
80
|
+
<a
|
|
81
|
+
href="/sandbox/register"
|
|
82
|
+
className="bg-myblue hover:bg-myorange rounded-md px-4 py-2 font-bold text-white"
|
|
83
|
+
>
|
|
84
|
+
Sign Up Now
|
|
85
|
+
</a>
|
|
86
|
+
<button
|
|
87
|
+
onClick={onClose}
|
|
88
|
+
className="rounded-md bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300"
|
|
89
|
+
>
|
|
90
|
+
Keep Editing
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</Dialog.Content>
|
|
95
|
+
</Dialog.Positioner>
|
|
96
|
+
</Portal>
|
|
97
|
+
</Dialog.Root>
|
|
98
|
+
);
|
|
99
|
+
|
|
64
100
|
export default function SaveModal({
|
|
65
101
|
show,
|
|
66
102
|
slug,
|
|
67
103
|
isContext,
|
|
68
104
|
onClose,
|
|
105
|
+
isSandboxMode = false,
|
|
69
106
|
}: SaveModalProps) {
|
|
70
107
|
const [stage, setStage] = useState<SaveStage>('PREPARING');
|
|
71
108
|
const [progress, setProgress] = useState(0);
|
|
@@ -871,6 +908,10 @@ export default function SaveModal({
|
|
|
871
908
|
}
|
|
872
909
|
})();
|
|
873
910
|
|
|
911
|
+
if (isSandboxMode) {
|
|
912
|
+
return show ? <SandboxUpgradeNotice onClose={onClose} /> : null;
|
|
913
|
+
}
|
|
914
|
+
|
|
874
915
|
return (
|
|
875
916
|
<Dialog.Root
|
|
876
917
|
open={show}
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
},
|
|
46
46
|
"aiPaneCopyPrompt": {
|
|
47
47
|
"system": "You are an expert **web designer and copywriter**. Your task is to generate a single, visually compelling block of HTML content. You must ensure the content is well-written, engaging, **beautifully spaced**, and **highly readable**.",
|
|
48
|
-
"user_template": "Here is the design 'shell' and 'theme' (bgColour, parentClasses, and defaultClasses) you must write your HTML for. Your HTML will be placed *inside* this shell. Use the `defaultClasses` as your base theme for styling:\n{{SHELL_JSON}}\n\nNow, generate the HTML content based on these inputs:\n\nContent Prompt: \"{{COPY_INPUT}}\"\nDesign Style: **strictly for visual reference** when choosing element styles **DO NOT** include any words or concepts from the `Design Style` input in the written copy text itself.\"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You are responsible for the **inner layout and visual rhythm**. You MUST add appropriate vertical margins (e.g., `mt-4`, `mt-6`, `mt-8`) directly to any HTML block elements that *deviate* from the default spacing. **Elements must not touch.**\n2. You **MUST NOT** use `<h1>` tags. You must use `<h2>`, `<h3>`, and `<p>` tags for all text content.\n3. For responsive styles, you *must* only use `md:` and `xl:` prefixes.\n4. To make headlines pop, you MUST wrap key words in `<span>` tags with creative classes (e.g., gradient text, different colors).\n5. **All text**, even short links or phrases (like 'Learn more →'), **must** be wrapped in a block element like `<p>` or `<button>`.\n6. You MUST include at least one `<button>` tag for the primary call-to-action.\n7. Verify that **all text elements**, including text within `<span>` tags, `<button>` elements, and any elements using override classes, maintain **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) against the `bgColour` provided in the `SHELL_JSON`. **Prioritize readability above all else**.\n8. Respond *only* with the raw HTML.\n\nEXAMPLE of a good, well-spaced response:\n<h2 class=\"text-4xl font-bold tracking-tight text-white md:text-6xl\"><span class=\"bg-gradient-to-r from-purple-500 to-indigo-400 bg-clip-text text-transparent\">Own the Art.</span> Possess the Reality.</h2>\n<p class=\"mt-6 text-lg leading-8 text-gray-300 md:text-xl\">Every Sneaky Productions NFT is your key. This is where digital rarity meets tangible legacy.</p>\n<button class=\"mt-8 rounded-md bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-500\">Secure Your Drop</button>\n<p class=\"mt-4 text-sm text-gray-400\">Learn more <span>→</span></p>"
|
|
48
|
+
"user_template": "Here is the design 'shell' and 'theme' (bgColour, parentClasses, and defaultClasses) you must write your HTML for. Your HTML will be placed *inside* this shell. Use the `defaultClasses` as your base theme for styling:\n{{SHELL_JSON}}\n\nNow, generate the HTML content based on these inputs:\n\nContent Prompt: \"{{COPY_INPUT}}\"\nDesign Style: **strictly for visual reference** when choosing element styles **DO NOT** include any words or concepts from the `Design Style` input in the written copy text itself.\"{{DESIGN_INPUT}}\"\nLayout Type: \"{{LAYOUT_TYPE}}\"\n\nCRITICAL RULES:\n1. You are responsible for the **inner layout and visual rhythm**. You MUST add appropriate vertical margins (e.g., `mt-4`, `mt-6`, `mt-8`) directly to any HTML block elements that *deviate* from the default spacing. **Elements must not touch.**\n2. You **MUST NOT** use `<h1>` tags. You must use `<h2>`, `<h3>`, and `<p>` tags for all text content.\n3. For responsive styles, you *must* only use `md:` and `xl:` prefixes.\n4. To make headlines pop, you MUST wrap key words in `<span>` tags with creative classes (e.g., gradient text, different colors).\n5. **All text**, even short links or phrases (like 'Learn more →'), **must** be wrapped in a block element like `<p>` or `<button>`.\n6. You MUST include at least one `<button>` tag for the primary call-to-action.\n7. Verify that **all text elements**, including text within `<span>` tags, `<button>` elements, and any elements using override classes, maintain **high contrast** (meeting at least WCAG AA standards - 4.5:1 for normal text, 3:1 for large text) against the `bgColour` provided in the `SHELL_JSON`. **Prioritize readability above all else**.\n8. Respond *only* with the raw HTML.\n\nEXAMPLE of a good, well-spaced response:\n<h2 class=\"text-4xl font-bold tracking-tight text-white md:text-6xl\"><span class=\"bg-gradient-to-r from-purple-500 to-indigo-400 bg-clip-text text-transparent\">Own the Art.</span> Possess the Reality.</h2>\n<p class=\"mt-6 text-lg leading-8 text-gray-300 md:text-xl\">Every Sneaky Productions NFT is your key. This is where digital rarity meets tangible legacy.</p>\n<button class=\"mt-8 rounded-md bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-500\">Secure Your Drop</button>\n<p class=\"mt-4 text-sm text-gray-400\">Learn more <span>→</span></p>",
|
|
49
|
+
"heroDefault": "A compelling hero section for a website about [topic]. It should have a strong, attention-grabbing headline, a brief paragraph explaining the core value proposition, and a clear call-to-action.",
|
|
50
|
+
"contentDefault": "A content section that follows a hero. It should elaborate on a key feature or benefit related to [topic]. Include a sub-headline and a descriptive paragraph."
|
|
49
51
|
}
|
|
50
52
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { APIRoute } from '@/types/astro';
|
|
2
|
+
|
|
3
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
4
|
+
console.log(1);
|
|
5
|
+
const goBackend =
|
|
6
|
+
import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
|
|
7
|
+
const sharedSecret = import.meta.env.PRIVATE_SANDBOX_SECRET;
|
|
8
|
+
const tenantId =
|
|
9
|
+
request.headers.get('X-Tenant-ID') ||
|
|
10
|
+
import.meta.env.PUBLIC_TENANTID ||
|
|
11
|
+
'default';
|
|
12
|
+
console.log(goBackend);
|
|
13
|
+
console.log(sharedSecret);
|
|
14
|
+
console.log(tenantId);
|
|
15
|
+
|
|
16
|
+
if (!sharedSecret || sharedSecret === 'false' || sharedSecret === 'true') {
|
|
17
|
+
return new Response(
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
success: false,
|
|
20
|
+
error: 'Sandbox feature is not configured on the server.',
|
|
21
|
+
}),
|
|
22
|
+
{ status: 501, headers: { 'Content-Type': 'application/json' } }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const profileCookie = request.headers
|
|
27
|
+
.get('cookie')
|
|
28
|
+
?.includes('tractstack_profile');
|
|
29
|
+
if (!profileCookie) {
|
|
30
|
+
return new Response(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
success: false,
|
|
33
|
+
error: 'Forbidden: Missing sandbox profile.',
|
|
34
|
+
}),
|
|
35
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
console.log(profileCookie);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const body = await request.json();
|
|
42
|
+
const { action, payload } = body;
|
|
43
|
+
console.log(action, payload);
|
|
44
|
+
|
|
45
|
+
if (action !== 'askLemur') {
|
|
46
|
+
return new Response(
|
|
47
|
+
JSON.stringify({ success: false, error: 'Invalid action.' }),
|
|
48
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const backendResponse = await fetch(`${goBackend}/api/v1/aai/askLemur`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
'X-Tenant-ID': tenantId,
|
|
57
|
+
Authorization: `Bearer ${sharedSecret}`,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(payload),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!backendResponse.ok) {
|
|
63
|
+
const errorText = await backendResponse.text();
|
|
64
|
+
return new Response(errorText, {
|
|
65
|
+
status: backendResponse.status,
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const data = await backendResponse.json();
|
|
71
|
+
return new Response(JSON.stringify(data), {
|
|
72
|
+
status: 200,
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const errorMessage =
|
|
77
|
+
error instanceof Error ? error.message : 'An unknown error occurred.';
|
|
78
|
+
return new Response(
|
|
79
|
+
JSON.stringify({ success: false, error: errorMessage }),
|
|
80
|
+
{
|
|
81
|
+
status: 500,
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { ulid } from 'ulid';
|
|
3
|
+
import Layout from '@/layouts/Layout.astro';
|
|
4
|
+
import Header from '@/components/Header.astro';
|
|
5
|
+
import { getFullContentMap } from '@/stores/analytics';
|
|
6
|
+
import { getBrandConfig } from '@/utils/api/brandConfig';
|
|
7
|
+
import { components as codeHookComponents } from '@/custom/CodeHook.astro';
|
|
8
|
+
import StoryKeepHeader from '@/components/edit/Header';
|
|
9
|
+
import StoryKeepToolBar from '@/components/edit/ToolBar';
|
|
10
|
+
import StoryKeepToolMode from '@/components/edit/ToolMode';
|
|
11
|
+
import SettingsPanel from '@/components/edit/SettingsPanel';
|
|
12
|
+
import { Compositor } from '@/components/compositor/Compositor';
|
|
13
|
+
import SandboxAuthWrapper from '@/components/codehooks/SandboxAuthWrapper.tsx';
|
|
14
|
+
import { preHealthCheck } from '@/utils/backend';
|
|
15
|
+
|
|
16
|
+
if (!import.meta.env.PRIVATE_SANDBOX_SECRET) {
|
|
17
|
+
return Astro.redirect('/');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const tenantId =
|
|
21
|
+
Astro.locals.tenant?.id || import.meta.env.PUBLIC_TENANTID || 'default';
|
|
22
|
+
|
|
23
|
+
const healthCheckRedirect = await preHealthCheck(tenantId);
|
|
24
|
+
if (healthCheckRedirect !== undefined) {
|
|
25
|
+
return healthCheckRedirect;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const brandConfig = await getBrandConfig(tenantId);
|
|
29
|
+
|
|
30
|
+
const emptyStoryFragment = {
|
|
31
|
+
id: ulid(),
|
|
32
|
+
nodeType: 'StoryFragment' as const,
|
|
33
|
+
parentId: null,
|
|
34
|
+
title: 'Sandbox Page',
|
|
35
|
+
slug: 'sandbox-page',
|
|
36
|
+
paneIds: [],
|
|
37
|
+
isChanged: false,
|
|
38
|
+
created: new Date(),
|
|
39
|
+
changed: new Date(),
|
|
40
|
+
};
|
|
41
|
+
const loadData = {
|
|
42
|
+
storyfragmentNodes: [emptyStoryFragment],
|
|
43
|
+
};
|
|
44
|
+
const title = 'Sandbox - TractStack Editor';
|
|
45
|
+
const storyFragmentID = emptyStoryFragment.id;
|
|
46
|
+
|
|
47
|
+
const fullContentMap = await getFullContentMap(tenantId);
|
|
48
|
+
const urlParams: Record<string, string | boolean> = {};
|
|
49
|
+
for (const [key, value] of Astro.url.searchParams) {
|
|
50
|
+
urlParams[key] = value === '' ? true : value;
|
|
51
|
+
}
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
<Layout
|
|
55
|
+
title={title}
|
|
56
|
+
slug="sandbox"
|
|
57
|
+
brandConfig={brandConfig}
|
|
58
|
+
storyfragmentId={storyFragmentID}
|
|
59
|
+
isStoryKeep={true}
|
|
60
|
+
isEditor={true}
|
|
61
|
+
>
|
|
62
|
+
<SandboxAuthWrapper client:load />
|
|
63
|
+
<Header
|
|
64
|
+
title={title}
|
|
65
|
+
slug="sandbox"
|
|
66
|
+
brandConfig={brandConfig}
|
|
67
|
+
isContext={false}
|
|
68
|
+
isStoryKeep={true}
|
|
69
|
+
isEditable={false}
|
|
70
|
+
menu={null}
|
|
71
|
+
/>
|
|
72
|
+
|
|
73
|
+
<section
|
|
74
|
+
id="storykeepHeader"
|
|
75
|
+
role="banner"
|
|
76
|
+
class="z-101 bg-mywhite left-0 right-0 drop-shadow transition-all duration-200"
|
|
77
|
+
>
|
|
78
|
+
<StoryKeepHeader
|
|
79
|
+
slug="sandbox"
|
|
80
|
+
isContext={false}
|
|
81
|
+
isSandboxMode={true}
|
|
82
|
+
client:only="react"
|
|
83
|
+
/>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
<div class="flex min-h-screen">
|
|
87
|
+
<StoryKeepToolMode isContext={false} client:only="react" />
|
|
88
|
+
|
|
89
|
+
<main id="mainContent" class="relative flex-1 overflow-x-auto">
|
|
90
|
+
<div class="bg-myblue/20 bg-mylightgrey h-full p-1.5">
|
|
91
|
+
<div
|
|
92
|
+
class="h-fit min-h-screen pb-96"
|
|
93
|
+
style={{
|
|
94
|
+
backgroundImage:
|
|
95
|
+
'repeating-linear-gradient(135deg, transparent, transparent 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 20px)',
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<Compositor
|
|
99
|
+
id={storyFragmentID}
|
|
100
|
+
nodes={loadData}
|
|
101
|
+
config={brandConfig}
|
|
102
|
+
fullContentMap={fullContentMap}
|
|
103
|
+
fullCanonicalURL="/sandbox"
|
|
104
|
+
urlParams={urlParams}
|
|
105
|
+
availableCodeHooks={Object.keys(codeHookComponents)}
|
|
106
|
+
isSandboxMode={true}
|
|
107
|
+
client:only="react"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</main>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<aside
|
|
115
|
+
id="settingsControls"
|
|
116
|
+
class="z-101 pointer-events-none fixed bottom-16 right-2 flex flex-col items-end gap-2 md:bottom-2"
|
|
117
|
+
>
|
|
118
|
+
<div class="pointer-events-none flex-grow"></div>
|
|
119
|
+
|
|
120
|
+
<div class="pointer-events-auto flex-shrink-0">
|
|
121
|
+
<StoryKeepToolBar client:only="react" />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div class="pointer-events-auto max-h-full">
|
|
125
|
+
<SettingsPanel
|
|
126
|
+
config={brandConfig}
|
|
127
|
+
availableCodeHooks={Object.keys(codeHookComponents)}
|
|
128
|
+
client:only="react"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</aside>
|
|
132
|
+
</Layout>
|
|
133
|
+
|
|
134
|
+
<script>
|
|
135
|
+
import { setupLayoutObservers } from '@/utils/layout';
|
|
136
|
+
document.addEventListener('astro:page-load', setupLayoutObservers);
|
|
137
|
+
</script>
|
|
@@ -78,7 +78,6 @@ function buildKeyNormalizationLookup(): Map<string, string> {
|
|
|
78
78
|
|
|
79
79
|
const keyMap = new Map<string, string>();
|
|
80
80
|
for (const key in tailwindClasses) {
|
|
81
|
-
// Store lowercase key -> correctly cased key
|
|
82
81
|
keyMap.set(key.toLowerCase(), key);
|
|
83
82
|
}
|
|
84
83
|
KEY_NORMALIZATION_LOOKUP = keyMap;
|
|
@@ -97,7 +96,6 @@ function normalizeKeys(
|
|
|
97
96
|
if (Object.prototype.hasOwnProperty.call(styleObj, key)) {
|
|
98
97
|
const lowerKey = key.toLowerCase();
|
|
99
98
|
const correctKey = keyMap.get(lowerKey);
|
|
100
|
-
// Use the correctly cased key if found, otherwise keep original (handles potential non-Tailwind keys)
|
|
101
99
|
normalized[correctKey || key] = styleObj[key];
|
|
102
100
|
}
|
|
103
101
|
}
|
|
@@ -258,22 +256,16 @@ function walkDom(
|
|
|
258
256
|
) {
|
|
259
257
|
if (domNode.nodeType === Node.TEXT_NODE) {
|
|
260
258
|
const copy = domNode.textContent || '';
|
|
261
|
-
// Preserve leading/trailing spaces unless the *entire* content is just whitespace.
|
|
262
|
-
// Trim internal excessive whitespace as a basic sanitation step.
|
|
263
259
|
const trimmedCopy = copy.replace(/\s+/g, ' ').trim();
|
|
264
260
|
|
|
265
261
|
if (trimmedCopy.length > 0) {
|
|
266
|
-
// Use the original copy to preserve meaningful spaces, but cleaned up.
|
|
267
262
|
let finalCopy = copy.replace(/\s+/g, ' ');
|
|
268
|
-
// Preserve single leading space if original had one AND previous sibling exists
|
|
269
263
|
if (copy.startsWith(' ') && domNode.previousSibling) {
|
|
270
264
|
finalCopy = ' ' + finalCopy.trimStart();
|
|
271
265
|
}
|
|
272
|
-
// Preserve single trailing space if original had one AND next sibling exists
|
|
273
266
|
if (copy.endsWith(' ') && domNode.nextSibling) {
|
|
274
267
|
finalCopy = finalCopy.trimEnd() + ' ';
|
|
275
268
|
}
|
|
276
|
-
// Special case: if it was ONLY space, respect if it was intended between elements
|
|
277
269
|
if (
|
|
278
270
|
trimmedCopy.length === 0 &&
|
|
279
271
|
copy.length > 0 &&
|
|
@@ -283,15 +275,13 @@ function walkDom(
|
|
|
283
275
|
finalCopy = ' ';
|
|
284
276
|
}
|
|
285
277
|
|
|
286
|
-
// Only create node if there's actual content or a meaningful space
|
|
287
278
|
if (finalCopy.trim().length > 0 || finalCopy === ' ') {
|
|
288
279
|
const textNode: TemplateNode = {
|
|
289
280
|
id: ulid(),
|
|
290
281
|
nodeType: 'TagElement',
|
|
291
282
|
parentId: parentId,
|
|
292
283
|
tagName: 'text',
|
|
293
|
-
copy: finalCopy,
|
|
294
|
-
overrideClasses: {},
|
|
284
|
+
copy: finalCopy,
|
|
295
285
|
};
|
|
296
286
|
parsedNodes.push({
|
|
297
287
|
flatNode: textNode,
|
|
@@ -326,7 +316,6 @@ function walkDom(
|
|
|
326
316
|
nodeType: 'TagElement',
|
|
327
317
|
parentId: parentId,
|
|
328
318
|
tagName: 'p',
|
|
329
|
-
overrideClasses: {},
|
|
330
319
|
};
|
|
331
320
|
parsedNodes.push({
|
|
332
321
|
flatNode: pNode,
|
|
@@ -341,8 +330,7 @@ function walkDom(
|
|
|
341
330
|
id: ulid(),
|
|
342
331
|
nodeType: 'TagElement',
|
|
343
332
|
parentId: finalParentId,
|
|
344
|
-
tagName: 'a',
|
|
345
|
-
overrideClasses: {},
|
|
333
|
+
tagName: 'a', // Buttons are converted to anchor tags for our system
|
|
346
334
|
href: '#',
|
|
347
335
|
buttonPayload: {
|
|
348
336
|
...buttonPayload,
|
|
@@ -368,7 +356,6 @@ function walkDom(
|
|
|
368
356
|
nodeType: 'TagElement',
|
|
369
357
|
parentId: parentId,
|
|
370
358
|
tagName: tagName,
|
|
371
|
-
overrideClasses: {},
|
|
372
359
|
};
|
|
373
360
|
|
|
374
361
|
if (tagName === 'span') {
|
|
@@ -493,14 +480,41 @@ function parseDefaultClassesFromShell(
|
|
|
493
480
|
return sanitizedDefaults;
|
|
494
481
|
}
|
|
495
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Parses a raw HTML string from the AI into a structured array of TemplateNodes.
|
|
485
|
+
* @param copyHtml The raw HTML string.
|
|
486
|
+
* @param markdownId The parent ID for the top-level nodes.
|
|
487
|
+
* @returns An array of TemplateNodes representing the structured content.
|
|
488
|
+
*/
|
|
489
|
+
export function parseAiCopyHtml(
|
|
490
|
+
copyHtml: string,
|
|
491
|
+
markdownId: string
|
|
492
|
+
): TemplateNode[] {
|
|
493
|
+
const parser = new DOMParser();
|
|
494
|
+
const doc = parser.parseFromString(copyHtml, 'text/html');
|
|
495
|
+
|
|
496
|
+
const allParsedNodes: ParsedNode[] = [];
|
|
497
|
+
walkDom(doc.body, markdownId, allParsedNodes, markdownId);
|
|
498
|
+
|
|
499
|
+
// When parsing copy in isolation, all classes are treated as potential overrides.
|
|
500
|
+
// The consumer is responsible for merging these with a set of defaults if needed.
|
|
501
|
+
return allParsedNodes.map((pNode) => {
|
|
502
|
+
if (
|
|
503
|
+
Object.keys(pNode.responsiveClasses).length > 0 &&
|
|
504
|
+
pNode.flatNode.tagName !== 'span'
|
|
505
|
+
) {
|
|
506
|
+
pNode.flatNode.overrideClasses = pNode.responsiveClasses;
|
|
507
|
+
}
|
|
508
|
+
return pNode.flatNode;
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
496
512
|
export const parseAiPane = (
|
|
497
513
|
shellJson: string,
|
|
498
514
|
copyHtml: string,
|
|
499
515
|
layout: string
|
|
500
516
|
): TemplatePane => {
|
|
501
517
|
const shell: ShellJson = JSON.parse(shellJson);
|
|
502
|
-
const parser = new DOMParser();
|
|
503
|
-
const doc = parser.parseFromString(copyHtml, 'text/html');
|
|
504
518
|
|
|
505
519
|
const paneId = ulid();
|
|
506
520
|
const markdownId = ulid();
|
|
@@ -527,73 +541,7 @@ export const parseAiPane = (
|
|
|
527
541
|
defaultClasses: shellDefaults,
|
|
528
542
|
};
|
|
529
543
|
|
|
530
|
-
const
|
|
531
|
-
walkDom(doc.body, markdownId, allParsedNodes, markdownId);
|
|
532
|
-
|
|
533
|
-
const templateNodes: TemplateNode[] = [];
|
|
534
|
-
const nodesByTag = new Map<string, ParsedNode[]>();
|
|
535
|
-
|
|
536
|
-
allParsedNodes.forEach((parsedNode) => {
|
|
537
|
-
templateNodes.push(parsedNode.flatNode);
|
|
538
|
-
const tagName = parsedNode.flatNode.tagName;
|
|
539
|
-
|
|
540
|
-
if (
|
|
541
|
-
tagName &&
|
|
542
|
-
tagName !== 'span' &&
|
|
543
|
-
tagName !== 'text' &&
|
|
544
|
-
tagName !== 'em' &&
|
|
545
|
-
tagName !== 'strong' &&
|
|
546
|
-
tagName !== 'a'
|
|
547
|
-
) {
|
|
548
|
-
if (!nodesByTag.has(tagName)) {
|
|
549
|
-
nodesByTag.set(tagName, []);
|
|
550
|
-
}
|
|
551
|
-
nodesByTag.get(tagName)!.push(parsedNode);
|
|
552
|
-
}
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
nodesByTag.forEach((nodes, tagName) => {
|
|
556
|
-
const commonResponsiveFromCopy = findMostCommonClasses(nodes);
|
|
557
|
-
const requiredCommonFromCopy = ensureRequiredViewports(
|
|
558
|
-
commonResponsiveFromCopy
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
const existingShellDefault = markdownNode.defaultClasses![tagName];
|
|
562
|
-
const mergedDefault = ensureRequiredViewports(
|
|
563
|
-
mergeResponsive(existingShellDefault, commonResponsiveFromCopy)
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
markdownNode.defaultClasses![tagName] = mergedDefault;
|
|
567
|
-
|
|
568
|
-
nodes.forEach((parsedNode) => {
|
|
569
|
-
const requiredNodeResponsive = ensureRequiredViewports(
|
|
570
|
-
parsedNode.responsiveClasses
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
if (!isDeepEqual(requiredNodeResponsive, requiredCommonFromCopy)) {
|
|
574
|
-
if (!parsedNode.flatNode.overrideClasses) {
|
|
575
|
-
parsedNode.flatNode.overrideClasses = {};
|
|
576
|
-
}
|
|
577
|
-
parsedNode.flatNode.overrideClasses = parsedNode.responsiveClasses;
|
|
578
|
-
}
|
|
579
|
-
});
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
if (layout.includes('Image')) {
|
|
583
|
-
const imgNode: TemplateNode = {
|
|
584
|
-
id: ulid(),
|
|
585
|
-
nodeType: 'TagElement',
|
|
586
|
-
parentId: markdownId,
|
|
587
|
-
tagName: 'img',
|
|
588
|
-
src: '/static.jpg',
|
|
589
|
-
overrideClasses: {},
|
|
590
|
-
};
|
|
591
|
-
if (layout === 'Text + Image Left') {
|
|
592
|
-
templateNodes.unshift(imgNode);
|
|
593
|
-
} else {
|
|
594
|
-
templateNodes.push(imgNode);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
544
|
+
const templateNodes = parseAiCopyHtml(copyHtml, markdownId);
|
|
597
545
|
|
|
598
546
|
const templatePane: TemplatePane = {
|
|
599
547
|
id: paneId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ulid } from 'ulid';
|
|
2
2
|
import { getCtx, type NodesContext } from '@/stores/nodes';
|
|
3
|
+
import { tailwindClasses } from '@/utils/compositor/tailwindClasses';
|
|
3
4
|
import {
|
|
4
5
|
type PaneNode,
|
|
5
6
|
type FlatNode,
|
|
@@ -13,8 +14,6 @@ import {
|
|
|
13
14
|
type VisualBreakNode,
|
|
14
15
|
type TemplatePane,
|
|
15
16
|
type TemplateNode,
|
|
16
|
-
type BaseNode,
|
|
17
|
-
type TemplateMarkdown,
|
|
18
17
|
} from '@/types/compositorTypes';
|
|
19
18
|
import type {
|
|
20
19
|
BrandConfig,
|
|
@@ -329,3 +328,89 @@ export function convertStorageToLiveTemplate(
|
|
|
329
328
|
|
|
330
329
|
return liveTemplatePane;
|
|
331
330
|
}
|
|
331
|
+
|
|
332
|
+
// Helper to convert a style object { "px": "4", "fontBOLD": "bold" } to "px-4 font-bold"
|
|
333
|
+
function classObjectToString(
|
|
334
|
+
classObj: Record<string, string> | undefined
|
|
335
|
+
): string {
|
|
336
|
+
if (!classObj) return '';
|
|
337
|
+
|
|
338
|
+
return Object.entries(classObj)
|
|
339
|
+
.map(([key, value]) => {
|
|
340
|
+
const definition = tailwindClasses[key];
|
|
341
|
+
if (!definition) return ''; // Ignore keys not in our definitions
|
|
342
|
+
|
|
343
|
+
if (definition.useKeyAsClass) {
|
|
344
|
+
return value; // e.g., for 'fontBOLD', value is 'font-bold'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Handle negative values
|
|
348
|
+
if (typeof value === 'string' && value.startsWith('-')) {
|
|
349
|
+
return `-${definition.prefix}${value.substring(1)}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return `${definition.prefix}${value}`;
|
|
353
|
+
})
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join(' ');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Translates a TemplatePane from the design library into an AI-compatible JSON shell
|
|
360
|
+
* for the hybrid AI copy generation path.
|
|
361
|
+
* @param template The TemplatePane object selected by the user.
|
|
362
|
+
* @returns A JSON string compatible with the AI's second-stage prompt.
|
|
363
|
+
*/
|
|
364
|
+
export function convertTemplateToAIShell(template: TemplatePane): string {
|
|
365
|
+
const shell: any = {
|
|
366
|
+
bgColour: template.bgColour || '#ffffff',
|
|
367
|
+
parentClasses: [],
|
|
368
|
+
defaultClasses: {},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
// 1. Process parentClasses (layout)
|
|
372
|
+
if (template.markdown?.parentClasses) {
|
|
373
|
+
shell.parentClasses = template.markdown.parentClasses.map((layer) => {
|
|
374
|
+
const newLayer: { mobile?: string; tablet?: string; desktop?: string } =
|
|
375
|
+
{};
|
|
376
|
+
if (layer.mobile && Object.keys(layer.mobile).length > 0) {
|
|
377
|
+
newLayer.mobile = classObjectToString(layer.mobile);
|
|
378
|
+
}
|
|
379
|
+
if (layer.tablet && Object.keys(layer.tablet).length > 0) {
|
|
380
|
+
newLayer.tablet = classObjectToString(layer.tablet);
|
|
381
|
+
}
|
|
382
|
+
if (layer.desktop && Object.keys(layer.desktop).length > 0) {
|
|
383
|
+
newLayer.desktop = classObjectToString(layer.desktop);
|
|
384
|
+
}
|
|
385
|
+
return newLayer;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 2. Process defaultClasses (typography, etc.)
|
|
390
|
+
if (template.markdown?.defaultClasses) {
|
|
391
|
+
for (const tag in template.markdown.defaultClasses) {
|
|
392
|
+
const styles = template.markdown.defaultClasses[tag];
|
|
393
|
+
const newTagStyles: {
|
|
394
|
+
mobile?: string;
|
|
395
|
+
tablet?: string;
|
|
396
|
+
desktop?: string;
|
|
397
|
+
} = {};
|
|
398
|
+
|
|
399
|
+
if (styles.mobile && Object.keys(styles.mobile).length > 0) {
|
|
400
|
+
newTagStyles.mobile = classObjectToString(styles.mobile);
|
|
401
|
+
}
|
|
402
|
+
if (styles.tablet && Object.keys(styles.tablet).length > 0) {
|
|
403
|
+
newTagStyles.tablet = classObjectToString(styles.tablet);
|
|
404
|
+
}
|
|
405
|
+
if (styles.desktop && Object.keys(styles.desktop).length > 0) {
|
|
406
|
+
newTagStyles.desktop = classObjectToString(styles.desktop);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (Object.keys(newTagStyles).length > 0) {
|
|
410
|
+
shell.defaultClasses[tag] = newTagStyles;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return JSON.stringify(shell, null, 2);
|
|
416
|
+
}
|
|
@@ -148,6 +148,13 @@ export class ProfileStorage {
|
|
|
148
148
|
StorageManager.set(this.STORAGE_KEYS.profileToken, token);
|
|
149
149
|
StorageManager.set(this.STORAGE_KEYS.hasProfile, '1');
|
|
150
150
|
StorageManager.set(this.STORAGE_KEYS.unlockedProfile, '1');
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const maxAge = 60 * 60 * 24;
|
|
154
|
+
document.cookie = `tractstack_profile=true; path=/; SameSite=Lax; max-age=${maxAge}`;
|
|
155
|
+
} catch {
|
|
156
|
+
// Silently fail if cookies are blocked
|
|
157
|
+
}
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
/**
|
|
@@ -157,6 +164,12 @@ export class ProfileStorage {
|
|
|
157
164
|
StorageManager.remove(this.STORAGE_KEYS.profileToken);
|
|
158
165
|
StorageManager.remove(this.STORAGE_KEYS.hasProfile);
|
|
159
166
|
StorageManager.remove(this.STORAGE_KEYS.unlockedProfile);
|
|
167
|
+
try {
|
|
168
|
+
document.cookie =
|
|
169
|
+
'tractstack_profile=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax';
|
|
170
|
+
} catch {
|
|
171
|
+
// Silently fail
|
|
172
|
+
}
|
|
160
173
|
}
|
|
161
174
|
|
|
162
175
|
/**
|