create-ng-tailwind 3.1.0 → 4.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.
- package/CHANGELOG.md +81 -350
- package/README.md +93 -157
- package/lib/cli/index.js +29 -3
- package/lib/cli/interactive.js +26 -1
- package/lib/managers/ProjectManager.js +0 -4
- package/lib/templates/base/components.js +243 -0
- package/lib/templates/base/index.js +207 -0
- package/lib/templates/base/infrastructure.js +314 -0
- package/lib/templates/base/linting.js +359 -0
- package/lib/templates/base/pwa.js +103 -0
- package/lib/templates/base/services.js +362 -0
- package/lib/templates/blog/app.js +250 -0
- package/lib/templates/blog/components.js +360 -0
- package/lib/templates/blog/i18n.js +77 -0
- package/lib/templates/blog/index.js +126 -0
- package/lib/templates/blog/pages.js +554 -0
- package/lib/templates/blog/services.js +390 -0
- package/lib/templates/dashboard/app.js +320 -0
- package/lib/templates/dashboard/charts.js +305 -0
- package/lib/templates/dashboard/components.js +410 -0
- package/lib/templates/dashboard/i18n.js +340 -0
- package/lib/templates/dashboard/index.js +141 -0
- package/lib/templates/dashboard/layout.js +310 -0
- package/lib/templates/dashboard/pages.js +681 -0
- package/lib/templates/ecommerce/app.js +315 -0
- package/lib/templates/ecommerce/components.js +496 -0
- package/lib/templates/ecommerce/i18n.js +389 -0
- package/lib/templates/ecommerce/index.js +152 -0
- package/lib/templates/ecommerce/layout.js +270 -0
- package/lib/templates/ecommerce/pages.js +969 -0
- package/lib/templates/ecommerce/services.js +300 -0
- package/lib/templates/index.js +12 -0
- package/lib/templates/landing/index.js +1117 -0
- package/lib/templates/portfolio/index.js +1160 -0
- package/lib/templates/saas/index.js +1371 -0
- package/lib/templates/starter/app.js +364 -0
- package/lib/templates/starter/i18n.js +856 -0
- package/lib/templates/starter/index.js +52 -4060
- package/lib/templates/starter/layout.js +852 -0
- package/lib/templates/starter/pages.js +1241 -0
- package/package.json +1 -1
- package/lib/templates/starter/features.js +0 -867
- package/lib/utils/ai-config.js +0 -641
- /package/lib/templates/{starter → base}/advanced-features.js +0 -0
- /package/lib/templates/{starter → base}/seo-assets.js +0 -0
- /package/lib/templates/{starter → base}/seo-features.js +0 -0
- /package/lib/templates/{starter → base}/ui-features.js +0 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create dashboard shared components (StatsCard, Breadcrumb, DataTable)
|
|
6
|
+
*/
|
|
7
|
+
async function createComponents(config) {
|
|
8
|
+
// Stats Card Component
|
|
9
|
+
const statsCardComponent = `import { Component, Input } from '@angular/core';
|
|
10
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
11
|
+
import {
|
|
12
|
+
heroArrowTrendingUp,
|
|
13
|
+
heroArrowTrendingDown,
|
|
14
|
+
} from '@ng-icons/heroicons/outline';
|
|
15
|
+
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'app-stats-card',
|
|
18
|
+
standalone: true,
|
|
19
|
+
imports: [NgIconComponent],
|
|
20
|
+
viewProviders: [provideIcons({ heroArrowTrendingUp, heroArrowTrendingDown })],
|
|
21
|
+
template: \`
|
|
22
|
+
<div class="group relative overflow-hidden rounded-2xl border border-gray-100 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-xl hover:shadow-gray-200/50 hover:-translate-y-1">
|
|
23
|
+
<div class="absolute inset-0 bg-linear-to-br opacity-0 transition-opacity group-hover:opacity-100" [class]="gradientClass"></div>
|
|
24
|
+
<div class="relative">
|
|
25
|
+
<div class="flex items-center justify-between">
|
|
26
|
+
<div>
|
|
27
|
+
<p class="text-sm font-medium text-gray-500">{{ label }}</p>
|
|
28
|
+
<p class="mt-2 text-3xl font-bold text-gray-900">{{ value }}</p>
|
|
29
|
+
</div>
|
|
30
|
+
@if (icon) {
|
|
31
|
+
<div
|
|
32
|
+
class="flex h-14 w-14 items-center justify-center rounded-2xl shadow-lg transition-transform group-hover:scale-110"
|
|
33
|
+
[class]="iconBgClass">
|
|
34
|
+
<ng-icon [name]="icon" size="28" [style.color]="iconColor"></ng-icon>
|
|
35
|
+
</div>
|
|
36
|
+
}
|
|
37
|
+
</div>
|
|
38
|
+
@if (trend !== undefined) {
|
|
39
|
+
<div class="mt-4 flex items-center">
|
|
40
|
+
<div class="flex items-center rounded-full px-2 py-1" [class]="trend >= 0 ? 'bg-emerald-50' : 'bg-red-50'">
|
|
41
|
+
<ng-icon
|
|
42
|
+
[name]="trend >= 0 ? 'heroArrowTrendingUp' : 'heroArrowTrendingDown'"
|
|
43
|
+
size="14"
|
|
44
|
+
[class]="trend >= 0 ? 'text-emerald-500' : 'text-red-500'">
|
|
45
|
+
</ng-icon>
|
|
46
|
+
<span
|
|
47
|
+
class="ms-1 text-xs font-semibold"
|
|
48
|
+
[class]="trend >= 0 ? 'text-emerald-600' : 'text-red-600'">
|
|
49
|
+
{{ trend >= 0 ? '+' : '' }}{{ trend }}%
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
52
|
+
<span class="ms-2 text-xs text-gray-500">vs last month</span>
|
|
53
|
+
</div>
|
|
54
|
+
}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
\`,
|
|
58
|
+
})
|
|
59
|
+
export class StatsCardComponent {
|
|
60
|
+
@Input() label = '';
|
|
61
|
+
@Input() value: string | number = '';
|
|
62
|
+
@Input() icon?: string;
|
|
63
|
+
@Input() iconBgClass = 'bg-linear-to-br from-indigo-500 to-purple-600';
|
|
64
|
+
@Input() iconColor = 'white';
|
|
65
|
+
@Input() trend?: number;
|
|
66
|
+
@Input() gradientClass = 'from-indigo-50/50 to-purple-50/50';
|
|
67
|
+
}`;
|
|
68
|
+
|
|
69
|
+
await fs.writeFile(
|
|
70
|
+
path.join(config.fullPath, 'src/app/shared/components/stats-card/stats-card.component.ts'),
|
|
71
|
+
statsCardComponent
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Breadcrumb Component
|
|
75
|
+
const breadcrumbComponent = `import { Component, Input } from '@angular/core';
|
|
76
|
+
import { RouterModule } from '@angular/router';
|
|
77
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
78
|
+
import { heroChevronRight, heroHome } from '@ng-icons/heroicons/outline';
|
|
79
|
+
|
|
80
|
+
export interface BreadcrumbItem {
|
|
81
|
+
label: string;
|
|
82
|
+
route?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Component({
|
|
86
|
+
selector: 'app-breadcrumb',
|
|
87
|
+
standalone: true,
|
|
88
|
+
imports: [RouterModule, NgIconComponent],
|
|
89
|
+
viewProviders: [provideIcons({ heroChevronRight, heroHome })],
|
|
90
|
+
template: \`
|
|
91
|
+
<nav class="flex items-center gap-2 text-sm">
|
|
92
|
+
<a routerLink="/dashboard" class="text-gray-500 hover:text-primary-600 transition-colors">
|
|
93
|
+
<ng-icon name="heroHome" size="16"></ng-icon>
|
|
94
|
+
</a>
|
|
95
|
+
@for (item of items; track item.label; let last = $last) {
|
|
96
|
+
<ng-icon name="heroChevronRight" size="14" class="text-gray-400"></ng-icon>
|
|
97
|
+
@if (item.route && !last) {
|
|
98
|
+
<a [routerLink]="item.route" class="text-gray-500 hover:text-primary-600 transition-colors">
|
|
99
|
+
{{ item.label }}
|
|
100
|
+
</a>
|
|
101
|
+
} @else {
|
|
102
|
+
<span class="font-medium text-gray-900">{{ item.label }}</span>
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
</nav>
|
|
106
|
+
\`,
|
|
107
|
+
})
|
|
108
|
+
export class BreadcrumbComponent {
|
|
109
|
+
@Input() items: BreadcrumbItem[] = [];
|
|
110
|
+
}`;
|
|
111
|
+
|
|
112
|
+
await fs.writeFile(
|
|
113
|
+
path.join(config.fullPath, 'src/app/shared/components/breadcrumb/breadcrumb.component.ts'),
|
|
114
|
+
breadcrumbComponent
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Data Table Component
|
|
118
|
+
const dataTableComponent = `import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
|
|
119
|
+
import { DecimalPipe, DatePipe } from '@angular/common';
|
|
120
|
+
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
|
121
|
+
import {
|
|
122
|
+
heroChevronUp,
|
|
123
|
+
heroChevronDown,
|
|
124
|
+
heroChevronLeft,
|
|
125
|
+
heroChevronRight,
|
|
126
|
+
heroMagnifyingGlass,
|
|
127
|
+
} from '@ng-icons/heroicons/outline';
|
|
128
|
+
import { FormsModule } from '@angular/forms';
|
|
129
|
+
|
|
130
|
+
export interface TableColumn {
|
|
131
|
+
key: string;
|
|
132
|
+
label: string;
|
|
133
|
+
sortable?: boolean;
|
|
134
|
+
type?: 'text' | 'number' | 'date' | 'status' | 'actions';
|
|
135
|
+
width?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface TableAction {
|
|
139
|
+
label: string;
|
|
140
|
+
icon?: string;
|
|
141
|
+
handler: (row: Record<string, unknown>) => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Component({
|
|
145
|
+
selector: 'app-data-table',
|
|
146
|
+
standalone: true,
|
|
147
|
+
imports: [NgIconComponent, FormsModule, DecimalPipe, DatePipe],
|
|
148
|
+
viewProviders: [
|
|
149
|
+
provideIcons({
|
|
150
|
+
heroChevronUp,
|
|
151
|
+
heroChevronDown,
|
|
152
|
+
heroChevronLeft,
|
|
153
|
+
heroChevronRight,
|
|
154
|
+
heroMagnifyingGlass,
|
|
155
|
+
}),
|
|
156
|
+
],
|
|
157
|
+
template: \`
|
|
158
|
+
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
159
|
+
<!-- Search and filters -->
|
|
160
|
+
@if (searchable) {
|
|
161
|
+
<div class="border-b border-gray-200 px-6 py-4">
|
|
162
|
+
<div class="relative max-w-md">
|
|
163
|
+
<ng-icon
|
|
164
|
+
name="heroMagnifyingGlass"
|
|
165
|
+
size="18"
|
|
166
|
+
class="absolute top-1/2 -translate-y-1/2 text-gray-400"
|
|
167
|
+
style="inset-inline-start: 12px;">
|
|
168
|
+
</ng-icon>
|
|
169
|
+
<input
|
|
170
|
+
type="text"
|
|
171
|
+
[(ngModel)]="searchTerm"
|
|
172
|
+
(ngModelChange)="onSearch()"
|
|
173
|
+
placeholder="Search..."
|
|
174
|
+
class="w-full rounded-lg border border-gray-300 py-2 ps-10 pe-4 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
<!-- Table -->
|
|
181
|
+
<div class="overflow-x-auto">
|
|
182
|
+
<table class="w-full">
|
|
183
|
+
<thead>
|
|
184
|
+
<tr class="border-b border-gray-200 bg-gray-50">
|
|
185
|
+
@for (column of columns; track column.key) {
|
|
186
|
+
<th
|
|
187
|
+
class="px-6 py-3 text-start text-xs font-semibold uppercase tracking-wider text-gray-500"
|
|
188
|
+
[style.width]="column.width">
|
|
189
|
+
@if (column.sortable) {
|
|
190
|
+
<button
|
|
191
|
+
(click)="sort(column.key)"
|
|
192
|
+
class="flex items-center gap-1 hover:text-gray-900">
|
|
193
|
+
{{ column.label }}
|
|
194
|
+
@if (sortKey === column.key) {
|
|
195
|
+
<ng-icon
|
|
196
|
+
[name]="sortDirection === 'asc' ? 'heroChevronUp' : 'heroChevronDown'"
|
|
197
|
+
size="14">
|
|
198
|
+
</ng-icon>
|
|
199
|
+
}
|
|
200
|
+
</button>
|
|
201
|
+
} @else {
|
|
202
|
+
{{ column.label }}
|
|
203
|
+
}
|
|
204
|
+
</th>
|
|
205
|
+
}
|
|
206
|
+
</tr>
|
|
207
|
+
</thead>
|
|
208
|
+
<tbody class="divide-y divide-gray-200">
|
|
209
|
+
@for (row of paginatedData; track row['id'] || $index) {
|
|
210
|
+
<tr class="transition-colors hover:bg-gray-50">
|
|
211
|
+
@for (column of columns; track column.key) {
|
|
212
|
+
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
|
|
213
|
+
@switch (column.type) {
|
|
214
|
+
@case ('status') {
|
|
215
|
+
<span
|
|
216
|
+
class="inline-flex rounded-full px-2 py-1 text-xs font-medium"
|
|
217
|
+
[class]="getStatusClass(row[column.key])">
|
|
218
|
+
{{ row[column.key] }}
|
|
219
|
+
</span>
|
|
220
|
+
}
|
|
221
|
+
@case ('date') {
|
|
222
|
+
{{ $any(row[column.key]) | date:'mediumDate' }}
|
|
223
|
+
}
|
|
224
|
+
@case ('number') {
|
|
225
|
+
{{ $any(row[column.key]) | number }}
|
|
226
|
+
}
|
|
227
|
+
@default {
|
|
228
|
+
{{ row[column.key] }}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
</td>
|
|
232
|
+
}
|
|
233
|
+
</tr>
|
|
234
|
+
} @empty {
|
|
235
|
+
<tr>
|
|
236
|
+
<td [attr.colspan]="columns.length" class="px-6 py-12 text-center text-gray-500">
|
|
237
|
+
No data available
|
|
238
|
+
</td>
|
|
239
|
+
</tr>
|
|
240
|
+
}
|
|
241
|
+
</tbody>
|
|
242
|
+
</table>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<!-- Pagination -->
|
|
246
|
+
@if (paginate && filteredData.length > pageSize) {
|
|
247
|
+
<div class="flex items-center justify-between border-t border-gray-200 px-6 py-4">
|
|
248
|
+
<span class="text-sm text-gray-500">
|
|
249
|
+
Showing {{ startIndex + 1 }} to {{ endIndex }} of {{ filteredData.length }} results
|
|
250
|
+
</span>
|
|
251
|
+
<div class="flex items-center gap-2">
|
|
252
|
+
<button
|
|
253
|
+
(click)="prevPage()"
|
|
254
|
+
[disabled]="currentPage === 1"
|
|
255
|
+
class="rounded-lg border border-gray-300 p-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
|
|
256
|
+
<ng-icon name="heroChevronLeft" size="16"></ng-icon>
|
|
257
|
+
</button>
|
|
258
|
+
@for (page of visiblePages; track page) {
|
|
259
|
+
<button
|
|
260
|
+
(click)="goToPage(page)"
|
|
261
|
+
class="rounded-lg border px-3 py-1 text-sm transition-colors"
|
|
262
|
+
[class]="currentPage === page ? 'border-primary-500 bg-primary-50 text-primary-600' : 'border-gray-300 hover:bg-gray-50'">
|
|
263
|
+
{{ page }}
|
|
264
|
+
</button>
|
|
265
|
+
}
|
|
266
|
+
<button
|
|
267
|
+
(click)="nextPage()"
|
|
268
|
+
[disabled]="currentPage === totalPages"
|
|
269
|
+
class="rounded-lg border border-gray-300 p-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50">
|
|
270
|
+
<ng-icon name="heroChevronRight" size="16"></ng-icon>
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
}
|
|
275
|
+
</div>
|
|
276
|
+
\`,
|
|
277
|
+
})
|
|
278
|
+
export class DataTableComponent implements OnChanges {
|
|
279
|
+
@Input() data: Record<string, unknown>[] = [];
|
|
280
|
+
@Input() columns: TableColumn[] = [];
|
|
281
|
+
@Input() searchable = true;
|
|
282
|
+
@Input() paginate = true;
|
|
283
|
+
@Input() pageSize = 10;
|
|
284
|
+
@Output() rowClick = new EventEmitter<Record<string, unknown>>();
|
|
285
|
+
|
|
286
|
+
searchTerm = '';
|
|
287
|
+
sortKey = '';
|
|
288
|
+
sortDirection: 'asc' | 'desc' = 'asc';
|
|
289
|
+
currentPage = 1;
|
|
290
|
+
|
|
291
|
+
filteredData: Record<string, unknown>[] = [];
|
|
292
|
+
paginatedData: Record<string, unknown>[] = [];
|
|
293
|
+
|
|
294
|
+
ngOnChanges(changes: SimpleChanges): void {
|
|
295
|
+
if (changes['data']) {
|
|
296
|
+
this.applyFilters();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onSearch(): void {
|
|
301
|
+
this.currentPage = 1;
|
|
302
|
+
this.applyFilters();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
sort(key: string): void {
|
|
306
|
+
if (this.sortKey === key) {
|
|
307
|
+
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
308
|
+
} else {
|
|
309
|
+
this.sortKey = key;
|
|
310
|
+
this.sortDirection = 'asc';
|
|
311
|
+
}
|
|
312
|
+
this.applyFilters();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private applyFilters(): void {
|
|
316
|
+
let result = [...this.data];
|
|
317
|
+
|
|
318
|
+
// Search
|
|
319
|
+
if (this.searchTerm) {
|
|
320
|
+
const term = this.searchTerm.toLowerCase();
|
|
321
|
+
result = result.filter((row) =>
|
|
322
|
+
Object.values(row).some((value) =>
|
|
323
|
+
String(value).toLowerCase().includes(term)
|
|
324
|
+
)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Sort
|
|
329
|
+
if (this.sortKey) {
|
|
330
|
+
result.sort((a, b) => {
|
|
331
|
+
const aVal = a[this.sortKey];
|
|
332
|
+
const bVal = b[this.sortKey];
|
|
333
|
+
const comparison = String(aVal).localeCompare(String(bVal));
|
|
334
|
+
return this.sortDirection === 'asc' ? comparison : -comparison;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.filteredData = result;
|
|
339
|
+
this.updatePagination();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private updatePagination(): void {
|
|
343
|
+
const start = (this.currentPage - 1) * this.pageSize;
|
|
344
|
+
const end = start + this.pageSize;
|
|
345
|
+
this.paginatedData = this.filteredData.slice(start, end);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
get startIndex(): number {
|
|
349
|
+
return (this.currentPage - 1) * this.pageSize;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
get endIndex(): number {
|
|
353
|
+
return Math.min(this.currentPage * this.pageSize, this.filteredData.length);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
get totalPages(): number {
|
|
357
|
+
return Math.ceil(this.filteredData.length / this.pageSize);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
get visiblePages(): number[] {
|
|
361
|
+
const pages: number[] = [];
|
|
362
|
+
const start = Math.max(1, this.currentPage - 2);
|
|
363
|
+
const end = Math.min(this.totalPages, start + 4);
|
|
364
|
+
for (let i = start; i <= end; i++) {
|
|
365
|
+
pages.push(i);
|
|
366
|
+
}
|
|
367
|
+
return pages;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
goToPage(page: number): void {
|
|
371
|
+
this.currentPage = page;
|
|
372
|
+
this.updatePagination();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
prevPage(): void {
|
|
376
|
+
if (this.currentPage > 1) {
|
|
377
|
+
this.currentPage--;
|
|
378
|
+
this.updatePagination();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
nextPage(): void {
|
|
383
|
+
if (this.currentPage < this.totalPages) {
|
|
384
|
+
this.currentPage++;
|
|
385
|
+
this.updatePagination();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
getStatusClass(status: unknown): string {
|
|
390
|
+
const statusStr = String(status).toLowerCase();
|
|
391
|
+
const classes: Record<string, string> = {
|
|
392
|
+
active: 'bg-success-100 text-success-700',
|
|
393
|
+
completed: 'bg-success-100 text-success-700',
|
|
394
|
+
pending: 'bg-warning-100 text-warning-700',
|
|
395
|
+
processing: 'bg-info-100 text-info-700',
|
|
396
|
+
inactive: 'bg-gray-100 text-gray-700',
|
|
397
|
+
cancelled: 'bg-danger-100 text-danger-700',
|
|
398
|
+
failed: 'bg-danger-100 text-danger-700',
|
|
399
|
+
};
|
|
400
|
+
return classes[statusStr] || 'bg-gray-100 text-gray-700';
|
|
401
|
+
}
|
|
402
|
+
}`;
|
|
403
|
+
|
|
404
|
+
await fs.writeFile(
|
|
405
|
+
path.join(config.fullPath, 'src/app/shared/components/data-table/data-table.component.ts'),
|
|
406
|
+
dataTableComponent
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
module.exports = { createComponents };
|