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,382 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AdaptiveModal - Адаптивный модальный компонент
|
|
5
|
+
*
|
|
6
|
+
* Mobile: Bottom Sheet с drag-to-dismiss / Fullscreen / Modal
|
|
7
|
+
* Desktop: Center Modal с fade анимацией
|
|
8
|
+
*
|
|
9
|
+
* Powered by Radix UI Dialog & Motion for A11y and Animations
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ ВАЖНО ДЛЯ ИСПОЛЬЗОВАНИЯ:
|
|
12
|
+
* Чтобы exit анимация работала, НЕ оборачивай в условный рендеринг:
|
|
13
|
+
* ❌ {isOpen && <AdaptiveModal />} // Ломает exit анимацию
|
|
14
|
+
* ✅ <AdaptiveModal isOpen={isOpen} /> // Правильно
|
|
15
|
+
*
|
|
16
|
+
* Компонент должен оставаться в DOM, управляй через проп isOpen
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
20
|
+
import { IconX } from '@tabler/icons-react';
|
|
21
|
+
import {
|
|
22
|
+
AnimatePresence,
|
|
23
|
+
animate,
|
|
24
|
+
motion,
|
|
25
|
+
type PanInfo,
|
|
26
|
+
type Transition,
|
|
27
|
+
useDragControls,
|
|
28
|
+
useMotionValue,
|
|
29
|
+
useTransform,
|
|
30
|
+
} from 'framer-motion';
|
|
31
|
+
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
|
32
|
+
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
33
|
+
import { cn } from '@/lib/utils';
|
|
34
|
+
import { Button } from '.';
|
|
35
|
+
|
|
36
|
+
// ==========================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// ==========================================================================
|
|
39
|
+
|
|
40
|
+
export interface AdaptiveModalProps {
|
|
41
|
+
isOpen: boolean;
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
title?: string;
|
|
45
|
+
showDragIndicator?: boolean;
|
|
46
|
+
desktopWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
|
47
|
+
className?: string;
|
|
48
|
+
mobileVariant?: 'modal' | 'sheet' | 'fullscreen';
|
|
49
|
+
footer?: ReactNode;
|
|
50
|
+
/** Закрывать по клику вне модалки (по умолчанию true, работает для desktop, mobile modal и sheet, не применяется для fullscreen) */
|
|
51
|
+
closeOnOutsideClick?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ==========================================================================
|
|
55
|
+
// Constants
|
|
56
|
+
// ==========================================================================
|
|
57
|
+
|
|
58
|
+
const OPEN_TRANSITION: Transition = {
|
|
59
|
+
type: 'tween',
|
|
60
|
+
duration: 0.4,
|
|
61
|
+
ease: [0.16, 1, 0.3, 1],
|
|
62
|
+
};
|
|
63
|
+
const CLOSE_TRANSITION: Transition = {
|
|
64
|
+
type: 'tween',
|
|
65
|
+
duration: 0.3,
|
|
66
|
+
ease: [0.32, 0, 0.67, 0],
|
|
67
|
+
};
|
|
68
|
+
const SNAP_BACK_TRANSITION: Transition = { type: 'spring', stiffness: 400, damping: 35 };
|
|
69
|
+
const BACKDROP_TRANSITION: Transition = { duration: 0.3 };
|
|
70
|
+
const MODAL_OPEN_TRANSITION: Transition = {
|
|
71
|
+
type: 'tween',
|
|
72
|
+
duration: 0.3,
|
|
73
|
+
ease: [0.16, 1, 0.3, 1],
|
|
74
|
+
};
|
|
75
|
+
const MODAL_CLOSE_TRANSITION: Transition = {
|
|
76
|
+
type: 'tween',
|
|
77
|
+
duration: 0.2,
|
|
78
|
+
ease: [0.32, 0, 0.67, 0],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const ANIMATION_CONFIGS = {
|
|
82
|
+
open: OPEN_TRANSITION,
|
|
83
|
+
close: CLOSE_TRANSITION,
|
|
84
|
+
snapBack: SNAP_BACK_TRANSITION,
|
|
85
|
+
backdrop: BACKDROP_TRANSITION,
|
|
86
|
+
modal: {
|
|
87
|
+
open: MODAL_OPEN_TRANSITION,
|
|
88
|
+
close: MODAL_CLOSE_TRANSITION,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const DRAG_THRESHOLD = 0.35;
|
|
93
|
+
const VELOCITY_THRESHOLD = 200;
|
|
94
|
+
|
|
95
|
+
const DESKTOP_WIDTHS = {
|
|
96
|
+
sm: 'max-w-sm',
|
|
97
|
+
md: 'max-w-md',
|
|
98
|
+
lg: 'max-w-lg',
|
|
99
|
+
xl: 'max-w-xl',
|
|
100
|
+
'2xl': 'max-w-2xl',
|
|
101
|
+
'3xl': 'max-w-3xl',
|
|
102
|
+
'4xl': 'max-w-4xl',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ==========================================================================
|
|
106
|
+
// Internal Components
|
|
107
|
+
// ==========================================================================
|
|
108
|
+
|
|
109
|
+
interface ModalLayoutProps {
|
|
110
|
+
title?: string;
|
|
111
|
+
children: ReactNode;
|
|
112
|
+
footer?: ReactNode;
|
|
113
|
+
className?: string;
|
|
114
|
+
headerClassName?: string;
|
|
115
|
+
footerClassName?: string;
|
|
116
|
+
showCloseButton?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ModalLayout = ({
|
|
120
|
+
title,
|
|
121
|
+
children,
|
|
122
|
+
footer,
|
|
123
|
+
className,
|
|
124
|
+
headerClassName,
|
|
125
|
+
footerClassName,
|
|
126
|
+
showCloseButton = true,
|
|
127
|
+
}: ModalLayoutProps) => (
|
|
128
|
+
<div className={cn('flex min-h-0 flex-col overflow-hidden bg-background', className)}>
|
|
129
|
+
{/* Header */}
|
|
130
|
+
{(title || showCloseButton) && (
|
|
131
|
+
<div
|
|
132
|
+
className={cn(
|
|
133
|
+
'flex shrink-0 items-center justify-between px-4 pt-4 pb-2',
|
|
134
|
+
headerClassName,
|
|
135
|
+
)}
|
|
136
|
+
>
|
|
137
|
+
<DialogPrimitive.Title asChild>
|
|
138
|
+
{title ? (
|
|
139
|
+
<h3 className='text-lg font-medium font-display'>{title}</h3>
|
|
140
|
+
) : (
|
|
141
|
+
<span className='hidden'>Modal</span> // A11y requirement
|
|
142
|
+
)}
|
|
143
|
+
</DialogPrimitive.Title>
|
|
144
|
+
{showCloseButton && (
|
|
145
|
+
<DialogPrimitive.Close asChild>
|
|
146
|
+
<Button variant='ghost' size='icon' className='ml-auto' aria-label='Close dialog'>
|
|
147
|
+
<IconX size={20} stroke={1.5} />
|
|
148
|
+
</Button>
|
|
149
|
+
</DialogPrimitive.Close>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{/* Content */}
|
|
155
|
+
<div className='min-h-0 flex-1 overflow-y-auto'>{children}</div>
|
|
156
|
+
|
|
157
|
+
{/* Footer */}
|
|
158
|
+
{footer && <div className={cn('shrink-0 px-4 pb-4', footerClassName)}>{footer}</div>}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// ==========================================================================
|
|
163
|
+
// Main Component
|
|
164
|
+
// ==========================================================================
|
|
165
|
+
|
|
166
|
+
export function AdaptiveModal(props: AdaptiveModalProps) {
|
|
167
|
+
const isDesktop = useMediaQuery('(min-width: 768px)');
|
|
168
|
+
const [mounted, setMounted] = useState(false);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
setMounted(true);
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
if (!mounted) return null;
|
|
175
|
+
|
|
176
|
+
const { mobileVariant = 'modal' } = props;
|
|
177
|
+
const isSheet = !isDesktop && mobileVariant === 'sheet';
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<DialogPrimitive.Root open={props.isOpen} onOpenChange={props.onClose}>
|
|
181
|
+
<AnimatePresence>
|
|
182
|
+
{props.isOpen && (
|
|
183
|
+
<DialogPrimitive.Portal forceMount>
|
|
184
|
+
{isSheet ? (
|
|
185
|
+
<MobileBottomSheet {...props} />
|
|
186
|
+
) : (
|
|
187
|
+
<>
|
|
188
|
+
{/* Общий Overlay для desktop/mobile modal/fullscreen */}
|
|
189
|
+
<DialogPrimitive.Overlay asChild>
|
|
190
|
+
<motion.div
|
|
191
|
+
className='fixed inset-0 z-60 bg-overlay backdrop-blur-sm'
|
|
192
|
+
initial={{ opacity: 0 }}
|
|
193
|
+
animate={{ opacity: 1 }}
|
|
194
|
+
exit={{ opacity: 0 }}
|
|
195
|
+
transition={ANIMATION_CONFIGS.backdrop}
|
|
196
|
+
/>
|
|
197
|
+
</DialogPrimitive.Overlay>
|
|
198
|
+
|
|
199
|
+
<UnifiedContent {...props} isDesktop={isDesktop} />
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
</DialogPrimitive.Portal>
|
|
203
|
+
)}
|
|
204
|
+
</AnimatePresence>
|
|
205
|
+
</DialogPrimitive.Root>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ==========================================================================
|
|
210
|
+
// Unified Content (Desktop + Mobile Modal + Fullscreen)
|
|
211
|
+
// ==========================================================================
|
|
212
|
+
|
|
213
|
+
interface UnifiedContentProps extends AdaptiveModalProps {
|
|
214
|
+
isDesktop: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function UnifiedContent({ isDesktop, ...props }: UnifiedContentProps) {
|
|
218
|
+
const { mobileVariant = 'modal', closeOnOutsideClick = true } = props;
|
|
219
|
+
const isFullscreen = !isDesktop && mobileVariant === 'fullscreen';
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<DialogPrimitive.Content
|
|
223
|
+
asChild
|
|
224
|
+
aria-describedby={undefined}
|
|
225
|
+
onInteractOutside={(event) => {
|
|
226
|
+
if (!closeOnOutsideClick) event.preventDefault();
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<div
|
|
230
|
+
className={cn(
|
|
231
|
+
'fixed inset-0 z-70 flex',
|
|
232
|
+
!isFullscreen && 'items-center justify-center p-4',
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
<motion.div
|
|
236
|
+
className={cn(
|
|
237
|
+
'pointer-events-auto flex flex-col overflow-hidden bg-background shadow-lg outline-none',
|
|
238
|
+
!isFullscreen && 'rounded-2xl border border-border',
|
|
239
|
+
isDesktop && `w-full ${DESKTOP_WIDTHS[props.desktopWidth || 'md']}`,
|
|
240
|
+
!isDesktop && !isFullscreen && 'w-full max-w-sm',
|
|
241
|
+
isFullscreen && 'h-full w-full',
|
|
242
|
+
!isFullscreen && 'max-h-[85vh]',
|
|
243
|
+
props.className,
|
|
244
|
+
)}
|
|
245
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
246
|
+
animate={{
|
|
247
|
+
opacity: 1,
|
|
248
|
+
scale: 1,
|
|
249
|
+
transition: ANIMATION_CONFIGS.modal.open,
|
|
250
|
+
}}
|
|
251
|
+
exit={{
|
|
252
|
+
opacity: 0,
|
|
253
|
+
scale: 0.95,
|
|
254
|
+
transition: ANIMATION_CONFIGS.modal.close,
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
<ModalLayout
|
|
258
|
+
title={props.title}
|
|
259
|
+
footer={props.footer}
|
|
260
|
+
className={cn(isFullscreen && 'h-full')}
|
|
261
|
+
footerClassName={
|
|
262
|
+
isFullscreen
|
|
263
|
+
? 'border-t border-border py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]'
|
|
264
|
+
: undefined
|
|
265
|
+
}
|
|
266
|
+
>
|
|
267
|
+
{props.children}
|
|
268
|
+
</ModalLayout>
|
|
269
|
+
</motion.div>
|
|
270
|
+
</div>
|
|
271
|
+
</DialogPrimitive.Content>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ==========================================================================
|
|
276
|
+
// Bottom Sheet Implementation (Special Case)
|
|
277
|
+
// ==========================================================================
|
|
278
|
+
|
|
279
|
+
function MobileBottomSheet(props: AdaptiveModalProps) {
|
|
280
|
+
const { closeOnOutsideClick = true } = props;
|
|
281
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
282
|
+
const dragControls = useDragControls();
|
|
283
|
+
const [screenHeight, setScreenHeight] = useState(1000);
|
|
284
|
+
const y = useMotionValue(screenHeight);
|
|
285
|
+
|
|
286
|
+
// Sync screen height
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
setScreenHeight(window.innerHeight);
|
|
289
|
+
const onResize = () => setScreenHeight(window.innerHeight);
|
|
290
|
+
window.addEventListener('resize', onResize);
|
|
291
|
+
return () => window.removeEventListener('resize', onResize);
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
// Update initial Y
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
y.set(screenHeight);
|
|
297
|
+
}, [screenHeight, y]);
|
|
298
|
+
|
|
299
|
+
const overlayOpacity = useTransform(y, [0, screenHeight * 0.8], [1, 0], {
|
|
300
|
+
clamp: true,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const closeSheet = () => {
|
|
304
|
+
animate(y, [y.get(), screenHeight], ANIMATION_CONFIGS.close).then(props.onClose);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const handleDragEnd = (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
308
|
+
if (info.velocity.y > VELOCITY_THRESHOLD || y.get() > screenHeight * DRAG_THRESHOLD) {
|
|
309
|
+
closeSheet();
|
|
310
|
+
} else {
|
|
311
|
+
animate(y, [y.get(), 0], ANIMATION_CONFIGS.snapBack);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<>
|
|
317
|
+
{/*
|
|
318
|
+
Sheet Overlay - We use motion.div directly here for custom opacity animation,
|
|
319
|
+
but wrapped in DialogPrimitive.Overlay logic implicitly via manual close handling
|
|
320
|
+
*/}
|
|
321
|
+
<DialogPrimitive.Overlay asChild>
|
|
322
|
+
<motion.div
|
|
323
|
+
className='fixed inset-0 z-60 bg-overlay backdrop-blur-sm'
|
|
324
|
+
style={{ opacity: overlayOpacity }}
|
|
325
|
+
initial={{ opacity: 0 }}
|
|
326
|
+
animate={{ opacity: 1 }}
|
|
327
|
+
exit={{ opacity: 0 }}
|
|
328
|
+
transition={ANIMATION_CONFIGS.backdrop}
|
|
329
|
+
onClick={closeOnOutsideClick ? closeSheet : undefined}
|
|
330
|
+
/>
|
|
331
|
+
</DialogPrimitive.Overlay>
|
|
332
|
+
|
|
333
|
+
<DialogPrimitive.Content
|
|
334
|
+
asChild
|
|
335
|
+
aria-describedby={undefined}
|
|
336
|
+
onInteractOutside={(e) => e.preventDefault()}
|
|
337
|
+
>
|
|
338
|
+
{/*
|
|
339
|
+
We need to prevent Radix from handling pointer-events-none on the container
|
|
340
|
+
because our drag logic needs to work. We manually handle focus trap.
|
|
341
|
+
*/}
|
|
342
|
+
<motion.div
|
|
343
|
+
ref={sheetRef}
|
|
344
|
+
className='fixed right-0 bottom-0 left-0 z-70 flex min-h-0 flex-col outline-none'
|
|
345
|
+
style={{ y, maxHeight: '85vh' }}
|
|
346
|
+
initial={{ y: screenHeight }}
|
|
347
|
+
animate={{ y: 0, transition: ANIMATION_CONFIGS.open }}
|
|
348
|
+
exit={{ y: screenHeight, transition: ANIMATION_CONFIGS.close }}
|
|
349
|
+
drag='y'
|
|
350
|
+
dragConstraints={{ top: 0, bottom: screenHeight }}
|
|
351
|
+
dragElastic={{ top: 0.1, bottom: 0 }}
|
|
352
|
+
dragControls={dragControls}
|
|
353
|
+
dragListener={false}
|
|
354
|
+
onDragEnd={handleDragEnd}
|
|
355
|
+
>
|
|
356
|
+
{/* Drag Handle Area */}
|
|
357
|
+
<div
|
|
358
|
+
className='pointer-events-auto absolute right-0 left-0 z-10 flex cursor-grab items-start justify-center active:cursor-grabbing'
|
|
359
|
+
style={{ top: '-20px', height: '20px' }}
|
|
360
|
+
onPointerDown={(e) => dragControls.start(e)}
|
|
361
|
+
>
|
|
362
|
+
{props.showDragIndicator !== false && (
|
|
363
|
+
<div className='mt-2 h-1.5 w-12 rounded-full bg-muted-foreground/30' />
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<ModalLayout
|
|
368
|
+
{...props}
|
|
369
|
+
showCloseButton={false}
|
|
370
|
+
className={cn(
|
|
371
|
+
'rounded-t-2xl border border-border bg-background shadow-xl',
|
|
372
|
+
props.className,
|
|
373
|
+
)}
|
|
374
|
+
headerClassName='text-center pt-5'
|
|
375
|
+
>
|
|
376
|
+
{props.children}
|
|
377
|
+
</ModalLayout>
|
|
378
|
+
</motion.div>
|
|
379
|
+
</DialogPrimitive.Content>
|
|
380
|
+
</>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { motion } from 'framer-motion';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
interface AnimatedCounterProps {
|
|
7
|
+
value: number;
|
|
8
|
+
suffix?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
itemHeight?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Анимированный счётчик с эффектом барабана
|
|
15
|
+
*/
|
|
16
|
+
export function AnimatedCounter({
|
|
17
|
+
value,
|
|
18
|
+
suffix = '',
|
|
19
|
+
className,
|
|
20
|
+
itemHeight = 48,
|
|
21
|
+
}: AnimatedCounterProps) {
|
|
22
|
+
const prevValueRef = useRef(value);
|
|
23
|
+
const [animationKey, setAnimationKey] = useState(0);
|
|
24
|
+
const [items, setItems] = useState<number[]>([value]);
|
|
25
|
+
const [startY, setStartY] = useState(0);
|
|
26
|
+
const [endY, setEndY] = useState(0);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const prevValue = prevValueRef.current;
|
|
30
|
+
if (prevValue === value) return;
|
|
31
|
+
|
|
32
|
+
// Генерируем диапазон от большего к меньшему (сверху вниз)
|
|
33
|
+
const min = Math.min(prevValue, value);
|
|
34
|
+
const max = Math.max(prevValue, value);
|
|
35
|
+
const range: number[] = [];
|
|
36
|
+
for (let i = max; i >= min; i--) {
|
|
37
|
+
range.push(i);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const prevIndex = range.indexOf(prevValue);
|
|
41
|
+
const nextIndex = range.indexOf(value);
|
|
42
|
+
|
|
43
|
+
setItems(range);
|
|
44
|
+
setStartY(-prevIndex * itemHeight);
|
|
45
|
+
setEndY(-nextIndex * itemHeight);
|
|
46
|
+
setAnimationKey((k) => k + 1);
|
|
47
|
+
|
|
48
|
+
prevValueRef.current = value;
|
|
49
|
+
}, [value, itemHeight]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={`relative overflow-hidden ${className ?? ''}`}
|
|
54
|
+
style={{ height: itemHeight }}
|
|
55
|
+
>
|
|
56
|
+
<motion.div
|
|
57
|
+
key={animationKey}
|
|
58
|
+
initial={{ y: startY }}
|
|
59
|
+
animate={{ y: endY }}
|
|
60
|
+
transition={{
|
|
61
|
+
type: 'spring',
|
|
62
|
+
stiffness: 200,
|
|
63
|
+
damping: 20,
|
|
64
|
+
mass: 0.8,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{items.map((num) => (
|
|
68
|
+
<div
|
|
69
|
+
key={num}
|
|
70
|
+
className='flex items-center justify-center'
|
|
71
|
+
style={{ height: itemHeight }}
|
|
72
|
+
>
|
|
73
|
+
{num}
|
|
74
|
+
{suffix}
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</motion.div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
|
4
|
+
import { type ComponentPropsWithoutRef, type ComponentRef, forwardRef } from 'react';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
const Avatar = forwardRef<
|
|
8
|
+
ComponentRef<typeof AvatarPrimitive.Root>,
|
|
9
|
+
ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
|
10
|
+
>(({ className, ...props }, ref) => (
|
|
11
|
+
<AvatarPrimitive.Root
|
|
12
|
+
ref={ref}
|
|
13
|
+
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
));
|
|
17
|
+
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
|
18
|
+
|
|
19
|
+
const AvatarImage = forwardRef<
|
|
20
|
+
ComponentRef<typeof AvatarPrimitive.Image>,
|
|
21
|
+
ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
|
22
|
+
>(({ className, ...props }, ref) => (
|
|
23
|
+
<AvatarPrimitive.Image
|
|
24
|
+
ref={ref}
|
|
25
|
+
className={cn('aspect-square h-full w-full', className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
));
|
|
29
|
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
|
30
|
+
|
|
31
|
+
const AvatarFallback = forwardRef<
|
|
32
|
+
ComponentRef<typeof AvatarPrimitive.Fallback>,
|
|
33
|
+
ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
|
34
|
+
>(({ className, ...props }, ref) => (
|
|
35
|
+
<AvatarPrimitive.Fallback
|
|
36
|
+
ref={ref}
|
|
37
|
+
className={cn(
|
|
38
|
+
'flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium',
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
));
|
|
44
|
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
|
45
|
+
|
|
46
|
+
export { Avatar, AvatarFallback, AvatarImage };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Badge - Компонент бейджа/тега
|
|
5
|
+
*
|
|
6
|
+
* Варианты: default, primary, secondary, outline, success, warning, destructive
|
|
7
|
+
* Размеры: sm, md
|
|
8
|
+
* Опционально: иконка слева
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
12
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
13
|
+
import { cn } from '@/lib/utils';
|
|
14
|
+
|
|
15
|
+
// ==========================================================================
|
|
16
|
+
// Styles with CVA
|
|
17
|
+
// ==========================================================================
|
|
18
|
+
|
|
19
|
+
const badgeVariants = cva(
|
|
20
|
+
'inline-flex items-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
21
|
+
{
|
|
22
|
+
variants: {
|
|
23
|
+
variant: {
|
|
24
|
+
default: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
|
25
|
+
primary: 'bg-primary/10 text-primary hover:bg-primary/20',
|
|
26
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
27
|
+
outline: 'border border-border text-muted-foreground',
|
|
28
|
+
success: 'bg-green-500/10 text-green-800 dark:text-green-300',
|
|
29
|
+
warning: 'bg-warning/15 text-warning-foreground',
|
|
30
|
+
destructive: 'bg-destructive/10 text-destructive',
|
|
31
|
+
},
|
|
32
|
+
size: {
|
|
33
|
+
sm: 'px-2 py-0.5 text-xs gap-1',
|
|
34
|
+
md: 'px-3 py-1 text-sm gap-1.5',
|
|
35
|
+
},
|
|
36
|
+
rounded: {
|
|
37
|
+
full: 'rounded-full',
|
|
38
|
+
lg: 'rounded-lg',
|
|
39
|
+
md: 'rounded-md',
|
|
40
|
+
sm: 'rounded-sm',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
defaultVariants: {
|
|
44
|
+
variant: 'default',
|
|
45
|
+
size: 'md',
|
|
46
|
+
rounded: 'full',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const iconSizes: Record<NonNullable<VariantProps<typeof badgeVariants>['size']>, string> = {
|
|
52
|
+
sm: 'w-3 h-3',
|
|
53
|
+
md: 'w-4 h-4',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ==========================================================================
|
|
57
|
+
// Types
|
|
58
|
+
// ==========================================================================
|
|
59
|
+
|
|
60
|
+
export interface BadgeProps
|
|
61
|
+
extends HTMLAttributes<HTMLSpanElement>,
|
|
62
|
+
VariantProps<typeof badgeVariants> {
|
|
63
|
+
icon?: ReactNode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ==========================================================================
|
|
67
|
+
// Component
|
|
68
|
+
// ==========================================================================
|
|
69
|
+
|
|
70
|
+
export function Badge({
|
|
71
|
+
children,
|
|
72
|
+
variant,
|
|
73
|
+
size = 'md', // Default value needs to be explicit for iconSizes lookup
|
|
74
|
+
rounded,
|
|
75
|
+
icon,
|
|
76
|
+
className,
|
|
77
|
+
...props
|
|
78
|
+
}: BadgeProps) {
|
|
79
|
+
return (
|
|
80
|
+
<span className={cn(badgeVariants({ variant, size, rounded }), className)} {...props}>
|
|
81
|
+
{icon && (
|
|
82
|
+
<span
|
|
83
|
+
className={cn(
|
|
84
|
+
iconSizes[size || 'md'],
|
|
85
|
+
'shrink-0 [&>svg]:w-full [&>svg]:h-full [&>img]:w-full [&>img]:h-full',
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{icon}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
{children}
|
|
92
|
+
</span>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const buttonVariants = cva(
|
|
4
|
+
'inline-flex items-center justify-center font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'bg-muted text-foreground hover:bg-muted/80',
|
|
9
|
+
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
10
|
+
ghost: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
|
11
|
+
outline: 'border border-border bg-transparent hover:bg-muted',
|
|
12
|
+
destructive: 'bg-destructive text-white hover:bg-destructive/90',
|
|
13
|
+
},
|
|
14
|
+
size: {
|
|
15
|
+
sm: 'h-8 px-3 text-sm rounded-lg gap-1.5',
|
|
16
|
+
md: 'h-10 px-4 text-sm rounded-xl gap-2',
|
|
17
|
+
lg: 'h-12 px-6 text-base rounded-xl gap-2',
|
|
18
|
+
icon: 'h-8 w-8 rounded-lg',
|
|
19
|
+
'icon-md': 'h-10 w-10 rounded-xl',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: { variant: 'default', size: 'md' },
|
|
23
|
+
},
|
|
24
|
+
);
|