astro-tractstack 2.0.13 → 2.0.14

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.
@@ -0,0 +1,573 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import {
4
+ Dialog,
5
+ Select,
6
+ Combobox,
7
+ Pagination,
8
+ Portal,
9
+ type SelectValueChangeDetails,
10
+ type ComboboxInputValueChangeDetails,
11
+ type PaginationPageChangeDetails,
12
+ } from '@ark-ui/react';
13
+ import { createListCollection } from '@ark-ui/react/collection';
14
+ import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
15
+ import { selectionStore } from '@/stores/selection';
16
+ import { getCtx, NodesContext } from '@/stores/nodes';
17
+ import { createEmptyStorykeep } from '@/utils/compositor/nodesHelper';
18
+ import {
19
+ extractPaneCopy,
20
+ mergeCopyIntoTemplate,
21
+ convertStorageToLiveTemplate,
22
+ } from '@/utils/compositor/designLibraryHelper';
23
+ import type {
24
+ PaneNode,
25
+ StoragePane,
26
+ TemplatePane,
27
+ TemplateMarkdown, // Added import
28
+ BaseNode, // Added import
29
+ } from '@/types/compositorTypes';
30
+ import type { BrandConfig, DesignLibraryEntry } from '@/types/tractstack';
31
+ import {
32
+ PaneSnapshotGenerator,
33
+ type SnapshotData,
34
+ } from '@/components/compositor/preview/PaneSnapshotGenerator';
35
+ import {
36
+ PanesPreviewGenerator,
37
+ type PanePreviewRequest,
38
+ type PaneFragmentResult,
39
+ } from '@/components/compositor/preview/PanesPreviewGenerator';
40
+ import { classNames } from '@/utils/helpers';
41
+
42
+ const PAGE_SIZE = 6;
43
+ const VERBOSE = false;
44
+
45
+ interface TemplatePreviewItemProps {
46
+ template: TemplatePane;
47
+ config: BrandConfig;
48
+ onClick: () => void;
49
+ }
50
+
51
+ const TemplatePreviewItem = ({
52
+ template,
53
+ config,
54
+ onClick,
55
+ }: TemplatePreviewItemProps) => {
56
+ const [previewState, setPreviewState] = useState<{
57
+ htmlFragment?: string;
58
+ snapshot?: SnapshotData;
59
+ error?: string;
60
+ } | null>(null);
61
+
62
+ const fragmentRequest = useMemo((): PanePreviewRequest[] => {
63
+ // This preview logic is correct: it creates a *temporary* context.
64
+ const ctx = new NodesContext();
65
+ ctx.addNode(createEmptyStorykeep('tmp'));
66
+ ctx.addTemplatePane('tmp', template);
67
+ return [{ id: template.id, ctx }];
68
+ }, [template]);
69
+
70
+ const handleFragmentComplete = (results: PaneFragmentResult[]) => {
71
+ const result = results[0];
72
+ if (result?.htmlString) {
73
+ setPreviewState({ htmlFragment: result.htmlString });
74
+ } else {
75
+ setPreviewState({
76
+ error: result?.error || 'Failed to generate HTML fragment.',
77
+ });
78
+ }
79
+ };
80
+
81
+ const handleSnapshotComplete = (data: SnapshotData) => {
82
+ setPreviewState((prev) => (prev ? { ...prev, snapshot: data } : null));
83
+ };
84
+
85
+ return (
86
+ <div
87
+ className="cursor-pointer rounded-lg border bg-white shadow-sm transition-all hover:shadow-lg"
88
+ onClick={onClick}
89
+ >
90
+ <div className="h-64 overflow-hidden rounded-t-lg border-b bg-gray-50">
91
+ {!previewState && (
92
+ <div className="h-full w-full animate-pulse bg-gray-200" />
93
+ )}
94
+
95
+ {previewState?.error && (
96
+ <div className="flex h-full items-center justify-center p-4">
97
+ <p className="text-xs text-red-500">{previewState.error}</p>
98
+ </div>
99
+ )}
100
+
101
+ {fragmentRequest.length > 0 && !previewState?.htmlFragment && (
102
+ <PanesPreviewGenerator
103
+ requests={fragmentRequest}
104
+ onComplete={handleFragmentComplete}
105
+ onError={(err) => setPreviewState({ error: err })}
106
+ />
107
+ )}
108
+
109
+ {previewState?.htmlFragment && !previewState.snapshot && (
110
+ <PaneSnapshotGenerator
111
+ id={template.id}
112
+ htmlString={previewState.htmlFragment}
113
+ outputWidth={800}
114
+ config={config}
115
+ onComplete={(_id, data) => handleSnapshotComplete(data)}
116
+ onError={(_id, err) =>
117
+ setPreviewState((prev) =>
118
+ prev ? { ...prev, error: err } : { error: err }
119
+ )
120
+ }
121
+ />
122
+ )}
123
+
124
+ {previewState?.snapshot && (
125
+ <img
126
+ src={previewState.snapshot.imageData}
127
+ alt={`Preview for ${template.title}`}
128
+ className="block w-full"
129
+ />
130
+ )}
131
+ </div>
132
+ <div className="p-3">
133
+ <h3 className="truncate font-semibold" title={template.title}>
134
+ {template.title}
135
+ </h3>
136
+ <p className="text-sm text-gray-600">
137
+ {(template as any).category || 'Uncategorized'}
138
+ </p>
139
+ </div>
140
+ </div>
141
+ );
142
+ };
143
+
144
+ interface RestylePaneModalProps {
145
+ config: BrandConfig;
146
+ }
147
+
148
+ export const RestylePaneModal = ({ config }: RestylePaneModalProps) => {
149
+ const ctx = getCtx();
150
+ const { isRestyleModalOpen, paneToRestyleId } = useStore(selectionStore, {
151
+ keys: ['isRestyleModalOpen', 'paneToRestyleId'],
152
+ });
153
+ const designLibrary = config?.DESIGN_LIBRARY || [];
154
+
155
+ const [selectedCategory, setSelectedCategory] = useState<string>('all');
156
+ const [searchTerm, setSearchTerm] = useState('');
157
+ const [currentPage, setCurrentPage] = useState(1);
158
+
159
+ const categories = useMemo(() => {
160
+ const allCategories = new Set(
161
+ designLibrary.map((entry: DesignLibraryEntry) => entry.category)
162
+ );
163
+ return ['all', ...Array.from(allCategories)];
164
+ }, [designLibrary]);
165
+
166
+ const originalPaneData = useMemo(() => {
167
+ if (!paneToRestyleId) return null;
168
+ const paneNode = ctx.allNodes.get().get(paneToRestyleId) as PaneNode;
169
+ if (!paneNode) {
170
+ console.error(
171
+ 'DEBUG: originalPaneData - FAILED. PaneNode not found for id:',
172
+ paneToRestyleId
173
+ );
174
+ return null;
175
+ }
176
+
177
+ const copy = extractPaneCopy(paneNode);
178
+
179
+ if (VERBOSE)
180
+ console.log('DEBUG: originalPaneData (SUCCESS)', {
181
+ paneId: paneNode.id,
182
+ extractedCopy: copy,
183
+ });
184
+
185
+ return { paneNode, copy };
186
+ }, [paneToRestyleId, isRestyleModalOpen]);
187
+
188
+ const filteredEntries = useMemo(() => {
189
+ return designLibrary.filter(
190
+ (entry: DesignLibraryEntry) =>
191
+ (selectedCategory === 'all' || entry.category === selectedCategory) &&
192
+ entry.title.toLowerCase().includes(searchTerm.toLowerCase())
193
+ );
194
+ }, [designLibrary, selectedCategory, searchTerm]);
195
+
196
+ const paginatedEntries = useMemo(() => {
197
+ const start = (currentPage - 1) * PAGE_SIZE;
198
+ const end = start + PAGE_SIZE;
199
+ return filteredEntries.slice(start, end);
200
+ }, [filteredEntries, currentPage]);
201
+
202
+ const totalPages = Math.ceil(filteredEntries.length / PAGE_SIZE);
203
+
204
+ const mergedTemplates = useMemo<
205
+ { entry: DesignLibraryEntry; template: TemplatePane }[]
206
+ >(() => {
207
+ if (!originalPaneData) return [];
208
+
209
+ return paginatedEntries.map((entry: DesignLibraryEntry) => {
210
+ const mergedStoragePane: StoragePane = mergeCopyIntoTemplate(
211
+ entry.template,
212
+ originalPaneData.copy
213
+ );
214
+ const liveTemplatePane: TemplatePane =
215
+ convertStorageToLiveTemplate(mergedStoragePane);
216
+ liveTemplatePane.title = entry.title;
217
+ (liveTemplatePane as any).category = entry.category;
218
+ return { entry, template: liveTemplatePane };
219
+ });
220
+ }, [paginatedEntries, originalPaneData]);
221
+
222
+ if (VERBOSE)
223
+ console.log('DEBUG: Final mergedTemplates array:', mergedTemplates);
224
+
225
+ const handleClose = () => {
226
+ selectionStore.setKey('isRestyleModalOpen', false);
227
+ selectionStore.setKey('paneToRestyleId', null);
228
+ setCurrentPage(1);
229
+ setSearchTerm('');
230
+ setSelectedCategory('all');
231
+ };
232
+
233
+ const handleSelectTemplate = (template: TemplatePane) => {
234
+ if (VERBOSE)
235
+ console.log(
236
+ '%cDEBUG: handleSelectTemplate CLICKED (Hollow & Replace)',
237
+ 'color: #00A; font-weight: bold;',
238
+ {
239
+ templateToApply: template,
240
+ originalPaneData: originalPaneData,
241
+ }
242
+ );
243
+
244
+ if (!originalPaneData) {
245
+ console.error(
246
+ '%cDEBUG: handleSelectTemplate FAILED: originalPaneData is null.',
247
+ 'color: red; font-weight: bold;'
248
+ );
249
+ return;
250
+ }
251
+
252
+ const originalPane = originalPaneData.paneNode;
253
+ const originalPaneId = originalPane.id;
254
+
255
+ if (VERBOSE)
256
+ console.log(
257
+ `%cDEBUG: STEP 1 - HOLLOWING OUT original pane ${originalPaneId}`,
258
+ 'color: #A0A; font-weight: bold;'
259
+ );
260
+ const oldChildrenNodes = ctx
261
+ .getChildNodeIDs(originalPaneId)
262
+ .map((id) => ctx.allNodes.get().get(id));
263
+ if (VERBOSE)
264
+ console.log(
265
+ '%cDEBUG: Original pane children BEFORE delete:',
266
+ oldChildrenNodes
267
+ );
268
+
269
+ const deletedChildren = ctx.deleteChildren(originalPaneId); // This deletes *children*, not the pane itself
270
+
271
+ const childrenAfterDelete = ctx.getChildNodeIDs(originalPaneId);
272
+ if (VERBOSE) {
273
+ console.log(
274
+ `%cDEBUG: Deleted ${deletedChildren.length} old child nodes.`,
275
+ 'color: #A0A;'
276
+ );
277
+ console.log(
278
+ `%cDEBUG: Original pane children IDs AFTER delete: [${childrenAfterDelete.join(', ')}]`,
279
+ 'color: #A0A;'
280
+ );
281
+ console.log(
282
+ `%cDEBUG: STEP 2 - REFILLING pane with new nodes...`,
283
+ 'color: #0A0; font-weight: bold;'
284
+ );
285
+ }
286
+
287
+ const newNodesToAdd: BaseNode[] = [];
288
+ const newMarkdown = template.markdown as TemplateMarkdown | undefined;
289
+ const newBgPane = template.bgPane;
290
+
291
+ if (newMarkdown) {
292
+ // Re-parent the new Markdown node to the original pane
293
+ newMarkdown.parentId = originalPaneId;
294
+ newNodesToAdd.push(newMarkdown);
295
+
296
+ // The markdown.nodes are already parented to the newMarkdown.id, which is correct.
297
+ // We just need to add them to the context.
298
+ if (newMarkdown.nodes) {
299
+ newNodesToAdd.push(...newMarkdown.nodes);
300
+ }
301
+ if (VERBOSE) {
302
+ console.log(`%cDEBUG: Prepared new Markdown node:`, newMarkdown);
303
+ console.log(
304
+ `%cDEBUG: Prepared ${newMarkdown.nodes?.length || 0} new markdown sub-nodes:`,
305
+ newMarkdown.nodes
306
+ );
307
+ }
308
+ }
309
+
310
+ if (newBgPane) {
311
+ // Re-parent the new BgPane node to the original pane
312
+ newBgPane.parentId = originalPaneId;
313
+ newNodesToAdd.push(newBgPane);
314
+ if (VERBOSE) console.log(`%cDEBUG: Prepared new BgPane:`, newBgPane);
315
+ }
316
+
317
+ ctx.addNodes(newNodesToAdd); // This adds all nodes AND links them in parentNodes map
318
+
319
+ const childrenAfterAdd = ctx.getChildNodeIDs(originalPaneId);
320
+ const childrenNodesAfterAdd = childrenAfterAdd.map((id) =>
321
+ ctx.allNodes.get().get(id)
322
+ );
323
+ if (VERBOSE) {
324
+ console.log(
325
+ `%cDEBUG: Original pane children IDs AFTER add: [${childrenAfterAdd.join(', ')}]`,
326
+ 'color: #0A0;'
327
+ );
328
+ console.log(
329
+ `%cDEBUG: Original pane children nodes AFTER add:`,
330
+ childrenNodesAfterAdd
331
+ );
332
+ console.log(
333
+ `%cDEBUG: STEP 3 - UPDATING original pane properties...`,
334
+ 'color: #00F; font-weight: bold;'
335
+ );
336
+ }
337
+
338
+ // We must get a fresh reference from the store to modify
339
+ const paneToUpdate = ctx.allNodes.get().get(originalPaneId) as PaneNode;
340
+
341
+ if (!paneToUpdate) {
342
+ console.error(
343
+ `%cDEBUG: FAILED TO FIND PANE ${originalPaneId} IN STORE FOR FINAL UPDATE.`,
344
+ 'color: red; font-weight: bold;'
345
+ );
346
+ return;
347
+ }
348
+
349
+ // Copy all style/config properties from the template, but keep the original ID, parentId, slug, title
350
+ paneToUpdate.bgColour = template.bgColour;
351
+ paneToUpdate.isDecorative = template.isDecorative;
352
+ paneToUpdate.heightOffsetDesktop = template.heightOffsetDesktop;
353
+ paneToUpdate.heightOffsetMobile = template.heightOffsetMobile;
354
+ paneToUpdate.heightOffsetTablet = template.heightOffsetTablet;
355
+ paneToUpdate.heightRatioDesktop = template.heightRatioDesktop;
356
+ paneToUpdate.heightRatioMobile = template.heightRatioMobile;
357
+ paneToUpdate.heightRatioTablet = template.heightRatioTablet;
358
+ paneToUpdate.isChanged = true; // Mark as dirty
359
+
360
+ if (VERBOSE)
361
+ console.log(
362
+ `%cDEBUG: Calling modifyNodes with this pane object:`,
363
+ paneToUpdate
364
+ );
365
+ ctx.modifyNodes([paneToUpdate]); // This will save the changes and notify the UI
366
+
367
+ if (VERBOSE) {
368
+ console.log(
369
+ '%cDEBUG: handleSelectTemplate FINISHED.',
370
+ 'color: #00A; font-weight: bold;'
371
+ );
372
+ console.log(
373
+ '%cDEBUG: Notifying ROOT_NODE to force re-render.',
374
+ 'color: green; font-weight: bold;'
375
+ );
376
+ }
377
+ ctx.notifyNode('root');
378
+
379
+ handleClose();
380
+ };
381
+
382
+ const comboboxCollection = useMemo(
383
+ () =>
384
+ createListCollection({
385
+ items: filteredEntries,
386
+ itemToValue: (item) => item.title,
387
+ itemToString: (item) => item.title,
388
+ }),
389
+ [filteredEntries]
390
+ );
391
+
392
+ const selectCollection = useMemo(
393
+ () =>
394
+ createListCollection({
395
+ items: categories.map((c) => ({ label: c, value: c })),
396
+ itemToValue: (item) => item.value,
397
+ itemToString: (item) => item.label,
398
+ }),
399
+ [categories]
400
+ );
401
+
402
+ return (
403
+ <Dialog.Root open={isRestyleModalOpen} onOpenChange={handleClose} modal>
404
+ <Portal>
405
+ <Dialog.Backdrop className="z-103 fixed inset-0 bg-black/70" />
406
+ <Dialog.Positioner className="z-104 fixed inset-0 flex items-center justify-center">
407
+ <Dialog.Content className="flex h-[90vh] w-[90vw] flex-col rounded-lg bg-white shadow-2xl">
408
+ <header className="flex items-center justify-between border-b p-4">
409
+ <Dialog.Title className="text-xl font-semibold">
410
+ Restyle Pane from Design Library
411
+ </Dialog.Title>
412
+ <Dialog.CloseTrigger
413
+ type="button"
414
+ className="rounded-full p-1 text-gray-600 hover:bg-gray-100"
415
+ >
416
+ <XMarkIcon className="h-6 w-6" />
417
+ </Dialog.CloseTrigger>
418
+ </header>
419
+
420
+ <nav className="flex items-center gap-x-4 border-b bg-gray-50 p-4">
421
+ <Select.Root
422
+ collection={selectCollection}
423
+ value={[selectedCategory]}
424
+ onValueChange={(details: SelectValueChangeDetails) =>
425
+ setSelectedCategory(details.value[0])
426
+ }
427
+ className="w-48"
428
+ positioning={{ gutter: 4 }}
429
+ >
430
+ <Select.Label className="mb-1 text-sm font-medium">
431
+ Category
432
+ </Select.Label>
433
+ <Select.Control>
434
+ <Select.Trigger className="flex w-full items-center justify-between rounded border bg-white p-2 text-left">
435
+ <Select.ValueText />
436
+ <Select.Indicator>▼</Select.Indicator>
437
+ </Select.Trigger>
438
+ </Select.Control>
439
+ <Portal>
440
+ <Select.Positioner>
441
+ <Select.Content className="z-105 rounded border bg-white shadow-lg">
442
+ {categories.map((c) => (
443
+ <Select.Item
444
+ key={c}
445
+ item={{ label: c, value: c }}
446
+ className="cursor-pointer p-2 hover:bg-gray-100"
447
+ >
448
+ <Select.ItemText>{c}</Select.ItemText>
449
+ </Select.Item>
450
+ ))}
451
+ </Select.Content>
452
+ </Select.Positioner>
453
+ </Portal>
454
+ </Select.Root>
455
+
456
+ <Combobox.Root
457
+ collection={comboboxCollection}
458
+ onInputValueChange={(e: ComboboxInputValueChangeDetails) =>
459
+ setSearchTerm(e.inputValue)
460
+ }
461
+ className="flex-1"
462
+ positioning={{ gutter: 4 }}
463
+ >
464
+ <Combobox.Label className="mb-1 text-sm font-medium">
465
+ Filter by Title
466
+ </Combobox.Label>
467
+ <Combobox.Control>
468
+ <Combobox.Input
469
+ placeholder="Search by title..."
470
+ className="w-full rounded border p-2"
471
+ />
472
+ </Combobox.Control>
473
+ <Portal>
474
+ <Combobox.Positioner>
475
+ <Combobox.Content className="z-105 rounded border bg-white shadow-lg">
476
+ {filteredEntries.map((entry: DesignLibraryEntry) => (
477
+ <Combobox.Item
478
+ key={entry.title}
479
+ item={entry}
480
+ className="cursor-pointer p-2 hover:bg-gray-100"
481
+ >
482
+ <Combobox.ItemText>{entry.title}</Combobox.ItemText>
483
+ </Combobox.Item>
484
+ ))}
485
+ </Combobox.Content>
486
+ </Combobox.Positioner>
487
+ </Portal>
488
+ </Combobox.Root>
489
+ </nav>
490
+
491
+ <main className="flex-1 overflow-y-auto bg-gray-100 p-6">
492
+ {mergedTemplates.length === 0 ? (
493
+ <div className="flex h-full items-center justify-center">
494
+ <p className="text-gray-500">No designs found.</p>
495
+ </div>
496
+ ) : (
497
+ <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
498
+ {mergedTemplates.map(({ template }) => (
499
+ <TemplatePreviewItem
500
+ key={template.id}
501
+ template={template}
502
+ config={config}
503
+ onClick={() => handleSelectTemplate(template)}
504
+ />
505
+ ))}
506
+ </div>
507
+ )}
508
+ </main>
509
+
510
+ {totalPages > 1 && (
511
+ <footer className="flex items-center justify-center border-t p-4">
512
+ <Pagination.Root
513
+ count={totalPages * PAGE_SIZE}
514
+ pageSize={PAGE_SIZE}
515
+ siblingCount={1}
516
+ page={currentPage}
517
+ onPageChange={(details: PaginationPageChangeDetails) =>
518
+ setCurrentPage(details.page)
519
+ }
520
+ className="flex items-center gap-x-2"
521
+ >
522
+ <Pagination.PrevTrigger
523
+ type="button"
524
+ className="rounded p-2 text-sm hover:bg-gray-100 disabled:text-gray-400"
525
+ disabled={currentPage === 1}
526
+ >
527
+ Previous
528
+ </Pagination.PrevTrigger>
529
+ <Pagination.Context>
530
+ {(pagination) =>
531
+ pagination.pages.map((page, index: number) =>
532
+ page.type === 'page' ? (
533
+ <Pagination.Item
534
+ key={index}
535
+ {...page}
536
+ type="page"
537
+ className={classNames(
538
+ 'flex h-9 w-9 items-center justify-center rounded text-sm',
539
+ page.value === currentPage
540
+ ? 'bg-blue-600 font-bold text-white'
541
+ : 'hover:bg-gray-100'
542
+ )}
543
+ >
544
+ {page.value}
545
+ </Pagination.Item>
546
+ ) : (
547
+ <Pagination.Ellipsis
548
+ key={index}
549
+ index={index}
550
+ className="px-2 text-sm"
551
+ >
552
+ ...
553
+ </Pagination.Ellipsis>
554
+ )
555
+ )
556
+ }
557
+ </Pagination.Context>
558
+ <Pagination.NextTrigger
559
+ type="button"
560
+ className="rounded p-2 text-sm hover:bg-gray-100 disabled:text-gray-400"
561
+ disabled={currentPage === totalPages}
562
+ >
563
+ Next
564
+ </Pagination.NextTrigger>
565
+ </Pagination.Root>
566
+ </footer>
567
+ )}
568
+ </Dialog.Content>
569
+ </Dialog.Positioner>
570
+ </Portal>
571
+ </Dialog.Root>
572
+ );
573
+ };