astro-tractstack 2.0.0-rc.33 → 2.0.0-rc.35

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 CHANGED
@@ -478,10 +478,6 @@ async function w(t, e, c) {
478
478
  src: t("../templates/src/components/edit/pane/PanePanel_path.tsx"),
479
479
  dest: "src/components/edit/pane/PanePanel_path.tsx"
480
480
  },
481
- {
482
- src: t("../templates/src/components/edit/pane/PanePanel_slug.tsx"),
483
- dest: "src/components/edit/pane/PanePanel_slug.tsx"
484
- },
485
481
  {
486
482
  src: t("../templates/src/components/edit/pane/PanePanel_title.tsx"),
487
483
  dest: "src/components/edit/pane/PanePanel_title.tsx"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.33",
3
+ "version": "2.0.0-rc.35",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,10 +9,13 @@ import {
9
9
  isContextPaneNode,
10
10
  hasBeliefPayload,
11
11
  } from '@/utils/compositor/typeGuards';
12
- import { settingsPanelStore, viewportKeyStore } from '@/stores/storykeep';
12
+ import {
13
+ settingsPanelStore,
14
+ viewportKeyStore,
15
+ fullContentMapStore,
16
+ } from '@/stores/storykeep';
13
17
  import { getCtx } from '@/stores/nodes';
14
18
  import PaneTitlePanel from './PanePanel_title';
15
- import PaneSlugPanel from './PanePanel_slug';
16
19
  import PaneMagicPathPanel from './PanePanel_path';
17
20
  import PaneImpressionPanel from './PanePanel_impression';
18
21
  import { PaneConfigMode, type PaneNode } from '@/types/compositorTypes';
@@ -30,7 +33,7 @@ const ConfigPanePanel = ({ nodeId }: ConfigPanePanelProps) => {
30
33
  const reorderMode = toolMode.value === `move`;
31
34
  const isActiveMode =
32
35
  activePaneMode.panel === 'settings' && activePaneMode.paneId === nodeId;
33
-
36
+ const $contentMap = useStore(fullContentMapStore);
34
37
  const $viewportKey = useStore(viewportKeyStore);
35
38
  const isMobile = $viewportKey.value === `mobile`;
36
39
 
@@ -104,8 +107,6 @@ const ConfigPanePanel = ({ nodeId }: ConfigPanePanelProps) => {
104
107
 
105
108
  if (mode === PaneConfigMode.TITLE) {
106
109
  return <PaneTitlePanel nodeId={nodeId} setMode={setSaveMode} />;
107
- } else if (mode === PaneConfigMode.SLUG) {
108
- return <PaneSlugPanel nodeId={nodeId} setMode={setSaveMode} />;
109
110
  } else if (mode === PaneConfigMode.PATH) {
110
111
  return <PaneMagicPathPanel nodeId={nodeId} setMode={setSaveMode} />;
111
112
  } else if (mode === PaneConfigMode.IMPRESSION) {
@@ -137,28 +138,16 @@ const ConfigPanePanel = ({ nodeId }: ConfigPanePanelProps) => {
137
138
  <>
138
139
  {!isTemplate && (
139
140
  <>
140
- <button
141
- onClick={() => setSaveMode(PaneConfigMode.TITLE)}
142
- className={buttonClass}
143
- >
144
- Pane Title
145
- {!isMobile && (
146
- <>
147
- : <strong>{paneNode.title}</strong>
148
- </>
149
- )}
150
- </button>
151
- <button
152
- onClick={() => setSaveMode(PaneConfigMode.SLUG)}
153
- className={buttonClass}
154
- >
155
- Slug
156
- {!isMobile && (
157
- <>
158
- : <strong>{paneNode.slug}</strong>
159
- </>
160
- )}
161
- </button>
141
+ {$contentMap.some(
142
+ (item) => item.type === 'Pane' && item.id === nodeId
143
+ ) && (
144
+ <button
145
+ onClick={() => setSaveMode(PaneConfigMode.TITLE)}
146
+ className={buttonClass}
147
+ >
148
+ ID
149
+ </button>
150
+ )}
162
151
  <button
163
152
  onClick={() => setSaveMode(PaneConfigMode.IMPRESSION)}
164
153
  className={buttonClass}
@@ -5,10 +5,12 @@ import {
5
5
  type SetStateAction,
6
6
  type ChangeEvent,
7
7
  } from 'react';
8
+ import { useStore } from '@nanostores/react';
8
9
  import ExclamationTriangleIcon from '@heroicons/react/24/outline/ExclamationTriangleIcon';
9
10
  import CheckIcon from '@heroicons/react/24/outline/CheckIcon';
11
+ import { fullContentMapStore } from '@/stores/storykeep';
10
12
  import { getCtx } from '@/stores/nodes';
11
- import { cloneDeep } from '@/utils/helpers';
13
+ import { cloneDeep, findUniqueSlug, titleToSlug } from '@/utils/helpers';
12
14
  import { PaneConfigMode, type PaneNode } from '@/types/compositorTypes';
13
15
 
14
16
  interface PaneTitlePanelProps {
@@ -18,36 +20,141 @@ interface PaneTitlePanelProps {
18
20
 
19
21
  const PaneTitlePanel = ({ nodeId, setMode }: PaneTitlePanelProps) => {
20
22
  const [title, setTitle] = useState('');
21
- const [isValid, setIsValid] = useState(false);
22
- const [warning, setWarning] = useState(false);
23
- const [charCount, setCharCount] = useState(0);
23
+ const [slug, setSlug] = useState('');
24
+ const [isValidTitle, setIsValidTitle] = useState(false);
25
+ const [isValidSlug, setIsValidSlug] = useState(false);
26
+ const [warningTitle, setWarningTitle] = useState(false);
27
+ const [warningSlug, setWarningSlug] = useState(false);
28
+ const [titleCharCount, setTitleCharCount] = useState(0);
29
+ const [slugCharCount, setSlugCharCount] = useState(0);
30
+ const [slugValidationError, setSlugValidationError] = useState<string | null>(
31
+ null
32
+ );
33
+ const [canSaveSlug, setCanSaveSlug] = useState(false);
24
34
 
35
+ const $contentMap = useStore(fullContentMapStore);
25
36
  const ctx = getCtx();
26
37
  const allNodes = ctx.allNodes.get();
27
38
  const paneNode = allNodes.get(nodeId) as PaneNode;
28
39
  if (!paneNode) return null;
29
40
 
41
+ const existingSlugs = $contentMap
42
+ .filter(
43
+ (item) =>
44
+ ['Pane', 'StoryFragment'].includes(item.type) && item.id !== nodeId
45
+ )
46
+ .map((item) => item.slug);
47
+
30
48
  useEffect(() => {
31
49
  setTitle(paneNode.title);
32
- setCharCount(paneNode.title.length);
33
- }, [paneNode.title]);
50
+ setSlug(paneNode.slug);
51
+ setTitleCharCount(paneNode.title.length);
52
+ setSlugCharCount(paneNode.slug.length);
53
+ checkSlugLiveValidity(paneNode.slug);
54
+ }, [paneNode.title, paneNode.slug]);
34
55
 
35
56
  const handleTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
36
57
  const newTitle = e.target.value;
37
58
  if (newTitle.length <= 50) {
38
- // Prevent more than 70 chars
39
59
  setTitle(newTitle);
40
- setCharCount(newTitle.length);
41
- setIsValid(newTitle.length >= 5 && newTitle.length <= 35);
42
- setWarning(newTitle.length > 35 && newTitle.length <= 50);
60
+ setTitleCharCount(newTitle.length);
61
+ setIsValidTitle(newTitle.length >= 5 && newTitle.length <= 35);
62
+ setWarningTitle(newTitle.length > 35 && newTitle.length <= 50);
63
+ }
64
+ };
65
+
66
+ const handleSlugChange = (e: ChangeEvent<HTMLInputElement>) => {
67
+ const newSlug = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
68
+
69
+ if (newSlug.length <= 50) {
70
+ setSlug(newSlug);
71
+ checkSlugLiveValidity(newSlug);
72
+ }
73
+ };
74
+
75
+ const checkSlugLiveValidity = (value: string) => {
76
+ const length = value.length;
77
+ setSlugCharCount(length);
78
+
79
+ // Basic format check for allowed characters
80
+ if (!/^[a-z0-9-]*$/.test(value)) {
81
+ setSlugValidationError(
82
+ 'Only lowercase letters, numbers, and hyphens allowed'
83
+ );
84
+ setIsValidSlug(false);
85
+ setCanSaveSlug(false);
86
+ return false;
43
87
  }
88
+
89
+ // Length checks
90
+ setIsValidSlug(length >= 3 && length <= 40);
91
+ setWarningSlug(length > 40 && length <= 50);
92
+ setSlugValidationError(null);
93
+
94
+ // Check if we can save
95
+ if (length >= 3) {
96
+ const saveValidation = checkSlugSaveValidity(value);
97
+ setCanSaveSlug(saveValidation.isValid);
98
+ if (!saveValidation.isValid) {
99
+ setSlugValidationError(saveValidation.error || null);
100
+ }
101
+ } else {
102
+ setCanSaveSlug(false);
103
+ }
104
+
105
+ return true;
106
+ };
107
+
108
+ const checkSlugSaveValidity = (
109
+ value: string
110
+ ): { isValid: boolean; error?: string } => {
111
+ // Strict pattern that prevents leading/trailing hyphens and multiple consecutive hyphens
112
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
113
+ return {
114
+ isValid: false,
115
+ error:
116
+ 'Slug must start and end with letters or numbers, and no consecutive hyphens',
117
+ };
118
+ }
119
+
120
+ // Check duplicates
121
+ if (existingSlugs.includes(value)) {
122
+ return {
123
+ isValid: false,
124
+ error: 'This slug is already in use',
125
+ };
126
+ }
127
+
128
+ return { isValid: true };
44
129
  };
45
130
 
46
131
  const handleTitleBlur = () => {
47
132
  if (title.length >= 5) {
48
- // Only update if meets minimum length
133
+ // Auto-generate slug if slug is empty or still system-generated
134
+ let updatedSlug = slug;
135
+ if (!slug || slug === paneNode.slug) {
136
+ const generatedSlug = titleToSlug(title);
137
+ const uniqueSlug = findUniqueSlug(generatedSlug, existingSlugs);
138
+ updatedSlug = uniqueSlug;
139
+ setSlug(uniqueSlug);
140
+ checkSlugLiveValidity(uniqueSlug);
141
+ }
142
+
49
143
  const ctx = getCtx();
50
- const updatedNode = { ...cloneDeep(paneNode), title, isChanged: true };
144
+ const updatedNode = {
145
+ ...cloneDeep(paneNode),
146
+ title,
147
+ slug: updatedSlug,
148
+ isChanged: true,
149
+ };
150
+ ctx.modifyNodes([updatedNode]);
151
+ }
152
+ };
153
+
154
+ const handleSlugBlur = () => {
155
+ if (canSaveSlug) {
156
+ const ctx = getCtx();
157
+ const updatedNode = { ...cloneDeep(paneNode), slug, isChanged: true };
51
158
  ctx.modifyNodes([updatedNode]);
52
159
  }
53
160
  };
@@ -56,7 +163,7 @@ const PaneTitlePanel = ({ nodeId, setMode }: PaneTitlePanelProps) => {
56
163
  <div className="group mb-4 w-full rounded-b-md bg-white px-1.5 py-6 shadow-inner">
57
164
  <div className="px-3.5">
58
165
  <div className="mb-4 flex justify-between">
59
- <h3 className="text-lg font-bold">Pane Title</h3>
166
+ <h3 className="text-lg font-bold">Pane Title & Slug</h3>
60
167
  <button
61
168
  onClick={() => setMode(PaneConfigMode.DEFAULT)}
62
169
  className="text-myblue hover:text-black"
@@ -65,73 +172,133 @@ const PaneTitlePanel = ({ nodeId, setMode }: PaneTitlePanelProps) => {
65
172
  </button>
66
173
  </div>
67
174
 
68
- <div className="relative">
69
- <input
70
- type="text"
71
- value={title}
72
- onChange={handleTitleChange}
73
- onBlur={handleTitleBlur}
74
- onKeyDown={(e) => {
75
- if (e.key === 'Enter') {
76
- e.currentTarget.blur();
77
- }
78
- }}
79
- className={`w-full rounded-md border px-2 py-1 pr-16 ${
80
- charCount < 5
81
- ? 'border-red-500 bg-red-50'
82
- : isValid
83
- ? 'border-green-500 bg-green-50'
84
- : warning
85
- ? 'border-yellow-500 bg-yellow-50'
86
- : 'border-gray-300'
87
- }`}
88
- placeholder="Enter story fragment title (50-60 characters recommended)"
89
- />
90
- <div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-2">
91
- {charCount < 5 ? (
92
- <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
93
- ) : isValid ? (
94
- <CheckIcon className="h-5 w-5 text-green-500" />
95
- ) : warning ? (
96
- <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
97
- ) : null}
98
- <span
99
- className={`text-sm ${
100
- charCount < 5
101
- ? 'text-red-500'
102
- : isValid
103
- ? 'text-green-500'
104
- : warning
105
- ? 'text-yellow-500'
106
- : 'text-gray-500'
175
+ {/* Title Input */}
176
+ <div className="mb-6">
177
+ <label className="mb-2 block text-sm font-bold text-gray-700">
178
+ Title
179
+ </label>
180
+ <div className="relative">
181
+ <input
182
+ type="text"
183
+ value={title}
184
+ onChange={handleTitleChange}
185
+ onBlur={handleTitleBlur}
186
+ onKeyDown={(e) => {
187
+ if (e.key === 'Enter') {
188
+ e.currentTarget.blur();
189
+ }
190
+ }}
191
+ className={`w-full rounded-md border px-2 py-1 pr-16 ${
192
+ titleCharCount < 5
193
+ ? 'border-red-500 bg-red-50'
194
+ : isValidTitle
195
+ ? 'border-green-500 bg-green-50'
196
+ : warningTitle
197
+ ? 'border-yellow-500 bg-yellow-50'
198
+ : 'border-gray-300'
107
199
  }`}
108
- >
109
- {charCount}/50
110
- </span>
200
+ placeholder="Enter pane title (5-35 characters recommended)"
201
+ />
202
+ <div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-2">
203
+ {titleCharCount < 5 ? (
204
+ <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
205
+ ) : isValidTitle ? (
206
+ <CheckIcon className="h-5 w-5 text-green-500" />
207
+ ) : warningTitle ? (
208
+ <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
209
+ ) : null}
210
+ <span
211
+ className={`text-sm ${
212
+ titleCharCount < 5
213
+ ? 'text-red-500'
214
+ : isValidTitle
215
+ ? 'text-green-500'
216
+ : warningTitle
217
+ ? 'text-yellow-500'
218
+ : 'text-gray-500'
219
+ }`}
220
+ >
221
+ {titleCharCount}/50
222
+ </span>
223
+ </div>
111
224
  </div>
112
225
  </div>
113
- <div className="mt-4 space-y-4 text-lg">
114
- <div className="text-gray-600">
115
- Write a clear, descriptive title for this piece of content. This is
116
- used for your own internal analytics.
117
- </div>
118
- <div className="py-4">
119
- {charCount < 5 && (
120
- <span className="text-red-500">
121
- Title must be at least 5 characters
122
- </span>
123
- )}
124
- {charCount >= 5 && charCount < 10 && (
125
- <span className="text-gray-500">
126
- Add {10 - charCount} more characters for optimal length
226
+
227
+ {/* Slug Input */}
228
+ <div className="mb-4">
229
+ <label className="mb-2 block text-sm font-bold text-gray-700">
230
+ Slug (URL)
231
+ </label>
232
+ <div className="relative">
233
+ <input
234
+ type="text"
235
+ value={slug}
236
+ onChange={handleSlugChange}
237
+ onBlur={handleSlugBlur}
238
+ onKeyDown={(e) => {
239
+ if (e.key === 'Enter') {
240
+ e.currentTarget.blur();
241
+ }
242
+ }}
243
+ className={`w-full rounded-md border px-2 py-1 pr-16 ${
244
+ slugValidationError || slugCharCount < 3
245
+ ? 'border-red-500 bg-red-50'
246
+ : isValidSlug && canSaveSlug
247
+ ? 'border-green-500 bg-green-50'
248
+ : warningSlug
249
+ ? 'border-yellow-500 bg-yellow-50'
250
+ : 'border-gray-300'
251
+ }`}
252
+ placeholder="Enter pane slug (3-40 characters recommended)"
253
+ />
254
+ <div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-2">
255
+ {slugValidationError || slugCharCount < 3 ? (
256
+ <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
257
+ ) : isValidSlug && canSaveSlug ? (
258
+ <CheckIcon className="h-5 w-5 text-green-500" />
259
+ ) : warningSlug ? (
260
+ <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
261
+ ) : null}
262
+ <span
263
+ className={`text-sm ${
264
+ slugValidationError || slugCharCount < 3
265
+ ? 'text-red-500'
266
+ : isValidSlug && canSaveSlug
267
+ ? 'text-green-500'
268
+ : warningSlug
269
+ ? 'text-yellow-500'
270
+ : 'text-gray-500'
271
+ }`}
272
+ >
273
+ {slugCharCount}/50
127
274
  </span>
128
- )}
129
- {warning && (
130
- <span className="text-yellow-500">Title is getting long</span>
131
- )}
132
- {isValid && (
133
- <span className="text-green-500">Perfect title length!</span>
134
- )}
275
+ </div>
276
+ </div>
277
+ {slugValidationError && (
278
+ <div className="mt-2 text-sm text-red-600">
279
+ <ExclamationTriangleIcon className="mr-1 inline h-4 w-4" />
280
+ {slugValidationError}
281
+ </div>
282
+ )}
283
+ </div>
284
+
285
+ {/* Help Text */}
286
+ <div className="space-y-4 text-sm text-gray-600">
287
+ <div>
288
+ <h4 className="font-semibold">Title Guidelines:</h4>
289
+ <ul className="ml-4 mt-1 list-disc">
290
+ <li>5-35 characters recommended for optimal display</li>
291
+ <li>Clear, descriptive title for the pane content</li>
292
+ </ul>
293
+ </div>
294
+ <div>
295
+ <h4 className="font-semibold">Slug Guidelines:</h4>
296
+ <ul className="ml-4 mt-1 list-disc">
297
+ <li>Used for analytics tracking</li>
298
+ <li>Only lowercase letters, numbers, and hyphens</li>
299
+ <li>Must start and end with letter or number</li>
300
+ <li>No consecutive hyphens</li>
301
+ </ul>
135
302
  </div>
136
303
  </div>
137
304
  </div>
@@ -73,7 +73,6 @@ export default function SaveModal({
73
73
  const isSaving = useRef(false);
74
74
  const [isNavigating, setIsNavigating] = useState(false);
75
75
  const isCreateMode = slug === 'create';
76
- const contentMap = fullContentMapStore.get();
77
76
  const pendingHomePageSlug = pendingHomePageSlugStore.get();
78
77
  const goBackend =
79
78
  import.meta.env.PUBLIC_GO_BACKEND || 'http://localhost:8080';
@@ -334,113 +333,108 @@ export default function SaveModal({
334
333
  currentStep: 0,
335
334
  totalSteps: dirtyPanes.length,
336
335
  });
337
- for (let i = 0; i < dirtyPanes.length; i++) {
338
- const paneNode = dirtyPanes[i];
339
336
 
340
- try {
341
- const payload = transformLivePaneForSave(
342
- ctx,
343
- paneNode.id,
344
- isContext
345
- );
346
-
347
- payload.optionsPayload.nodes.forEach((transformedNode) => {
348
- const liveNode = ctx.allNodes.get().get(transformedNode.id);
349
- if (!liveNode) return;
350
-
351
- let needsUpdate = false;
352
- let updatedNode: BaseNode = { ...liveNode };
353
-
354
- if (
355
- transformedNode.nodeType === 'TagElement' &&
356
- transformedNode.elementCss
357
- ) {
358
- const flatNode = liveNode as FlatNode;
359
- if (flatNode.elementCss !== transformedNode.elementCss) {
360
- (updatedNode as FlatNode).elementCss =
361
- transformedNode.elementCss;
362
- needsUpdate = true;
363
- }
364
- }
337
+ const bulkPayload = dirtyPanes.map((paneNode) =>
338
+ transformLivePaneForSave(ctx, paneNode.id, isContext)
339
+ );
365
340
 
366
- if (
367
- transformedNode.nodeType === 'Markdown' &&
368
- transformedNode.parentCss
369
- ) {
370
- const markdownNode = liveNode as MarkdownPaneFragmentNode;
371
- const currentParentCss = markdownNode.parentCss;
372
- const newParentCss = transformedNode.parentCss as string[];
373
-
374
- const isDifferent =
375
- !currentParentCss ||
376
- currentParentCss.length !== newParentCss.length ||
377
- currentParentCss.some(
378
- (css, index) => css !== newParentCss[index]
379
- );
380
-
381
- if (isDifferent) {
382
- (updatedNode as MarkdownPaneFragmentNode).parentCss =
383
- newParentCss;
384
- needsUpdate = true;
385
- }
341
+ bulkPayload.forEach((payload) => {
342
+ payload.optionsPayload.nodes.forEach((transformedNode) => {
343
+ const liveNode = ctx.allNodes.get().get(transformedNode.id);
344
+ if (!liveNode) return;
345
+
346
+ let needsUpdate = false;
347
+ let updatedNode: BaseNode = { ...liveNode };
348
+
349
+ if (
350
+ transformedNode.nodeType === 'TagElement' &&
351
+ transformedNode.elementCss
352
+ ) {
353
+ const flatNode = liveNode as FlatNode;
354
+ if (flatNode.elementCss !== transformedNode.elementCss) {
355
+ (updatedNode as FlatNode).elementCss =
356
+ transformedNode.elementCss;
357
+ needsUpdate = true;
386
358
  }
359
+ }
387
360
 
388
- if (needsUpdate) {
389
- ctx.allNodes.get().set(transformedNode.id, updatedNode);
361
+ if (
362
+ transformedNode.nodeType === 'Markdown' &&
363
+ transformedNode.parentCss
364
+ ) {
365
+ const markdownNode = liveNode as MarkdownPaneFragmentNode;
366
+ const currentParentCss = markdownNode.parentCss;
367
+ const newParentCss = transformedNode.parentCss as string[];
368
+
369
+ const isDifferent =
370
+ !currentParentCss ||
371
+ currentParentCss.length !== newParentCss.length ||
372
+ currentParentCss.some(
373
+ (css, index) => css !== newParentCss[index]
374
+ );
375
+
376
+ if (isDifferent) {
377
+ (updatedNode as MarkdownPaneFragmentNode).parentCss =
378
+ newParentCss;
379
+ needsUpdate = true;
390
380
  }
391
- });
392
-
393
- const paneExistsInBackend = contentMap.some(
394
- (item) => item.type === 'Pane' && item.id === paneNode.id
395
- );
396
- const isCreatePaneMode = !paneExistsInBackend;
397
- const endpoint = isCreatePaneMode
398
- ? `${goBackend}/api/v1/nodes/panes/create`
399
- : `${goBackend}/api/v1/nodes/panes/${payload.id}`;
400
- const method = isCreatePaneMode ? 'POST' : 'PUT';
381
+ }
401
382
 
402
- addDebugMessage(
403
- `Processing pane ${i + 1}/${
404
- dirtyPanes.length
405
- }: ${paneNode.id} -> ${method} ${endpoint}`
406
- );
383
+ if (needsUpdate) {
384
+ ctx.allNodes.get().set(transformedNode.id, updatedNode);
385
+ }
386
+ });
387
+ });
407
388
 
408
- const response = await fetch(endpoint, {
409
- method,
410
- headers: {
411
- 'Content-Type': 'application/json',
412
- 'X-Tenant-ID': tenantId,
413
- },
414
- credentials: 'include',
415
- body: JSON.stringify(payload),
416
- });
389
+ const endpoint = `${goBackend}/api/v1/nodes/panes/bulk`;
390
+ addDebugMessage(
391
+ `Processing ${dirtyPanes.length} panes via -> POST ${endpoint}`
392
+ );
417
393
 
418
- if (!response.ok) {
419
- throw new Error(`HTTP error! status: ${response.status}`);
420
- }
394
+ try {
395
+ const response = await fetch(endpoint, {
396
+ method: 'POST',
397
+ headers: {
398
+ 'Content-Type': 'application/json',
399
+ 'X-Tenant-ID': tenantId,
400
+ },
401
+ credentials: 'include',
402
+ body: JSON.stringify({ panes: bulkPayload }),
403
+ });
421
404
 
422
- await response.json();
423
- addDebugMessage(`Pane ${paneNode.id} saved successfully`);
424
- } catch (etlError) {
425
- const errorMsg =
426
- etlError instanceof Error ? etlError.message : 'Unknown error';
427
- addDebugMessage(`Pane ${paneNode.id} ETL failed: ${errorMsg}`);
405
+ if (!response.ok) {
406
+ const errorText = await response.text();
428
407
  throw new Error(
429
- `Failed to save pane ${paneNode.id}: ${errorMsg}`
408
+ `HTTP error! status: ${response.status} - ${errorText}`
430
409
  );
431
410
  }
432
411
 
433
- setStageProgress((prev) => ({ ...prev, currentStep: i + 1 }));
434
- completedProcessingSteps++;
435
- const processingProgress =
436
- (completedProcessingSteps / totalProcessingSteps) *
437
- PROGRESS_PHASES.PROCESSING;
438
- setProgress(
439
- PROGRESS_PHASES.PREPARATION +
440
- PROGRESS_PHASES.UPLOADS +
441
- processingProgress
412
+ await response.json();
413
+ addDebugMessage(
414
+ `${dirtyPanes.length} panes saved successfully via bulk endpoint.`
442
415
  );
416
+ } catch (bulkError) {
417
+ const errorMsg =
418
+ bulkError instanceof Error
419
+ ? bulkError.message
420
+ : 'Unknown bulk save error';
421
+ addDebugMessage(`Bulk pane save failed: ${errorMsg}`);
422
+ throw new Error(`Failed to save panes in bulk: ${errorMsg}`);
443
423
  }
424
+
425
+ setStageProgress({
426
+ currentStep: dirtyPanes.length,
427
+ totalSteps: dirtyPanes.length,
428
+ });
429
+ completedProcessingSteps += dirtyPanes.length;
430
+ const processingProgress =
431
+ (completedProcessingSteps / totalProcessingSteps) *
432
+ PROGRESS_PHASES.PROCESSING;
433
+ setProgress(
434
+ PROGRESS_PHASES.PREPARATION +
435
+ PROGRESS_PHASES.UPLOADS +
436
+ processingProgress
437
+ );
444
438
  }
445
439
 
446
440
  if (!isContext && dirtyStoryFragments.length > 0) {
@@ -58,14 +58,15 @@ const logoPositions = generatePositions();
58
58
  {
59
59
  assetUrl && (
60
60
  <div
61
- class="pointer-events-none absolute overflow-hidden rounded-2xl p-1.5 md:p-3.5"
61
+ class="pointer-events-none absolute mr-6 overflow-hidden rounded-2xl p-1.5 md:p-3.5"
62
62
  style={{
63
63
  top: '2rem',
64
64
  left: '64rem',
65
65
  right: '0',
66
66
  bottom: '3.5rem',
67
- opacity: '0.85',
68
- //filter: 'grayscale(1)',
67
+ 'mix-blend-mode': 'multiply',
68
+ opacity: '0.07',
69
+ border: '2px dashed rgba(0, 0, 0, 1)',
69
70
  }}
70
71
  >
71
72
  {logoPositions.map((position) => (
@@ -479,10 +479,6 @@ export async function injectTemplateFiles(
479
479
  src: resolve('../templates/src/components/edit/pane/PanePanel_path.tsx'),
480
480
  dest: 'src/components/edit/pane/PanePanel_path.tsx',
481
481
  },
482
- {
483
- src: resolve('../templates/src/components/edit/pane/PanePanel_slug.tsx'),
484
- dest: 'src/components/edit/pane/PanePanel_slug.tsx',
485
- },
486
482
  {
487
483
  src: resolve('../templates/src/components/edit/pane/PanePanel_title.tsx'),
488
484
  dest: 'src/components/edit/pane/PanePanel_title.tsx',
@@ -1,219 +0,0 @@
1
- import { useState, useEffect, type Dispatch, type SetStateAction } from 'react';
2
- import ExclamationTriangleIcon from '@heroicons/react/24/outline/ExclamationTriangleIcon';
3
- import CheckIcon from '@heroicons/react/24/outline/CheckIcon';
4
- import { getCtx } from '@/stores/nodes';
5
- import { cloneDeep } from '@/utils/helpers';
6
- import { PaneConfigMode, type PaneNode } from '@/types/compositorTypes';
7
-
8
- interface PaneSlugPanelProps {
9
- nodeId: string;
10
- setMode: Dispatch<SetStateAction<PaneConfigMode>>;
11
- }
12
-
13
- const PaneSlugPanel = ({ nodeId, setMode }: PaneSlugPanelProps) => {
14
- const [slug, setSlug] = useState('');
15
- const [isValid, setIsValid] = useState(false);
16
- const [warning, setWarning] = useState(false);
17
- const [charCount, setCharCount] = useState(0);
18
- const [validationError, setValidationError] = useState<string | null>(null);
19
- const [canSave, setCanSave] = useState(false);
20
-
21
- const ctx = getCtx();
22
- const allNodes = ctx.allNodes.get();
23
- const paneNode = allNodes.get(nodeId) as PaneNode;
24
- if (!paneNode) return null;
25
-
26
- useEffect(() => {
27
- setSlug(paneNode.slug);
28
- setCharCount(paneNode.slug.length);
29
- checkLiveValidity(paneNode.slug);
30
- }, [paneNode.slug]);
31
-
32
- // More permissive validation for typing
33
- const checkLiveValidity = (value: string) => {
34
- const length = value.length;
35
- setCharCount(length);
36
-
37
- // Basic format check for allowed characters
38
- if (!/^[a-z0-9-]*$/.test(value)) {
39
- setValidationError(
40
- 'Only lowercase letters, numbers, and hyphens allowed'
41
- );
42
- setIsValid(false);
43
- setCanSave(false);
44
- return false;
45
- }
46
-
47
- // Length checks
48
- setIsValid(length >= 3 && length <= 40);
49
- setWarning(length > 40 && length <= 50);
50
- setValidationError(null);
51
-
52
- // Check if we can save
53
- if (length >= 3) {
54
- const saveValidation = checkSaveValidity(value);
55
- setCanSave(saveValidation.isValid);
56
- if (!saveValidation.isValid) {
57
- setValidationError(saveValidation.error || null);
58
- }
59
- } else {
60
- setCanSave(false);
61
- }
62
-
63
- return true;
64
- };
65
-
66
- // Strict validation for saving
67
- const checkSaveValidity = (
68
- value: string
69
- ): { isValid: boolean; error?: string } => {
70
- // Strict pattern that prevents leading/trailing hyphens and multiple consecutive hyphens
71
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
72
- return {
73
- isValid: false,
74
- error:
75
- 'Slug must start and end with letters or numbers, and no consecutive hyphens',
76
- };
77
- }
78
-
79
- // Check duplicates and reserved slugs
80
- return ctx.isSlugValid(value, nodeId);
81
- };
82
-
83
- const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
84
- const newSlug = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
85
-
86
- if (newSlug.length <= 50) {
87
- setSlug(newSlug);
88
- checkLiveValidity(newSlug);
89
- }
90
- };
91
-
92
- const handleSlugBlur = () => {
93
- if (canSave) {
94
- const ctx = getCtx();
95
- const updatedNode = { ...cloneDeep(paneNode), slug, isChanged: true };
96
- ctx.modifyNodes([updatedNode]);
97
- }
98
- };
99
-
100
- return (
101
- <div className="group mb-4 w-full rounded-b-md bg-white px-1.5 py-6 shadow-inner">
102
- <div className="px-3.5">
103
- <div className="mb-4 flex justify-between">
104
- <h3 className="text-lg font-bold">Pane Slug</h3>
105
- <button
106
- onClick={() => setMode(PaneConfigMode.DEFAULT)}
107
- className="text-myblue hover:text-black"
108
- >
109
- ← Go Back
110
- </button>
111
- </div>
112
-
113
- <div className="relative">
114
- <input
115
- type="text"
116
- value={slug}
117
- onChange={handleSlugChange}
118
- onBlur={handleSlugBlur}
119
- onKeyDown={(e) => {
120
- if (e.key === 'Enter') {
121
- e.currentTarget.blur();
122
- }
123
- }}
124
- className={`w-full rounded-md border px-2 py-1 pr-16 ${
125
- validationError || charCount < 3
126
- ? 'border-red-500 bg-red-50'
127
- : isValid && canSave
128
- ? 'border-green-500 bg-green-50'
129
- : warning
130
- ? 'border-yellow-500 bg-yellow-50'
131
- : 'border-gray-300'
132
- }`}
133
- placeholder="Enter pane slug (3-40 characters recommended)"
134
- />
135
- <div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-2">
136
- {validationError || charCount < 3 ? (
137
- <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
138
- ) : isValid && canSave ? (
139
- <CheckIcon className="h-5 w-5 text-green-500" />
140
- ) : warning ? (
141
- <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
142
- ) : null}
143
- <span
144
- className={`text-sm ${
145
- validationError || charCount < 3
146
- ? 'text-red-500'
147
- : isValid && canSave
148
- ? 'text-green-500'
149
- : warning
150
- ? 'text-yellow-500'
151
- : 'text-gray-500'
152
- }`}
153
- >
154
- {charCount}/50
155
- </span>
156
- </div>
157
- </div>
158
- {validationError && (
159
- <div className="mt-2 text-sm text-red-600">
160
- <ExclamationTriangleIcon className="mr-1 inline h-4 w-4" />
161
- {validationError}
162
- </div>
163
- )}
164
- <div className="mt-4 space-y-4 text-lg">
165
- <div className="text-gray-600">
166
- This is solely used for analytics!
167
- <ul className="ml-4 mt-1">
168
- <li>
169
- <CheckIcon className="inline h-4 w-4" /> Keep it concise and
170
- descriptive
171
- </li>
172
- <li>
173
- <CheckIcon className="inline h-4 w-4" /> Use lowercase letters
174
- and numbers
175
- </li>
176
- <li>
177
- <CheckIcon className="inline h-4 w-4" /> Use hyphens between
178
- words
179
- </li>
180
- <li>
181
- <CheckIcon className="inline h-4 w-4" /> Must start and end with
182
- a letter or number
183
- </li>
184
- </ul>
185
- </div>
186
- <div className="py-4">
187
- {charCount < 3 && (
188
- <span className="text-red-500">
189
- Slug must be at least 3 characters
190
- </span>
191
- )}
192
- {charCount >= 3 && charCount < 5 && !validationError && (
193
- <span className="text-gray-500">
194
- Consider adding more characters for better description
195
- </span>
196
- )}
197
- {warning && !validationError && (
198
- <span className="text-yellow-500">
199
- Slug is getting long - consider shortening it
200
- </span>
201
- )}
202
- {isValid && canSave && charCount >= 5 && !validationError && (
203
- <span className="text-green-500">
204
- Good slug length and format!
205
- </span>
206
- )}
207
- {isValid && !canSave && !validationError && (
208
- <span className="text-gray-500">
209
- Valid characters but needs proper formatting to save
210
- </span>
211
- )}
212
- </div>
213
- </div>
214
- </div>
215
- </div>
216
- );
217
- };
218
-
219
- export default PaneSlugPanel;