astro-tractstack 2.0.13 → 2.0.15

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 (28) hide show
  1. package/dist/index.js +40 -0
  2. package/package.json +1 -1
  3. package/templates/src/client/view.js +5 -0
  4. package/templates/src/components/compositor/Compositor.tsx +3 -2
  5. package/templates/src/components/compositor/Node.tsx +25 -8
  6. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +105 -0
  7. package/templates/src/components/edit/ToolMode.tsx +7 -0
  8. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +459 -561
  9. package/templates/src/components/edit/pane/AiPaneGenerator.tsx +19 -82
  10. package/templates/src/components/edit/pane/RestylePaneModal.tsx +573 -0
  11. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +140 -0
  12. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +105 -0
  13. package/templates/src/components/edit/pane/steps/DesignLibraryStep.tsx +395 -0
  14. package/templates/src/components/edit/state/SaveToLibraryModal.tsx +205 -0
  15. package/templates/src/constants/prompts.json +3 -1
  16. package/templates/src/stores/selection.ts +4 -0
  17. package/templates/src/types/compositorTypes.ts +51 -1
  18. package/templates/src/types/tractstack.ts +36 -31
  19. package/templates/src/utils/aai/getTitleSlug.ts +1 -1
  20. package/templates/src/utils/api/brandConfig.ts +8 -2
  21. package/templates/src/utils/api/brandHelpers.ts +4 -0
  22. package/templates/src/utils/compositor/aiPaneParser.ts +32 -84
  23. package/templates/src/utils/compositor/designLibraryHelper.ts +416 -0
  24. package/templates/src/utils/compositor/processMarkdown.ts +1 -1
  25. package/utils/inject-files.ts +40 -0
  26. package/templates/src/components/edit/pane/PageGen.tsx +0 -485
  27. package/templates/src/components/edit/pane/PageGenSelector.tsx +0 -245
  28. package/templates/src/components/edit/pane/PageGenSpecial.tsx +0 -339
