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.
- package/dist/index.js +40 -0
- package/package.json +1 -1
- package/templates/src/client/view.js +5 -0
- package/templates/src/components/compositor/Compositor.tsx +3 -2
- package/templates/src/components/compositor/Node.tsx +25 -8
- package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +105 -0
- package/templates/src/components/edit/ToolMode.tsx +7 -0
- package/templates/src/components/edit/pane/AddPanePanel_new.tsx +459 -561
- package/templates/src/components/edit/pane/AiPaneGenerator.tsx +19 -82
- package/templates/src/components/edit/pane/RestylePaneModal.tsx +573 -0
- package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +140 -0
- package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +105 -0
- package/templates/src/components/edit/pane/steps/DesignLibraryStep.tsx +395 -0
- package/templates/src/components/edit/state/SaveToLibraryModal.tsx +205 -0
- package/templates/src/constants/prompts.json +3 -1
- package/templates/src/stores/selection.ts +4 -0
- package/templates/src/types/compositorTypes.ts +51 -1
- package/templates/src/types/tractstack.ts +36 -31
- package/templates/src/utils/aai/getTitleSlug.ts +1 -1
- package/templates/src/utils/api/brandConfig.ts +8 -2
- package/templates/src/utils/api/brandHelpers.ts +4 -0
- package/templates/src/utils/compositor/aiPaneParser.ts +32 -84
- package/templates/src/utils/compositor/designLibraryHelper.ts +416 -0
- package/templates/src/utils/compositor/processMarkdown.ts +1 -1
- package/utils/inject-files.ts +40 -0
- package/templates/src/components/edit/pane/PageGen.tsx +0 -485
- package/templates/src/components/edit/pane/PageGenSelector.tsx +0 -245
- 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
|
+
};
|