create-stitchkit 0.1.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 (174) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/dist/cli.js +230 -0
  5. package/package.json +50 -0
  6. package/template/README.md +69 -0
  7. package/template/_env +11 -0
  8. package/template/_env.example +12 -0
  9. package/template/_gitignore +10 -0
  10. package/template/biome.json +43 -0
  11. package/template/bun.lock +1372 -0
  12. package/template/e2e/starter.spec.ts +237 -0
  13. package/template/ecosystem.config.cjs +28 -0
  14. package/template/ecosystem.dev.config.cjs +29 -0
  15. package/template/package.json +55 -0
  16. package/template/packages/backend/package.json +31 -0
  17. package/template/packages/backend/src/cli.ts +12 -0
  18. package/template/packages/backend/src/domain/errors.ts +9 -0
  19. package/template/packages/backend/src/domain/repository/github-cache.test.ts +72 -0
  20. package/template/packages/backend/src/domain/repository/github-cache.ts +205 -0
  21. package/template/packages/backend/src/index.ts +56 -0
  22. package/template/packages/backend/src/lib/db.ts +7 -0
  23. package/template/packages/backend/src/surface.ts +13 -0
  24. package/template/packages/backend/src/tools.ts +21 -0
  25. package/template/packages/backend/src/transport/errors.ts +28 -0
  26. package/template/packages/backend/src/transport/repository-service.ts +18 -0
  27. package/template/packages/backend/tsconfig.json +8 -0
  28. package/template/packages/config/package.json +22 -0
  29. package/template/packages/config/src/server.ts +24 -0
  30. package/template/packages/config/tsconfig.json +5 -0
  31. package/template/packages/db/migrations/20260808000000_init/migration.sql +16 -0
  32. package/template/packages/db/migrations/20260808170000_repository_visibility/migration.sql +4 -0
  33. package/template/packages/db/migrations/migration_lock.toml +1 -0
  34. package/template/packages/db/package.json +29 -0
  35. package/template/packages/db/prisma.config.ts +8 -0
  36. package/template/packages/db/schema.prisma +31 -0
  37. package/template/packages/db/tsconfig.json +5 -0
  38. package/template/packages/frontend/messages/en.json +15 -0
  39. package/template/packages/frontend/messages/ru.json +15 -0
  40. package/template/packages/frontend/next.config.ts +18 -0
  41. package/template/packages/frontend/package.json +62 -0
  42. package/template/packages/frontend/postcss.config.mjs +1 -0
  43. package/template/packages/frontend/public/ASSETS.md +6 -0
  44. package/template/packages/frontend/public/favicon/mascot-stitch.png +0 -0
  45. package/template/packages/frontend/public/icons/biome.svg +1 -0
  46. package/template/packages/frontend/public/icons/bun.svg +19 -0
  47. package/template/packages/frontend/public/icons/next.svg +1 -0
  48. package/template/packages/frontend/public/icons/postgresql.svg +1 -0
  49. package/template/packages/frontend/public/icons/prisma.svg +6 -0
  50. package/template/packages/frontend/public/icons/react-query.svg +9 -0
  51. package/template/packages/frontend/public/icons/react.svg +1 -0
  52. package/template/packages/frontend/public/icons/tailwind.svg +12 -0
  53. package/template/packages/frontend/public/icons/tsconfig.svg +1 -0
  54. package/template/packages/frontend/public/icons/typescript.svg +7 -0
  55. package/template/packages/frontend/public/icons/zod.svg +1 -0
  56. package/template/packages/frontend/public/mascot-stitch.png +0 -0
  57. package/template/packages/frontend/public/theme-dark.svg +8 -0
  58. package/template/packages/frontend/public/theme-light.svg +8 -0
  59. package/template/packages/frontend/src/app/[locale]/error.tsx +19 -0
  60. package/template/packages/frontend/src/app/[locale]/layout.tsx +59 -0
  61. package/template/packages/frontend/src/app/[locale]/loading.tsx +14 -0
  62. package/template/packages/frontend/src/app/[locale]/page.tsx +44 -0
  63. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
  64. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +40 -0
  65. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/account-showcase.tsx +112 -0
  66. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/admin-showcase.tsx +101 -0
  67. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-navigation.tsx +56 -0
  68. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-shell.tsx +51 -0
  69. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/code-foundation.tsx +35 -0
  70. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/components-story.tsx +54 -0
  71. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/data-story.tsx +126 -0
  72. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/image-preloader.tsx +8 -0
  73. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/index.ts +3 -0
  74. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-sections.tsx +269 -0
  75. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +159 -0
  76. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/motion-story.tsx +78 -0
  77. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/overlays-story.tsx +134 -0
  78. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/payment-showcase.tsx +82 -0
  79. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/presentation-story.tsx +68 -0
  80. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/primitives-story.tsx +267 -0
  81. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/story-page.tsx +14 -0
  82. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story-client.tsx +208 -0
  83. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story.tsx +13 -0
  84. package/template/packages/frontend/src/app/[locale]/ui/layout.tsx +6 -0
  85. package/template/packages/frontend/src/app/[locale]/ui/page.tsx +10 -0
  86. package/template/packages/frontend/src/app/api/og/[locale]/[page]/route.tsx +53 -0
  87. package/template/packages/frontend/src/app/globals.css +210 -0
  88. package/template/packages/frontend/src/app/robots.ts +9 -0
  89. package/template/packages/frontend/src/app/sitemap.ts +22 -0
  90. package/template/packages/frontend/src/components/brand-mark.tsx +22 -0
  91. package/template/packages/frontend/src/components/repository-summary.tsx +107 -0
  92. package/template/packages/frontend/src/components/system-controls.tsx +92 -0
  93. package/template/packages/frontend/src/components/ui/adaptive-modal.tsx +382 -0
  94. package/template/packages/frontend/src/components/ui/animated-counter.tsx +80 -0
  95. package/template/packages/frontend/src/components/ui/avatar.tsx +46 -0
  96. package/template/packages/frontend/src/components/ui/badge.tsx +94 -0
  97. package/template/packages/frontend/src/components/ui/button-variants.ts +24 -0
  98. package/template/packages/frontend/src/components/ui/button.tsx +81 -0
  99. package/template/packages/frontend/src/components/ui/card-deck.tsx +73 -0
  100. package/template/packages/frontend/src/components/ui/card.tsx +81 -0
  101. package/template/packages/frontend/src/components/ui/charts/area-chart.tsx +167 -0
  102. package/template/packages/frontend/src/components/ui/charts/index.ts +7 -0
  103. package/template/packages/frontend/src/components/ui/checkbox.tsx +133 -0
  104. package/template/packages/frontend/src/components/ui/confirmation-modal.tsx +129 -0
  105. package/template/packages/frontend/src/components/ui/copyable-text.tsx +85 -0
  106. package/template/packages/frontend/src/components/ui/drawer.tsx +136 -0
  107. package/template/packages/frontend/src/components/ui/dropdown-menu.tsx +185 -0
  108. package/template/packages/frontend/src/components/ui/field.tsx +28 -0
  109. package/template/packages/frontend/src/components/ui/hand-drawn-icons.tsx +41 -0
  110. package/template/packages/frontend/src/components/ui/index.ts +36 -0
  111. package/template/packages/frontend/src/components/ui/input.tsx +24 -0
  112. package/template/packages/frontend/src/components/ui/label.tsx +18 -0
  113. package/template/packages/frontend/src/components/ui/otp-input.tsx +140 -0
  114. package/template/packages/frontend/src/components/ui/select.tsx +240 -0
  115. package/template/packages/frontend/src/components/ui/sidebar.tsx +343 -0
  116. package/template/packages/frontend/src/components/ui/skeleton.tsx +8 -0
  117. package/template/packages/frontend/src/components/ui/slider.tsx +31 -0
  118. package/template/packages/frontend/src/components/ui/slot-drum.tsx +50 -0
  119. package/template/packages/frontend/src/components/ui/spinner.tsx +30 -0
  120. package/template/packages/frontend/src/components/ui/switch.tsx +31 -0
  121. package/template/packages/frontend/src/components/ui/table.tsx +106 -0
  122. package/template/packages/frontend/src/components/ui/tabs.tsx +196 -0
  123. package/template/packages/frontend/src/components/ui/textarea.tsx +21 -0
  124. package/template/packages/frontend/src/components/ui/toaster.tsx +72 -0
  125. package/template/packages/frontend/src/components/ui/tooltip.tsx +31 -0
  126. package/template/packages/frontend/src/env.ts +21 -0
  127. package/template/packages/frontend/src/features/data-table/components/column-helpers.tsx +136 -0
  128. package/template/packages/frontend/src/features/data-table/components/data-table.tsx +303 -0
  129. package/template/packages/frontend/src/features/data-table/components/inline-stat.tsx +113 -0
  130. package/template/packages/frontend/src/features/data-table/components/sortable-header.tsx +33 -0
  131. package/template/packages/frontend/src/features/data-table/components/table-error.tsx +24 -0
  132. package/template/packages/frontend/src/features/data-table/components/table-filters.tsx +82 -0
  133. package/template/packages/frontend/src/features/data-table/hooks/useTableState.ts +111 -0
  134. package/template/packages/frontend/src/features/data-table/index.ts +26 -0
  135. package/template/packages/frontend/src/features/data-table/table-features.ts +26 -0
  136. package/template/packages/frontend/src/hooks/index.ts +3 -0
  137. package/template/packages/frontend/src/hooks/useInfiniteScroll.ts +67 -0
  138. package/template/packages/frontend/src/hooks/useIsTouchDevice.ts +25 -0
  139. package/template/packages/frontend/src/hooks/useMediaQuery.ts +30 -0
  140. package/template/packages/frontend/src/i18n/locales.ts +6 -0
  141. package/template/packages/frontend/src/i18n/navigation.ts +4 -0
  142. package/template/packages/frontend/src/i18n/request.ts +9 -0
  143. package/template/packages/frontend/src/i18n/routing.ts +8 -0
  144. package/template/packages/frontend/src/lib/api/client.ts +20 -0
  145. package/template/packages/frontend/src/lib/api/queries.ts +11 -0
  146. package/template/packages/frontend/src/lib/query-client.ts +20 -0
  147. package/template/packages/frontend/src/lib/realtime/repository.ts +21 -0
  148. package/template/packages/frontend/src/lib/seo/metadata.ts +50 -0
  149. package/template/packages/frontend/src/lib/seo/pages.test.ts +25 -0
  150. package/template/packages/frontend/src/lib/seo/pages.ts +107 -0
  151. package/template/packages/frontend/src/lib/utils/cn.ts +6 -0
  152. package/template/packages/frontend/src/lib/utils/format.ts +25 -0
  153. package/template/packages/frontend/src/lib/utils/index.ts +2 -0
  154. package/template/packages/frontend/src/providers/index.tsx +18 -0
  155. package/template/packages/frontend/src/providers/react-query.tsx +9 -0
  156. package/template/packages/frontend/src/providers/realtime.tsx +16 -0
  157. package/template/packages/frontend/src/proxy.ts +6 -0
  158. package/template/packages/frontend/src/theme/config.ts +20 -0
  159. package/template/packages/frontend/src/theme/transition.ts +54 -0
  160. package/template/packages/frontend/tsconfig.json +13 -0
  161. package/template/packages/shared/package.json +23 -0
  162. package/template/packages/shared/src/contracts/repository.ts +28 -0
  163. package/template/packages/shared/src/events/repository.ts +9 -0
  164. package/template/packages/shared/src/index.ts +3 -0
  165. package/template/packages/shared/src/schemas/repository.test.ts +36 -0
  166. package/template/packages/shared/src/schemas/repository.ts +28 -0
  167. package/template/packages/shared/tsconfig.json +5 -0
  168. package/template/playwright.config.ts +15 -0
  169. package/template/scripts/check-authored.ts +81 -0
  170. package/template/scripts/dev.ts +23 -0
  171. package/template/scripts/local-env.ts +12 -0
  172. package/template/scripts/runtime-smoke.ts +67 -0
  173. package/template/scripts/tooling-env.ts +15 -0
  174. package/template/tsconfig.json +13 -0
