astro-tractstack 2.0.0-rc.9 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +8 -97
  2. package/README.md +7 -5
  3. package/bin/create-tractstack.js +31 -8
  4. package/dist/index.js +106 -29
  5. package/package.json +10 -5
  6. package/templates/css/frontend.css +1 -1
  7. package/templates/custom/minimal/CodeHook.astro +13 -12
  8. package/templates/custom/minimal/CustomRoutes.astro +25 -31
  9. package/templates/custom/with-examples/CodeHook.astro +22 -11
  10. package/templates/custom/with-examples/CustomRoutes.astro +4 -8
  11. package/templates/custom/with-examples/ProductCard.astro +29 -0
  12. package/templates/custom/with-examples/ProductCardWrapper.astro +43 -0
  13. package/templates/custom/with-examples/ProductGrid.astro +64 -0
  14. package/templates/custom/with-examples/pages/Collections.astro +58 -98
  15. package/templates/gitignore +42 -0
  16. package/templates/prettierignore +5 -0
  17. package/templates/prettierrc +19 -0
  18. package/templates/src/client/app.js +127 -0
  19. package/templates/src/client/htmx.min.js +3519 -0
  20. package/templates/src/client/view.js +429 -0
  21. package/templates/src/components/Footer.astro +4 -9
  22. package/templates/src/components/Header.astro +67 -60
  23. package/templates/src/components/Menu.tsx +188 -52
  24. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +2 -2
  25. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +9 -13
  26. package/templates/src/components/codehooks/EpinetTableView.tsx +11 -7
  27. package/templates/src/components/codehooks/EpinetWrapper.tsx +1 -0
  28. package/templates/src/components/codehooks/FeaturedArticle.astro +105 -0
  29. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +318 -0
  30. package/templates/src/components/codehooks/ListContent.astro +32 -162
  31. package/templates/src/components/codehooks/ListContentSetup.tsx +43 -138
  32. package/templates/src/components/codehooks/ProductCardSetup.tsx +152 -0
  33. package/templates/src/components/codehooks/ProductGridSetup.tsx +274 -0
  34. package/templates/src/components/codehooks/SearchWidget.tsx +453 -0
  35. package/templates/src/components/compositor/Node.tsx +3 -6
  36. package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +21 -11
  37. package/templates/src/components/compositor/elements/BunnyVideo.tsx +21 -20
  38. package/templates/src/components/compositor/nodes/Pane.tsx +51 -21
  39. package/templates/src/components/compositor/nodes/RenderChildren.tsx +6 -1
  40. package/templates/src/components/compositor/nodes/Widget.tsx +16 -2
  41. package/templates/src/components/compositor/preview/FeaturedArticlePreview.tsx +155 -0
  42. package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +20 -1
  43. package/templates/src/components/edit/Header.tsx +10 -4
  44. package/templates/src/components/edit/PanelSwitch.tsx +11 -7
  45. package/templates/src/components/edit/SettingsPanel.tsx +29 -18
  46. package/templates/src/components/edit/ToolBar.tsx +1 -28
  47. package/templates/src/components/edit/ToolMode.tsx +45 -32
  48. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +12 -2
  49. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +8 -2
  50. package/templates/src/components/edit/pane/AddPanePanel_newAICopy_modal.tsx +1 -1
  51. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +17 -27
  52. package/templates/src/components/edit/pane/PageGenSelector.tsx +16 -16
  53. package/templates/src/components/edit/pane/PageGenSpecial.tsx +26 -49
  54. package/templates/src/components/edit/pane/PageGen_preview.tsx +17 -2
  55. package/templates/src/components/edit/pane/PanePanel_path.tsx +2 -4
  56. package/templates/src/components/edit/pane/PanePanel_title.tsx +243 -76
  57. package/templates/src/components/edit/panels/StyleBreakPanel.tsx +17 -19
  58. package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +48 -37
  59. package/templates/src/components/edit/panels/StyleElementPanel_add.tsx +60 -55
  60. package/templates/src/components/edit/panels/StyleImagePanel_add.tsx +56 -50
  61. package/templates/src/components/edit/panels/StyleLiElementPanel_add.tsx +54 -47
  62. package/templates/src/components/edit/panels/StyleLinkPanel_add.tsx +54 -44
  63. package/templates/src/components/edit/panels/StyleLinkPanel_config.tsx +113 -138
  64. package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +54 -40
  65. package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +3 -3
  66. package/templates/src/components/edit/panels/StyleWidgetPanel_add.tsx +56 -49
  67. package/templates/src/components/edit/panels/StyleWidgetPanel_config.tsx +14 -5
  68. package/templates/src/components/edit/state/SaveModal.tsx +316 -169
  69. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_og.tsx +1 -1
  70. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_slug.tsx +56 -55
  71. package/templates/src/components/edit/widgets/BunnyWidget.tsx +538 -59
  72. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +656 -0
  73. package/templates/src/components/edit/widgets/ToggleWidget.tsx +9 -16
  74. package/templates/src/components/fields/ArtpackImage.tsx +4 -1
  75. package/templates/src/components/fields/BackgroundImage.tsx +1 -1
  76. package/templates/src/components/fields/BackgroundImageWrapper.tsx +127 -35
  77. package/templates/src/components/fields/ColorPickerCombo.tsx +66 -62
  78. package/templates/src/components/fields/ImageUpload.tsx +1 -1
  79. package/templates/src/components/fields/ViewportComboBox.tsx +59 -42
  80. package/templates/src/components/form/ActionBuilderBeliefSelector.tsx +117 -0
  81. package/templates/src/components/form/ActionBuilderField.tsx +306 -87
  82. package/templates/src/components/search/SearchModal.tsx +420 -0
  83. package/templates/src/components/search/SearchResults.tsx +367 -0
  84. package/templates/src/components/search/SearchWrapper.tsx +46 -0
  85. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +1 -1
  86. package/templates/src/components/storykeep/Dashboard_Analytics.tsx +34 -8
  87. package/templates/src/components/storykeep/Dashboard_Branding.tsx +1 -1
  88. package/templates/src/components/storykeep/Dashboard_Content.tsx +6 -0
  89. package/templates/src/components/storykeep/StoryKeepBackdrop.astro +87 -0
  90. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +38 -34
  91. package/templates/src/components/storykeep/controls/content/KnownResourceForm.tsx +1 -1
  92. package/templates/src/components/storykeep/controls/content/MenuForm.tsx +56 -8
  93. package/templates/src/components/storykeep/controls/content/ResourceForm.tsx +18 -3
  94. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +5 -8
  95. package/templates/src/components/storykeep/state/FetchAnalytics.tsx +274 -228
  96. package/templates/src/components/storykeep/widgets/Wizard.tsx +14 -7
  97. package/templates/src/components/widgets/ImpressionWrapper.tsx +0 -1
  98. package/templates/src/constants/shapes.ts +9 -0
  99. package/templates/src/constants.ts +2121 -16
  100. package/templates/src/hooks/useSearch.ts +228 -0
  101. package/templates/src/layouts/Layout.astro +213 -104
  102. package/templates/src/lib/storyData.ts +4 -1
  103. package/templates/src/pages/[...slug]/edit.astro +14 -14
  104. package/templates/src/pages/[...slug].astro +82 -21
  105. package/templates/src/pages/api/orphan-analysis.ts +0 -1
  106. package/templates/src/pages/api/tailwind.ts +23 -21
  107. package/templates/src/pages/context/[...contextSlug]/edit.astro +14 -14
  108. package/templates/src/pages/context/[...contextSlug].astro +7 -2
  109. package/templates/src/pages/storykeep/advanced.astro +5 -4
  110. package/templates/src/pages/storykeep/branding.astro +5 -4
  111. package/templates/src/pages/storykeep/content.astro +5 -4
  112. package/templates/src/pages/storykeep/init.astro +40 -1
  113. package/templates/src/pages/storykeep/login.astro +1 -1
  114. package/templates/src/pages/storykeep.astro +5 -4
  115. package/templates/src/stores/nodes.ts +59 -88
  116. package/templates/src/stores/orphanAnalysis.ts +19 -21
  117. package/templates/src/stores/storykeep.ts +7 -0
  118. package/templates/src/types/compositorTypes.ts +6 -0
  119. package/templates/src/types/tractstack.ts +17 -0
  120. package/templates/src/utils/actions/lispLexer.ts +2 -2
  121. package/templates/src/utils/actions/preParse_Action.ts +3 -0
  122. package/templates/src/utils/api/beliefHelpers.ts +12 -36
  123. package/templates/src/utils/api/menuHelpers.ts +2 -2
  124. package/templates/src/utils/api.ts +26 -0
  125. package/templates/src/utils/compositor/TemplateNodes.ts +7 -0
  126. package/templates/src/utils/compositor/allowInsert.ts +5 -3
  127. package/templates/src/utils/compositor/nodesHelper.ts +4 -0
  128. package/templates/src/utils/compositor/processMarkdown.ts +16 -2
  129. package/templates/src/utils/compositor/reduceNodesClassNames.ts +4 -0
  130. package/templates/src/utils/compositor/templateMarkdownStyles.ts +13 -13
  131. package/templates/src/utils/compositor/typeGuards.ts +1 -0
  132. package/templates/src/utils/customHelpers.ts +38 -0
  133. package/templates/src/utils/helpers.ts +2 -2
  134. package/templates/src/utils/layout.ts +65 -144
  135. package/utils/inject-files.ts +95 -18
  136. package/templates/src/client/analytics-events.js +0 -207
  137. package/templates/src/client/belief-events.js +0 -191
  138. package/templates/src/client/sse.js +0 -613
  139. package/templates/src/components/codehooks/FeaturedContent.astro +0 -273
  140. package/templates/src/components/codehooks/FeaturedContentSetup.tsx +0 -738
  141. package/templates/src/components/compositor/preview/FeaturedContentPreview.tsx +0 -128
  142. package/templates/src/components/edit/pane/PanePanel_slug.tsx +0 -219
