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.
- package/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/cli.js +230 -0
- package/package.json +50 -0
- package/template/README.md +69 -0
- package/template/_env +11 -0
- package/template/_env.example +12 -0
- package/template/_gitignore +10 -0
- package/template/biome.json +43 -0
- package/template/bun.lock +1372 -0
- package/template/e2e/starter.spec.ts +237 -0
- package/template/ecosystem.config.cjs +28 -0
- package/template/ecosystem.dev.config.cjs +29 -0
- package/template/package.json +55 -0
- package/template/packages/backend/package.json +31 -0
- package/template/packages/backend/src/cli.ts +12 -0
- package/template/packages/backend/src/domain/errors.ts +9 -0
- package/template/packages/backend/src/domain/repository/github-cache.test.ts +72 -0
- package/template/packages/backend/src/domain/repository/github-cache.ts +205 -0
- package/template/packages/backend/src/index.ts +56 -0
- package/template/packages/backend/src/lib/db.ts +7 -0
- package/template/packages/backend/src/surface.ts +13 -0
- package/template/packages/backend/src/tools.ts +21 -0
- package/template/packages/backend/src/transport/errors.ts +28 -0
- package/template/packages/backend/src/transport/repository-service.ts +18 -0
- package/template/packages/backend/tsconfig.json +8 -0
- package/template/packages/config/package.json +22 -0
- package/template/packages/config/src/server.ts +24 -0
- package/template/packages/config/tsconfig.json +5 -0
- package/template/packages/db/migrations/20260808000000_init/migration.sql +16 -0
- package/template/packages/db/migrations/20260808170000_repository_visibility/migration.sql +4 -0
- package/template/packages/db/migrations/migration_lock.toml +1 -0
- package/template/packages/db/package.json +29 -0
- package/template/packages/db/prisma.config.ts +8 -0
- package/template/packages/db/schema.prisma +31 -0
- package/template/packages/db/tsconfig.json +5 -0
- package/template/packages/frontend/messages/en.json +15 -0
- package/template/packages/frontend/messages/ru.json +15 -0
- package/template/packages/frontend/next.config.ts +18 -0
- package/template/packages/frontend/package.json +62 -0
- package/template/packages/frontend/postcss.config.mjs +1 -0
- package/template/packages/frontend/public/ASSETS.md +6 -0
- package/template/packages/frontend/public/favicon/mascot-stitch.png +0 -0
- package/template/packages/frontend/public/icons/biome.svg +1 -0
- package/template/packages/frontend/public/icons/bun.svg +19 -0
- package/template/packages/frontend/public/icons/next.svg +1 -0
- package/template/packages/frontend/public/icons/postgresql.svg +1 -0
- package/template/packages/frontend/public/icons/prisma.svg +6 -0
- package/template/packages/frontend/public/icons/react-query.svg +9 -0
- package/template/packages/frontend/public/icons/react.svg +1 -0
- package/template/packages/frontend/public/icons/tailwind.svg +12 -0
- package/template/packages/frontend/public/icons/tsconfig.svg +1 -0
- package/template/packages/frontend/public/icons/typescript.svg +7 -0
- package/template/packages/frontend/public/icons/zod.svg +1 -0
- package/template/packages/frontend/public/mascot-stitch.png +0 -0
- package/template/packages/frontend/public/theme-dark.svg +8 -0
- package/template/packages/frontend/public/theme-light.svg +8 -0
- package/template/packages/frontend/src/app/[locale]/error.tsx +19 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +59 -0
- package/template/packages/frontend/src/app/[locale]/loading.tsx +14 -0
- package/template/packages/frontend/src/app/[locale]/page.tsx +44 -0
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +40 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/account-showcase.tsx +112 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/admin-showcase.tsx +101 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-navigation.tsx +56 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-shell.tsx +51 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/code-foundation.tsx +35 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/components-story.tsx +54 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/data-story.tsx +126 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/image-preloader.tsx +8 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/index.ts +3 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-sections.tsx +269 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +159 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/motion-story.tsx +78 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/overlays-story.tsx +134 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/payment-showcase.tsx +82 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/presentation-story.tsx +68 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/primitives-story.tsx +267 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/story-page.tsx +14 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story-client.tsx +208 -0
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story.tsx +13 -0
- package/template/packages/frontend/src/app/[locale]/ui/layout.tsx +6 -0
- package/template/packages/frontend/src/app/[locale]/ui/page.tsx +10 -0
- package/template/packages/frontend/src/app/api/og/[locale]/[page]/route.tsx +53 -0
- package/template/packages/frontend/src/app/globals.css +210 -0
- package/template/packages/frontend/src/app/robots.ts +9 -0
- package/template/packages/frontend/src/app/sitemap.ts +22 -0
- package/template/packages/frontend/src/components/brand-mark.tsx +22 -0
- package/template/packages/frontend/src/components/repository-summary.tsx +107 -0
- package/template/packages/frontend/src/components/system-controls.tsx +92 -0
- package/template/packages/frontend/src/components/ui/adaptive-modal.tsx +382 -0
- package/template/packages/frontend/src/components/ui/animated-counter.tsx +80 -0
- package/template/packages/frontend/src/components/ui/avatar.tsx +46 -0
- package/template/packages/frontend/src/components/ui/badge.tsx +94 -0
- package/template/packages/frontend/src/components/ui/button-variants.ts +24 -0
- package/template/packages/frontend/src/components/ui/button.tsx +81 -0
- package/template/packages/frontend/src/components/ui/card-deck.tsx +73 -0
- package/template/packages/frontend/src/components/ui/card.tsx +81 -0
- package/template/packages/frontend/src/components/ui/charts/area-chart.tsx +167 -0
- package/template/packages/frontend/src/components/ui/charts/index.ts +7 -0
- package/template/packages/frontend/src/components/ui/checkbox.tsx +133 -0
- package/template/packages/frontend/src/components/ui/confirmation-modal.tsx +129 -0
- package/template/packages/frontend/src/components/ui/copyable-text.tsx +85 -0
- package/template/packages/frontend/src/components/ui/drawer.tsx +136 -0
- package/template/packages/frontend/src/components/ui/dropdown-menu.tsx +185 -0
- package/template/packages/frontend/src/components/ui/field.tsx +28 -0
- package/template/packages/frontend/src/components/ui/hand-drawn-icons.tsx +41 -0
- package/template/packages/frontend/src/components/ui/index.ts +36 -0
- package/template/packages/frontend/src/components/ui/input.tsx +24 -0
- package/template/packages/frontend/src/components/ui/label.tsx +18 -0
- package/template/packages/frontend/src/components/ui/otp-input.tsx +140 -0
- package/template/packages/frontend/src/components/ui/select.tsx +240 -0
- package/template/packages/frontend/src/components/ui/sidebar.tsx +343 -0
- package/template/packages/frontend/src/components/ui/skeleton.tsx +8 -0
- package/template/packages/frontend/src/components/ui/slider.tsx +31 -0
- package/template/packages/frontend/src/components/ui/slot-drum.tsx +50 -0
- package/template/packages/frontend/src/components/ui/spinner.tsx +30 -0
- package/template/packages/frontend/src/components/ui/switch.tsx +31 -0
- package/template/packages/frontend/src/components/ui/table.tsx +106 -0
- package/template/packages/frontend/src/components/ui/tabs.tsx +196 -0
- package/template/packages/frontend/src/components/ui/textarea.tsx +21 -0
- package/template/packages/frontend/src/components/ui/toaster.tsx +72 -0
- package/template/packages/frontend/src/components/ui/tooltip.tsx +31 -0
- package/template/packages/frontend/src/env.ts +21 -0
- package/template/packages/frontend/src/features/data-table/components/column-helpers.tsx +136 -0
- package/template/packages/frontend/src/features/data-table/components/data-table.tsx +303 -0
- package/template/packages/frontend/src/features/data-table/components/inline-stat.tsx +113 -0
- package/template/packages/frontend/src/features/data-table/components/sortable-header.tsx +33 -0
- package/template/packages/frontend/src/features/data-table/components/table-error.tsx +24 -0
- package/template/packages/frontend/src/features/data-table/components/table-filters.tsx +82 -0
- package/template/packages/frontend/src/features/data-table/hooks/useTableState.ts +111 -0
- package/template/packages/frontend/src/features/data-table/index.ts +26 -0
- package/template/packages/frontend/src/features/data-table/table-features.ts +26 -0
- package/template/packages/frontend/src/hooks/index.ts +3 -0
- package/template/packages/frontend/src/hooks/useInfiniteScroll.ts +67 -0
- package/template/packages/frontend/src/hooks/useIsTouchDevice.ts +25 -0
- package/template/packages/frontend/src/hooks/useMediaQuery.ts +30 -0
- package/template/packages/frontend/src/i18n/locales.ts +6 -0
- package/template/packages/frontend/src/i18n/navigation.ts +4 -0
- package/template/packages/frontend/src/i18n/request.ts +9 -0
- package/template/packages/frontend/src/i18n/routing.ts +8 -0
- package/template/packages/frontend/src/lib/api/client.ts +20 -0
- package/template/packages/frontend/src/lib/api/queries.ts +11 -0
- package/template/packages/frontend/src/lib/query-client.ts +20 -0
- package/template/packages/frontend/src/lib/realtime/repository.ts +21 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +50 -0
- package/template/packages/frontend/src/lib/seo/pages.test.ts +25 -0
- package/template/packages/frontend/src/lib/seo/pages.ts +107 -0
- package/template/packages/frontend/src/lib/utils/cn.ts +6 -0
- package/template/packages/frontend/src/lib/utils/format.ts +25 -0
- package/template/packages/frontend/src/lib/utils/index.ts +2 -0
- package/template/packages/frontend/src/providers/index.tsx +18 -0
- package/template/packages/frontend/src/providers/react-query.tsx +9 -0
- package/template/packages/frontend/src/providers/realtime.tsx +16 -0
- package/template/packages/frontend/src/proxy.ts +6 -0
- package/template/packages/frontend/src/theme/config.ts +20 -0
- package/template/packages/frontend/src/theme/transition.ts +54 -0
- package/template/packages/frontend/tsconfig.json +13 -0
- package/template/packages/shared/package.json +23 -0
- package/template/packages/shared/src/contracts/repository.ts +28 -0
- package/template/packages/shared/src/events/repository.ts +9 -0
- package/template/packages/shared/src/index.ts +3 -0
- package/template/packages/shared/src/schemas/repository.test.ts +36 -0
- package/template/packages/shared/src/schemas/repository.ts +28 -0
- package/template/packages/shared/tsconfig.json +5 -0
- package/template/playwright.config.ts +15 -0
- package/template/scripts/check-authored.ts +81 -0
- package/template/scripts/dev.ts +23 -0
- package/template/scripts/local-env.ts +12 -0
- package/template/scripts/runtime-smoke.ts +67 -0
- package/template/scripts/tooling-env.ts +15 -0
- 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';
|