@@ -0,0 +1,303 @@
1
+ 'use client';
2
+
3
+ import { IconChevronLeft, IconChevronRight, IconSearch } from '@tabler/icons-react';
4
+ import {
5
+ type ColumnDef,
6
+ type ColumnFiltersState,
7
+ type ColumnVisibilityState,
8
+ type OnChangeFn,
9
+ type PaginationState,
10
+ type RowSelectionState,
11
+ type SortingState,
12
+ useTable,
13
+ } from '@tanstack/react-table';
14
+ import { type ReactNode, useId, useMemo, useState } from 'react';
15
+ import {
16
+ Button,
17
+ Input,
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue,
23
+ Skeleton,
24
+ Table,
25
+ TableBody,
26
+ TableCell,
27
+ TableHead,
28
+ TableHeader,
29
+ TableRow,
30
+ } from '@/components/ui';
31
+ import { dataTableFeatures } from '../table-features';
32
+
33
+ interface DataTableProps<TData extends object> {
34
+ columns: ColumnDef<typeof dataTableFeatures, TData, unknown>[];
35
+ data: TData[];
36
+ searchKey?: string;
37
+ searchPlaceholder?: string;
38
+ actions?: ReactNode;
39
+ isLoading?: boolean;
40
+ isFetching?: boolean;
41
+ pageCount?: number;
42
+ totalCount?: number;
43
+ manualPagination?: boolean;
44
+ manualSorting?: boolean;
45
+ sorting?: SortingState;
46
+ pagination?: PaginationState;
47
+ search?: string;
48
+ onPaginationChange?: OnChangeFn<PaginationState>;
49
+ onSortingChange?: OnChangeFn<SortingState>;
50
+ onSearchChange?: (search: string) => void;
51
+ }
52
+
53
+ interface PaginationItem {
54
+ id: string;
55
+ page?: number;
56
+ }
57
+
58
+ function paginationItems(currentPage: number, totalPages: number): PaginationItem[] {
59
+ if (totalPages <= 7) {
60
+ return Array.from({ length: totalPages }, (_, page) => ({ id: `page-${page}`, page }));
61
+ }
62
+ const pages: PaginationItem[] = [{ id: 'page-0', page: 0 }];
63
+ if (currentPage > 2) pages.push({ id: 'ellipsis-left' });
64
+ for (
65
+ let index = Math.max(1, currentPage - 1);
66
+ index <= Math.min(totalPages - 2, currentPage + 1);
67
+ index++
68
+ ) {
69
+ pages.push({ id: `page-${index}`, page: index });
70
+ }
71
+ if (currentPage < totalPages - 3) pages.push({ id: 'ellipsis-right' });
72
+ pages.push({ id: `page-${totalPages - 1}`, page: totalPages - 1 });
73
+ return pages;
74
+ }
75
+
76
+ export function DataTable<TData extends object>({
77
+ columns,
78
+ data,
79
+ searchKey,
80
+ searchPlaceholder = 'Search…',
81
+ actions,
82
+ isLoading = false,
83
+ isFetching = false,
84
+ pageCount,
85
+ totalCount,
86
+ manualPagination = false,
87
+ manualSorting = false,
88
+ sorting: sortingProp,
89
+ pagination: paginationProp,
90
+ search: searchProp,
91
+ onPaginationChange,
92
+ onSortingChange,
93
+ onSearchChange,
94
+ }: DataTableProps<TData>) {
95
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
96
+ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>({});
97
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
98
+ const [internalSorting, setInternalSorting] = useState<SortingState>([]);
99
+ const [internalPagination, setInternalPagination] = useState<PaginationState>({
100
+ pageIndex: 0,
101
+ pageSize: 20,
102
+ });
103
+ const [internalSearch, setInternalSearch] = useState('');
104
+
105
+ const sorting = sortingProp ?? internalSorting;
106
+ const pagination = paginationProp ?? internalPagination;
107
+ const skeletonRootId = useId();
108
+ const skeletonRows = useMemo(
109
+ () =>
110
+ Array.from({ length: pagination.pageSize }, (_, position) => ({
111
+ id: `${skeletonRootId}-row-${position}`,
112
+ cells: columns.map(
113
+ (_, cellPosition) => `${skeletonRootId}-${position}-${cellPosition}`,
114
+ ),
115
+ })),
116
+ [columns, pagination.pageSize, skeletonRootId],
117
+ );
118
+ const searchValue = searchProp ?? internalSearch;
119
+ const isServerSide = manualPagination;
120
+ const table = useTable(
121
+ {
122
+ features: dataTableFeatures,
123
+ data,
124
+ columns,
125
+ pageCount,
126
+ manualPagination,
127
+ manualSorting,
128
+ manualFiltering: isServerSide,
129
+ enableSortingRemoval: false,
130
+ onSortingChange: onSortingChange ?? setInternalSorting,
131
+ onPaginationChange: onPaginationChange ?? setInternalPagination,
132
+ onColumnFiltersChange: setColumnFilters,
133
+ onColumnVisibilityChange: setColumnVisibility,
134
+ onRowSelectionChange: setRowSelection,
135
+ state: { sorting, columnFilters, columnVisibility, rowSelection, pagination },
136
+ },
137
+ (state) => state,
138
+ );
139
+
140
+ function handleSearchChange(value: string): void {
141
+ if (!isServerSide) {
142
+ setInternalSearch(value);
143
+ if (searchKey) table.getColumn(searchKey)?.setFilterValue(value);
144
+ }
145
+ onSearchChange?.(value);
146
+ }
147
+
148
+ const selectedCount = table.getFilteredSelectedRowModel().rows.length;
149
+ const displayedCount = isServerSide
150
+ ? (totalCount ?? data.length)
151
+ : table.getFilteredRowModel().rows.length;
152
+ const currentPage = table.state.pagination.pageIndex;
153
+ const totalPages = table.getPageCount();
154
+
155
+ return (
156
+ <div className='flex h-full flex-col'>
157
+ {(onSearchChange || searchKey || actions) && (
158
+ <div className='flex shrink-0 items-center justify-between gap-3 border-b border-border bg-background px-4 py-3'>
159
+ {onSearchChange || searchKey ? (
160
+ <div className='relative max-w-xs flex-1'>
161
+ <IconSearch className='absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
162
+ <Input
163
+ className='h-8 pl-8 text-sm'
164
+ placeholder={searchPlaceholder}
165
+ value={searchValue}
166
+ onChange={(event) => handleSearchChange(event.target.value)}
167
+ />
168
+ </div>
169
+ ) : (
170
+ <div />
171
+ )}
172
+ {actions && <div className='flex items-center gap-2'>{actions}</div>}
173
+ </div>
174
+ )}
175
+
176
+ <div
177
+ className={`flex-1 overflow-auto transition-opacity ${isFetching ? 'opacity-50' : ''}`}
178
+ >
179
+ <Table>
180
+ <TableHeader className='sticky top-0 z-10 bg-background'>
181
+ {table.getHeaderGroups().map((headerGroup) => (
182
+ <TableRow
183
+ key={headerGroup.id}
184
+ className='border-b border-border hover:bg-transparent'
185
+ >
186
+ {headerGroup.headers.map((header) => (
187
+ <TableHead
188
+ key={header.id}
189
+ className='h-10 whitespace-nowrap bg-background text-xs font-medium'
190
+ >
191
+ {header.isPlaceholder ? null : <table.FlexRender header={header} />}
192
+ </TableHead>
193
+ ))}
194
+ </TableRow>
195
+ ))}
196
+ </TableHeader>
197
+ <TableBody>
198
+ {isLoading ? (
199
+ skeletonRows.map((row) => (
200
+ <TableRow key={row.id}>
201
+ {row.cells.map((cellId) => (
202
+ <TableCell key={cellId} className='h-12 py-2'>
203
+ <Skeleton className='h-4 w-28' />
204
+ </TableCell>
205
+ ))}
206
+ </TableRow>
207
+ ))
208
+ ) : table.getRowModel().rows.length ? (
209
+ table.getRowModel().rows.map((row) => (
210
+ <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
211
+ {row.getVisibleCells().map((cell) => (
212
+ <TableCell key={cell.id} className='h-12 py-2'>
213
+ <table.FlexRender cell={cell} />
214
+ </TableCell>
215
+ ))}
216
+ </TableRow>
217
+ ))
218
+ ) : (
219
+ <TableRow>
220
+ <TableCell
221
+ colSpan={columns.length}
222
+ className='h-24 text-center text-sm text-muted-foreground'
223
+ >
224
+ No results
225
+ </TableCell>
226
+ </TableRow>
227
+ )}
228
+ </TableBody>
229
+ </Table>
230
+ </div>
231
+
232
+ <div className='flex h-11 shrink-0 items-center justify-between border-t border-border bg-background px-4'>
233
+ <div className='flex items-center gap-4'>
234
+ <span className='text-xs text-muted-foreground'>
235
+ {selectedCount > 0
236
+ ? `${selectedCount} of ${displayedCount} selected`
237
+ : `${displayedCount} projects`}
238
+ </span>
239
+ <Select
240
+ value={String(table.state.pagination.pageSize)}
241
+ onValueChange={(value) => table.setPageSize(Number(value))}
242
+ >
243
+ <SelectTrigger
244
+ aria-label='Rows per page'
245
+ variant='ghost'
246
+ className='h-7 w-fit rounded px-2 text-xs'
247
+ >
248
+ <SelectValue />
249
+ </SelectTrigger>
250
+ <SelectContent>
251
+ {[10, 20, 50, 100].map((size) => (
252
+ <SelectItem key={size} value={String(size)}>
253
+ {size}
254
+ </SelectItem>
255
+ ))}
256
+ </SelectContent>
257
+ </Select>
258
+ </div>
259
+ <div className='flex items-center gap-0.5'>
260
+ <Button
261
+ aria-label='Previous page'
262
+ variant='ghost'
263
+ size='icon'
264
+ onClick={() => table.previousPage()}
265
+ disabled={!table.getCanPreviousPage()}
266
+ >
267
+ <IconChevronLeft size={14} />
268
+ </Button>
269
+ {paginationItems(currentPage, totalPages).map((item) => {
270
+ if (item.page === undefined) {
271
+ return (
272
+ <span key={item.id} className='px-1 text-xs text-muted-foreground'>
273
+ …
274
+ </span>
275
+ );
276
+ }
277
+ const page = item.page;
278
+ return (
279
+ <Button
280
+ key={item.id}
281
+ aria-label={`Go to page ${page + 1}`}
282
+ variant={currentPage === page ? 'default' : 'ghost'}
283
+ size='icon'
284
+ onClick={() => table.setPageIndex(page)}
285
+ >
286
+ {page + 1}
287
+ </Button>
288
+ );
289
+ })}
290
+ <Button
291
+ aria-label='Next page'
292
+ variant='ghost'
293
+ size='icon'
294
+ onClick={() => table.nextPage()}
295
+ disabled={!table.getCanNextPage()}
296
+ >
297
+ <IconChevronRight size={14} />
298
+ </Button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ );
303
+ }
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ import type { ComponentType } from 'react';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ interface InlineStatProps {
7
+ label: string;
8
+ value: string;
9
+ icon: ComponentType<{ size?: number; className?: string }>;
10
+ isLoading?: boolean;
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Compact inline stat card with icon and single value
16
+ */
17
+ export function InlineStat({
18
+ label,
19
+ value,
20
+ icon: Icon,
21
+ isLoading,
22
+ className,
23
+ }: InlineStatProps) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ 'flex items-center gap-2 lg:gap-3 px-3 lg:px-4 py-2 lg:py-3 bg-muted/40 rounded-lg',
28
+ className,
29
+ )}
30
+ >
31
+ <div className='flex items-center justify-center w-7 h-7 lg:w-9 lg:h-9 bg-background rounded-md shrink-0'>
32
+ <Icon className='w-4 h-4 lg:w-[18px] lg:h-[18px] text-muted-foreground' />
33
+ </div>
34
+ <div className='flex flex-col min-w-0'>
35
+ <span className='text-[10px] lg:text-xs text-muted-foreground'>{label}</span>
36
+ {isLoading ? (
37
+ <div className='h-5 lg:h-7 w-16 bg-muted animate-pulse rounded' />
38
+ ) : (
39
+ <span className='text-base lg:text-lg font-semibold tracking-tight'>{value}</span>
40
+ )}
41
+ </div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ interface DualStatItem {
47
+ value: string;
48
+ suffix: string;
49
+ /** Цвет точки (на мобильных показывается вместо suffix) */
50
+ dotColor?: string;
51
+ }
52
+
53
+ interface DualInlineStatProps {
54
+ label: string;
55
+ items: DualStatItem[];
56
+ icon: ComponentType<{ size?: number; className?: string }>;
57
+ isLoading?: boolean;
58
+ className?: string;
59
+ }
60
+
61
+ /**
62
+ * Compact inline stat card with icon and two values side by side
63
+ */
64
+ export function DualInlineStat({
65
+ label,
66
+ items,
67
+ icon: Icon,
68
+ isLoading,
69
+ className,
70
+ }: DualInlineStatProps) {
71
+ return (
72
+ <div
73
+ className={cn(
74
+ 'flex items-center gap-2 lg:gap-3 px-3 lg:px-4 py-2 lg:py-3 bg-muted/40 rounded-lg',
75
+ className,
76
+ )}
77
+ >
78
+ <div className='flex items-center justify-center w-7 h-7 lg:w-9 lg:h-9 bg-background rounded-md shrink-0'>
79
+ <Icon className='w-4 h-4 lg:w-[18px] lg:h-[18px] text-muted-foreground' />
80
+ </div>
81
+ <div className='flex flex-col min-w-0'>
82
+ <span className='text-[10px] lg:text-xs text-muted-foreground'>{label}</span>
83
+ {isLoading ? (
84
+ <div className='h-5 lg:h-7 w-24 bg-muted animate-pulse rounded' />
85
+ ) : (
86
+ <div className='flex items-baseline gap-2 lg:gap-3 whitespace-nowrap'>
87
+ {items.map((item, i) => (
88
+ <span key={item.suffix} className='flex items-center gap-1.5 lg:gap-1.5'>
89
+ <span className='text-base lg:text-lg font-semibold tracking-tight'>
90
+ {item.value}
91
+ </span>
92
+ {/* На мобильных — точка, на десктопе — текст */}
93
+ {item.dotColor ? (
94
+ <>
95
+ <span className={cn('w-1.5 h-1.5 rounded-full lg:hidden', item.dotColor)} />
96
+ <span className='hidden lg:inline text-[10px] lg:text-xs text-muted-foreground'>
97
+ {item.suffix}
98
+ </span>
99
+ </>
100
+ ) : (
101
+ <span className='text-[10px] lg:text-xs text-muted-foreground'>
102
+ {item.suffix}
103
+ </span>
104
+ )}
105
+ {i < items.length - 1 && <span className='w-px h-3 bg-border ml-1 lg:ml-1' />}
106
+ </span>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ import { IconChevronDown, IconChevronUp, IconSelector } from '@tabler/icons-react';
4
+ import type { ReactNode } from 'react';
5
+
6
+ interface SortableHeaderProps {
7
+ column: {
8
+ getIsSorted: () => false | 'asc' | 'desc';
9
+ toggleSorting: (desc: boolean) => void;
10
+ };
11
+ children: ReactNode;
12
+ }
13
+
14
+ export function SortableHeader({ column, children }: SortableHeaderProps) {
15
+ const sorted = column.getIsSorted();
16
+
17
+ const Icon =
18
+ sorted === 'asc' ? IconChevronUp : sorted === 'desc' ? IconChevronDown : IconSelector;
19
+
20
+ return (
21
+ <button
22
+ type='button'
23
+ className='flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer whitespace-nowrap'
24
+ onClick={() => {
25
+ const currentSorted = column.getIsSorted();
26
+ column.toggleSorting(currentSorted === 'asc');
27
+ }}
28
+ >
29
+ {children}
30
+ <Icon size={14} className={sorted ? undefined : 'opacity-40'} />
31
+ </button>
32
+ );
33
+ }
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ interface TableErrorProps {
4
+ error: unknown;
5
+ entity?: string;
6
+ }
7
+
8
+ /**
9
+ * Error state component for table pages
10
+ * Extracts error code from ApiError for display
11
+ */
12
+ export function TableError({ error, entity = 'data' }: TableErrorProps) {
13
+ const message = error instanceof Error ? error.message : 'Unknown error';
14
+
15
+ return (
16
+ <div className='flex-1 flex items-center justify-center'>
17
+ <p className='text-sm text-destructive text-center'>
18
+ Could not load {entity}
19
+ <br />
20
+ <span className='text-xs font-medium'>{message}</span>
21
+ </p>
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import { IconX } from '@tabler/icons-react';
4
+ import {
5
+ Button,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@/components/ui';
12
+
13
+ export interface FilterOption {
14
+ value: string;
15
+ label: string;
16
+ color?: string;
17
+ }
18
+
19
+ export interface FilterConfig {
20
+ key: string;
21
+ placeholder: string;
22
+ allLabel: string;
23
+ options: FilterOption[];
24
+ }
25
+
26
+ interface TableFiltersProps {
27
+ filters: FilterConfig[];
28
+ values: Record<string, string | undefined>;
29
+ onChange: (key: string, value: string | undefined) => void;
30
+ onClear?: () => void;
31
+ }
32
+
33
+ export function TableFilters({ filters, values, onChange, onClear }: TableFiltersProps) {
34
+ const activeCount = Object.values(values).filter(Boolean).length;
35
+
36
+ return (
37
+ <div className='flex items-center gap-2'>
38
+ {filters.map((filter) => (
39
+ <Select
40
+ key={filter.key}
41
+ value={values[filter.key] ?? 'all'}
42
+ onValueChange={(v) => onChange(filter.key, v === 'all' ? undefined : v)}
43
+ >
44
+ <SelectTrigger
45
+ aria-label={`Filter by ${filter.placeholder}`}
46
+ variant='ghost'
47
+ className='w-fit h-8 text-xs px-2 gap-1 rounded-lg'
48
+ >
49
+ <SelectValue placeholder={filter.placeholder} />
50
+ </SelectTrigger>
51
+ <SelectContent>
52
+ <SelectItem value='all'>{filter.allLabel}</SelectItem>
53
+ {filter.options.map((opt) => (
54
+ <SelectItem key={opt.value} value={opt.value}>
55
+ {opt.color ? (
56
+ <span className='flex items-center gap-2'>
57
+ <span className={`h-2 w-2 rounded-full ${opt.color}`} />
58
+ {opt.label}
59
+ </span>
60
+ ) : (
61
+ opt.label
62
+ )}
63
+ </SelectItem>
64
+ ))}
65
+ </SelectContent>
66
+ </Select>
67
+ ))}
68
+
69
+ {activeCount > 0 && onClear && (
70
+ <Button
71
+ variant='ghost'
72
+ size='sm'
73
+ onClick={onClear}
74
+ className='h-8 px-2 text-xs text-muted-foreground hover:text-foreground'
75
+ >
76
+ <IconX size={14} />
77
+ Clear
78
+ </Button>
79
+ )}
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+
3
+ import type { OnChangeFn, PaginationState, SortingState } from '@tanstack/react-table';
4
+ import { useCallback, useMemo, useState } from 'react';
5
+
6
+ export interface UseTableStateOptions {
7
+ defaultSort?: string;
8
+ defaultSortOrder?: 'asc' | 'desc';
9
+ defaultPageSize?: number;
10
+ defaultSearch?: string;
11
+ }
12
+
13
+ export interface TableQueryParams {
14
+ page: number;
15
+ limit: number;
16
+ sortBy: string;
17
+ sortOrder: 'asc' | 'desc';
18
+ search?: string;
19
+ }
20
+
21
+ export interface TableProps {
22
+ sorting: SortingState;
23
+ pagination: PaginationState;
24
+ search: string;
25
+ onSortingChange: OnChangeFn<SortingState>;
26
+ onPaginationChange: OnChangeFn<PaginationState>;
27
+ onSearchChange: (search: string) => void;
28
+ manualPagination: true;
29
+ manualSorting: true;
30
+ }
31
+
32
+ export interface UseTableStateReturn {
33
+ // State values
34
+ search: string;
35
+ pagination: PaginationState;
36
+ sorting: SortingState;
37
+
38
+ // Setters
39
+ setSearch: (value: string) => void;
40
+ setPagination: OnChangeFn<PaginationState>;
41
+ setSorting: OnChangeFn<SortingState>;
42
+
43
+ // Handler that resets to page 0 on search
44
+ handleSearchChange: (value: string) => void;
45
+
46
+ // Ready-to-use query params for API calls
47
+ queryParams: TableQueryParams;
48
+
49
+ // Props to spread directly to DataTable
50
+ tableProps: TableProps;
51
+ }
52
+
53
+ export function useTableState(options: UseTableStateOptions = {}): UseTableStateReturn {
54
+ const {
55
+ defaultSort = 'createdAt',
56
+ defaultSortOrder = 'desc',
57
+ defaultPageSize = 20,
58
+ defaultSearch = '',
59
+ } = options;
60
+
61
+ const [search, setSearch] = useState(defaultSearch);
62
+ const [pagination, setPagination] = useState<PaginationState>({
63
+ pageIndex: 0,
64
+ pageSize: defaultPageSize,
65
+ });
66
+ const [sorting, setSorting] = useState<SortingState>([
67
+ { id: defaultSort, desc: defaultSortOrder === 'desc' },
68
+ ]);
69
+
70
+ const handleSearchChange = useCallback((value: string) => {
71
+ setSearch(value);
72
+ setPagination((prev) => ({ ...prev, pageIndex: 0 }));
73
+ }, []);
74
+
75
+ const queryParams = useMemo<TableQueryParams>(
76
+ () => ({
77
+ page: pagination.pageIndex + 1,
78
+ limit: pagination.pageSize,
79
+ sortBy: sorting[0]?.id ?? defaultSort,
80
+ sortOrder: sorting[0]?.desc ? 'desc' : 'asc',
81
+ search: search || undefined,
82
+ }),
83
+ [pagination, sorting, search, defaultSort],
84
+ );
85
+
86
+ const tableProps = useMemo<TableProps>(
87
+ () => ({
88
+ sorting,
89
+ pagination,
90
+ search,
91
+ onSortingChange: setSorting,
92
+ onPaginationChange: setPagination,
93
+ onSearchChange: handleSearchChange,
94
+ manualPagination: true,
95
+ manualSorting: true,
96
+ }),
97
+ [sorting, pagination, search, handleSearchChange],
98
+ );
99
+
100
+ return {
101
+ search,
102
+ pagination,
103
+ sorting,
104
+ setSearch,
105
+ setPagination,
106
+ setSorting,
107
+ handleSearchChange,
108
+ queryParams,
109
+ tableProps,
110
+ };
111
+ }
@@ -0,0 +1,26 @@
1
+ // Components
2
+
3
+ export {
4
+ type ActionItem,
5
+ createActionsColumn,
6
+ createDateColumn,
7
+ createSelectColumn,
8
+ } from './components/column-helpers';
9
+ export { DataTable } from './components/data-table';
10
+ export { DualInlineStat, InlineStat } from './components/inline-stat';
11
+ export { SortableHeader } from './components/sortable-header';
12
+ export { TableError } from './components/table-error';
13
+ export {
14
+ type FilterConfig,
15
+ type FilterOption,
16
+ TableFilters,
17
+ } from './components/table-filters';
18
+
19
+ // Hooks
20
+ export {
21
+ type TableProps,
22
+ type TableQueryParams,
23
+ type UseTableStateOptions,
24
+ type UseTableStateReturn,
25
+ useTableState,
26
+ } from './hooks/useTableState';