@@ -2,131 +2,610 @@ 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
 
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
+ };
51
+
12
52
  function BunnyWidget({ node, onUpdate }: BunnyWidgetProps) {
13
- const [embedUrl, setEmbedUrl] = useState(
53
+ const [videoId, setVideoId] = useState(
14
54
  String(node.codeHookParams?.[0] || '')
15
55
  );
16
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>>({});
17
60
  const [isDuplicate, setIsDuplicate] = useState(false);
18
61
  const [validationError, setValidationError] = useState<string | null>(null);
62
+ const [isCopied, setIsCopied] = useState(false);
19
63
 
20
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);
21
110
 
22
111
  useEffect(() => {
23
- setEmbedUrl(String(node.codeHookParams?.[0] || ''));
24
- setTitle(String(node.codeHookParams?.[1] || ''));
25
- validateUrl(String(node.codeHookParams?.[0] || ''));
26
- }, [node]);
112
+ const newVideoId = String(node.codeHookParams?.[0] || '');
113
+ const newTitle = String(node.codeHookParams?.[1] || '');
114
+ const chaptersJson = String(node.codeHookParams?.[2] || '');
27
115
 
28
- const checkForDuplicates = (url: string): boolean => {
29
- if (!url) return false;
116
+ setVideoId(newVideoId);
117
+ setTitle(newTitle);
118
+ validateVideoId(newVideoId);
30
119
 
31
- try {
32
- const videoId = extractVideoId(url);
33
- if (!videoId) return false;
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
+ }
135
+ }, [node]);
34
136
 
