@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.
Files changed (173) hide show
  1. package/README.md +423 -0
  2. package/dist/features/index.d.ts +5 -0
  3. package/dist/features/index.d.ts.map +1 -0
  4. package/dist/features/index.js +3 -0
  5. package/dist/features/index.js.map +1 -0
  6. package/dist/features/registry.d.ts +10 -0
  7. package/dist/features/registry.d.ts.map +1 -0
  8. package/dist/features/registry.js +508 -0
  9. package/dist/features/registry.js.map +1 -0
  10. package/dist/features/types.d.ts +45 -0
  11. package/dist/features/types.d.ts.map +1 -0
  12. package/dist/features/types.js +5 -0
  13. package/dist/features/types.js.map +1 -0
  14. package/dist/features/versions.d.ts +16 -0
  15. package/dist/features/versions.d.ts.map +1 -0
  16. package/dist/features/versions.js +46 -0
  17. package/dist/features/versions.js.map +1 -0
  18. package/dist/features/versions.json +5 -0
  19. package/dist/index.d.ts +22 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +43 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/resources/docs.d.ts +29 -0
  24. package/dist/resources/docs.d.ts.map +1 -0
  25. package/dist/resources/docs.js +105 -0
  26. package/dist/resources/docs.js.map +1 -0
  27. package/dist/resources/index.d.ts +2 -0
  28. package/dist/resources/index.d.ts.map +1 -0
  29. package/dist/resources/index.js +2 -0
  30. package/dist/resources/index.js.map +1 -0
  31. package/dist/server.d.ts +12 -0
  32. package/dist/server.d.ts.map +1 -0
  33. package/dist/server.js +115 -0
  34. package/dist/server.js.map +1 -0
  35. package/dist/tools/get-example.d.ts +51 -0
  36. package/dist/tools/get-example.d.ts.map +1 -0
  37. package/dist/tools/get-example.js +90 -0
  38. package/dist/tools/get-example.js.map +1 -0
  39. package/dist/tools/get-features.d.ts +30 -0
  40. package/dist/tools/get-features.d.ts.map +1 -0
  41. package/dist/tools/get-features.js +46 -0
  42. package/dist/tools/get-features.js.map +1 -0
  43. package/dist/tools/get-scaffold.d.ts +77 -0
  44. package/dist/tools/get-scaffold.d.ts.map +1 -0
  45. package/dist/tools/get-scaffold.js +153 -0
  46. package/dist/tools/get-scaffold.js.map +1 -0
  47. package/dist/tools/index.d.ts +4 -0
  48. package/dist/tools/index.d.ts.map +1 -0
  49. package/dist/tools/index.js +4 -0
  50. package/dist/tools/index.js.map +1 -0
  51. package/dist/utils/docs.d.ts +14 -0
  52. package/dist/utils/docs.d.ts.map +1 -0
  53. package/dist/utils/docs.js +64 -0
  54. package/dist/utils/docs.js.map +1 -0
  55. package/dist/utils/examples.d.ts +27 -0
  56. package/dist/utils/examples.d.ts.map +1 -0
  57. package/dist/utils/examples.js +399 -0
  58. package/dist/utils/examples.js.map +1 -0
  59. package/dist/utils/index.d.ts +5 -0
  60. package/dist/utils/index.d.ts.map +1 -0
  61. package/dist/utils/index.js +5 -0
  62. package/dist/utils/index.js.map +1 -0
  63. package/dist/utils/paths.d.ts +28 -0
  64. package/dist/utils/paths.d.ts.map +1 -0
  65. package/dist/utils/paths.js +40 -0
  66. package/dist/utils/paths.js.map +1 -0
  67. package/dist/utils/scaffold.d.ts +50 -0
  68. package/dist/utils/scaffold.d.ts.map +1 -0
  69. package/dist/utils/scaffold.js +500 -0
  70. package/dist/utils/scaffold.js.map +1 -0
  71. package/dist/version.d.ts +5 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +19 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +63 -0
  76. package/templates/.bundled +0 -0
  77. package/templates/CLAUDE.md +145 -0
  78. package/templates/docs/API_REFERENCE.md +58 -0
  79. package/templates/docs/ARCHITECTURE.md +185 -0
  80. package/templates/docs/CODING_STANDARDS.md +53 -0
  81. package/templates/docs/COMPONENT_GUIDELINES.md +301 -0
  82. package/templates/docs/E2E_TESTING.md +116 -0
  83. package/templates/docs/INTERNATIONALIZATION.md +67 -0
  84. package/templates/docs/TESTING.md +259 -0
  85. package/templates/docs/WORKFLOW.md +170 -0
  86. package/templates/src/App.tsx +42 -0
  87. package/templates/src/components/layout/Header.tsx +19 -0
  88. package/templates/src/components/layout/index.ts +1 -0
  89. package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +104 -0
  90. package/templates/src/components/shared/ErrorBoundary/index.ts +1 -0
  91. package/templates/src/components/shared/LanguageSwitcher/LanguageSwitcher.tsx +45 -0
  92. package/templates/src/components/shared/LanguageSwitcher/index.ts +1 -0
  93. package/templates/src/components/shared/SEO/SEO.tsx +55 -0
  94. package/templates/src/components/shared/SEO/index.ts +1 -0
  95. package/templates/src/components/shared/ThemeToggle/ThemeToggle.tsx +41 -0
  96. package/templates/src/components/shared/ThemeToggle/index.ts +1 -0
  97. package/templates/src/components/shared/index.ts +4 -0
  98. package/templates/src/components/ui/button.tsx +48 -0
  99. package/templates/src/components/ui/dropdown-menu.tsx +228 -0
  100. package/templates/src/components/ui/form-error.tsx +95 -0
  101. package/templates/src/components/ui/loading.tsx +58 -0
  102. package/templates/src/components/ui/skeleton.tsx +52 -0
  103. package/templates/src/components/ui/sonner.tsx +34 -0
  104. package/templates/src/components/ui/spinner.tsx +40 -0
  105. package/templates/src/components/ui/visually-hidden.tsx +51 -0
  106. package/templates/src/contexts/mobileContext.tsx +66 -0
  107. package/templates/src/contexts/queryContext.tsx +28 -0
  108. package/templates/src/hooks/index.ts +7 -0
  109. package/templates/src/hooks/useContactForm.ts +33 -0
  110. package/templates/src/hooks/useExampleQuery.ts +20 -0
  111. package/templates/src/hooks/useLanguage.ts +23 -0
  112. package/templates/src/hooks/useMediaQuery.ts +53 -0
  113. package/templates/src/hooks/useThemeEffect.ts +31 -0
  114. package/templates/src/hooks/useTouchSizes.ts +16 -0
  115. package/templates/src/i18n/config.ts +11 -0
  116. package/templates/src/i18n/detectLanguage.ts +57 -0
  117. package/templates/src/i18n/index.ts +20 -0
  118. package/templates/src/i18n/loadCatalog.ts +30 -0
  119. package/templates/src/index.css +98 -0
  120. package/templates/src/lib/api.ts +142 -0
  121. package/templates/src/lib/config.ts +15 -0
  122. package/templates/src/lib/constants.ts +8 -0
  123. package/templates/src/lib/env.ts +53 -0
  124. package/templates/src/lib/format.ts +119 -0
  125. package/templates/src/lib/index.ts +24 -0
  126. package/templates/src/lib/routes.ts +11 -0
  127. package/templates/src/lib/storage.ts +91 -0
  128. package/templates/src/lib/storageKeys.ts +10 -0
  129. package/templates/src/lib/utils.ts +6 -0
  130. package/templates/src/lib/validations.ts +39 -0
  131. package/templates/src/locales/de.po +65 -0
  132. package/templates/src/locales/en.po +65 -0
  133. package/templates/src/locales/es.po +65 -0
  134. package/templates/src/main.tsx +107 -0
  135. package/templates/src/mocks/fixtures/index.ts +1 -0
  136. package/templates/src/mocks/fixtures/todos.ts +40 -0
  137. package/templates/src/mocks/handlers/index.ts +7 -0
  138. package/templates/src/mocks/handlers/todos.ts +59 -0
  139. package/templates/src/mocks/index.ts +3 -0
  140. package/templates/src/mocks/node.ts +9 -0
  141. package/templates/src/pages/Home.tsx +27 -0
  142. package/templates/src/pages/NotFound.tsx +28 -0
  143. package/templates/src/pages/index.ts +2 -0
  144. package/templates/src/stores/index.ts +2 -0
  145. package/templates/src/stores/preferencesStore.ts +85 -0
  146. package/templates/src/test/index.ts +8 -0
  147. package/templates/src/test/mocks.ts +17 -0
  148. package/templates/src/test/providers.tsx +54 -0
  149. package/templates/src/test-setup.ts +54 -0
  150. package/templates/src/types/api.ts +31 -0
  151. package/templates/src/types/index.ts +2 -0
  152. package/templates/src/types/preferences.ts +5 -0
  153. package/templates/src/vite-env.d.ts +10 -0
  154. package/templates/tests/unit/components/ErrorBoundary.test.tsx +193 -0
  155. package/templates/tests/unit/components/Header.test.tsx +33 -0
  156. package/templates/tests/unit/components/LanguageSwitcher.test.tsx +40 -0
  157. package/templates/tests/unit/components/Loading.test.tsx +76 -0
  158. package/templates/tests/unit/components/SEO.test.tsx +80 -0
  159. package/templates/tests/unit/components/ThemeToggle.test.tsx +62 -0
  160. package/templates/tests/unit/contexts/mobileContext.test.tsx +54 -0
  161. package/templates/tests/unit/hooks/useContactForm.test.ts +60 -0
  162. package/templates/tests/unit/hooks/useExampleQuery.test.tsx +94 -0
  163. package/templates/tests/unit/hooks/useLanguage.test.tsx +75 -0
  164. package/templates/tests/unit/hooks/useMediaQuery.test.ts +57 -0
  165. package/templates/tests/unit/hooks/useThemeEffect.test.ts +42 -0
  166. package/templates/tests/unit/i18n/detectLanguage.test.ts +40 -0
  167. package/templates/tests/unit/i18n/loadCatalog.test.ts +70 -0
  168. package/templates/tests/unit/lib/api.test.ts +142 -0
  169. package/templates/tests/unit/lib/format.test.ts +100 -0
  170. package/templates/tests/unit/lib/storage.test.ts +90 -0
  171. package/templates/tests/unit/lib/utils.test.ts +19 -0
  172. package/templates/tests/unit/lib/validations.test.ts +56 -0
  173. 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,4 @@
1
+ export { ThemeToggle } from './ThemeToggle';
2
+ export { ErrorBoundary } from './ErrorBoundary';
3
+ export { SEO } from './SEO';
4
+ export { LanguageSwitcher } from './LanguageSwitcher';
@@ -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
+ }