@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,51 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface VisuallyHiddenProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
as?: 'span' | 'div';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Visually hidden content for screen readers.
|
|
10
|
+
* Content is hidden visually but remains accessible to assistive technologies.
|
|
11
|
+
*/
|
|
12
|
+
export function VisuallyHidden({ children, as: Component = 'span' }: VisuallyHiddenProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Component
|
|
15
|
+
style={{
|
|
16
|
+
position: 'absolute',
|
|
17
|
+
width: '1px',
|
|
18
|
+
height: '1px',
|
|
19
|
+
padding: 0,
|
|
20
|
+
margin: '-1px',
|
|
21
|
+
overflow: 'hidden',
|
|
22
|
+
clip: 'rect(0, 0, 0, 0)',
|
|
23
|
+
whiteSpace: 'nowrap',
|
|
24
|
+
border: 0,
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</Component>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Skip link for keyboard navigation.
|
|
34
|
+
* Becomes visible on focus.
|
|
35
|
+
*/
|
|
36
|
+
export function SkipLink({
|
|
37
|
+
href = '#main',
|
|
38
|
+
children = 'Skip to main content',
|
|
39
|
+
}: {
|
|
40
|
+
href?: string;
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<a
|
|
45
|
+
href={href}
|
|
46
|
+
className="bg-background text-foreground ring-ring fixed top-4 left-4 z-50 -translate-y-16 rounded-md px-4 py-2 font-medium transition-transform focus:translate-y-0 focus:ring-2"
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</a>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { BREAKPOINTS } from '@/hooks/useMediaQuery';
|
|
4
|
+
|
|
5
|
+
interface MobileContextValue {
|
|
6
|
+
isMobile: boolean;
|
|
7
|
+
isTablet: boolean;
|
|
8
|
+
isDesktop: boolean;
|
|
9
|
+
/** Current viewport width in pixels */
|
|
10
|
+
width: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MobileContext = createContext<MobileContextValue | null>(null);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Optimized MobileProvider that uses a single resize listener
|
|
17
|
+
* instead of multiple matchMedia listeners.
|
|
18
|
+
*/
|
|
19
|
+
export function MobileProvider({ children }: { children: ReactNode }) {
|
|
20
|
+
const [width, setWidth] = useState(() => {
|
|
21
|
+
if (typeof window === 'undefined') return BREAKPOINTS.lg;
|
|
22
|
+
return window.innerWidth;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let rafId: number;
|
|
27
|
+
let lastWidth = window.innerWidth;
|
|
28
|
+
|
|
29
|
+
const handleResize = () => {
|
|
30
|
+
// Debounce with requestAnimationFrame for performance
|
|
31
|
+
cancelAnimationFrame(rafId);
|
|
32
|
+
rafId = requestAnimationFrame(() => {
|
|
33
|
+
const newWidth = window.innerWidth;
|
|
34
|
+
// Only update if width actually changed to prevent unnecessary re-renders
|
|
35
|
+
if (newWidth !== lastWidth) {
|
|
36
|
+
lastWidth = newWidth;
|
|
37
|
+
setWidth(newWidth);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
window.addEventListener('resize', handleResize, { passive: true });
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener('resize', handleResize);
|
|
45
|
+
cancelAnimationFrame(rafId);
|
|
46
|
+
};
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const value = useMemo(() => {
|
|
50
|
+
const isMobile = width < BREAKPOINTS.md;
|
|
51
|
+
const isTablet = width >= BREAKPOINTS.md && width < BREAKPOINTS.lg;
|
|
52
|
+
const isDesktop = width >= BREAKPOINTS.lg;
|
|
53
|
+
|
|
54
|
+
return { isMobile, isTablet, isDesktop, width };
|
|
55
|
+
}, [width]);
|
|
56
|
+
|
|
57
|
+
return <MobileContext.Provider value={value}>{children}</MobileContext.Provider>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useMobileContext(): MobileContextValue {
|
|
61
|
+
const context = useContext(MobileContext);
|
|
62
|
+
if (!context) {
|
|
63
|
+
throw new Error('useMobileContext must be used within MobileProvider');
|
|
64
|
+
}
|
|
65
|
+
return context;
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { type ReactNode, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create QueryClient with optimized defaults.
|
|
6
|
+
* Using a function ensures each provider instance gets its own client,
|
|
7
|
+
* which is important for SSR and testing.
|
|
8
|
+
*/
|
|
9
|
+
function createQueryClient() {
|
|
10
|
+
return new QueryClient({
|
|
11
|
+
defaultOptions: {
|
|
12
|
+
queries: {
|
|
13
|
+
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
14
|
+
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
|
|
15
|
+
retry: 1,
|
|
16
|
+
refetchOnWindowFocus: false,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function QueryProvider({ children }: { children: ReactNode }) {
|
|
23
|
+
// Use useState to ensure queryClient is created once per component instance
|
|
24
|
+
// This is the recommended pattern from TanStack Query docs
|
|
25
|
+
const [queryClient] = useState(createQueryClient);
|
|
26
|
+
|
|
27
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { useMediaQuery, BREAKPOINTS } from './useMediaQuery';
|
|
2
|
+
export { useThemeEffect } from './useThemeEffect';
|
|
3
|
+
export { useTouchSizes } from './useTouchSizes';
|
|
4
|
+
export { useLanguage } from './useLanguage';
|
|
5
|
+
export { useExampleQuery } from './useExampleQuery';
|
|
6
|
+
export { useContactForm } from './useContactForm';
|
|
7
|
+
export { useMobileContext } from '@/contexts/mobileContext';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
2
|
+
import { useForm } from 'react-hook-form';
|
|
3
|
+
|
|
4
|
+
import { type ContactFormData, contactFormSchema } from '@/lib/validations';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Example React Hook Form + Zod hook for a contact form.
|
|
8
|
+
* Demonstrates the pattern for form validation.
|
|
9
|
+
*/
|
|
10
|
+
export function useContactForm() {
|
|
11
|
+
const form = useForm<ContactFormData>({
|
|
12
|
+
resolver: zodResolver(contactFormSchema),
|
|
13
|
+
defaultValues: {
|
|
14
|
+
name: '',
|
|
15
|
+
email: '',
|
|
16
|
+
message: '',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const onSubmit = async (data: ContactFormData) => {
|
|
21
|
+
// Replace with your actual form submission logic
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.log('Form submitted:', data);
|
|
24
|
+
// await api.submitContactForm(data);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
form,
|
|
29
|
+
onSubmit: form.handleSubmit(onSubmit),
|
|
30
|
+
isSubmitting: form.formState.isSubmitting,
|
|
31
|
+
errors: form.formState.errors,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
|
|
3
|
+
import { api } from '@/lib/api';
|
|
4
|
+
import type { Todo } from '@/types/api';
|
|
5
|
+
|
|
6
|
+
async function fetchTodos(): Promise<Todo[]> {
|
|
7
|
+
return api.get<Todo[]>('/todos?_limit=5');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Example TanStack Query hook demonstrating the pattern.
|
|
12
|
+
* Uses the centralized API client and shared types.
|
|
13
|
+
*/
|
|
14
|
+
export function useExampleQuery() {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: ['example', 'todos'],
|
|
17
|
+
queryFn: fetchTodos,
|
|
18
|
+
// Uses default staleTime from QueryProvider (5 minutes)
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useLingui } from '@lingui/react/macro';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
import { dynamicActivate, SUPPORTED_LOCALES, type SupportedLocale } from '@/i18n';
|
|
5
|
+
import { setStorageItem } from '@/lib/storage';
|
|
6
|
+
import { STORAGE_KEYS } from '@/lib/storageKeys';
|
|
7
|
+
|
|
8
|
+
export function useLanguage() {
|
|
9
|
+
const { i18n } = useLingui();
|
|
10
|
+
|
|
11
|
+
const currentLocale = i18n.locale as SupportedLocale;
|
|
12
|
+
|
|
13
|
+
const changeLanguage = useCallback(async (locale: SupportedLocale) => {
|
|
14
|
+
await dynamicActivate(locale);
|
|
15
|
+
setStorageItem(STORAGE_KEYS.locale, locale);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
currentLocale,
|
|
20
|
+
changeLanguage,
|
|
21
|
+
supportedLocales: SUPPORTED_LOCALES,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export const BREAKPOINTS = {
|
|
4
|
+
sm: 640,
|
|
5
|
+
md: 768,
|
|
6
|
+
lg: 1024,
|
|
7
|
+
xl: 1280,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hook to track a media query match status.
|
|
12
|
+
* Optimized to reuse matchMedia objects when the query doesn't change.
|
|
13
|
+
*/
|
|
14
|
+
export function useMediaQuery(query: string): boolean {
|
|
15
|
+
const [matches, setMatches] = useState(() => {
|
|
16
|
+
if (typeof window !== 'undefined') {
|
|
17
|
+
return window.matchMedia(query).matches;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Use ref to store the MediaQueryList to avoid recreating on every render
|
|
23
|
+
const mediaQueryRef = useRef<MediaQueryList | null>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// Create or update the MediaQueryList only when query changes
|
|
27
|
+
mediaQueryRef.current = window.matchMedia(query);
|
|
28
|
+
|
|
29
|
+
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
|
30
|
+
|
|
31
|
+
// Sync initial state
|
|
32
|
+
setMatches(mediaQueryRef.current.matches);
|
|
33
|
+
|
|
34
|
+
mediaQueryRef.current.addEventListener('change', handler);
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
mediaQueryRef.current?.removeEventListener('change', handler);
|
|
38
|
+
};
|
|
39
|
+
}, [query]);
|
|
40
|
+
|
|
41
|
+
return matches;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convenience hooks for common breakpoints
|
|
46
|
+
*/
|
|
47
|
+
export function useIsMobile(): boolean {
|
|
48
|
+
return !useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useIsDesktop(): boolean {
|
|
52
|
+
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
import { usePreferencesStore } from '@/stores';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hook that syncs the theme preference to the DOM.
|
|
7
|
+
* Handles 'system' theme by listening to prefers-color-scheme changes.
|
|
8
|
+
*/
|
|
9
|
+
export function useThemeEffect() {
|
|
10
|
+
const theme = usePreferencesStore((state) => state.theme);
|
|
11
|
+
const getResolvedTheme = usePreferencesStore((state) => state.getResolvedTheme);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
// Apply the resolved theme to the DOM
|
|
15
|
+
const applyTheme = () => {
|
|
16
|
+
const resolved = getResolvedTheme();
|
|
17
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
applyTheme();
|
|
21
|
+
|
|
22
|
+
// If theme is 'system', listen for system preference changes
|
|
23
|
+
if (theme === 'system') {
|
|
24
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
25
|
+
const handleChange = () => applyTheme();
|
|
26
|
+
|
|
27
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
28
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
29
|
+
}
|
|
30
|
+
}, [theme, getResolvedTheme]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useMobileContext } from '@/contexts/mobileContext';
|
|
2
|
+
|
|
3
|
+
export function useTouchSizes() {
|
|
4
|
+
const { isMobile } = useMobileContext();
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
button: isMobile ? 'touch' : 'default',
|
|
8
|
+
buttonSm: isMobile ? 'touch' : 'sm',
|
|
9
|
+
iconButton: isMobile ? 'icon-touch' : 'icon-sm',
|
|
10
|
+
iconButtonLg: isMobile ? 'icon-touch' : 'icon',
|
|
11
|
+
input: isMobile ? 'touch' : 'default',
|
|
12
|
+
select: isMobile ? 'touch' : 'default',
|
|
13
|
+
toggle: isMobile ? 'touch' : 'sm',
|
|
14
|
+
textarea: isMobile ? 'touch' : 'default',
|
|
15
|
+
} as const;
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const SUPPORTED_LOCALES = ['en', 'es', 'de'] as const;
|
|
2
|
+
|
|
3
|
+
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_LOCALE: SupportedLocale = 'en';
|
|
6
|
+
|
|
7
|
+
export const LOCALE_LABELS: Record<SupportedLocale, { native: string; english: string }> = {
|
|
8
|
+
en: { native: 'English', english: 'English' },
|
|
9
|
+
es: { native: 'Español', english: 'Spanish' },
|
|
10
|
+
de: { native: 'Deutsch', english: 'German' },
|
|
11
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { STORAGE_KEYS } from '@/lib/storageKeys';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SupportedLocale } from './config';
|
|
4
|
+
|
|
5
|
+
function isSupported(locale: string): locale is SupportedLocale {
|
|
6
|
+
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as SupportedLocale);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function findBestMatch(langTag: string): SupportedLocale | null {
|
|
10
|
+
const normalized = langTag.toLowerCase();
|
|
11
|
+
|
|
12
|
+
if (isSupported(normalized)) return normalized;
|
|
13
|
+
|
|
14
|
+
const exactMatch = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase() === normalized);
|
|
15
|
+
if (exactMatch) return exactMatch;
|
|
16
|
+
|
|
17
|
+
const baseLanguage = normalized.split('-')[0];
|
|
18
|
+
if (isSupported(baseLanguage)) return baseLanguage as SupportedLocale;
|
|
19
|
+
|
|
20
|
+
const regionalFallback = SUPPORTED_LOCALES.find((locale) => locale.toLowerCase().startsWith(baseLanguage));
|
|
21
|
+
if (regionalFallback) return regionalFallback;
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function detectLanguage(): SupportedLocale {
|
|
27
|
+
// 1. Check localStorage for user preference
|
|
28
|
+
if (typeof localStorage !== 'undefined') {
|
|
29
|
+
const stored = localStorage.getItem(STORAGE_KEYS.locale);
|
|
30
|
+
if (stored) {
|
|
31
|
+
let parsedLocale: string;
|
|
32
|
+
try {
|
|
33
|
+
parsedLocale = JSON.parse(stored) as string;
|
|
34
|
+
} catch {
|
|
35
|
+
parsedLocale = stored;
|
|
36
|
+
}
|
|
37
|
+
const match = findBestMatch(parsedLocale);
|
|
38
|
+
if (match) return match;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Check browser language preferences
|
|
43
|
+
if (typeof navigator !== 'undefined' && navigator.languages) {
|
|
44
|
+
for (const lang of navigator.languages) {
|
|
45
|
+
const match = findBestMatch(lang);
|
|
46
|
+
if (match) return match;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 3. Check primary browser language
|
|
51
|
+
if (typeof navigator !== 'undefined' && navigator.language) {
|
|
52
|
+
const match = findBestMatch(navigator.language);
|
|
53
|
+
if (match) return match;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return DEFAULT_LOCALE;
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { i18n } from '@lingui/core';
|
|
2
|
+
|
|
3
|
+
import { type SupportedLocale } from './config';
|
|
4
|
+
import { detectLanguage } from './detectLanguage';
|
|
5
|
+
import { dynamicActivate } from './loadCatalog';
|
|
6
|
+
|
|
7
|
+
// Re-export everything from a single source
|
|
8
|
+
export { i18n };
|
|
9
|
+
export { detectLanguage };
|
|
10
|
+
export { dynamicActivate };
|
|
11
|
+
export { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, type SupportedLocale } from './config';
|
|
12
|
+
|
|
13
|
+
export async function initI18n(locale?: SupportedLocale): Promise<void> {
|
|
14
|
+
const targetLocale = locale || detectLanguage();
|
|
15
|
+
await dynamicActivate(targetLocale);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getLocale(): string {
|
|
19
|
+
return i18n.locale || 'en';
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { i18n } from '@lingui/core';
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_LOCALE, type SupportedLocale } from './config';
|
|
4
|
+
|
|
5
|
+
export async function dynamicActivate(locale: SupportedLocale): Promise<void> {
|
|
6
|
+
if (i18n.locale === locale && i18n.messages[locale]) return;
|
|
7
|
+
if (i18n.messages[locale]) {
|
|
8
|
+
i18n.activate(locale);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const { messages } = await import(`../locales/${locale}.po`);
|
|
14
|
+
i18n.loadAndActivate({ locale, messages });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(`Failed to load locale: ${locale}`, error);
|
|
17
|
+
if (locale !== DEFAULT_LOCALE) {
|
|
18
|
+
if (i18n.messages[DEFAULT_LOCALE]) {
|
|
19
|
+
i18n.activate(DEFAULT_LOCALE);
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
const { messages } = await import(`../locales/${DEFAULT_LOCALE}.po`);
|
|
23
|
+
i18n.loadAndActivate({ locale: DEFAULT_LOCALE, messages });
|
|
24
|
+
} catch {
|
|
25
|
+
console.error('Failed to load default locale');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
@import 'tw-animate-css';
|
|
3
|
+
@import 'shadcn/tailwind.css';
|
|
4
|
+
@import '@fontsource-variable/inter';
|
|
5
|
+
|
|
6
|
+
@custom-variant dark (&:is(.dark *));
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--background: oklch(1 0 0);
|
|
10
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
11
|
+
--card: oklch(1 0 0);
|
|
12
|
+
--card-foreground: oklch(0.141 0.005 285.823);
|
|
13
|
+
--popover: oklch(1 0 0);
|
|
14
|
+
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
15
|
+
--primary: oklch(0.488 0.243 264.376);
|
|
16
|
+
--primary-foreground: oklch(0.97 0.014 254.604);
|
|
17
|
+
--secondary: oklch(0.967 0.001 286.375);
|
|
18
|
+
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
19
|
+
--muted: oklch(0.967 0.001 286.375);
|
|
20
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
21
|
+
--accent: oklch(0.967 0.001 286.375);
|
|
22
|
+
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
23
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
24
|
+
--border: oklch(0.92 0.004 286.32);
|
|
25
|
+
--input: oklch(0.92 0.004 286.32);
|
|
26
|
+
--ring: oklch(0.705 0.015 286.067);
|
|
27
|
+
--radius: 0.45rem;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.dark {
|
|
31
|
+
--background: oklch(0.141 0.005 285.823);
|
|
32
|
+
--foreground: oklch(0.985 0 0);
|
|
33
|
+
--card: oklch(0.21 0.006 285.885);
|
|
34
|
+
--card-foreground: oklch(0.985 0 0);
|
|
35
|
+
--popover: oklch(0.21 0.006 285.885);
|
|
36
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
37
|
+
--primary: oklch(0.42 0.18 266);
|
|
38
|
+
--primary-foreground: oklch(0.97 0.014 254.604);
|
|
39
|
+
--secondary: oklch(0.274 0.006 286.033);
|
|
40
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
41
|
+
--muted: oklch(0.274 0.006 286.033);
|
|
42
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
43
|
+
--accent: oklch(0.274 0.006 286.033);
|
|
44
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
45
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
46
|
+
--border: oklch(1 0 0 / 10%);
|
|
47
|
+
--input: oklch(1 0 0 / 15%);
|
|
48
|
+
--ring: oklch(0.552 0.016 285.938);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@theme inline {
|
|
52
|
+
--font-sans: 'Inter Variable', sans-serif;
|
|
53
|
+
--color-ring: var(--ring);
|
|
54
|
+
--color-input: var(--input);
|
|
55
|
+
--color-border: var(--border);
|
|
56
|
+
--color-destructive: var(--destructive);
|
|
57
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
58
|
+
--color-accent: var(--accent);
|
|
59
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
60
|
+
--color-muted: var(--muted);
|
|
61
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
62
|
+
--color-secondary: var(--secondary);
|
|
63
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
64
|
+
--color-primary: var(--primary);
|
|
65
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
66
|
+
--color-popover: var(--popover);
|
|
67
|
+
--color-card-foreground: var(--card-foreground);
|
|
68
|
+
--color-card: var(--card);
|
|
69
|
+
--color-foreground: var(--foreground);
|
|
70
|
+
--color-background: var(--background);
|
|
71
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
72
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
73
|
+
--radius-lg: var(--radius);
|
|
74
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@layer base {
|
|
78
|
+
* {
|
|
79
|
+
@apply border-border outline-ring/50;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
html {
|
|
83
|
+
@apply h-full font-sans;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
body {
|
|
87
|
+
@apply bg-background text-foreground h-full font-sans;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Prevent iOS Safari zoom on input focus - must be 16px+ */
|
|
91
|
+
@media screen and (max-width: 640px) {
|
|
92
|
+
input,
|
|
93
|
+
textarea,
|
|
94
|
+
select {
|
|
95
|
+
font-size: 16px !important;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|