astro-tractstack 2.0.0-rc.50 → 2.0.0-rc.53

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.50",
3
+ "version": "2.0.0-rc.53",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -106,12 +106,7 @@ const createdDate = created ? new Date(created) : new Date();
106
106
  {allLinks.map((item: any) => (
107
107
  <a
108
108
  href={item.to}
109
- class={
110
- 'z-10 whitespace-nowrap rounded px-3.5 py-1.5 text-lg shadow-sm transition-colors ' +
111
- (item.featured
112
- ? 'bg-brand-7 hover:bg-myblack focus:bg-brand-7 text-white hover:text-white focus:text-white'
113
- : 'text-brand-7 hover:bg-myblack focus:bg-brand-7 bg-white hover:text-white focus:text-white')
114
- }
109
+ class="bg-brand-7 hover:bg-myblack focus:bg-brand-7 z-10 whitespace-nowrap rounded px-3.5 py-1.5 text-lg text-white shadow-sm transition-colors hover:text-white focus:text-white"
115
110
  title={item.description}
116
111
  >
117
112
  <span class="font-bold">{item.name}</span>
@@ -87,7 +87,7 @@ const getElement = (
87
87
  const isPreview = getCtx(props).rootNodeId.get() === `tmp`;
88
88
  const hasPanes = useStore(getCtx(props).hasPanes);
89
89
  const isTemplate = useStore(getCtx(props).isTemplate);
90
- const sharedProps = { nodeId: node.id, ctx: props.ctx };
90
+ const sharedProps = { nodeId: node.id, ctx: props.ctx, config: props.config };
91
91
  const type = getType(node);
92
92
 
93
93
  switch (type) {
@@ -163,7 +163,11 @@ const Pane = memo(
163
163
  ) : codeHookPayload && codeHookTarget === 'list-content' ? (
164
164
  <ListContentSetup nodeId={props.nodeId} params={codeHookParams} />
165
165
  ) : codeHookPayload && codeHookTarget === 'bunny-video' ? (
166
- <BunnyVideoSetup nodeId={props.nodeId} params={codeHookParams} />
166
+ <BunnyVideoSetup
167
+ nodeId={props.nodeId}
168
+ params={codeHookParams}
169
+ config={props.config}
170
+ />
167
171
  ) : codeHookPayload && codeHookTarget ? (
168
172
  <CodeHookContainer
169
173
  payload={{ target: codeHookTarget, params: codeHookParams }}
@@ -12,7 +12,12 @@ export const RenderChildren = (props: RenderChildrenProps) => {
12
12
  return (
13
13
  <>
14
14
  {children.map((id: string) => (
15
- <Node nodeId={id} key={id} ctx={nodeProps.ctx} />
15
+ <Node
16
+ nodeId={id}
17
+ key={id}
18
+ ctx={nodeProps.ctx}
19
+ config={nodeProps.config}
20
+ />
16
21
  ))}
17
22
  </>
18
23
  );
@@ -75,7 +75,9 @@ function StyleWidgetConfigPanel({ node }: StyleWidgetConfigPanelProps) {
75
75
  newNode.codeHookParams = stringParams;
76
76
 
77
77
  // Update the copy field to match the new params
78
- newNode.copy = `${widgetType}(${stringParams.join('|')})`;
78
+ const paramsForCopy = stringParams.slice(0, widgetInfo.parameters.length);
79
+ newNode.copy = `${widgetType}(${paramsForCopy.join('|')})`;
80
+ console.log(newNode.copy);
79
81
 
80
82
  // Mark the node as changed
81
83
  newNode.isChanged = true;
@@ -2,51 +2,168 @@ import { useState, useEffect } from 'react';
2
2
  import { getCtx } from '@/stores/nodes';
3
3
  import SingleParam from '@/components/fields/SingleParam';
4
4
  import { widgetMeta } from '@/constants';
5
- import type { FlatNode } from '@/types/compositorTypes';
5
+ import type {
6
+ FlatNode,
7
+ VideoMoment,
8
+ PaneNode,
9
+ StoryFragmentNode,
10
+ } from '@/types/compositorTypes';
11
+ import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
12
+ import TrashIcon from '@heroicons/react/24/outline/TrashIcon';
13
+ import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
14
+ import CheckIcon from '@heroicons/react/24/outline/CheckIcon';
15
+ import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
16
+ import ActionBuilderSlugSelector from '@/components/form/ActionBuilderSlugSelector';
17
+ import ChevronDownIcon from '@heroicons/react/24/outline/ChevronDownIcon';
18
+ import { Dialog } from '@ark-ui/react/dialog';
19
+ import { Portal } from '@ark-ui/react/portal';
20
+ import { canonicalURLStore } from '@/stores/storykeep';
6
21
 
7
22
  interface BunnyWidgetProps {
8
23
  node: FlatNode;
9
24
  onUpdate: (params: string[]) => void;
10
25
  }
11
26
 
12
- const BUNNY_EMBED_BASE_URL = 'https://iframe.mediadelivery.net/embed/';
27
+ interface Chapter extends VideoMoment {
28
+ id: string;
29
+ }
30
+
31
+ interface PaneListItem {
32
+ id: string;
33
+ title: string;
34
+ slug: string;
35
+ type: 'Pane';
36
+ isContext: boolean;
37
+ }
38
+
39
+ interface SelectorItem {
40
+ id: string;
41
+ title: string;
42
+ slug: string;
43
+ type: 'Pane' | 'StoryFragment';
44
+ panes?: string[];
45
+ isContext?: boolean;
46
+ }
47
+
48
+ const generateId = (): string => {
49
+ return Math.random().toString(36).substring(2, 9);
50
+ };
13
51
 
14
52
  function BunnyWidget({ node, onUpdate }: BunnyWidgetProps) {
15
53
  const [videoId, setVideoId] = useState(
16
54
  String(node.codeHookParams?.[0] || '')
17
55
  );
18
56
  const [title, setTitle] = useState(String(node.codeHookParams?.[1] || ''));
57
+ const [chapters, setChapters] = useState<Chapter[]>([]);
58
+ const [showChapterModal, setShowChapterModal] = useState(false);
59
+ const [formErrors, setFormErrors] = useState<Record<string, string>>({});
19
60
  const [isDuplicate, setIsDuplicate] = useState(false);
20
61
  const [validationError, setValidationError] = useState<string | null>(null);
62
+ const [isCopied, setIsCopied] = useState(false);
21
63
 
22
64
  const widgetInfo = widgetMeta.bunny;
65
+ const ctx = getCtx();
66
+ const allNodes = ctx.allNodes.get();
67
+
68
+ const storyFragmentId = ctx.getClosestNodeTypeFromId(
69
+ node.id,
70
+ 'StoryFragment'
71
+ );
72
+ const storyFragmentNode = allNodes.get(storyFragmentId) as
73
+ | StoryFragmentNode
74
+ | undefined;
75
+ const paneIds = storyFragmentNode?.paneIds || [];
76
+
77
+ const paneList: PaneListItem[] = paneIds
78
+ .map((paneId): PaneListItem | null => {
79
+ const paneNode = allNodes.get(paneId) as PaneNode | undefined;
80
+ if (paneNode && paneNode.nodeType === 'Pane') {
81
+ return {
82
+ id: paneNode.id,
83
+ title: paneNode.title,
84
+ slug: paneNode.slug,
85
+ type: 'Pane',
86
+ isContext: paneNode.isContextPane || false,
87
+ };
88
+ }
89
+ return null;
90
+ })
91
+ .filter((item): item is PaneListItem => item !== null);
92
+
93
+ const storyFragmentEntry: SelectorItem | null = storyFragmentNode
94
+ ? {
95
+ id: storyFragmentNode.id,
96
+ title: storyFragmentNode.title,
97
+ slug: storyFragmentNode.slug,
98
+ type: 'StoryFragment',
99
+ panes: storyFragmentNode.paneIds,
100
+ }
101
+ : null;
102
+
103
+ const contentMapForSelector: SelectorItem[] = [
104
+ ...(storyFragmentEntry ? [storyFragmentEntry] : []),
105
+ ...paneList,
106
+ ];
107
+
108
+ const sortChapters = (chapArr: Chapter[]) =>
109
+ [...chapArr].sort((a, b) => a.startTime - b.startTime);
23
110
 
24
111
  useEffect(() => {
25
112
  const newVideoId = String(node.codeHookParams?.[0] || '');
113
+ const newTitle = String(node.codeHookParams?.[1] || '');
114
+ const chaptersJson = String(node.codeHookParams?.[2] || '');
115
+
26
116
  setVideoId(newVideoId);
27
- setTitle(String(node.codeHookParams?.[1] || ''));
117
+ setTitle(newTitle);
28
118
  validateVideoId(newVideoId);
119
+
120
+ if (chaptersJson) {
121
+ try {
122
+ const parsedChapters = JSON.parse(chaptersJson);
123
+ if (Array.isArray(parsedChapters)) {
124
+ const chaptersWithIds = parsedChapters.map(
125
+ (chapter: VideoMoment) => ({ ...chapter, id: generateId() })
126
+ );
127
+ setChapters(sortChapters(chaptersWithIds));
128
+ }
129
+ } catch (e) {
130
+ setChapters([]);
131
+ }
132
+ } else {
133
+ setChapters([]);
134
+ }
29
135
  }, [node]);
30
136
 
137
+ const handleUpdate = (
138
+ newVideoId: string,
139
+ newTitle: string,
140
+ newChapters: Chapter[]
141
+ ) => {
142
+ const chaptersToStore = sortChapters(newChapters).map(
143
+ ({ id, ...rest }) => rest
144
+ );
145
+ if (chaptersToStore.length > 0) {
146
+ onUpdate([newVideoId, newTitle, JSON.stringify(chaptersToStore)]);
147
+ } else {
148
+ onUpdate([newVideoId, newTitle]);
149
+ }
150
+ };
151
+
31
152
  const checkForDuplicates = (id: string): boolean => {
32
153
  if (!id) return false;
33
154
  try {
34
- const ctx = getCtx();
35
- const existingVideos = ctx.getAllBunnyVideoInfo();
36
- const count = existingVideos.filter(
37
- (video) => video.videoId === id
38
- ).length;
39
- return count > 1;
155
+ return (
156
+ ctx.getAllBunnyVideoInfo().filter((video) => video.videoId === id)
157
+ .length > 1
158
+ );
40
159
  } catch (e) {
41
- console.error('Error checking for duplicates:', e);
42
160
  return false;
43
161
  }
44
162
  };
45
163
 
46
164
  const isValidVideoIdFormat = (id: string): boolean => {
47
- if (!id) return true; // An empty string is not an error itself.
48
- const videoIdRegex = /^\d+\/[a-f0-9\-]{36}$/;
49
- return videoIdRegex.test(id);
165
+ if (!id) return true;
166
+ return /^\d+\/[a-f0-9\-]{36}$/.test(id);
50
167
  };
51
168
 
52
169
  const validateVideoId = (id: string) => {
@@ -55,7 +172,6 @@ function BunnyWidget({ node, onUpdate }: BunnyWidgetProps) {
55
172
  setIsDuplicate(false);
56
173
  return;
57
174
  }
58
-
59
175
  if (!isValidVideoIdFormat(id)) {
60
176
  setValidationError(
61
177
  "Invalid format. Use 'LibraryID/VideoGUID' from Bunny."
@@ -63,7 +179,6 @@ function BunnyWidget({ node, onUpdate }: BunnyWidgetProps) {
63
179
  setIsDuplicate(false);
64
180
  return;
65
181
  }
66
-
67
182
  const duplicate = checkForDuplicates(id);
68
183
  setIsDuplicate(duplicate);
69
184
  setValidationError(
@@ -74,18 +189,139 @@ function BunnyWidget({ node, onUpdate }: BunnyWidgetProps) {
74
189
  const handleVideoIdChange = (value: string) => {
75
190
  setVideoId(value);
76
191
  validateVideoId(value);
77
- onUpdate([value, title]);
192
+ handleUpdate(value, title, chapters);
78
193
  };
79
194
 
80
195
  const handleTitleChange = (value: string) => {
81
196
  setTitle(value);
82
- onUpdate([videoId, value]);
197
+ handleUpdate(videoId, value, chapters);
198
+ };
199
+
200
+ const validateAllChapters = (allChaps: Chapter[]) =>
201
+ allChaps.reduce(
202
+ (acc, chap, idx) => ({ ...acc, ...validateChapter(chap, idx, allChaps) }),
203
+ {}
204
+ );
205
+
206
+ const validateChapter = (
207
+ chap: Chapter,
208
+ index: number,
209
+ allChaps: Chapter[]
210
+ ): Record<string, string> => {
211
+ const errors: Record<string, string> = {};
212
+ if (!chap.title?.trim()) {
213
+ errors[`title-${index}`] = 'Title is required';
214
+ }
215
+ if (
216
+ typeof chap.startTime !== 'number' ||
217
+ isNaN(chap.startTime) ||
218
+ chap.startTime < 0
219
+ ) {
220
+ errors[`startTime-${index}`] = 'Start time is required';
221
+ }
222
+ if (typeof chap.endTime !== 'number' || isNaN(chap.endTime)) {
223
+ errors[`endTime-${index}`] = 'End time is required';
224
+ } else if (chap.startTime !== undefined && chap.endTime <= chap.startTime) {
225
+ errors[`endTime-${index}`] = 'End time must be > start time';
226
+ }
227
+ const otherChapters = allChaps.filter((_, i) => i !== index);
228
+ for (const other of otherChapters) {
229
+ if (
230
+ Math.max(chap.startTime, other.startTime) <
231
+ Math.min(chap.endTime, other.endTime)
232
+ ) {
233
+ errors[`overlap-${index}`] = 'Chapter times overlap';
234
+ break;
235
+ }
236
+ }
237
+ return errors;
238
+ };
239
+
240
+ const addChapter = () => {
241
+ const newChapter: Chapter = {
242
+ id: generateId(),
243
+ title: 'New Chapter',
244
+ startTime:
245
+ chapters.length > 0 ? chapters[chapters.length - 1].endTime : 0,
246
+ endTime:
247
+ chapters.length > 0 ? chapters[chapters.length - 1].endTime + 60 : 60,
248
+ };
249
+ const updatedChapters = sortChapters([...chapters, newChapter]);
250
+ setFormErrors(validateAllChapters(updatedChapters));
251
+ setChapters(updatedChapters);
252
+ handleUpdate(videoId, title, updatedChapters);
253
+ };
254
+
255
+ const updateChapter = (chapterId: string, updates: Partial<Chapter>) => {
256
+ const chapterIndex = chapters.findIndex((c) => c.id === chapterId);
257
+ if (chapterIndex === -1) return;
258
+ const updatedChapters = [...chapters];
259
+ updatedChapters[chapterIndex] = {
260
+ ...updatedChapters[chapterIndex],
261
+ ...updates,
262
+ };
263
+ const sortedChapters = sortChapters(updatedChapters);
264
+ setFormErrors(validateAllChapters(sortedChapters));
265
+ setChapters(sortedChapters);
266
+ };
267
+
268
+ const removeChapter = (idToRemove: string) => {
269
+ const updatedChapters = chapters.filter((c) => c.id !== idToRemove);
270
+ setFormErrors(validateAllChapters(updatedChapters));
271
+ setChapters(updatedChapters);
272
+ handleUpdate(videoId, title, updatedChapters);
273
+ };
274
+
275
+ const handlePaneSelect = (chapterId: string, slug: string) => {
276
+ const chapterIndex = chapters.findIndex((c) => c.id === chapterId);
277
+ if (chapterIndex === -1) return;
278
+ const updatedChapters = [...chapters];
279
+ const selectedPane = paneList.find((p) => p.slug === slug);
280
+ updatedChapters[chapterIndex].linkedPaneId = selectedPane
281
+ ? selectedPane.id
282
+ : undefined;
283
+ setChapters(updatedChapters);
284
+ handleUpdate(videoId, title, updatedChapters);
285
+ };
286
+
287
+ const handleUnlinkPane = (chapterId: string) => {
288
+ const chapterIndex = chapters.findIndex((c) => c.id === chapterId);
289
+ if (chapterIndex === -1) return;
290
+ const updatedChapters = [...chapters];
291
+ delete updatedChapters[chapterIndex].linkedPaneId;
292
+ setChapters(updatedChapters);
293
+ handleUpdate(videoId, title, updatedChapters);
294
+ };
295
+
296
+ const getLinkedPaneSlug = (linkedPaneId?: string): string => {
297
+ if (!linkedPaneId) return '';
298
+ const paneNode = allNodes.get(linkedPaneId) as PaneNode | undefined;
299
+ return paneNode?.slug || '';
83
300
  };
84
301
 
85
- const showPreview = videoId && !validationError && !isDuplicate;
86
- const embedUrlForPreview = showPreview
87
- ? `${BUNNY_EMBED_BASE_URL}${videoId}`
88
- : '';
302
+ const handleCopyAll = () => {
303
+ const canonicalURL = getCanonicalURL();
304
+ if (!canonicalURL) return;
305
+ const linksText = chapters
306
+ .filter((c) => c.linkedPaneId && getLinkedPaneSlug(c.linkedPaneId))
307
+ .map((chapter) => {
308
+ const paneSlug = getLinkedPaneSlug(chapter.linkedPaneId);
309
+ return `${chapter.title}\n${canonicalURL}#${paneSlug}\n${canonicalURL}?t=${chapter.startTime}s`;
310
+ })
311
+ .join('\n\n');
312
+ const fullBlock = `${canonicalURL}\n${canonicalURL}?t=0s\n\n${linksText}`;
313
+ navigator.clipboard.writeText(fullBlock);
314
+ setIsCopied(true);
315
+ setTimeout(() => setIsCopied(false), 2000);
316
+ };
317
+
318
+ const getCanonicalURL = () => {
319
+ try {
320
+ return canonicalURLStore.get();
321
+ } catch (e) {
322
+ return '';
323
+ }
324
+ };
89
325
 
90
326
  return (
91
327
  <div className="space-y-4">
@@ -100,31 +336,276 @@ function BunnyWidget({ node, onUpdate }: BunnyWidgetProps) {
100
336
  )}
101
337
  {isDuplicate && (
102
338
  <div className="rounded border border-yellow-200 bg-yellow-50 p-2 text-xs text-yellow-800">
103
- Warning: This video is already used elsewhere on this page.
339
+ Warning: This video is already used elsewhere.
104
340
  </div>
105
341
  )}
106
-
107
342
  <SingleParam
108
343
  label={widgetInfo.parameters[1].label}
109
344
  value={title}
110
345
  onChange={handleTitleChange}
111
346
  />
112
-
113
- {showPreview && (
114
- <div className="mt-4 space-y-2">
115
- <label className="block text-sm font-medium text-gray-500">
116
- Preview
117
- </label>
118
- <div className="aspect-video w-full">
119
- <iframe
120
- src={embedUrlForPreview}
121
- className="h-full w-full rounded border"
122
- title={`Preview: ${title}`}
123
- allow="autoplay; fullscreen"
124
- />
125
- </div>
126
- </div>
127
- )}
347
+ <div className="mt-4 border-t border-gray-200 pt-4">
348
+ <button
349
+ type="button"
350
+ onClick={() => setShowChapterModal(true)}
351
+ className="flex w-full items-center justify-center rounded-md bg-gray-100 px-3 py-2 text-sm font-bold text-gray-700 hover:bg-gray-200"
352
+ >
353
+ <ChevronDownIcon className="mr-2 h-5 w-5" />
354
+ {chapters.length > 0
355
+ ? `Configure ${chapters.length} Chapter(s)`
356
+ : 'Configure Chapters'}
357
+ </button>
358
+ </div>
359
+ <Dialog.Root
360
+ open={showChapterModal}
361
+ onOpenChange={(details) => setShowChapterModal(details.open)}
362
+ modal={true}
363
+ preventScroll={true}
364
+ >
365
+ <Portal>
366
+ <Dialog.Backdrop
367
+ className="fixed inset-0 bg-black bg-opacity-75 backdrop-blur-sm"
368
+ style={{ zIndex: 9005 }}
369
+ />
370
+ <Dialog.Positioner
371
+ className="fixed inset-0 flex items-center justify-center p-4"
372
+ style={{ zIndex: 9005 }}
373
+ >
374
+ <Dialog.Content
375
+ className="w-full max-w-4xl overflow-hidden rounded-lg bg-slate-50 shadow-xl"
376
+ style={{ height: '80vh' }}
377
+ >
378
+ <div className="flex h-full flex-col">
379
+ <div className="flex-shrink-0 border-b border-gray-200 bg-white px-6 py-3">
380
+ <Dialog.Title className="text-lg font-bold text-gray-900">
381
+ Chapter Configuration
382
+ </Dialog.Title>
383
+ </div>
384
+ <div className="flex-1 overflow-y-auto p-4">
385
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
386
+ <div className="rounded-lg border border-gray-200 bg-white shadow-sm">
387
+ <div className="flex items-center justify-between border-b border-gray-200 p-3">
388
+ <h3 className="text-base font-bold text-gray-900">
389
+ Video Chapters
390
+ </h3>
391
+ <button
392
+ type="button"
393
+ onClick={addChapter}
394
+ className="flex items-center rounded bg-cyan-600 px-3 py-1 text-sm font-bold text-white hover:bg-cyan-700"
395
+ >
396
+ <PlusIcon className="mr-1 h-4 w-4" /> Add
397
+ </button>
398
+ </div>
399
+ <div className="divide-y divide-gray-200">
400
+ {chapters.length === 0 && (
401
+ <div className="p-6 text-center text-sm text-gray-500">
402
+ No chapters added yet.
403
+ </div>
404
+ )}
405
+ {chapters.map((chapter, index) => (
406
+ <div key={chapter.id} className="p-3">
407
+ <div className="mb-2 flex items-center justify-between">
408
+ <h4 className="text-sm font-bold text-gray-900">
409
+ Chapter {index + 1}: {chapter.title}
410
+ </h4>
411
+ <button
412
+ type="button"
413
+ onClick={() => removeChapter(chapter.id)}
414
+ className="rounded p-1 text-red-600 hover:bg-gray-100 hover:text-red-700"
415
+ title="Remove chapter"
416
+ >
417
+ <TrashIcon className="h-4 w-4" />
418
+ </button>
419
+ </div>
420
+ <div className="grid grid-cols-1 gap-2">
421
+ <div>
422
+ <label className="block text-xs font-bold text-gray-700">
423
+ Title
424
+ </label>
425
+ <input
426
+ type="text"
427
+ value={chapter.title}
428
+ onChange={(e) =>
429
+ updateChapter(chapter.id, {
430
+ title: e.target.value,
431
+ })
432
+ }
433
+ onBlur={() =>
434
+ handleUpdate(videoId, title, chapters)
435
+ }
436
+ className={`mt-1 block w-full rounded-md border-gray-300 px-2 py-1 shadow-sm sm:text-sm ${formErrors[`title-${index}`] ? 'border-red-300' : 'focus:border-cyan-500 focus:ring-cyan-500'}`}
437
+ />
438
+ </div>
439
+ <div>
440
+ <label className="block text-xs font-bold text-gray-700">
441
+ Description
442
+ </label>
443
+ <input
444
+ type="text"
445
+ value={chapter.description || ''}
446
+ onChange={(e) =>
447
+ updateChapter(chapter.id, {
448
+ description: e.target.value,
449
+ })
450
+ }
451
+ onBlur={() =>
452
+ handleUpdate(videoId, title, chapters)
453
+ }
454
+ className="mt-1 block w-full rounded-md border-gray-300 px-2 py-1 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
455
+ />
456
+ </div>
457
+ <div className="grid grid-cols-2 gap-2">
458
+ <div>
459
+ <label className="block text-xs font-bold text-gray-700">
460
+ Start (s)
461
+ </label>
462
+ <input
463
+ type="number"
464
+ min="0"
465
+ value={chapter.startTime}
466
+ onChange={(e) =>
467
+ updateChapter(chapter.id, {
468
+ startTime:
469
+ parseInt(e.target.value) || 0,
470
+ })
471
+ }
472
+ onBlur={() =>
473
+ handleUpdate(videoId, title, chapters)
474
+ }
475
+ className={`mt-1 block w-full rounded-md border-gray-300 px-2 py-1 shadow-sm sm:text-sm ${formErrors[`startTime-${index}`] || formErrors[`overlap-${index}`] ? 'border-red-300' : 'focus:border-cyan-500 focus:ring-cyan-500'}`}
476
+ />
477
+ </div>
478
+ <div>
479
+ <label className="block text-xs font-bold text-gray-700">
480
+ End (s)
481
+ </label>
482
+ <input
483
+ type="number"
484
+ min="0"
485
+ value={chapter.endTime}
486
+ onChange={(e) =>
487
+ updateChapter(chapter.id, {
488
+ endTime: parseInt(e.target.value) || 0,
489
+ })
490
+ }
491
+ onBlur={() =>
492
+ handleUpdate(videoId, title, chapters)
493
+ }
494
+ className={`mt-1 block w-full rounded-md border-gray-300 px-2 py-1 shadow-sm sm:text-sm ${formErrors[`endTime-${index}`] || formErrors[`overlap-${index}`] ? 'border-red-300' : 'focus:border-cyan-500 focus:ring-cyan-500'}`}
495
+ />
496
+ </div>
497
+ </div>
498
+ {(formErrors[`overlap-${index}`] ||
499
+ formErrors[`endTime-${index}`]) && (
500
+ <p className="mt-1 text-xs text-red-600">
501
+ {formErrors[`overlap-${index}`] ||
502
+ formErrors[`endTime-${index}`]}
503
+ </p>
504
+ )}
505
+ <div className="relative">
506
+ <div className="flex items-center justify-between">
507
+ <label className="block text-xs font-bold text-gray-700">
508
+ Linked Pane
509
+ </label>
510
+ {chapter.linkedPaneId && (
511
+ <button
512
+ type="button"
513
+ onClick={() =>
514
+ handleUnlinkPane(chapter.id)
515
+ }
516
+ className="flex items-center text-xs text-red-600 hover:underline"
517
+ >
518
+ <XMarkIcon className="mr-1 h-3 w-3" />{' '}
519
+ Unlink
520
+ </button>
521
+ )}
522
+ </div>
523
+ <ActionBuilderSlugSelector
524
+ type="pane"
525
+ value={getLinkedPaneSlug(
526
+ chapter.linkedPaneId
527
+ )}
528
+ onSelect={(slug: string) =>
529
+ handlePaneSelect(chapter.id, slug)
530
+ }
531
+ label="Linked Pane"
532
+ placeholder="Select a pane"
533
+ contentMap={contentMapForSelector}
534
+ parentSlug={storyFragmentNode?.slug}
535
+ />
536
+ </div>
537
+ </div>
538
+ </div>
539
+ ))}
540
+ </div>
541
+ </div>
542
+ <div className="rounded-lg border border-gray-200 bg-white shadow-sm">
543
+ <div className="flex items-center justify-between border-b border-gray-200 p-3">
544
+ <h3 className="text-base font-bold text-gray-900">
545
+ Chapter Links
546
+ </h3>
547
+ <button
548
+ onClick={handleCopyAll}
549
+ className="flex items-center rounded bg-gray-200 px-3 py-1 text-sm font-bold text-gray-700 hover:bg-gray-300"
550
+ >
551
+ {isCopied ? (
552
+ <>
553
+ <CheckIcon className="mr-1 h-4 w-4 text-green-500" />{' '}
554
+ Copied
555
+ </>
556
+ ) : (
557
+ <>
558
+ <ClipboardDocumentIcon className="mr-1 h-4 w-4" />{' '}
559
+ Copy All
560
+ </>
561
+ )}
562
+ </button>
563
+ </div>
564
+ <div className="overflow-y-auto bg-gray-50 p-4 font-mono text-xs">
565
+ {getCanonicalURL() ? (
566
+ <>
567
+ <p className="mb-2 font-bold">
568
+ {getCanonicalURL()}
569
+ </p>
570
+ <p className="mb-3">{getCanonicalURL()}?t=0s</p>
571
+ {chapters
572
+ .filter(
573
+ (c) =>
574
+ c.linkedPaneId &&
575
+ getLinkedPaneSlug(c.linkedPaneId)
576
+ )
577
+ .map((chapter) => (
578
+ <div
579
+ key={chapter.id}
580
+ className="mb-3 border-t pt-3"
581
+ >
582
+ <p className="mb-1 italic">{chapter.title}</p>
583
+ <p>{`${getCanonicalURL()}#${getLinkedPaneSlug(chapter.linkedPaneId)}`}</p>
584
+ <p>{`${getCanonicalURL()}?t=${chapter.startTime}s`}</p>
585
+ </div>
586
+ ))}
587
+ </>
588
+ ) : (
589
+ <p className="text-gray-500">
590
+ Canonical URL not available.
591
+ </p>
592
+ )}
593
+ </div>
594
+ </div>
595
+ </div>
596
+ </div>
597
+ <div className="flex-shrink-0 justify-end border-t border-gray-200 bg-white px-6 py-3">
598
+ <Dialog.CloseTrigger asChild>
599
+ <button className="rounded bg-gray-600 px-4 py-2 text-sm font-bold text-white hover:bg-gray-700">
600
+ Close
601
+ </button>
602
+ </Dialog.CloseTrigger>
603
+ </div>
604
+ </div>
605
+ </Dialog.Content>
606
+ </Dialog.Positioner>
607
+ </Portal>
608
+ </Dialog.Root>
128
609
  </div>
129
610
  );
130
611
  }
@@ -1,5 +1,6 @@
1
1
  import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
2
2
  import { Combobox } from '@ark-ui/react';
3
+ import { Portal } from '@ark-ui/react/portal';
3
4
  import { createListCollection } from '@ark-ui/react/collection';
4
5
  import ChevronUpDownIcon from '@heroicons/react/24/outline/ChevronUpDownIcon';
5
6
  import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
@@ -42,7 +43,7 @@ const ColorPickerCombo = ({
42
43
 
43
44
  // Add ref and useDropdownDirection hook
44
45
  const comboboxRef = useRef<HTMLDivElement>(null);
45
- const { openAbove, maxHeight } = useDropdownDirection(comboboxRef);
46
+ const { openAbove } = useDropdownDirection(comboboxRef);
46
47
 
47
48
  // Get all available Tailwind color options
48
49
  const allTailwindColorOptions = useMemo(() => {
@@ -223,56 +224,69 @@ const ColorPickerCombo = ({
223
224
  onValueChange={handleTailwindColorChange}
224
225
  onInputValueChange={handleInputChange}
225
226
  selectionBehavior="replace"
227
+ loopFocus={true}
228
+ openOnKeyPress={true}
229
+ composite={true}
230
+ positioning={{
231
+ placement: openAbove ? 'top' : 'bottom',
232
+ gutter: 4,
233
+ sameWidth: true,
234
+ }}
226
235
  >
227
- <div ref={comboboxRef} className="relative max-w-48">
228
- <Combobox.Input
229
- className="border-mydarkgrey focus:border-myblue focus:ring-myblue xs:text-sm w-full rounded-md py-2 pl-3 pr-10 shadow-sm"
230
- placeholder="Search Tailwind colors..."
231
- autoComplete="off"
232
- />
233
- <Combobox.Trigger className="absolute inset-y-0 right-0 flex items-center pr-2">
234
- <ChevronUpDownIcon
235
- className="text-mydarkgrey h-5 w-5"
236
- aria-hidden="true"
236
+ <Combobox.Control ref={comboboxRef}>
237
+ <div className="relative">
238
+ <Combobox.Input
239
+ className="border-mydarkgrey focus:border-myblue focus:ring-myblue xs:text-sm w-full max-w-48 rounded-md py-2 pl-3 pr-10 shadow-sm"
240
+ placeholder="Search Tailwind colors..."
241
+ autoComplete="off"
237
242
  />
238
- </Combobox.Trigger>
239
- <Combobox.Content
240
- className={`xs:text-sm absolute z-10 mt-1 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
241
- openAbove ? 'bottom-full mb-1' : 'top-full'
242
- }`}
243
- style={{ maxHeight }}
244
- >
245
- {filteredColors.length === 0 ? (
246
- <div className="relative cursor-default select-none py-2 pl-3 pr-4 text-black">
247
- Nothing found.
248
- </div>
249
- ) : (
250
- filteredColors.map((color) => (
251
- <Combobox.Item
252
- key={color}
253
- item={color}
254
- className="color-item relative cursor-default select-none py-2 pl-3 pr-4"
255
- >
256
- <div className="flex items-center">
257
- <div
258
- className="mr-3 h-6 w-6 flex-shrink-0 rounded"
259
- style={{
260
- backgroundColor: tailwindToHex(
261
- `bg-${color}`,
262
- config.BRAND_COLOURS || null
263
- ),
264
- }}
265
- />
266
- <span className="block truncate">{color}</span>
267
- <span className="color-indicator absolute inset-y-0 right-0 flex items-center pr-3 text-cyan-600">
268
- <CheckIcon className="h-5 w-5" aria-hidden="true" />
269
- </span>
270
- </div>
271
- </Combobox.Item>
272
- ))
273
- )}
274
- </Combobox.Content>
275
- </div>
243
+ <Combobox.Trigger className="absolute inset-y-0 right-0 flex items-center pr-2">
244
+ <ChevronUpDownIcon
245
+ className="text-mydarkgrey h-5 w-5"
246
+ aria-hidden="true"
247
+ />
248
+ </Combobox.Trigger>
249
+ </div>
250
+ </Combobox.Control>
251
+
252
+ <Portal>
253
+ <Combobox.Positioner style={{ zIndex: 1002 }}>
254
+ <Combobox.Content className="xs:text-sm max-h-64 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
255
+ {filteredColors.length === 0 ? (
256
+ <div className="relative cursor-default select-none py-2 pl-3 pr-4 text-black">
257
+ Nothing found.
258
+ </div>
259
+ ) : (
260
+ filteredColors.map((color) => (
261
+ <Combobox.Item
262
+ key={color}
263
+ item={color}
264
+ className="color-item relative cursor-default select-none py-2 pl-3 pr-4"
265
+ >
266
+ <div className="flex items-center">
267
+ <div
268
+ className="mr-3 h-6 w-6 flex-shrink-0 rounded"
269
+ style={{
270
+ backgroundColor: tailwindToHex(
271
+ `bg-${color}`,
272
+ config.BRAND_COLOURS || null
273
+ ),
274
+ }}
275
+ />
276
+ <span className="block truncate">{color}</span>
277
+ <span className="color-indicator absolute inset-y-0 right-0 flex items-center pr-3 text-cyan-600">
278
+ <CheckIcon
279
+ className="h-5 w-5"
280
+ aria-hidden="true"
281
+ />
282
+ </span>
283
+ </div>
284
+ </Combobox.Item>
285
+ ))
286
+ )}
287
+ </Combobox.Content>
288
+ </Combobox.Positioner>
289
+ </Portal>
276
290
  </Combobox.Root>
277
291
  </div>
278
292
  )}
@@ -30,7 +30,9 @@ let storyData;
30
30
  try {
31
31
  storyData = await getStoryData(Astro, lookup, sessionId, tenantId);
32
32
  } catch (error) {
33
- if (error instanceof Response) {
33
+ if (error instanceof Response && error.status === 404) {
34
+ return Astro.redirect('/404');
35
+ } else if (error instanceof Response) {
34
36
  return error;
35
37
  }
36
38
  console.error('Error fetching storyfragment:', error);
@@ -2190,9 +2190,10 @@ export class NodesContext {
2190
2190
  getAllBunnyVideoInfo(): { url: string; title: string; videoId: string }[] {
2191
2191
  const results: { url: string; title: string; videoId: string }[] = [];
2192
2192
  const processedVideoIds = new Set<string>();
2193
-
2194
- // Find panes with bunny-video code hook
2195
2193
  const allNodes = Array.from(this.allNodes.get().values());
2194
+ const BUNNY_EMBED_BASE_URL = 'https://iframe.mediadelivery.net/embed/';
2195
+
2196
+ // Process pane-level bunny videos (which use full URLs)
2196
2197
  const paneNodes = allNodes.filter(
2197
2198
  (node) =>
2198
2199
  node.nodeType === 'Pane' &&
@@ -2200,7 +2201,6 @@ export class NodesContext {
2200
2201
  node.codeHookTarget === 'bunny-video'
2201
2202
  ) as PaneNode[];
2202
2203
 
2203
- // Process pane-level bunny videos
2204
2204
  for (const paneNode of paneNodes) {
2205
2205
  try {
2206
2206
  if (
@@ -2243,7 +2243,7 @@ export class NodesContext {
2243
2243
  }
2244
2244
  }
2245
2245
 
2246
- // Find inline bunny widgets
2246
+ // Process inline bunny widgets (which use ID/GUID fragments)
2247
2247
  const codeNodes = allNodes.filter(
2248
2248
  (node) =>
2249
2249
  node.nodeType === 'TagElement' &&
@@ -2255,47 +2255,26 @@ export class NodesContext {
2255
2255
  node.copy.includes('bunny(')
2256
2256
  ) as FlatNode[];
2257
2257
 
2258
- // Process inline widgets
2259
2258
  for (const codeNode of codeNodes) {
2260
2259
  if (
2261
2260
  Array.isArray(codeNode.codeHookParams) &&
2262
- codeNode.codeHookParams.length >= 2
2261
+ codeNode.codeHookParams.length > 0
2263
2262
  ) {
2264
- const urlParam = codeNode.codeHookParams[0];
2265
- const titleParam = codeNode.codeHookParams[1];
2266
-
2267
- const url = Array.isArray(urlParam)
2268
- ? urlParam[0]
2269
- : String(urlParam || '');
2270
- const title = Array.isArray(titleParam)
2271
- ? titleParam[0]
2272
- : String(titleParam || 'Untitled Video');
2273
-
2274
- if (url) {
2275
- let videoId = '';
2276
- try {
2277
- const urlObj = new URL(url);
2278
- if (
2279
- urlObj.hostname === 'iframe.mediadelivery.net' &&
2280
- urlObj.pathname.startsWith('/embed/')
2281
- ) {
2282
- const pathParts = urlObj.pathname.split('/');
2283
- if (pathParts.length >= 4) {
2284
- videoId = `${pathParts[2]}/${pathParts[3]}`;
2285
- }
2286
- }
2287
- } catch (error) {
2288
- console.error('Error extracting video ID from URL:', error);
2289
- }
2290
-
2291
- if (videoId && !processedVideoIds.has(videoId)) {
2292
- results.push({ url, title, videoId });
2263
+ const videoId = String(codeNode.codeHookParams[0] || '');
2264
+ const title = String(codeNode.codeHookParams[1] || 'Untitled Video');
2265
+
2266
+ if (videoId && /^\d+\/[a-f0-9\-]{36}$/.test(videoId)) {
2267
+ if (!processedVideoIds.has(videoId)) {
2268
+ results.push({
2269
+ url: `${BUNNY_EMBED_BASE_URL}${videoId}`,
2270
+ title,
2271
+ videoId,
2272
+ });
2293
2273
  processedVideoIds.add(videoId);
2294
2274
  }
2295
2275
  }
2296
2276
  }
2297
2277
  }
2298
-
2299
2278
  return results;
2300
2279
  }
2301
2280