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,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { IconCheck, IconCopy } from '@tabler/icons-react';
|
|
4
|
+
import { type ReactNode, useCallback, useState } from 'react';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
export interface CopyableTextProps {
|
|
8
|
+
/** Value to copy to clipboard */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Custom display content (defaults to value) */
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
/** Additional class names */
|
|
13
|
+
className?: string;
|
|
14
|
+
/** Icon size (default: 14) */
|
|
15
|
+
iconSize?: number;
|
|
16
|
+
/** Duration to show success state in ms (default: 2000) */
|
|
17
|
+
successDuration?: number;
|
|
18
|
+
/** Callback after successful copy */
|
|
19
|
+
onCopy?: (value: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Text with click-to-copy functionality.
|
|
24
|
+
* Shows copy icon on hover, checkmark on success.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // Basic usage
|
|
28
|
+
* <CopyableText value={email} />
|
|
29
|
+
*
|
|
30
|
+
* // Custom display
|
|
31
|
+
* <CopyableText value={fullId}>
|
|
32
|
+
* {shortId}
|
|
33
|
+
* </CopyableText>
|
|
34
|
+
*
|
|
35
|
+
* // Custom styling
|
|
36
|
+
* <CopyableText value={code} className="font-mono text-xs" />
|
|
37
|
+
*/
|
|
38
|
+
export function CopyableText({
|
|
39
|
+
value,
|
|
40
|
+
children,
|
|
41
|
+
className,
|
|
42
|
+
iconSize = 14,
|
|
43
|
+
successDuration = 2000,
|
|
44
|
+
onCopy,
|
|
45
|
+
}: CopyableTextProps) {
|
|
46
|
+
const [copied, setCopied] = useState(false);
|
|
47
|
+
|
|
48
|
+
const handleCopy = useCallback(async () => {
|
|
49
|
+
try {
|
|
50
|
+
await navigator.clipboard.writeText(value);
|
|
51
|
+
setCopied(true);
|
|
52
|
+
onCopy?.(value);
|
|
53
|
+
setTimeout(() => setCopied(false), successDuration);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('Failed to copy:', err);
|
|
56
|
+
}
|
|
57
|
+
}, [value, successDuration, onCopy]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<button
|
|
61
|
+
type='button'
|
|
62
|
+
onClick={handleCopy}
|
|
63
|
+
className={cn(
|
|
64
|
+
'group inline-flex items-center gap-1.5 text-left transition-colors',
|
|
65
|
+
'text-muted-foreground hover:text-foreground',
|
|
66
|
+
'cursor-pointer',
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
title={`Copy: ${value}`}
|
|
70
|
+
>
|
|
71
|
+
<span className='truncate'>{children ?? value}</span>
|
|
72
|
+
<span className='shrink-0'>
|
|
73
|
+
{copied ? (
|
|
74
|
+
<IconCheck size={iconSize} className='text-success' aria-label='Copied' />
|
|
75
|
+
) : (
|
|
76
|
+
<IconCopy
|
|
77
|
+
size={iconSize}
|
|
78
|
+
className='opacity-0 group-hover:opacity-50 transition-opacity'
|
|
79
|
+
aria-label='Copy to clipboard'
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
</span>
|
|
83
|
+
</button>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ComponentPropsWithoutRef,
|
|
5
|
+
type ComponentRef,
|
|
6
|
+
createContext,
|
|
7
|
+
forwardRef,
|
|
8
|
+
type HTMLAttributes,
|
|
9
|
+
useContext,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { Drawer as DrawerPrimitive } from 'vaul';
|
|
12
|
+
import { cn } from '@/lib/utils';
|
|
13
|
+
|
|
14
|
+
type DrawerDirection = 'left' | 'right' | 'top' | 'bottom';
|
|
15
|
+
|
|
16
|
+
const DrawerDirectionContext = createContext<DrawerDirection>('right');
|
|
17
|
+
|
|
18
|
+
const Drawer = ({
|
|
19
|
+
shouldScaleBackground = true,
|
|
20
|
+
direction = 'right',
|
|
21
|
+
...props
|
|
22
|
+
}: ComponentPropsWithoutRef<typeof DrawerPrimitive.Root>) => (
|
|
23
|
+
<DrawerDirectionContext.Provider value={direction}>
|
|
24
|
+
<DrawerPrimitive.Root
|
|
25
|
+
direction={direction}
|
|
26
|
+
shouldScaleBackground={shouldScaleBackground}
|
|
27
|
+
{...props}
|
|
28
|
+
/>
|
|
29
|
+
</DrawerDirectionContext.Provider>
|
|
30
|
+
);
|
|
31
|
+
Drawer.displayName = 'Drawer';
|
|
32
|
+
|
|
33
|
+
const DrawerTrigger = DrawerPrimitive.Trigger;
|
|
34
|
+
|
|
35
|
+
const DrawerPortal = DrawerPrimitive.Portal;
|
|
36
|
+
|
|
37
|
+
const DrawerClose = DrawerPrimitive.Close;
|
|
38
|
+
|
|
39
|
+
const DrawerOverlay = forwardRef<
|
|
40
|
+
ComponentRef<typeof DrawerPrimitive.Overlay>,
|
|
41
|
+
ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
|
42
|
+
>(({ className, ...props }, ref) => (
|
|
43
|
+
<DrawerPrimitive.Overlay
|
|
44
|
+
ref={ref}
|
|
45
|
+
className={cn('fixed inset-0 z-50 bg-overlay backdrop-blur-sm', className)}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
));
|
|
49
|
+
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
|
50
|
+
|
|
51
|
+
const DrawerContent = forwardRef<
|
|
52
|
+
ComponentRef<typeof DrawerPrimitive.Content>,
|
|
53
|
+
ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
|
54
|
+
>(({ className, children, ...props }, ref) => {
|
|
55
|
+
const direction = useContext(DrawerDirectionContext);
|
|
56
|
+
const directionStyles = {
|
|
57
|
+
left: 'inset-y-3 left-3 h-auto w-[min(22rem,calc(100vw-1.5rem))] rounded-2xl',
|
|
58
|
+
right: 'inset-y-3 right-3 h-auto w-[min(22rem,calc(100vw-1.5rem))] rounded-2xl',
|
|
59
|
+
top: 'inset-x-3 top-3 w-auto rounded-2xl',
|
|
60
|
+
bottom: 'inset-x-3 bottom-3 w-auto rounded-2xl',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<DrawerPortal>
|
|
65
|
+
<DrawerOverlay />
|
|
66
|
+
<DrawerPrimitive.Content
|
|
67
|
+
ref={ref}
|
|
68
|
+
className={cn(
|
|
69
|
+
'fixed z-50 flex flex-col overflow-hidden border bg-card shadow-xl',
|
|
70
|
+
directionStyles[direction],
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
>
|
|
75
|
+
{/* Handle for bottom/top drawers */}
|
|
76
|
+
{(direction === 'bottom' || direction === 'top') && (
|
|
77
|
+
<div
|
|
78
|
+
className={cn(
|
|
79
|
+
'mx-auto w-12 h-1.5 rounded-full bg-muted-foreground/20',
|
|
80
|
+
direction === 'bottom' ? 'mt-4' : 'mb-4 mt-auto',
|
|
81
|
+
)}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
{children}
|
|
85
|
+
</DrawerPrimitive.Content>
|
|
86
|
+
</DrawerPortal>
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
DrawerContent.displayName = 'DrawerContent';
|
|
90
|
+
|
|
91
|
+
const DrawerHeader = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
|
|
92
|
+
<div className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
|
|
93
|
+
);
|
|
94
|
+
DrawerHeader.displayName = 'DrawerHeader';
|
|
95
|
+
|
|
96
|
+
const DrawerFooter = ({ className, ...props }: HTMLAttributes<HTMLDivElement>) => (
|
|
97
|
+
<div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />
|
|
98
|
+
);
|
|
99
|
+
DrawerFooter.displayName = 'DrawerFooter';
|
|
100
|
+
|
|
101
|
+
const DrawerTitle = forwardRef<
|
|
102
|
+
ComponentRef<typeof DrawerPrimitive.Title>,
|
|
103
|
+
ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
|
104
|
+
>(({ className, ...props }, ref) => (
|
|
105
|
+
<DrawerPrimitive.Title
|
|
106
|
+
ref={ref}
|
|
107
|
+
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
|
108
|
+
{...props}
|
|
109
|
+
/>
|
|
110
|
+
));
|
|
111
|
+
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
|
112
|
+
|
|
113
|
+
const DrawerDescription = forwardRef<
|
|
114
|
+
ComponentRef<typeof DrawerPrimitive.Description>,
|
|
115
|
+
ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
|
116
|
+
>(({ className, ...props }, ref) => (
|
|
117
|
+
<DrawerPrimitive.Description
|
|
118
|
+
ref={ref}
|
|
119
|
+
className={cn('text-sm text-muted-foreground', className)}
|
|
120
|
+
{...props}
|
|
121
|
+
/>
|
|
122
|
+
));
|
|
123
|
+
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
Drawer,
|
|
127
|
+
DrawerClose,
|
|
128
|
+
DrawerContent,
|
|
129
|
+
DrawerDescription,
|
|
130
|
+
DrawerFooter,
|
|
131
|
+
DrawerHeader,
|
|
132
|
+
DrawerOverlay,
|
|
133
|
+
DrawerPortal,
|
|
134
|
+
DrawerTitle,
|
|
135
|
+
DrawerTrigger,
|
|
136
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
|
4
|
+
import { IconCheck, IconChevronRight, IconCircle } from '@tabler/icons-react';
|
|
5
|
+
import {
|
|
6
|
+
type ComponentPropsWithoutRef,
|
|
7
|
+
type ComponentRef,
|
|
8
|
+
forwardRef,
|
|
9
|
+
type HTMLAttributes,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
14
|
+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
|
15
|
+
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
|
16
|
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
|
17
|
+
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|
18
|
+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
|
19
|
+
|
|
20
|
+
const DropdownMenuSubTrigger = forwardRef<
|
|
21
|
+
ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
22
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
23
|
+
inset?: boolean;
|
|
24
|
+
}
|
|
25
|
+
>(({ className, inset, children, ...props }, ref) => (
|
|
26
|
+
<DropdownMenuPrimitive.SubTrigger
|
|
27
|
+
ref={ref}
|
|
28
|
+
className={cn(
|
|
29
|
+
'flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none focus:bg-muted data-[state=open]:bg-muted [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
30
|
+
inset && 'pl-8',
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
<IconChevronRight className='ml-auto' />
|
|
37
|
+
</DropdownMenuPrimitive.SubTrigger>
|
|
38
|
+
));
|
|
39
|
+
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
|
40
|
+
|
|
41
|
+
const DropdownMenuSubContent = forwardRef<
|
|
42
|
+
ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
43
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
44
|
+
>(({ className, ...props }, ref) => (
|
|
45
|
+
<DropdownMenuPrimitive.SubContent
|
|
46
|
+
ref={ref}
|
|
47
|
+
className={cn(
|
|
48
|
+
'z-50 min-w-32 overflow-hidden rounded-xl border bg-background p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
49
|
+
className,
|
|
50
|
+
)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
));
|
|
54
|
+
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
|
55
|
+
|
|
56
|
+
const DropdownMenuContent = forwardRef<
|
|
57
|
+
ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
|
58
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
59
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
60
|
+
<DropdownMenuPrimitive.Portal>
|
|
61
|
+
<DropdownMenuPrimitive.Content
|
|
62
|
+
ref={ref}
|
|
63
|
+
sideOffset={sideOffset}
|
|
64
|
+
className={cn(
|
|
65
|
+
'z-50 min-w-32 overflow-hidden rounded-xl border bg-background p-1 text-foreground shadow-lg',
|
|
66
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
</DropdownMenuPrimitive.Portal>
|
|
72
|
+
));
|
|
73
|
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
|
74
|
+
|
|
75
|
+
const DropdownMenuItem = forwardRef<
|
|
76
|
+
ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
|
77
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
|
78
|
+
inset?: boolean;
|
|
79
|
+
}
|
|
80
|
+
>(({ className, inset, ...props }, ref) => (
|
|
81
|
+
<DropdownMenuPrimitive.Item
|
|
82
|
+
ref={ref}
|
|
83
|
+
className={cn(
|
|
84
|
+
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
85
|
+
inset && 'pl-8',
|
|
86
|
+
className,
|
|
87
|
+
)}
|
|
88
|
+
{...props}
|
|
89
|
+
/>
|
|
90
|
+
));
|
|
91
|
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|
92
|
+
|
|
93
|
+
const DropdownMenuCheckboxItem = forwardRef<
|
|
94
|
+
ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
95
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
96
|
+
>(({ className, children, checked, ...props }, ref) => (
|
|
97
|
+
<DropdownMenuPrimitive.CheckboxItem
|
|
98
|
+
ref={ref}
|
|
99
|
+
className={cn(
|
|
100
|
+
'relative flex cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50',
|
|
101
|
+
className,
|
|
102
|
+
)}
|
|
103
|
+
checked={checked}
|
|
104
|
+
{...props}
|
|
105
|
+
>
|
|
106
|
+
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
|
107
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
108
|
+
<IconCheck className='h-4 w-4' />
|
|
109
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
110
|
+
</span>
|
|
111
|
+
{children}
|
|
112
|
+
</DropdownMenuPrimitive.CheckboxItem>
|
|
113
|
+
));
|
|
114
|
+
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
|
115
|
+
|
|
116
|
+
const DropdownMenuRadioItem = forwardRef<
|
|
117
|
+
ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
118
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
119
|
+
>(({ className, children, ...props }, ref) => (
|
|
120
|
+
<DropdownMenuPrimitive.RadioItem
|
|
121
|
+
ref={ref}
|
|
122
|
+
className={cn(
|
|
123
|
+
'relative flex cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50',
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
{...props}
|
|
127
|
+
>
|
|
128
|
+
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
|
|
129
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
130
|
+
<IconCircle className='h-2 w-2 fill-current' />
|
|
131
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
132
|
+
</span>
|
|
133
|
+
{children}
|
|
134
|
+
</DropdownMenuPrimitive.RadioItem>
|
|
135
|
+
));
|
|
136
|
+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
|
137
|
+
|
|
138
|
+
const DropdownMenuLabel = forwardRef<
|
|
139
|
+
ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
|
140
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
|
141
|
+
inset?: boolean;
|
|
142
|
+
}
|
|
143
|
+
>(({ className, inset, ...props }, ref) => (
|
|
144
|
+
<DropdownMenuPrimitive.Label
|
|
145
|
+
ref={ref}
|
|
146
|
+
className={cn('px-2 py-1.5 text-sm font-medium', inset && 'pl-8', className)}
|
|
147
|
+
{...props}
|
|
148
|
+
/>
|
|
149
|
+
));
|
|
150
|
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
|
151
|
+
|
|
152
|
+
const DropdownMenuSeparator = forwardRef<
|
|
153
|
+
ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
|
154
|
+
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
155
|
+
>(({ className, ...props }, ref) => (
|
|
156
|
+
<DropdownMenuPrimitive.Separator
|
|
157
|
+
ref={ref}
|
|
158
|
+
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
|
159
|
+
{...props}
|
|
160
|
+
/>
|
|
161
|
+
));
|
|
162
|
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
|
163
|
+
|
|
164
|
+
const DropdownMenuShortcut = ({ className, ...props }: HTMLAttributes<HTMLSpanElement>) => (
|
|
165
|
+
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
|
166
|
+
);
|
|
167
|
+
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
|
168
|
+
|
|
169
|
+
export {
|
|
170
|
+
DropdownMenu,
|
|
171
|
+
DropdownMenuCheckboxItem,
|
|
172
|
+
DropdownMenuContent,
|
|
173
|
+
DropdownMenuGroup,
|
|
174
|
+
DropdownMenuItem,
|
|
175
|
+
DropdownMenuLabel,
|
|
176
|
+
DropdownMenuPortal,
|
|
177
|
+
DropdownMenuRadioGroup,
|
|
178
|
+
DropdownMenuRadioItem,
|
|
179
|
+
DropdownMenuSeparator,
|
|
180
|
+
DropdownMenuShortcut,
|
|
181
|
+
DropdownMenuSub,
|
|
182
|
+
DropdownMenuSubContent,
|
|
183
|
+
DropdownMenuSubTrigger,
|
|
184
|
+
DropdownMenuTrigger,
|
|
185
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { Label } from './label';
|
|
3
|
+
|
|
4
|
+
interface FieldProps {
|
|
5
|
+
label: string;
|
|
6
|
+
htmlFor: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Field({ label, htmlFor, description, error, children }: FieldProps) {
|
|
13
|
+
const messageId = `${htmlFor}-message`;
|
|
14
|
+
return (
|
|
15
|
+
<div className='grid gap-2'>
|
|
16
|
+
<Label htmlFor={htmlFor}>{label}</Label>
|
|
17
|
+
{children}
|
|
18
|
+
{(error || description) && (
|
|
19
|
+
<p
|
|
20
|
+
id={messageId}
|
|
21
|
+
className={error ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}
|
|
22
|
+
>
|
|
23
|
+
{error ?? description}
|
|
24
|
+
</p>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
|
|
3
|
+
interface HandDrawnIconProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Рукописный крестик — для "плохо", "не нужно" */
|
|
8
|
+
export function HandDrawnX({ className }: HandDrawnIconProps) {
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
aria-hidden='true'
|
|
12
|
+
viewBox='0 0 24 24'
|
|
13
|
+
fill='none'
|
|
14
|
+
stroke='currentColor'
|
|
15
|
+
strokeWidth='2.5'
|
|
16
|
+
strokeLinecap='round'
|
|
17
|
+
className={cn('w-4 h-4', className)}
|
|
18
|
+
>
|
|
19
|
+
<path d='M5 5 C 8 9, 14 16, 19 19' />
|
|
20
|
+
<path d='M19 5 C 15 8, 10 14, 5 19' />
|
|
21
|
+
</svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Рукописная галочка — для "хорошо", "нужно" */
|
|
26
|
+
export function HandDrawnCheck({ className }: HandDrawnIconProps) {
|
|
27
|
+
return (
|
|
28
|
+
<svg
|
|
29
|
+
aria-hidden='true'
|
|
30
|
+
viewBox='0 0 24 24'
|
|
31
|
+
fill='none'
|
|
32
|
+
stroke='currentColor'
|
|
33
|
+
strokeWidth='2.5'
|
|
34
|
+
strokeLinecap='round'
|
|
35
|
+
strokeLinejoin='round'
|
|
36
|
+
className={cn('w-4 h-4', className)}
|
|
37
|
+
>
|
|
38
|
+
<path d='M4 12 C 6 14, 8 17, 10 19 C 12 15, 16 9, 20 5' />
|
|
39
|
+
</svg>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { AdaptiveModal } from './adaptive-modal';
|
|
2
|
+
export { AnimatedCounter } from './animated-counter';
|
|
3
|
+
export { Avatar, AvatarFallback, AvatarImage } from './avatar';
|
|
4
|
+
export { Badge } from './badge';
|
|
5
|
+
export { Button } from './button';
|
|
6
|
+
export { buttonVariants } from './button-variants';
|
|
7
|
+
export * from './card';
|
|
8
|
+
export { CardDeck, type CardDeckProps } from './card-deck';
|
|
9
|
+
export * from './charts';
|
|
10
|
+
export { Checkbox, TriStateCheckbox } from './checkbox';
|
|
11
|
+
export { ConfirmationModal } from './confirmation-modal';
|
|
12
|
+
export { CopyableText } from './copyable-text';
|
|
13
|
+
export * from './drawer';
|
|
14
|
+
export * from './dropdown-menu';
|
|
15
|
+
export { Field } from './field';
|
|
16
|
+
export { HandDrawnCheck, HandDrawnX } from './hand-drawn-icons';
|
|
17
|
+
export { Input } from './input';
|
|
18
|
+
export { Label } from './label';
|
|
19
|
+
export { OtpInput } from './otp-input';
|
|
20
|
+
export * from './select';
|
|
21
|
+
export * from './sidebar';
|
|
22
|
+
export { Skeleton } from './skeleton';
|
|
23
|
+
export { Slider } from './slider';
|
|
24
|
+
export { SlotDrum } from './slot-drum';
|
|
25
|
+
export { Spinner } from './spinner';
|
|
26
|
+
export { Switch } from './switch';
|
|
27
|
+
export * from './table';
|
|
28
|
+
export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs';
|
|
29
|
+
export { Textarea } from './textarea';
|
|
30
|
+
export { Toaster, toast } from './toaster';
|
|
31
|
+
export {
|
|
32
|
+
Tooltip,
|
|
33
|
+
TooltipContent,
|
|
34
|
+
TooltipProvider,
|
|
35
|
+
TooltipTrigger,
|
|
36
|
+
} from './tooltip';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { forwardRef, type InputHTMLAttributes } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
|
|
5
|
+
|
|
6
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
7
|
+
({ className, ...props }, ref) => {
|
|
8
|
+
return (
|
|
9
|
+
<input
|
|
10
|
+
ref={ref}
|
|
11
|
+
className={cn(
|
|
12
|
+
'h-10 w-full px-4 bg-background border border-border rounded-lg',
|
|
13
|
+
'placeholder:text-muted-foreground',
|
|
14
|
+
'focus:outline-none focus:border-primary',
|
|
15
|
+
'transition-colors',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
Input.displayName = 'Input';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LabelHTMLAttributes } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
interface LabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
|
5
|
+
htmlFor: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Label({ children, className, htmlFor, ...props }: LabelProps) {
|
|
9
|
+
return (
|
|
10
|
+
<label
|
|
11
|
+
className={cn('text-sm font-medium text-foreground', className)}
|
|
12
|
+
htmlFor={htmlFor}
|
|
13
|
+
{...props}
|
|
14
|
+
>
|
|
15
|
+
{children}
|
|
16
|
+
</label>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OTP Input
|
|
5
|
+
*
|
|
6
|
+
* Компонент для ввода 6-значного кода верификации.
|
|
7
|
+
* - 6 отдельных полей ввода
|
|
8
|
+
* - Автофокус на следующее поле
|
|
9
|
+
* - Поддержка вставки из буфера
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from 'react';
|
|
13
|
+
import { cn } from '@/lib/utils';
|
|
14
|
+
|
|
15
|
+
interface OtpInputProps {
|
|
16
|
+
length?: number;
|
|
17
|
+
value: string;
|
|
18
|
+
onChange: (value: string) => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
error?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function OtpInput({
|
|
25
|
+
length = 6,
|
|
26
|
+
value,
|
|
27
|
+
onChange,
|
|
28
|
+
disabled = false,
|
|
29
|
+
error = false,
|
|
30
|
+
className,
|
|
31
|
+
}: OtpInputProps) {
|
|
32
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
33
|
+
const inputGroupId = useId();
|
|
34
|
+
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
|
35
|
+
|
|
36
|
+
const digits = value.split('').slice(0, length);
|
|
37
|
+
while (digits.length < length) {
|
|
38
|
+
digits.push('');
|
|
39
|
+
}
|
|
40
|
+
const positions = digits.map((digit, position) => ({
|
|
41
|
+
digit,
|
|
42
|
+
id: `${inputGroupId}-digit-${position + 1}`,
|
|
43
|
+
position,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const focusInput = (index: number) => {
|
|
47
|
+
const input = inputRefs.current[index];
|
|
48
|
+
if (input) {
|
|
49
|
+
input.focus();
|
|
50
|
+
input.select();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleChange = (index: number, digit: string) => {
|
|
55
|
+
if (disabled) return;
|
|
56
|
+
|
|
57
|
+
// Только цифры
|
|
58
|
+
if (digit && !/^\d$/.test(digit)) return;
|
|
59
|
+
|
|
60
|
+
const newDigits = [...digits];
|
|
61
|
+
newDigits[index] = digit;
|
|
62
|
+
const newValue = newDigits.join('');
|
|
63
|
+
onChange(newValue);
|
|
64
|
+
|
|
65
|
+
// Автофокус на следующее поле
|
|
66
|
+
if (digit && index < length - 1) {
|
|
67
|
+
focusInput(index + 1);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
|
72
|
+
if (disabled) return;
|
|
73
|
+
|
|
74
|
+
if (e.key === 'Backspace') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
if (digits[index]) {
|
|
77
|
+
// Удалить текущую цифру
|
|
78
|
+
handleChange(index, '');
|
|
79
|
+
} else if (index > 0) {
|
|
80
|
+
// Перейти на предыдущее поле и удалить
|
|
81
|
+
focusInput(index - 1);
|
|
82
|
+
handleChange(index - 1, '');
|
|
83
|
+
}
|
|
84
|
+
} else if (e.key === 'ArrowLeft' && index > 0) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
focusInput(index - 1);
|
|
87
|
+
} else if (e.key === 'ArrowRight' && index < length - 1) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
focusInput(index + 1);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
|
94
|
+
if (disabled) return;
|
|
95
|
+
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
const pastedData = e.clipboardData.getData('text');
|
|
98
|
+
const digits = pastedData.replace(/\D/g, '').slice(0, length);
|
|
99
|
+
|
|
100
|
+
if (digits.length > 0) {
|
|
101
|
+
onChange(digits);
|
|
102
|
+
// Фокус на последнюю заполненную цифру или следующую пустую
|
|
103
|
+
const focusIndex = Math.min(digits.length, length - 1);
|
|
104
|
+
focusInput(focusIndex);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={cn('flex gap-2 justify-center', className)}>
|
|
110
|
+
{positions.map(({ digit, id, position }) => (
|
|
111
|
+
<input
|
|
112
|
+
key={id}
|
|
113
|
+
ref={(el) => {
|
|
114
|
+
inputRefs.current[position] = el;
|
|
115
|
+
}}
|
|
116
|
+
type='text'
|
|
117
|
+
inputMode='numeric'
|
|
118
|
+
maxLength={1}
|
|
119
|
+
value={digit}
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
onChange={(e) => handleChange(position, e.target.value)}
|
|
122
|
+
onKeyDown={(e) => handleKeyDown(position, e)}
|
|
123
|
+
onPaste={handlePaste}
|
|
124
|
+
onFocus={() => setFocusedIndex(position)}
|
|
125
|
+
onBlur={() => setFocusedIndex(null)}
|
|
126
|
+
className={cn(
|
|
127
|
+
'w-12 h-14 text-center text-2xl font-mono font-semibold',
|
|
128
|
+
'bg-background border rounded-lg',
|
|
129
|
+
'focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary',
|
|
130
|
+
'transition-all duration-150',
|
|
131
|
+
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
132
|
+
error ? 'border-destructive focus:ring-destructive' : 'border-border',
|
|
133
|
+
focusedIndex === position && 'scale-105',
|
|
134
|
+
)}
|
|
135
|
+
aria-label={`Digit ${position + 1}`}
|
|
136
|
+
/>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|