35
- const ctx = getCtx();
36
- const existingVideos = ctx.getAllBunnyVideoInfo();
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
+ };
37
151
 
38
- // Check if this video ID already exists in another node
39
- return existingVideos.some(
40
- (video) =>
41
- video.videoId === videoId &&
42
- !(node.codeHookParams?.[0] || '').includes(videoId)
152
+ const checkForDuplicates = (id: string): boolean => {
153
+ if (!id) return false;
154
+ try {
155
+ return (
156
+ ctx.getAllBunnyVideoInfo().filter((video) => video.videoId === id)
157
+ .length > 1
43
158
  );
44
159
  } catch (e) {
45
- console.error('Error checking for duplicates:', e);
46
160
  return false;
47
161
  }
48
162
  };
49
163
 
50
- const extractVideoId = (url: string): string | null => {
51
- try {
52
- const match = url.match(/embed\/([^/]+\/[^/?]+)/);
53
- return match ? match[1] : null;
54
- } catch (e) {
55
- console.error('Error extracting video ID:', e);
56
- return null;
57
- }
164
+ const isValidVideoIdFormat = (id: string): boolean => {
165
+ if (!id) return true;
166
+ return /^\d+\/[a-f0-9\-]{36}$/.test(id);
58
167
  };
59
168
 
60
- // Validate URL format and check for duplicates
61
- const validateUrl = (url: string): void => {
62
- if (!url) {
169
+ const validateVideoId = (id: string) => {
170
+ if (!id) {
63
171
  setValidationError(null);
64
172
  setIsDuplicate(false);
65
173
  return;
66
174
  }
67
-
68
- const isValid = isValidUrl(url);
69
- if (!isValid) {
70
- setValidationError('URL should be a valid Bunny embed URL');
175
+ if (!isValidVideoIdFormat(id)) {
176
+ setValidationError(
177
+ "Invalid format. Use 'LibraryID/VideoGUID' from Bunny."
178
+ );
71
179
  setIsDuplicate(false);
72
180
  return;
73
181
  }
74
-
75
- const duplicate = checkForDuplicates(url);
182
+ const duplicate = checkForDuplicates(id);
76
183
  setIsDuplicate(duplicate);
77
184
  setValidationError(
78
- duplicate ? 'This video is already used elsewhere in this page' : null
185
+ duplicate ? 'This video is already used elsewhere on this page.' : null
79
186
  );
80
187
  };
81
188
 
82
- const handleEmbedUrlChange = (value: string) => {
83
- setEmbedUrl(value);
84
- validateUrl(value);
85
- onUpdate([value, title]);
189
+ const handleVideoIdChange = (value: string) => {
190
+ setVideoId(value);
191
+ validateVideoId(value);
192
+ handleUpdate(value, title, chapters);
86
193
  };
87
194
 
88
195
  const handleTitleChange = (value: string) => {
89
196
  setTitle(value);
90
- onUpdate([embedUrl, 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);
91
266
  };
92
267
 
93
- // Validate URL format
94
- const isValidUrl = (url: string): boolean => {
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 || '';
300
+ };
301
+
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 = () => {
95
319
  try {
96
- if (!url) return false;
97
- new URL(url);
98
- return (
99
- url.includes('//iframe.mediadelivery.net/embed/') ||
100
- url.includes('//video.bunnycdn.com/')
101
- );
320
+ return canonicalURLStore.get();
102
321
  } catch (e) {
103
- return false;
322
+ return '';
104
323
  }
105
324
  };
106
325
 
107
326
  return (
108
327
  <div className="space-y-4">
109
328
  <SingleParam
110
- label={widgetInfo.parameters[0].label}
111
- value={embedUrl}
112
- onChange={handleEmbedUrlChange}
329
+ label="Video ID"
330
+ value={videoId}
331
+ onChange={handleVideoIdChange}
332
+ placeholder="e.g., 12345/abcde-12345-fghij-67890"
113
333
  />
114
- {validationError && embedUrl && (
334
+ {validationError && videoId && (
115
335
  <div className="mt-1 text-xs text-red-500">{validationError}</div>
116
336
  )}
117
337
  {isDuplicate && (
118
338
  <div className="rounded border border-yellow-200 bg-yellow-50 p-2 text-xs text-yellow-800">
119
- Warning: This video is already used elsewhere in this page. Using the
120
- same video multiple times may cause playback conflicts. Consider using
121
- a single video with chapter navigation instead.
339
+ Warning: This video is already used elsewhere.
122
340
  </div>
123
341
  )}
124
-
125
342
  <SingleParam
126
343
  label={widgetInfo.parameters[1].label}
127
344
  value={title}
128
345
  onChange={handleTitleChange}
129
346
  />
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>
130
609
  </div>
131
610
  );
132
611
  }