@@ -0,0 +1,140 @@
1
+ import ColorPickerCombo from '@/components/fields/ColorPickerCombo';
2
+ import type { BrandConfig } from '@/types/tractstack';
3
+
4
+ export interface AiDesignConfig {
5
+ harmony: string;
6
+ baseColor: string;
7
+ accentColor: string;
8
+ theme: string;
9
+ additionalNotes: string;
10
+ }
11
+
12
+ interface AiDesignStepProps {
13
+ config: BrandConfig;
14
+ designConfig: AiDesignConfig;
15
+ onDesignConfigChange: (newConfig: AiDesignConfig) => void;
16
+ }
17
+
18
+ const harmonyOptions = [
19
+ 'Analogous',
20
+ 'Monochromatic',
21
+ 'Complementary',
22
+ 'Triadic',
23
+ ];
24
+ const themeOptions = ['Light', 'Dark', 'Bright', 'Muted', 'Pastel', 'Earthy'];
25
+
26
+ export const AiDesignStep = ({
27
+ config,
28
+ designConfig,
29
+ onDesignConfigChange,
30
+ }: AiDesignStepProps) => {
31
+ const updateField = <K extends keyof AiDesignConfig>(
32
+ field: K,
33
+ value: AiDesignConfig[K]
34
+ ) => {
35
+ onDesignConfigChange({ ...designConfig, [field]: value });
36
+ };
37
+
38
+ return (
39
+ <div className="space-y-6 rounded-lg bg-gray-50 p-4 shadow-inner">
40
+ <label className="block text-lg font-semibold text-gray-800">
41
+ 2. Configure AI Design
42
+ </label>
43
+ <div>
44
+ <label className="block text-base font-semibold text-gray-800">
45
+ Color Harmony
46
+ </label>
47
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
48
+ {harmonyOptions.map((option) => (
49
+ <div key={option} className="flex items-center space-x-2">
50
+ <input
51
+ type="radio"
52
+ id={`harmony-${option}`}
53
+ name="harmonyOptions"
54
+ value={option}
55
+ checked={designConfig.harmony === option}
56
+ onChange={(e) => updateField('harmony', e.target.value)}
57
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
58
+ />
59
+ <label
60
+ htmlFor={`harmony-${option}`}
61
+ className="text-sm font-medium text-gray-700"
62
+ >
63
+ {option}
64
+ </label>
65
+ </div>
66
+ ))}
67
+ </div>
68
+ </div>
69
+
70
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
71
+ <div>
72
+ <ColorPickerCombo
73
+ title="Base Color (Optional)"
74
+ config={config}
75
+ defaultColor={designConfig.baseColor}
76
+ onColorChange={(color) => updateField('baseColor', color)}
77
+ allowNull={true}
78
+ />
79
+ </div>
80
+ <div>
81
+ <ColorPickerCombo
82
+ title="Accent Color (Optional)"
83
+ config={config}
84
+ defaultColor={designConfig.accentColor}
85
+ onColorChange={(color) => updateField('accentColor', color)}
86
+ allowNull={true}
87
+ />
88
+ </div>
89
+ </div>
90
+
91
+ <div>
92
+ <label className="block text-base font-semibold text-gray-800">
93
+ Theme / Mood
94
+ </label>
95
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
96
+ {themeOptions.map((option) => (
97
+ <div key={option} className="flex items-center space-x-2">
98
+ <input
99
+ type="radio"
100
+ id={`theme-${option}`}
101
+ name="themeOptions"
102
+ value={option}
103
+ checked={designConfig.theme === option}
104
+ onChange={(e) => updateField('theme', e.target.value)}
105
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
106
+ />
107
+ <label
108
+ htmlFor={`theme-${option}`}
109
+ className="text-sm font-medium text-gray-700"
110
+ >
111
+ {option}
112
+ </label>
113
+ </div>
114
+ ))}
115
+ </div>
116
+ </div>
117
+
118
+ <div>
119
+ <label
120
+ htmlFor="additional-notes"
121
+ className="block text-base font-semibold text-gray-800"
122
+ >
123
+ Additional Design Notes (Optional)
124
+ </label>
125
+ <p className="mb-2 mt-1 text-sm text-gray-500">
126
+ Add specific requests like "use rounded corners", "add subtle
127
+ texture".
128
+ </p>
129
+ <textarea
130
+ id="additional-notes"
131
+ value={designConfig.additionalNotes}
132
+ onChange={(e) => updateField('additionalNotes', e.target.value)}
133
+ placeholder="Enter additional notes..."
134
+ rows={3}
135
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
136
+ />
137
+ </div>
138
+ </div>
139
+ );
140
+ };
@@ -0,0 +1,105 @@
1
+ import { useEffect } from 'react';
2
+
3
+ type CopyMode = 'prompt' | 'raw';
4
+
5
+ interface CopyInputStepProps {
6
+ copyMode: CopyMode;
7
+ onCopyModeChange: (mode: CopyMode) => void;
8
+ promptValue: string;
9
+ onPromptValueChange: (value: string) => void;
10
+ copyValue: string;
11
+ onCopyValueChange: (value: string) => void;
12
+ defaultPrompt?: string;
13
+ }
14
+
15
+ export const CopyInputStep = ({
16
+ copyMode,
17
+ onCopyModeChange,
18
+ promptValue,
19
+ onPromptValueChange,
20
+ copyValue,
21
+ onCopyValueChange,
22
+ defaultPrompt,
23
+ }: CopyInputStepProps) => {
24
+ useEffect(() => {
25
+ // Pre-populate the prompt field if a default is provided and the field is empty
26
+ if (defaultPrompt && !promptValue) {
27
+ onPromptValueChange(defaultPrompt);
28
+ }
29
+ }, [defaultPrompt, promptValue, onPromptValueChange]);
30
+
31
+ return (
32
+ <div className="space-y-4 rounded-lg bg-gray-50 p-4 shadow-inner">
33
+ <label className="block text-lg font-semibold text-gray-800">
34
+ 1. Provide Content
35
+ </label>
36
+ <div className="my-2 flex space-x-4">
37
+ <div className="flex items-center space-x-2">
38
+ <input
39
+ type="radio"
40
+ id="copy-prompt-mode"
41
+ name="copyModeOptions"
42
+ value="prompt"
43
+ checked={copyMode === 'prompt'}
44
+ onChange={(e) => onCopyModeChange(e.target.value as CopyMode)}
45
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
46
+ />
47
+ <label
48
+ htmlFor="copy-prompt-mode"
49
+ className="text-sm font-medium text-gray-700"
50
+ >
51
+ Write a prompt
52
+ </label>
53
+ </div>
54
+ <div className="flex items-center space-x-2">
55
+ <input
56
+ type="radio"
57
+ id="copy-raw-mode"
58
+ name="copyModeOptions"
59
+ value="raw"
60
+ checked={copyMode === 'raw'}
61
+ onChange={(e) => onCopyModeChange(e.target.value as CopyMode)}
62
+ className="h-4 w-4 border-gray-300 text-cyan-600 focus:ring-cyan-500"
63
+ />
64
+ <label
65
+ htmlFor="copy-raw-mode"
66
+ className="text-sm font-medium text-gray-700"
67
+ >
68
+ Provide Copy (Markdown)
69
+ </label>
70
+ </div>
71
+ </div>
72
+
73
+ {copyMode === 'prompt' ? (
74
+ <>
75
+ <p className="mb-2 text-sm text-gray-500">
76
+ Let the AI write the copy based on your prompt.
77
+ </p>
78
+ <textarea
79
+ id="copy-prompt"
80
+ value={promptValue}
81
+ onChange={(e) => onPromptValueChange(e.target.value)}
82
+ placeholder="e.g., A hero section for a SaaS product that helps teams collaborate..."
83
+ rows={4}
84
+ className="block w-full rounded-md border-gray-300 p-2 shadow-sm focus:border-cyan-500 focus:ring-cyan-500 sm:text-sm"
85
+ />
86
+ </>
87
+ ) : (
88
+ <>
89
+ <p className="mb-2 text-sm text-gray-500">
90
+ Provide your raw copy here. Use Markdown for formatting (e.g., ##
91
+ Headline, **bold**).
92
+ </p>
93
+ <textarea
94
+ id="raw-copy"
95
+ value={copyValue}
96
+ onChange={(e) => onCopyValueChange(e.target.value)}
97
+ placeholder="## My Awesome Headline..."
98
+ rows={6}
99
+ className="block w-full rounded-md border-gray-300 p-2 font-mono text-sm shadow-sm focus:border-cyan-500 focus:ring-cyan-500"
100
+ />
101
+ </>
102
+ )}
103
+ </div>
104
+ );
105
+ };
@@ -0,0 +1,395 @@
1
+ import { useState, useMemo } from 'react';
2
+ import { useStore } from '@nanostores/react';
3
+ import {
4
+ Select,
5
+ Combobox,
6
+ Pagination,
7
+ Portal,
8
+ type SelectValueChangeDetails,
9
+ type ComboboxInputValueChangeDetails,
10
+ type PaginationPageChangeDetails,
11
+ } from '@ark-ui/react';
12
+ import { createListCollection } from '@ark-ui/react/collection';
13
+ import { NodesContext } from '@/stores/nodes';
14
+ import { viewportKeyStore } from '@/stores/storykeep';
15
+ import { createEmptyStorykeep } from '@/utils/compositor/nodesHelper';
16
+ import { convertStorageToLiveTemplate } from '@/utils/compositor/designLibraryHelper';
17
+ import type { StoragePane } from '@/types/compositorTypes';
18
+ import type { BrandConfig, DesignLibraryEntry } from '@/types/tractstack';
19
+ import {
20
+ PaneSnapshotGenerator,
21
+ type SnapshotData,
22
+ } from '@/components/compositor/preview/PaneSnapshotGenerator';
23
+ import {
24
+ PanesPreviewGenerator,
25
+ type PanePreviewRequest,
26
+ type PaneFragmentResult,
27
+ } from '@/components/compositor/preview/PanesPreviewGenerator';
28
+ import { classNames } from '@/utils/helpers';
29
+
30
+ const PAGE_SIZE = 12;
31
+
32
+ // --- Sub-component for rendering a single preview item ---
33
+ interface TemplatePreviewItemProps {
34
+ storageTemplate: StoragePane;
35
+ config: BrandConfig;
36
+ onClick: () => void;
37
+ title: string;
38
+ category: string;
39
+ }
40
+
41
+ const TemplatePreviewItem = ({
42
+ storageTemplate,
43
+ config,
44
+ onClick,
45
+ title,
46
+ category,
47
+ }: TemplatePreviewItemProps) => {
48
+ const [previewState, setPreviewState] = useState<{
49
+ htmlFragment?: string;
50
+ snapshot?: SnapshotData;
51
+ error?: string;
52
+ } | null>(null);
53
+
54
+ // Convert storage template to live template for previewing
55
+ const liveTemplate = useMemo(
56
+ () => convertStorageToLiveTemplate(storageTemplate),
57
+ [storageTemplate]
58
+ );
59
+
60
+ const fragmentRequest = useMemo((): PanePreviewRequest[] => {
61
+ // This preview logic is correct: it creates a *temporary* context.
62
+ const ctx = new NodesContext();
63
+ ctx.addNode(createEmptyStorykeep('tmp'));
64
+ ctx.addTemplatePane('tmp', liveTemplate);
65
+ return [{ id: liveTemplate.id, ctx }];
66
+ }, [liveTemplate]);
67
+
68
+ const handleFragmentComplete = (results: PaneFragmentResult[]) => {
69
+ const result = results[0];
70
+ if (result?.htmlString) {
71
+ setPreviewState({ htmlFragment: result.htmlString });
72
+ } else {
73
+ setPreviewState({
74
+ error: result?.error || 'Failed to generate HTML fragment.',
75
+ });
76
+ }
77
+ };
78
+
79
+ const handleSnapshotComplete = (data: SnapshotData) => {
80
+ setPreviewState((prev) => (prev ? { ...prev, snapshot: data } : null));
81
+ };
82
+
83
+ return (
84
+ <div
85
+ className="group flex cursor-pointer flex-col rounded-lg border bg-white shadow-sm transition-all hover:border-cyan-600 hover:shadow-lg"
86
+ onClick={onClick}
87
+ role="button"
88
+ tabIndex={0}
89
+ >
90
+ <div className="relative overflow-hidden rounded-t-lg border-b bg-gray-50">
91
+ {!previewState?.snapshot && (
92
+ <div className="flex h-48 w-full animate-pulse items-center justify-center bg-gray-200 text-sm text-gray-500">
93
+ Generating preview...
94
+ </div>
95
+ )}
96
+
97
+ {previewState?.error && (
98
+ <div className="flex h-full items-center justify-center p-4">
99
+ <p className="text-xs text-red-500">{previewState.error}</p>
100
+ </div>
101
+ )}
102
+
103
+ {fragmentRequest.length > 0 && !previewState?.htmlFragment && (
104
+ <PanesPreviewGenerator
105
+ requests={fragmentRequest}
106
+ onComplete={handleFragmentComplete}
107
+ onError={(err) => setPreviewState({ error: err })}
108
+ />
109
+ )}
110
+
111
+ {previewState?.htmlFragment && !previewState.snapshot && (
112
+ <PaneSnapshotGenerator
113
+ id={liveTemplate.id}
114
+ htmlString={previewState.htmlFragment}
115
+ outputWidth={800}
116
+ config={config}
117
+ onComplete={(_id, data) => handleSnapshotComplete(data)}
118
+ onError={(_id, err) =>
119
+ setPreviewState((prev) =>
120
+ prev ? { ...prev, error: err } : { error: err }
121
+ )
122
+ }
123
+ />
124
+ )}
125
+
126
+ {previewState?.snapshot && (
127
+ <img
128
+ src={previewState.snapshot.imageData}
129
+ alt={`Preview for ${title}`}
130
+ className="block h-auto w-full object-contain"
131
+ />
132
+ )}
133
+ <div className="absolute inset-0 bg-cyan-600/80 opacity-0 transition-opacity group-hover:opacity-100">
134
+ <div className="flex h-full items-center justify-center">
135
+ <span className="font-action text-xl font-bold text-white">
136
+ Select Design
137
+ </span>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ <div className="flex-grow p-3">
142
+ <h3 className="truncate font-semibold" title={title}>
143
+ {title}
144
+ </h3>
145
+ <p className="text-sm capitalize text-gray-600">{category}</p>
146
+ </div>
147
+ </div>
148
+ );
149
+ };
150
+
151
+ // --- Main component ---
152
+ interface DesignLibraryStepProps {
153
+ config: BrandConfig;
154
+ onSelect: (entry: DesignLibraryEntry) => void;
155
+ }
156
+
157
+ export const DesignLibraryStep = ({
158
+ config,
159
+ onSelect,
160
+ }: DesignLibraryStepProps) => {
161
+ const designLibrary = config?.DESIGN_LIBRARY || [];
162
+ const viewport = useStore(viewportKeyStore).value;
163
+
164
+ const [selectedCategory, setSelectedCategory] = useState<string>('all');
165
+ const [searchTerm, setSearchTerm] = useState('');
166
+ const [currentPage, setCurrentPage] = useState(1);
167
+
168
+ const gridClass = useMemo(() => {
169
+ switch (viewport) {
170
+ case 'mobile':
171
+ return 'grid-cols-1';
172
+ case 'tablet':
173
+ return 'grid-cols-2';
174
+ case 'desktop':
175
+ return 'grid-cols-3';
176
+ }
177
+ }, [viewport]);
178
+
179
+ const categories = useMemo(() => {
180
+ const allCategories = new Set(
181
+ designLibrary.map((entry: DesignLibraryEntry) => entry.category)
182
+ );
183
+ return ['all', ...Array.from(allCategories)];
184
+ }, [designLibrary]);
185
+
186
+ const filteredEntries = useMemo(() => {
187
+ return designLibrary
188
+ .filter(
189
+ (entry: DesignLibraryEntry) =>
190
+ (selectedCategory === 'all' || entry.category === selectedCategory) &&
191
+ entry.title.toLowerCase().includes(searchTerm.toLowerCase())
192
+ )
193
+ .sort((a, b) => a.title.localeCompare(b.title));
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 comboboxCollection = useMemo(
205
+ () =>
206
+ createListCollection({
207
+ items: filteredEntries,
208
+ itemToValue: (item) => item.title,
209
+ itemToString: (item) => item.title,
210
+ }),
211
+ [filteredEntries]
212
+ );
213
+
214
+ const selectCollection = useMemo(
215
+ () =>
216
+ createListCollection({
217
+ items: categories.map((c) => ({
218
+ label: c.charAt(0).toUpperCase() + c.slice(1),
219
+ value: c,
220
+ })),
221
+ itemToValue: (item) => item.value,
222
+ itemToString: (item) => item.label,
223
+ }),
224
+ [categories]
225
+ );
226
+
227
+ return (
228
+ <div className="flex h-full flex-col space-y-4 rounded-lg bg-gray-50 p-4 shadow-inner">
229
+ <label className="block text-lg font-semibold text-gray-800">
230
+ 2. Choose a Design
231
+ </label>
232
+
233
+ {/* --- Filters --- */}
234
+ <nav className="flex items-center gap-x-4 rounded-md border bg-white p-3">
235
+ <Select.Root
236
+ collection={selectCollection}
237
+ value={[selectedCategory]}
238
+ onValueChange={(details: SelectValueChangeDetails) =>
239
+ setSelectedCategory(details.value[0])
240
+ }
241
+ className="w-48"
242
+ positioning={{ gutter: 4 }}
243
+ >
244
+ <Select.Label className="mb-1 text-sm font-medium text-gray-700">
245
+ Category
246
+ </Select.Label>
247
+ <Select.Control>
248
+ <Select.Trigger className="flex w-full items-center justify-between rounded-md border bg-white p-2 text-left shadow-sm">
249
+ <Select.ValueText />
250
+ <Select.Indicator>▼</Select.Indicator>
251
+ </Select.Trigger>
252
+ </Select.Control>
253
+ <Portal>
254
+ <Select.Positioner>
255
+ <Select.Content className="z-50 rounded-md border bg-white shadow-lg">
256
+ {categories.map((c) => (
257
+ <Select.Item
258
+ key={c}
259
+ item={{
260
+ label: c.charAt(0).toUpperCase() + c.slice(1),
261
+ value: c,
262
+ }}
263
+ className="cursor-pointer p-2 hover:bg-gray-100"
264
+ >
265
+ <Select.ItemText>
266
+ {c.charAt(0).toUpperCase() + c.slice(1)}
267
+ </Select.ItemText>
268
+ </Select.Item>
269
+ ))}
270
+ </Select.Content>
271
+ </Select.Positioner>
272
+ </Portal>
273
+ </Select.Root>
274
+
275
+ <Combobox.Root
276
+ collection={comboboxCollection}
277
+ onInputValueChange={(e: ComboboxInputValueChangeDetails) =>
278
+ setSearchTerm(e.inputValue)
279
+ }
280
+ className="flex-1"
281
+ positioning={{ gutter: 4 }}
282
+ >
283
+ <Combobox.Label className="mb-1 text-sm font-medium text-gray-700">
284
+ Filter by Title
285
+ </Combobox.Label>
286
+ <Combobox.Control>
287
+ <Combobox.Input
288
+ placeholder="Search by title..."
289
+ className="w-full rounded-md border p-2 shadow-sm"
290
+ />
291
+ </Combobox.Control>
292
+ <Portal>
293
+ <Combobox.Positioner>
294
+ <Combobox.Content className="z-50 rounded-md border bg-white shadow-lg">
295
+ {filteredEntries.map((entry: DesignLibraryEntry) => (
296
+ <Combobox.Item
297
+ key={entry.title}
298
+ item={entry}
299
+ className="cursor-pointer p-2 hover:bg-gray-100"
300
+ >
301
+ <Combobox.ItemText>{entry.title}</Combobox.ItemText>
302
+ </Combobox.Item>
303
+ ))}
304
+ </Combobox.Content>
305
+ </Combobox.Positioner>
306
+ </Portal>
307
+ </Combobox.Root>
308
+ </nav>
309
+
310
+ {/* --- Previews Grid --- */}
311
+ <main className="flex-1 overflow-y-auto">
312
+ {paginatedEntries.length === 0 ? (
313
+ <div className="flex h-full items-center justify-center rounded-md bg-white p-6">
314
+ <p className="text-gray-500">
315
+ No designs found matching your criteria.
316
+ </p>
317
+ </div>
318
+ ) : (
319
+ <div className={classNames('grid gap-6', gridClass)}>
320
+ {paginatedEntries.map((entry) => (
321
+ <TemplatePreviewItem
322
+ key={entry.title}
323
+ storageTemplate={entry.template}
324
+ config={config}
325
+ onClick={() => onSelect(entry)}
326
+ title={entry.title}
327
+ category={entry.category}
328
+ />
329
+ ))}
330
+ </div>
331
+ )}
332
+ </main>
333
+
334
+ {/* --- Pagination --- */}
335
+ {totalPages > 1 && (
336
+ <footer className="flex items-center justify-center border-t pt-4">
337
+ <Pagination.Root
338
+ count={totalPages * PAGE_SIZE}
339
+ pageSize={PAGE_SIZE}
340
+ siblingCount={1}
341
+ page={currentPage}
342
+ onPageChange={(details: PaginationPageChangeDetails) =>
343
+ setCurrentPage(details.page)
344
+ }
345
+ className="flex items-center gap-x-2"
346
+ >
347
+ <Pagination.PrevTrigger
348
+ type="button"
349
+ className="rounded p-2 text-sm hover:bg-gray-100 disabled:text-gray-400"
350
+ disabled={currentPage === 1}
351
+ >
352
+ Previous
353
+ </Pagination.PrevTrigger>
354
+ <Pagination.Context>
355
+ {(pagination) =>
356
+ pagination.pages.map((page, index: number) =>
357
+ page.type === 'page' ? (
358
+ <Pagination.Item
359
+ key={index}
360
+ {...page}
361
+ type="page"
362
+ className={classNames(
363
+ 'flex h-9 w-9 items-center justify-center rounded-md text-sm',
364
+ page.value === currentPage
365
+ ? 'bg-cyan-600 font-bold text-white'
366
+ : 'hover:bg-gray-100'
367
+ )}
368
+ >
369
+ {page.value}
370
+ </Pagination.Item>
371
+ ) : (
372
+ <Pagination.Ellipsis
373
+ key={index}
374
+ index={index}
375
+ className="px-2 text-sm"
376
+ >
377
+ ...
378
+ </Pagination.Ellipsis>
379
+ )
380
+ )
381
+ }
382
+ </Pagination.Context>
383
+ <Pagination.NextTrigger
384
+ type="button"
385
+ className="rounded p-2 text-sm hover:bg-gray-100 disabled:text-gray-400"
386
+ disabled={currentPage === totalPages}
387
+ >
388
+ Next
389
+ </Pagination.NextTrigger>
390
+ </Pagination.Root>
391
+ </footer>
392
+ )}
393
+ </div>
394
+ );
395
+ };