@react-spa-scaffold/mcp 0.3.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/README.md +423 -0
- package/dist/features/index.d.ts +5 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +3 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/registry.d.ts +10 -0
- package/dist/features/registry.d.ts.map +1 -0
- package/dist/features/registry.js +508 -0
- package/dist/features/registry.js.map +1 -0
- package/dist/features/types.d.ts +45 -0
- package/dist/features/types.d.ts.map +1 -0
- package/dist/features/types.js +5 -0
- package/dist/features/types.js.map +1 -0
- package/dist/features/versions.d.ts +16 -0
- package/dist/features/versions.d.ts.map +1 -0
- package/dist/features/versions.js +46 -0
- package/dist/features/versions.js.map +1 -0
- package/dist/features/versions.json +5 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/resources/docs.d.ts +29 -0
- package/dist/resources/docs.d.ts.map +1 -0
- package/dist/resources/docs.js +105 -0
- package/dist/resources/docs.js.map +1 -0
- package/dist/resources/index.d.ts +2 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +2 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +115 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/get-example.d.ts +51 -0
- package/dist/tools/get-example.d.ts.map +1 -0
- package/dist/tools/get-example.js +90 -0
- package/dist/tools/get-example.js.map +1 -0
- package/dist/tools/get-features.d.ts +30 -0
- package/dist/tools/get-features.d.ts.map +1 -0
- package/dist/tools/get-features.js +46 -0
- package/dist/tools/get-features.js.map +1 -0
- package/dist/tools/get-scaffold.d.ts +77 -0
- package/dist/tools/get-scaffold.d.ts.map +1 -0
- package/dist/tools/get-scaffold.js +153 -0
- package/dist/tools/get-scaffold.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/utils/docs.d.ts +14 -0
- package/dist/utils/docs.d.ts.map +1 -0
- package/dist/utils/docs.js +64 -0
- package/dist/utils/docs.js.map +1 -0
- package/dist/utils/examples.d.ts +27 -0
- package/dist/utils/examples.d.ts.map +1 -0
- package/dist/utils/examples.js +399 -0
- package/dist/utils/examples.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/paths.d.ts +28 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +40 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/scaffold.d.ts +50 -0
- package/dist/utils/scaffold.d.ts.map +1 -0
- package/dist/utils/scaffold.js +500 -0
- package/dist/utils/scaffold.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +19 -0
- package/dist/version.js.map +1 -0
- package/package.json +63 -0
- package/templates/.bundled +0 -0
- package/templates/CLAUDE.md +145 -0
- package/templates/docs/API_REFERENCE.md +58 -0
- package/templates/docs/ARCHITECTURE.md +185 -0
- package/templates/docs/CODING_STANDARDS.md +53 -0
- package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
- package/templates/docs/E2E_TESTING.md +116 -0
- package/templates/docs/INTERNATIONALIZATION.md +67 -0
- package/templates/docs/TESTING.md +259 -0
- package/templates/docs/WORKFLOW.md +170 -0
- package/templates/src/App.tsx +42 -0
- package/templates/src/components/layout/Header.tsx +19 -0
- package/templates/src/components/layout/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
- package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
- package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
- package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
- package/templates/src/components/shared/SEO/SEO.tsx +55 -0
- package/templates/src/components/shared/SEO/index.ts +1 -0
- package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
- package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
- package/templates/src/components/shared/index.ts +4 -0
- package/templates/src/components/ui/button.tsx +48 -0
- package/templates/src/components/ui/dropdown-menu.tsx +228 -0
- package/templates/src/components/ui/form-error.tsx +95 -0
- package/templates/src/components/ui/loading.tsx +58 -0
- package/templates/src/components/ui/skeleton.tsx +52 -0
- package/templates/src/components/ui/sonner.tsx +34 -0
- package/templates/src/components/ui/spinner.tsx +40 -0
- package/templates/src/components/ui/visually-hidden.tsx +51 -0
- package/templates/src/contexts/mobileContext.tsx +66 -0
- package/templates/src/contexts/queryContext.tsx +28 -0
- package/templates/src/hooks/index.ts +7 -0
- package/templates/src/hooks/useContactForm.ts +33 -0
- package/templates/src/hooks/useExampleQuery.ts +20 -0
- package/templates/src/hooks/useLanguage.ts +23 -0
- package/templates/src/hooks/useMediaQuery.ts +53 -0
- package/templates/src/hooks/useThemeEffect.ts +31 -0
- package/templates/src/hooks/useTouchSizes.ts +16 -0
- package/templates/src/i18n/config.ts +11 -0
- package/templates/src/i18n/detectLanguage.ts +57 -0
- package/templates/src/i18n/index.ts +20 -0
- package/templates/src/i18n/loadCatalog.ts +30 -0
- package/templates/src/index.css +98 -0
- package/templates/src/lib/api.ts +142 -0
- package/templates/src/lib/config.ts +15 -0
- package/templates/src/lib/constants.ts +8 -0
- package/templates/src/lib/env.ts +53 -0
- package/templates/src/lib/format.ts +119 -0
- package/templates/src/lib/index.ts +24 -0
- package/templates/src/lib/routes.ts +11 -0
- package/templates/src/lib/storage.ts +91 -0
- package/templates/src/lib/storageKeys.ts +10 -0
- package/templates/src/lib/utils.ts +6 -0
- package/templates/src/lib/validations.ts +39 -0
- package/templates/src/locales/de.po +65 -0
- package/templates/src/locales/en.po +65 -0
- package/templates/src/locales/es.po +65 -0
- package/templates/src/main.tsx +107 -0
- package/templates/src/mocks/fixtures/index.ts +1 -0
- package/templates/src/mocks/fixtures/todos.ts +40 -0
- package/templates/src/mocks/handlers/index.ts +7 -0
- package/templates/src/mocks/handlers/todos.ts +59 -0
- package/templates/src/mocks/index.ts +3 -0
- package/templates/src/mocks/node.ts +9 -0
- package/templates/src/pages/Home.tsx +27 -0
- package/templates/src/pages/NotFound.tsx +28 -0
- package/templates/src/pages/index.ts +2 -0
- package/templates/src/stores/index.ts +2 -0
- package/templates/src/stores/preferencesStore.ts +85 -0
- package/templates/src/test/index.ts +8 -0
- package/templates/src/test/mocks.ts +17 -0
- package/templates/src/test/providers.tsx +54 -0
- package/templates/src/test-setup.ts +54 -0
- package/templates/src/types/api.ts +31 -0
- package/templates/src/types/index.ts +2 -0
- package/templates/src/types/preferences.ts +5 -0
- package/templates/src/vite-env.d.ts +10 -0
- package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
- package/templates/tests/unit/components/Header.test.tsx +33 -0
- package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
- package/templates/tests/unit/components/Loading.test.tsx +76 -0
- package/templates/tests/unit/components/SEO.test.tsx +80 -0
- package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
- package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
- package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
- package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
- package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
- package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
- package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
- package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
- package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
- package/templates/tests/unit/lib/api.test.ts +142 -0
- package/templates/tests/unit/lib/format.test.ts +100 -0
- package/templates/tests/unit/lib/storage.test.ts +90 -0
- package/templates/tests/unit/lib/utils.test.ts +19 -0
- package/templates/tests/unit/lib/validations.test.ts +56 -0
- package/templates/tests/unit/stores/preferencesStore.test.ts +75 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { APP_CONFIG } from '@/lib/config';
|
|
2
|
+
|
|
3
|
+
export interface SEOProps {
|
|
4
|
+
title?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
keywords?: string[];
|
|
7
|
+
ogImage?: string;
|
|
8
|
+
ogType?: 'website' | 'article';
|
|
9
|
+
canonical?: string;
|
|
10
|
+
noIndex?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_DESCRIPTION = 'A modern React application';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SEO component using React 19's native document metadata support.
|
|
17
|
+
* These tags are automatically hoisted to the <head> section.
|
|
18
|
+
*/
|
|
19
|
+
export function SEO({
|
|
20
|
+
title,
|
|
21
|
+
description = DEFAULT_DESCRIPTION,
|
|
22
|
+
keywords = [],
|
|
23
|
+
ogImage,
|
|
24
|
+
ogType = 'website',
|
|
25
|
+
canonical,
|
|
26
|
+
noIndex = false,
|
|
27
|
+
}: SEOProps) {
|
|
28
|
+
const fullTitle = title ? `${title} | ${APP_CONFIG.name}` : APP_CONFIG.name;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<title>{fullTitle}</title>
|
|
33
|
+
<meta name="description" content={description} />
|
|
34
|
+
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
|
|
35
|
+
|
|
36
|
+
{/* Open Graph */}
|
|
37
|
+
<meta property="og:title" content={fullTitle} />
|
|
38
|
+
<meta property="og:description" content={description} />
|
|
39
|
+
<meta property="og:type" content={ogType} />
|
|
40
|
+
{ogImage && <meta property="og:image" content={ogImage} />}
|
|
41
|
+
|
|
42
|
+
{/* Twitter Card */}
|
|
43
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
44
|
+
<meta name="twitter:title" content={fullTitle} />
|
|
45
|
+
<meta name="twitter:description" content={description} />
|
|
46
|
+
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
|
47
|
+
|
|
48
|
+
{/* Canonical URL */}
|
|
49
|
+
{canonical && <link rel="canonical" href={canonical} />}
|
|
50
|
+
|
|
51
|
+
{/* Robots */}
|
|
52
|
+
{noIndex && <meta name="robots" content="noindex, nofollow" />}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SEO, type SEOProps } from './SEO';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useLingui } from '@lingui/react/macro';
|
|
2
|
+
import { Monitor, Moon, Sun } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { useTouchSizes } from '@/hooks';
|
|
6
|
+
import { usePreferencesStore } from '@/stores';
|
|
7
|
+
|
|
8
|
+
export function ThemeToggle() {
|
|
9
|
+
const { t } = useLingui();
|
|
10
|
+
const theme = usePreferencesStore((state) => state.theme);
|
|
11
|
+
const toggleTheme = usePreferencesStore((state) => state.toggleTheme);
|
|
12
|
+
const getResolvedTheme = usePreferencesStore((state) => state.getResolvedTheme);
|
|
13
|
+
const sizes = useTouchSizes();
|
|
14
|
+
|
|
15
|
+
// Get the resolved theme for icon display
|
|
16
|
+
const resolvedTheme = getResolvedTheme();
|
|
17
|
+
|
|
18
|
+
// Determine the aria-label based on current theme
|
|
19
|
+
const getAriaLabel = () => {
|
|
20
|
+
if (theme === 'system') {
|
|
21
|
+
return resolvedTheme === 'dark'
|
|
22
|
+
? t({
|
|
23
|
+
message: 'Switch to light mode',
|
|
24
|
+
comment: 'Accessibility label when clicking will switch to light theme',
|
|
25
|
+
})
|
|
26
|
+
: t({ message: 'Switch to dark mode', comment: 'Accessibility label when clicking will switch to dark theme' });
|
|
27
|
+
}
|
|
28
|
+
return theme === 'light'
|
|
29
|
+
? t({ message: 'Switch to dark mode', comment: 'Accessibility label when clicking will switch to dark theme' })
|
|
30
|
+
: t({ message: 'Switch to light mode', comment: 'Accessibility label when clicking will switch to light theme' });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Show the icon for the current resolved theme
|
|
34
|
+
const ThemeIcon = theme === 'system' ? Monitor : resolvedTheme === 'dark' ? Sun : Moon;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Button variant="ghost" size={sizes.iconButtonLg} onClick={toggleTheme} aria-label={getAriaLabel()}>
|
|
38
|
+
<ThemeIcon className="size-5" />
|
|
39
|
+
</Button>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ThemeToggle } from './ThemeToggle';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import { type ButtonHTMLAttributes, forwardRef } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
|
13
|
+
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
|
14
|
+
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
|
15
|
+
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
|
16
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
17
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
default: 'h-9 px-4 py-2',
|
|
21
|
+
sm: 'h-8 rounded-md px-3 text-xs',
|
|
22
|
+
lg: 'h-10 rounded-md px-8',
|
|
23
|
+
icon: 'size-9',
|
|
24
|
+
'icon-sm': 'size-8',
|
|
25
|
+
touch: 'h-11 px-4 py-2',
|
|
26
|
+
'icon-touch': 'size-11',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: 'default',
|
|
31
|
+
size: 'default',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
37
|
+
asChild?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
41
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
42
|
+
const Comp = asChild ? Slot : 'button';
|
|
43
|
+
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
Button.displayName = 'Button';
|
|
47
|
+
|
|
48
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
8
|
+
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
12
|
+
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
16
|
+
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function DropdownMenuContent({
|
|
20
|
+
className,
|
|
21
|
+
align = 'start',
|
|
22
|
+
sideOffset = 4,
|
|
23
|
+
...props
|
|
24
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
25
|
+
return (
|
|
26
|
+
<DropdownMenuPrimitive.Portal>
|
|
27
|
+
<DropdownMenuPrimitive.Content
|
|
28
|
+
data-slot="dropdown-menu-content"
|
|
29
|
+
sideOffset={sideOffset}
|
|
30
|
+
align={align}
|
|
31
|
+
className={cn(
|
|
32
|
+
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 shadow-md ring-1 duration-100 data-[state=closed]:overflow-hidden',
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
</DropdownMenuPrimitive.Portal>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
42
|
+
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function DropdownMenuItem({
|
|
46
|
+
className,
|
|
47
|
+
inset,
|
|
48
|
+
variant = 'default',
|
|
49
|
+
...props
|
|
50
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
51
|
+
inset?: boolean;
|
|
52
|
+
variant?: 'default' | 'destructive';
|
|
53
|
+
}) {
|
|
54
|
+
return (
|
|
55
|
+
<DropdownMenuPrimitive.Item
|
|
56
|
+
data-slot="dropdown-menu-item"
|
|
57
|
+
data-inset={inset}
|
|
58
|
+
data-variant={variant}
|
|
59
|
+
className={cn(
|
|
60
|
+
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
61
|
+
className,
|
|
62
|
+
)}
|
|
63
|
+
{...props}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function DropdownMenuCheckboxItem({
|
|
69
|
+
className,
|
|
70
|
+
children,
|
|
71
|
+
checked,
|
|
72
|
+
...props
|
|
73
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
74
|
+
return (
|
|
75
|
+
<DropdownMenuPrimitive.CheckboxItem
|
|
76
|
+
data-slot="dropdown-menu-checkbox-item"
|
|
77
|
+
className={cn(
|
|
78
|
+
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
checked={checked}
|
|
82
|
+
{...props}
|
|
83
|
+
>
|
|
84
|
+
<span
|
|
85
|
+
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
|
86
|
+
data-slot="dropdown-menu-checkbox-item-indicator"
|
|
87
|
+
>
|
|
88
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
89
|
+
<CheckIcon />
|
|
90
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
91
|
+
</span>
|
|
92
|
+
{children}
|
|
93
|
+
</DropdownMenuPrimitive.CheckboxItem>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
98
|
+
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function DropdownMenuRadioItem({
|
|
102
|
+
className,
|
|
103
|
+
children,
|
|
104
|
+
...props
|
|
105
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
106
|
+
return (
|
|
107
|
+
<DropdownMenuPrimitive.RadioItem
|
|
108
|
+
data-slot="dropdown-menu-radio-item"
|
|
109
|
+
className={cn(
|
|
110
|
+
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
111
|
+
className,
|
|
112
|
+
)}
|
|
113
|
+
{...props}
|
|
114
|
+
>
|
|
115
|
+
<span
|
|
116
|
+
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
|
117
|
+
data-slot="dropdown-menu-radio-item-indicator"
|
|
118
|
+
>
|
|
119
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
120
|
+
<CheckIcon />
|
|
121
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
122
|
+
</span>
|
|
123
|
+
{children}
|
|
124
|
+
</DropdownMenuPrimitive.RadioItem>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function DropdownMenuLabel({
|
|
129
|
+
className,
|
|
130
|
+
inset,
|
|
131
|
+
...props
|
|
132
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
133
|
+
inset?: boolean;
|
|
134
|
+
}) {
|
|
135
|
+
return (
|
|
136
|
+
<DropdownMenuPrimitive.Label
|
|
137
|
+
data-slot="dropdown-menu-label"
|
|
138
|
+
data-inset={inset}
|
|
139
|
+
className={cn('text-muted-foreground px-1.5 py-1 text-xs font-medium data-[inset]:pl-8', className)}
|
|
140
|
+
{...props}
|
|
141
|
+
/>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
146
|
+
return (
|
|
147
|
+
<DropdownMenuPrimitive.Separator
|
|
148
|
+
data-slot="dropdown-menu-separator"
|
|
149
|
+
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
|
150
|
+
{...props}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
|
156
|
+
return (
|
|
157
|
+
<span
|
|
158
|
+
data-slot="dropdown-menu-shortcut"
|
|
159
|
+
className={cn(
|
|
160
|
+
'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest',
|
|
161
|
+
className,
|
|
162
|
+
)}
|
|
163
|
+
{...props}
|
|
164
|
+
/>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
169
|
+
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function DropdownMenuSubTrigger({
|
|
173
|
+
className,
|
|
174
|
+
inset,
|
|
175
|
+
children,
|
|
176
|
+
...props
|
|
177
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
178
|
+
inset?: boolean;
|
|
179
|
+
}) {
|
|
180
|
+
return (
|
|
181
|
+
<DropdownMenuPrimitive.SubTrigger
|
|
182
|
+
data-slot="dropdown-menu-sub-trigger"
|
|
183
|
+
data-inset={inset}
|
|
184
|
+
className={cn(
|
|
185
|
+
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
186
|
+
className,
|
|
187
|
+
)}
|
|
188
|
+
{...props}
|
|
189
|
+
>
|
|
190
|
+
{children}
|
|
191
|
+
<ChevronRightIcon className="ml-auto" />
|
|
192
|
+
</DropdownMenuPrimitive.SubTrigger>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function DropdownMenuSubContent({
|
|
197
|
+
className,
|
|
198
|
+
...props
|
|
199
|
+
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
200
|
+
return (
|
|
201
|
+
<DropdownMenuPrimitive.SubContent
|
|
202
|
+
data-slot="dropdown-menu-sub-content"
|
|
203
|
+
className={cn(
|
|
204
|
+
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md p-1 shadow-lg ring-1 duration-100',
|
|
205
|
+
className,
|
|
206
|
+
)}
|
|
207
|
+
{...props}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export {
|
|
213
|
+
DropdownMenu,
|
|
214
|
+
DropdownMenuPortal,
|
|
215
|
+
DropdownMenuTrigger,
|
|
216
|
+
DropdownMenuContent,
|
|
217
|
+
DropdownMenuGroup,
|
|
218
|
+
DropdownMenuLabel,
|
|
219
|
+
DropdownMenuItem,
|
|
220
|
+
DropdownMenuCheckboxItem,
|
|
221
|
+
DropdownMenuRadioGroup,
|
|
222
|
+
DropdownMenuRadioItem,
|
|
223
|
+
DropdownMenuSeparator,
|
|
224
|
+
DropdownMenuShortcut,
|
|
225
|
+
DropdownMenuSub,
|
|
226
|
+
DropdownMenuSubTrigger,
|
|
227
|
+
DropdownMenuSubContent,
|
|
228
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AlertCircle } from 'lucide-react';
|
|
2
|
+
import type { FieldError, FieldErrors } from 'react-hook-form';
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
export interface FieldErrorProps {
|
|
7
|
+
error?: FieldError;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Display a single field error
|
|
13
|
+
*/
|
|
14
|
+
export function FieldErrorMessage({ error, className }: FieldErrorProps) {
|
|
15
|
+
if (!error?.message) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<p className={cn('text-destructive mt-1 flex items-center gap-1 text-sm', className)} role="alert">
|
|
21
|
+
<AlertCircle className="size-3.5 shrink-0" />
|
|
22
|
+
<span>{error.message}</span>
|
|
23
|
+
</p>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FormErrorProps {
|
|
28
|
+
errors?: FieldErrors;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Display all form errors in a summary
|
|
34
|
+
*/
|
|
35
|
+
export function FormErrorSummary({ errors, className }: FormErrorProps) {
|
|
36
|
+
if (!errors || Object.keys(errors).length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const errorMessages = Object.entries(errors)
|
|
41
|
+
.filter(([, error]) => error?.message)
|
|
42
|
+
.map(([field, error]) => ({
|
|
43
|
+
field,
|
|
44
|
+
message: (error as FieldError).message!,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
if (errorMessages.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={cn('bg-destructive/10 border-destructive/50 rounded-md border p-3', className)}
|
|
54
|
+
role="alert"
|
|
55
|
+
aria-live="polite"
|
|
56
|
+
>
|
|
57
|
+
<div className="text-destructive mb-2 flex items-center gap-2 font-medium">
|
|
58
|
+
<AlertCircle className="size-4" />
|
|
59
|
+
<span>Please fix the following errors:</span>
|
|
60
|
+
</div>
|
|
61
|
+
<ul className="text-destructive list-inside list-disc space-y-1 text-sm">
|
|
62
|
+
{errorMessages.map(({ field, message }) => (
|
|
63
|
+
<li key={field}>{message}</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RootErrorProps {
|
|
71
|
+
message?: string;
|
|
72
|
+
className?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Display a root-level form error (e.g., API error)
|
|
77
|
+
*/
|
|
78
|
+
export function RootFormError({ message, className }: RootErrorProps) {
|
|
79
|
+
if (!message) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
'bg-destructive/10 border-destructive text-destructive flex items-center gap-2 rounded-md border px-4 py-3 text-sm',
|
|
87
|
+
className,
|
|
88
|
+
)}
|
|
89
|
+
role="alert"
|
|
90
|
+
>
|
|
91
|
+
<AlertCircle className="size-4 shrink-0" />
|
|
92
|
+
<span>{message}</span>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Trans } from '@lingui/react/macro';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
import { Spinner } from './spinner';
|
|
6
|
+
|
|
7
|
+
export interface LoadingProps {
|
|
8
|
+
className?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
fullScreen?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Loading indicator with optional text
|
|
15
|
+
*/
|
|
16
|
+
export function Loading({ className, text, fullScreen }: LoadingProps) {
|
|
17
|
+
const content = (
|
|
18
|
+
<div className={cn('flex flex-col items-center justify-center gap-3', className)}>
|
|
19
|
+
<Spinner size="lg" />
|
|
20
|
+
{text && <p className="text-muted-foreground text-sm">{text}</p>}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (fullScreen) {
|
|
25
|
+
return (
|
|
26
|
+
<div className="bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
|
|
27
|
+
{content}
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return content;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Page-level loading state
|
|
37
|
+
*/
|
|
38
|
+
export function PageLoading() {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex min-h-[50vh] items-center justify-center">
|
|
41
|
+
<Loading text="Loading..." />
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Inline loading for buttons/small areas
|
|
48
|
+
*/
|
|
49
|
+
export function InlineLoading({ className }: { className?: string }) {
|
|
50
|
+
return (
|
|
51
|
+
<span className={cn('inline-flex items-center gap-2', className)}>
|
|
52
|
+
<Spinner size="sm" />
|
|
53
|
+
<span className="text-muted-foreground text-sm">
|
|
54
|
+
<Trans>Loading...</Trans>
|
|
55
|
+
</span>
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
|
|
3
|
+
export type SkeletonProps = React.HTMLAttributes<HTMLDivElement>;
|
|
4
|
+
|
|
5
|
+
export function Skeleton({ className, ...props }: SkeletonProps) {
|
|
6
|
+
return <div className={cn('bg-muted animate-pulse rounded-md', className)} {...props} />;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Skeleton for text content
|
|
11
|
+
*/
|
|
12
|
+
export function SkeletonText({ className, lines = 1 }: { className?: string; lines?: number }) {
|
|
13
|
+
return (
|
|
14
|
+
<div className={cn('space-y-2', className)}>
|
|
15
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
16
|
+
<Skeleton key={i} className={cn('h-4', i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full')} />
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Skeleton for card content
|
|
24
|
+
*/
|
|
25
|
+
export function SkeletonCard({ className }: { className?: string }) {
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn('rounded-lg border p-4', className)}>
|
|
28
|
+
<Skeleton className="mb-4 h-32 w-full" />
|
|
29
|
+
<Skeleton className="mb-2 h-4 w-3/4" />
|
|
30
|
+
<Skeleton className="h-4 w-1/2" />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Skeleton for avatar
|
|
37
|
+
*/
|
|
38
|
+
export function SkeletonAvatar({
|
|
39
|
+
className,
|
|
40
|
+
size = 'default',
|
|
41
|
+
}: {
|
|
42
|
+
className?: string;
|
|
43
|
+
size?: 'sm' | 'default' | 'lg';
|
|
44
|
+
}) {
|
|
45
|
+
const sizeClasses = {
|
|
46
|
+
sm: 'size-8',
|
|
47
|
+
default: 'size-10',
|
|
48
|
+
lg: 'size-12',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return <Skeleton className={cn('rounded-full', sizeClasses[size], className)} />;
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from 'lucide-react';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
import { Toaster as Sonner, type ToasterProps } from 'sonner';
|
|
4
|
+
|
|
5
|
+
import { usePreferencesStore } from '@/stores';
|
|
6
|
+
|
|
7
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
8
|
+
const theme = usePreferencesStore((state) => state.theme);
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<Sonner
|
|
12
|
+
theme={theme}
|
|
13
|
+
className="toaster group"
|
|
14
|
+
icons={{
|
|
15
|
+
success: <CircleCheckIcon className="size-4" />,
|
|
16
|
+
info: <InfoIcon className="size-4" />,
|
|
17
|
+
warning: <TriangleAlertIcon className="size-4" />,
|
|
18
|
+
error: <OctagonXIcon className="size-4" />,
|
|
19
|
+
loading: <Loader2Icon className="size-4 animate-spin" />,
|
|
20
|
+
}}
|
|
21
|
+
style={
|
|
22
|
+
{
|
|
23
|
+
'--normal-bg': 'var(--popover)',
|
|
24
|
+
'--normal-text': 'var(--popover-foreground)',
|
|
25
|
+
'--normal-border': 'var(--border)',
|
|
26
|
+
'--border-radius': 'var(--radius)',
|
|
27
|
+
} as React.CSSProperties
|
|
28
|
+
}
|
|
29
|
+
{...props}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export { Toaster };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
const spinnerVariants = cva('animate-spin text-muted-foreground', {
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'size-4',
|
|
9
|
+
default: 'size-6',
|
|
10
|
+
lg: 'size-8',
|
|
11
|
+
xl: 'size-12',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
defaultVariants: {
|
|
15
|
+
size: 'default',
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export interface SpinnerProps extends VariantProps<typeof spinnerVariants> {
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Spinner({ size, className }: SpinnerProps) {
|
|
24
|
+
return (
|
|
25
|
+
<svg
|
|
26
|
+
className={cn(spinnerVariants({ size }), className)}
|
|
27
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
28
|
+
fill="none"
|
|
29
|
+
viewBox="0 0 24 24"
|
|
30
|
+
aria-hidden="true"
|
|
31
|
+
>
|
|
32
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
33
|
+
<path
|
|
34
|
+
className="opacity-75"
|
|
35
|
+
fill="currentColor"
|
|
36
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
37
|
+
/>
|
|
38
|
+
</svg>
|
|
39
|
+
);
|
|
40
|
+
}
|