astro-tractstack 2.0.0-rc.32 → 2.0.0-rc.34

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"
@@ -1028,6 +1024,12 @@ async function w(t, e, c) {
1028
1024
  dest: "src/components/form/advanced/APIConfigSection.tsx"
1029
1025
  },
1030
1026
  // StoryKeep Dashboard Components
1027
+ {
1028
+ src: t(
1029
+ "../templates/src/components/storykeep/StoryKeepBackdrop.astro"
1030
+ ),
1031
+ dest: "src/components/storykeep/StoryKeepBackdrop.astro"
1032
+ },
1031
1033
  {
1032
1034
  src: t("../templates/src/components/storykeep/Dashboard.tsx"),
1033
1035
  dest: "src/components/storykeep/Dashboard.tsx"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.32",
3
+ "version": "2.0.0-rc.34",
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) {
@@ -1,10 +1,12 @@
1
- import { useState, useCallback } from 'react';
1
+ import { useState, useCallback, useMemo } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
+ import { Select } from '@ark-ui/react/select';
4
+ import { createListCollection } from '@ark-ui/react/collection';
3
5
  import BackgroundImage from './BackgroundImage';
4
6
  import ArtpackImage from './ArtpackImage';
5
7
  import ColorPickerCombo from './ColorPickerCombo';
6
8
  import { getCtx } from '@/stores/nodes';
7
- import { hasArtpacksStore } from '@/stores/storykeep';
9
+ import { hasArtpacksStore, settingsPanelStore } from '@/stores/storykeep';
8
10
  import { cloneDeep } from '@/utils/helpers';
9
11
  import type { BrandConfig } from '@/types/tractstack';
10
12
  import type {
@@ -19,6 +21,36 @@ export interface BackgroundImageWrapperProps {
19
21
  config?: BrandConfig;
20
22
  }
21
23
 
24
+ const CheckIcon = () => (
25
+ <svg
26
+ xmlns="http://www.w3.org/2000/svg"
27
+ className="h-5 w-5"
28
+ viewBox="0 0 20 20"
29
+ fill="currentColor"
30
+ >
31
+ <path
32
+ fillRule="evenodd"
33
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
34
+ clipRule="evenodd"
35
+ />
36
+ </svg>
37
+ );
38
+
39
+ const ChevronDownIcon = () => (
40
+ <svg
41
+ xmlns="http://www.w3.org/2000/svg"
42
+ className="h-5 w-5"
43
+ viewBox="0 0 20 20"
44
+ fill="currentColor"
45
+ >
46
+ <path
47
+ fillRule="evenodd"
48
+ d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
49
+ clipRule="evenodd"
50
+ />
51
+ </svg>
52
+ );
53
+
22
54
  const BackgroundImageWrapper = ({
23
55
  paneId,
24
56
  config,
@@ -27,11 +59,8 @@ const BackgroundImageWrapper = ({
27
59
  const allNodes = useStore(ctx.allNodes);
28
60
  const $artpacks = useStore(hasArtpacksStore);
29
61
  const hasArtpacks = $artpacks && Object.keys($artpacks).length > 0;
30
-
31
- // State to force re-renders when child components need it
32
62
  const [, setUpdateCounter] = useState(0);
33
63
 
34
- // Using useCallback to create a stable reference to the update function
35
64
  const onUpdate = useCallback(() => {
36
65
  setUpdateCounter((prev) => prev + 1);
37
66
  }, []);
@@ -89,8 +118,47 @@ const BackgroundImageWrapper = ({
89
118
  const position = bgNode?.position || 'background';
90
119
  const size = bgNode?.size || 'equal';
91
120
 
121
+ const positionOptions = [
122
+ { label: 'Background', value: 'background' },
123
+ { label: 'Left', value: 'left' },
124
+ { label: 'Right', value: 'right' },
125
+ { label: 'Left Bleed', value: 'leftBleed' },
126
+ { label: 'Right Bleed', value: 'rightBleed' },
127
+ ];
128
+
129
+ const collection = useMemo(
130
+ () =>
131
+ createListCollection({
132
+ items: positionOptions,
133
+ itemToValue: (item) => item.value,
134
+ itemToString: (item) => item.label,
135
+ }),
136
+ []
137
+ );
138
+
139
+ const selectItemStyles = `
140
+ .position-item[data-highlighted] {
141
+ background-color: #0891b2; /* bg-cyan-600 */
142
+ color: white;
143
+ }
144
+ .position-item[data-highlighted] .position-indicator {
145
+ color: white;
146
+ }
147
+ .position-item[data-state="checked"] .position-indicator {
148
+ display: flex;
149
+ }
150
+ .position-item .position-indicator {
151
+ display: none;
152
+ }
153
+ .position-item[data-state="checked"] {
154
+ font-weight: bold;
155
+ }
156
+ `;
157
+
92
158
  return (
93
159
  <div className="w-full space-y-6">
160
+ <style>{selectItemStyles}</style>
161
+
94
162
  <h3 className="text-sm font-bold text-gray-700">Background</h3>
95
163
 
96
164
  <ColorPickerCombo
@@ -115,39 +183,64 @@ const BackgroundImageWrapper = ({
115
183
  )}
116
184
  {bgNode && (
117
185
  <div className="w-full space-y-6">
118
- {/* Position Toggle */}
119
186
  <div className="space-y-2">
120
- <label className="block text-sm font-bold text-gray-700">
121
- Position
122
- </label>
123
- <div className="flex flex-wrap space-x-4">
124
- {(
125
- [
126
- 'background',
127
- 'left',
128
- 'right',
129
- 'leftBleed',
130
- 'rightBleed',
131
- ] as const
132
- ).map((pos) => (
133
- <label key={pos} className="inline-flex items-center">
134
- <input
135
- type="radio"
136
- name="position"
137
- value={pos}
138
- checked={position === pos}
139
- onChange={() => handlePositionChange(pos)}
140
- className="text-myblue focus:ring-myblue h-4 w-4 border-gray-300"
187
+ <Select.Root
188
+ collection={collection}
189
+ positioning={{ sameWidth: true }}
190
+ value={[position]}
191
+ onValueChange={(details) => {
192
+ const currentSignal = settingsPanelStore.get();
193
+ if (currentSignal) {
194
+ settingsPanelStore.set({
195
+ ...currentSignal,
196
+ editLock: Date.now(),
197
+ });
198
+ }
199
+ handlePositionChange(
200
+ details.value[0] as
201
+ | 'background'
202
+ | 'left'
203
+ | 'right'
204
+ | 'leftBleed'
205
+ | 'rightBleed'
206
+ );
207
+ }}
208
+ >
209
+ <Select.Label className="block text-sm font-bold text-gray-700">
210
+ Position
211
+ </Select.Label>
212
+ <Select.Control>
213
+ <Select.Trigger className="focus:border-myblue focus:ring-myblue flex w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1">
214
+ <Select.ValueText
215
+ className="capitalize"
216
+ placeholder="Select a position"
141
217
  />
142
- <span className="ml-2 text-sm capitalize text-gray-700">
143
- {pos}
144
- </span>
145
- </label>
146
- ))}
147
- </div>
218
+ <Select.Indicator>
219
+ <ChevronDownIcon />
220
+ </Select.Indicator>
221
+ </Select.Trigger>
222
+ </Select.Control>
223
+ <Select.Positioner>
224
+ <Select.Content className="z-10 mt-1 w-full rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
225
+ <Select.ItemGroup>
226
+ {collection.items.map((item) => (
227
+ <Select.Item
228
+ key={item.value}
229
+ item={item}
230
+ className="position-item relative cursor-pointer select-none py-2 pl-10 pr-4 text-sm text-gray-900"
231
+ >
232
+ <Select.ItemText>{item.label}</Select.ItemText>
233
+ <Select.ItemIndicator className="position-indicator absolute inset-y-0 left-0 flex items-center pl-3 text-cyan-600">
234
+ <CheckIcon />
235
+ </Select.ItemIndicator>
236
+ </Select.Item>
237
+ ))}
238
+ </Select.ItemGroup>
239
+ </Select.Content>
240
+ </Select.Positioner>
241
+ </Select.Root>
148
242
  </div>
149
243
 
150
- {/* Size Toggle - Only show when position is left or right */}
151
244
  {position !== 'background' && (
152
245
  <div className="space-y-2">
153
246
  <label className="block text-sm font-bold text-gray-700">
@@ -177,7 +270,6 @@ const BackgroundImageWrapper = ({
177
270
  </div>
178
271
  )}
179
272
 
180
- {/* Render the appropriate image component */}
181
273
  {isArtpackImageNode(bgNode) ? (
182
274
  <ArtpackImage paneId={paneId} onUpdate={onUpdate} />
183
275
  ) : (
@@ -202,7 +202,9 @@ export default function ActionBuilderField({
202
202
  onChange={(e) => {
203
203
  const newValue = e.target.value;
204
204
  setParam1(newValue);
205
- updateValue(selectedTarget, '', newValue);
205
+ }}
206
+ onBlur={(e) => {
207
+ updateValue(selectedTarget, '', e.target.value);
206
208
  }}
207
209
  placeholder="https://..."
208
210
  className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-cyan-700 focus:ring-cyan-700"
@@ -0,0 +1,86 @@
1
+ ---
2
+ let MODE = 'logo'; // 'wordmark' | 'logo'
3
+
4
+ interface Props {
5
+ brandConfig: {
6
+ LOGO?: string;
7
+ WORDMARK?: string;
8
+ };
9
+ }
10
+
11
+ const { brandConfig } = Astro.props;
12
+
13
+ const getAssetPath = (
14
+ configPath: string | undefined,
15
+ fallback: string
16
+ ): string => {
17
+ if (configPath && configPath !== '') {
18
+ return configPath;
19
+ }
20
+ return fallback;
21
+ };
22
+
23
+ let assetUrl;
24
+ if (MODE === `wordmark`)
25
+ assetUrl = getAssetPath(brandConfig?.WORDMARK, '/brand/wordmark.svg');
26
+ else assetUrl = getAssetPath(brandConfig?.LOGO, '/brand/logo.svg');
27
+
28
+ // Generate positions programmatically for triple density
29
+ const generatePositions = () => {
30
+ const positions = [];
31
+ const rows = 15; // More rows to extend beyond boundaries
32
+ const cols = 12; // More cols to extend beyond boundaries
33
+
34
+ for (let row = 0; row < rows; row++) {
35
+ for (let col = 0; col < cols; col++) {
36
+ // Skip some positions for natural spacing
37
+ if ((row + col) % 3 !== 0) continue;
38
+
39
+ // Allow logos to extend beyond container edges (no margins)
40
+ const top = (row / (rows - 1)) * 120 - 10; // Extend 10% beyond top/bottom
41
+ const left = (col / (cols - 1)) * 120 - 10; // Extend 10% beyond left/right
42
+ const rotation = -45 + Math.random() * 90;
43
+
44
+ positions.push({
45
+ top: `${top}%`,
46
+ left: `${left}%`,
47
+ rotation: `${rotation}deg`,
48
+ });
49
+ }
50
+ }
51
+
52
+ return positions;
53
+ };
54
+
55
+ const logoPositions = generatePositions();
56
+ ---
57
+
58
+ {
59
+ assetUrl && (
60
+ <div
61
+ class="pointer-events-none absolute overflow-hidden rounded-2xl p-1.5 md:p-3.5"
62
+ style={{
63
+ top: '2rem',
64
+ left: '64rem',
65
+ right: '0',
66
+ bottom: '3.5rem',
67
+ opacity: '0.85',
68
+ //filter: 'grayscale(1)',
69
+ }}
70
+ >
71
+ {logoPositions.map((position) => (
72
+ <img
73
+ src={assetUrl}
74
+ style={{
75
+ position: 'absolute',
76
+ top: position.top,
77
+ left: position.left,
78
+ width: '120px',
79
+ height: 'auto',
80
+ transform: `rotate(${position.rotation})`,
81
+ }}
82
+ />
83
+ ))}
84
+ </div>
85
+ )
86
+ }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Layout from '@/layouts/Layout.astro';
3
+ import StoryKeepBackdrop from '@/components/storykeep/StoryKeepBackdrop.astro';
3
4
  import StoryKeepDashboard from '@/components/storykeep/Dashboard';
4
5
  import StoryKeepDashboard_Advanced from '@/components/storykeep/Dashboard_Advanced';
5
6
  import { requireAdminOrEditor, isAuthenticated, isAdmin } from '@/utils/auth';
@@ -53,6 +54,7 @@ try {
53
54
 
54
55
  <Layout title={title} slug="storykeep" isStoryKeep={true}>
55
56
  <main id="main-content" class="min-h-screen w-full">
57
+ <StoryKeepBackdrop brandConfig={brandConfig} />
56
58
  <div class="max-w-5xl p-3.5 md:p-8">
57
59
  <StoryKeepDashboard
58
60
  client:only="react"
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Layout from '@/layouts/Layout.astro';
3
+ import StoryKeepBackdrop from '@/components/storykeep/StoryKeepBackdrop.astro';
3
4
  import BrandingPageWrapper from '@/components/storykeep/state/BrandingWrapper';
4
5
  import { requireAdminOrEditor, isAuthenticated, isAdmin } from '@/utils/auth';
5
6
  import { getFullContentMap } from '@/stores/analytics';
@@ -42,6 +43,7 @@ try {
42
43
 
43
44
  <Layout title={title} slug="storykeep" isStoryKeep={true}>
44
45
  <main id="main-content" class="min-h-screen w-full">
46
+ <StoryKeepBackdrop brandConfig={brandConfig} />
45
47
  <div class="max-w-5xl p-3.5 md:p-8">
46
48
  <BrandingPageWrapper
47
49
  client:only="react"
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Layout from '@/layouts/Layout.astro';
3
+ import StoryKeepBackdrop from '@/components/storykeep/StoryKeepBackdrop.astro';
3
4
  import StoryKeepDashboard from '@/components/storykeep/Dashboard';
4
5
  import StoryKeepDashboard_Content from '@/components/storykeep/Dashboard_Content';
5
6
  import { requireAdminOrEditor, isAuthenticated, isAdmin } from '@/utils/auth';
@@ -49,6 +50,7 @@ try {
49
50
 
50
51
  <Layout title={title} slug="storykeep" isStoryKeep={true}>
51
52
  <main id="main-content" class="min-h-screen w-full">
53
+ <StoryKeepBackdrop brandConfig={brandConfig} />
52
54
  <div class="max-w-5xl p-3.5 md:p-8">
53
55
  <StoryKeepDashboard
54
56
  client:only="react"
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Layout from '@/layouts/Layout.astro';
3
+ import StoryKeepBackdrop from '@/components/storykeep/StoryKeepBackdrop.astro';
3
4
  import StoryKeepDashboard from '@/components/storykeep/Dashboard';
4
5
  import StoryKeepDashboard_Analytics from '@/components/storykeep/Dashboard_Analytics';
5
6
  import { requireAdminOrEditor, isAuthenticated, isAdmin } from '@/utils/auth';
@@ -50,7 +51,8 @@ try {
50
51
  ---
51
52
 
52
53
  <Layout title={title} isStoryKeep={true} slug="storykeep">
53
- <main id="main-content" class="min-h-screen w-full">
54
+ <main id="main-content" class="relative min-h-screen w-full">
55
+ <StoryKeepBackdrop brandConfig={brandConfig} />
54
56
  <div class="max-w-5xl p-3.5 md:p-8">
55
57
  <StoryKeepDashboard
56
58
  client:only="react"
@@ -340,6 +340,18 @@ export class NodesContext {
340
340
  handleClickEventDefault(node, dblClick, this.clickedParentLayer.get());
341
341
  break;
342
342
  case `text`:
343
+ if (
344
+ node.nodeType === 'TagElement' &&
345
+ 'tagName' in node &&
346
+ (node.tagName === 'a' || node.tagName === 'button')
347
+ ) {
348
+ this.toolModeValStore.set({ value: 'styles' });
349
+ handleClickEventDefault(
350
+ node,
351
+ dblClick,
352
+ this.clickedParentLayer.get()
353
+ );
354
+ }
343
355
  if (dblClick && ![`Markdown`].includes(node.nodeType)) {
344
356
  this.toolModeValStore.set({ value: 'styles' });
345
357
  handleClickEventDefault(
@@ -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',
@@ -1046,6 +1042,12 @@ export async function injectTemplateFiles(
1046
1042
  },
1047
1043
 
1048
1044
  // StoryKeep Dashboard Components
1045
+ {
1046
+ src: resolve(
1047
+ '../templates/src/components/storykeep/StoryKeepBackdrop.astro'
1048
+ ),
1049
+ dest: 'src/components/storykeep/StoryKeepBackdrop.astro',
1050
+ },
1049
1051
  {
1050
1052
  src: resolve('../templates/src/components/storykeep/Dashboard.tsx'),
1051
1053
  dest: 'src/components/storykeep/Dashboard.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;