@pattern-stack/frontend-patterns 0.0.3 → 0.0.4
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/dist/index.es.js +1 -1
- package/dist/index.js +1 -0
- package/package.json +5 -3
- package/src/App.css +42 -0
- package/src/App.tsx +54 -0
- package/src/__tests__/README.md +221 -0
- package/src/__tests__/atoms/hooks/simple-hooks.test.ts +44 -0
- package/src/__tests__/atoms/ui/button.test.tsx +68 -0
- package/src/__tests__/atoms/utils/simple.test.ts +18 -0
- package/src/__tests__/atoms/utils/utils.test.ts +77 -0
- package/src/__tests__/features/auth/simple-auth.test.tsx +40 -0
- package/src/__tests__/molecules/layout/simple-layout.test.tsx +81 -0
- package/src/__tests__/organisms/showcase/simple-showcase.test.tsx +167 -0
- package/src/__tests__/setup.ts +51 -0
- package/src/__tests__/utils.tsx +123 -0
- package/src/atoms/composed/Accordion/Accordion.tsx +271 -0
- package/src/atoms/composed/Accordion/index.ts +1 -0
- package/src/atoms/composed/Alert/Alert.tsx +132 -0
- package/src/atoms/composed/Alert/index.ts +1 -0
- package/src/atoms/composed/Breadcrumb/Breadcrumb.tsx +83 -0
- package/src/atoms/composed/Breadcrumb/index.ts +1 -0
- package/src/atoms/composed/Chart/Chart.tsx +425 -0
- package/src/atoms/composed/Chart/index.ts +2 -0
- package/src/atoms/composed/ColorSwatch/ColorSwatch.tsx +72 -0
- package/src/atoms/composed/ColorSwatch/index.ts +1 -0
- package/src/atoms/composed/DarkModeToggle.tsx +66 -0
- package/src/atoms/composed/DataBadge/DataBadge.tsx +81 -0
- package/src/atoms/composed/DataBadge/index.ts +1 -0
- package/src/atoms/composed/DataTable/DataTable.tsx +394 -0
- package/src/atoms/composed/DataTable/TableCellWithTooltip.tsx +41 -0
- package/src/atoms/composed/DataTable/index.ts +2 -0
- package/src/atoms/composed/DateTimePicker/DateTimePicker.tsx +611 -0
- package/src/atoms/composed/DateTimePicker/index.ts +2 -0
- package/src/atoms/composed/DetailedCard/DetailedCard.tsx +181 -0
- package/src/atoms/composed/DetailedCard/index.ts +2 -0
- package/src/atoms/composed/EmptyState/EmptyState.tsx +90 -0
- package/src/atoms/composed/EmptyState/index.ts +1 -0
- package/src/atoms/composed/FileUpload/FileUpload.tsx +477 -0
- package/src/atoms/composed/FileUpload/index.ts +2 -0
- package/src/atoms/composed/FormField/FormField.tsx +92 -0
- package/src/atoms/composed/FormField/index.ts +1 -0
- package/src/atoms/composed/GlobalSearch/GlobalSearch.tsx +37 -0
- package/src/atoms/composed/GlobalSearch/index.ts +1 -0
- package/src/atoms/composed/IconBadge/IconBadge.tsx +95 -0
- package/src/atoms/composed/IconBadge/index.ts +2 -0
- package/src/atoms/composed/Modal/Modal.tsx +223 -0
- package/src/atoms/composed/Modal/index.ts +2 -0
- package/src/atoms/composed/PaletteSwitcher.tsx +386 -0
- package/src/atoms/composed/ProgressBar/ProgressBar.tsx +116 -0
- package/src/atoms/composed/ProgressBar/index.ts +1 -0
- package/src/atoms/composed/StatCard/StatCard.tsx +219 -0
- package/src/atoms/composed/StatCard/index.ts +1 -0
- package/src/atoms/composed/StyleGuide.tsx +717 -0
- package/src/atoms/composed/Toast/Toast.tsx +219 -0
- package/src/atoms/composed/Toast/index.ts +1 -0
- package/src/atoms/composed/Tooltip/Tooltip.tsx +213 -0
- package/src/atoms/composed/Tooltip/index.ts +1 -0
- package/src/atoms/composed/UserAvatar/UserAvatar.tsx +139 -0
- package/src/atoms/composed/UserAvatar/index.ts +1 -0
- package/src/atoms/composed/UserMenu/UserMenu.tsx +16 -0
- package/src/atoms/composed/UserMenu/index.ts +1 -0
- package/src/atoms/composed/index.ts +29 -0
- package/src/atoms/hooks/useApi.ts +80 -0
- package/src/atoms/hooks/useHealth.ts +17 -0
- package/src/atoms/index.ts +13 -0
- package/src/atoms/services/api/client.ts +134 -0
- package/src/atoms/services/auth-service.ts +248 -0
- package/src/atoms/services/health.ts +15 -0
- package/src/atoms/services/index.ts +3 -0
- package/src/atoms/shared/config/constants.ts +17 -0
- package/src/atoms/shared/config/dashboard-sizes.ts +111 -0
- package/src/atoms/shared/config/environment.ts +10 -0
- package/src/atoms/shared/index.ts +4 -0
- package/src/atoms/shared/styles/color-palettes.css +566 -0
- package/src/atoms/types/auth.ts +62 -0
- package/src/atoms/types/generated.ts +1469 -0
- package/src/atoms/types/index.ts +4 -0
- package/src/atoms/types/loading.ts +28 -0
- package/src/atoms/ui/Badge.tsx +30 -0
- package/src/atoms/ui/ErrorBoundary.tsx +59 -0
- package/src/atoms/ui/Select.tsx +53 -0
- package/src/atoms/ui/Switch.tsx +42 -0
- package/src/atoms/ui/Tabs.tsx +118 -0
- package/src/atoms/ui/avatar.tsx +48 -0
- package/src/atoms/ui/button.tsx +70 -0
- package/src/atoms/ui/card.tsx +76 -0
- package/src/atoms/ui/dropdown-menu.tsx +199 -0
- package/src/atoms/ui/index.ts +39 -0
- package/src/atoms/ui/input.tsx +23 -0
- package/src/atoms/ui/label.tsx +23 -0
- package/src/atoms/ui/skeleton.tsx +13 -0
- package/src/atoms/ui/spinner.tsx +49 -0
- package/src/atoms/ui/table.tsx +116 -0
- package/src/atoms/utils/animations.ts +135 -0
- package/src/atoms/utils/tooltip-helpers.ts +140 -0
- package/src/atoms/utils/utils.ts +9 -0
- package/src/features/auth/components/LoginForm.tsx +168 -0
- package/src/features/auth/components/LogoutButton.tsx +19 -0
- package/src/features/auth/components/ProtectedRoute.tsx +60 -0
- package/src/features/auth/components/index.ts +4 -0
- package/src/features/auth/hooks/index.ts +2 -0
- package/src/features/auth/hooks/useAuth.tsx +205 -0
- package/src/features/auth/hooks/usePermissions.ts +35 -0
- package/src/features/auth/index.ts +2 -0
- package/src/features/index.ts +2 -0
- package/src/index.css +704 -0
- package/src/index.ts +13 -0
- package/src/main.tsx +48 -0
- package/src/molecules/.gitkeep +0 -0
- package/src/molecules/forms/FormGroup.tsx +75 -0
- package/src/molecules/forms/SearchInput.tsx +259 -0
- package/src/molecules/forms/index.ts +4 -0
- package/src/molecules/index.ts +4 -0
- package/src/molecules/layout/AppHeader/AppHeader.tsx +42 -0
- package/src/molecules/layout/AppHeader/index.ts +1 -0
- package/src/molecules/layout/AppLayout.tsx +29 -0
- package/src/molecules/layout/PageTemplate.tsx +87 -0
- package/src/molecules/layout/SectionHeader/SectionHeader.tsx +87 -0
- package/src/molecules/layout/SectionHeader/index.ts +1 -0
- package/src/molecules/layout/ShowcaseSection.tsx +57 -0
- package/src/molecules/layout/Sidebar.tsx +144 -0
- package/src/molecules/layout/SidebarButton/SidebarButton.tsx +99 -0
- package/src/molecules/layout/SidebarButton/index.ts +1 -0
- package/src/molecules/layout/SidebarContext.tsx +31 -0
- package/src/molecules/layout/index.ts +7 -0
- package/src/molecules/navigation/NavMenu.tsx +188 -0
- package/src/molecules/navigation/Pagination.tsx +172 -0
- package/src/molecules/navigation/index.ts +4 -0
- package/src/organisms/index.ts +5 -0
- package/src/organisms/showcase/ComponentShowcasePage.tsx +2496 -0
- package/src/organisms/showcase/index.ts +1 -0
- package/src/pages/AdminShowcase/AdminCRUDShowcase.tsx +242 -0
- package/src/pages/AdminShowcase/AdminDashboardShowcase.tsx +171 -0
- package/src/pages/AdminShowcase/AdminDetailShowcase.tsx +385 -0
- package/src/pages/AdminShowcase/index.tsx +3 -0
- package/src/pages/ComponentShowcase/BadgesShowcase.tsx +188 -0
- package/src/pages/ComponentShowcase/CardsShowcase.tsx +392 -0
- package/src/pages/ComponentShowcase/PalettesShowcase.tsx +207 -0
- package/src/pages/ComponentShowcase/StatesShowcase.tsx +485 -0
- package/src/pages/ComponentShowcase/TablesShowcase.tsx +134 -0
- package/src/pages/ComponentShowcase/TypographyShowcase.tsx +255 -0
- package/src/pages/ComponentShowcase/index.tsx +188 -0
- package/src/pages/index.ts +2 -0
- package/src/templates/AuthTemplate.tsx +216 -0
- package/src/templates/ComponentShowcaseTemplate.tsx +173 -0
- package/src/templates/DashboardTemplate.tsx +232 -0
- package/src/templates/DataTemplate.tsx +319 -0
- package/src/templates/admin/AdminCRUDTemplate.tsx +630 -0
- package/src/templates/admin/AdminDashboardTemplate.tsx +351 -0
- package/src/templates/admin/AdminDetailTemplate.tsx +563 -0
- package/src/templates/admin/index.ts +29 -0
- package/src/templates/factory.tsx +169 -0
- package/src/templates/index.ts +37 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient, type UseQueryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
|
2
|
+
import { apiClient } from '../services/api/client';
|
|
3
|
+
|
|
4
|
+
// Generic query hook
|
|
5
|
+
export function useApiQuery<T>(
|
|
6
|
+
queryKey: (string | number)[],
|
|
7
|
+
queryFn: () => Promise<T>,
|
|
8
|
+
options?: Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>
|
|
9
|
+
) {
|
|
10
|
+
return useQuery({
|
|
11
|
+
queryKey,
|
|
12
|
+
queryFn,
|
|
13
|
+
...options,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Generic mutation hook
|
|
18
|
+
export function useApiMutation<TData, TVariables = void>(
|
|
19
|
+
mutationFn: (variables: TVariables) => Promise<TData>,
|
|
20
|
+
options?: UseMutationOptions<TData, Error, TVariables>
|
|
21
|
+
) {
|
|
22
|
+
const queryClient = useQueryClient();
|
|
23
|
+
|
|
24
|
+
return useMutation({
|
|
25
|
+
mutationFn,
|
|
26
|
+
onSuccess: (data, variables, context) => {
|
|
27
|
+
// Invalidate queries on successful mutation
|
|
28
|
+
queryClient.invalidateQueries();
|
|
29
|
+
options?.onSuccess?.(data, variables, context);
|
|
30
|
+
},
|
|
31
|
+
...options,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Example types for API hooks
|
|
36
|
+
interface ExampleData {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CreateExampleData {
|
|
43
|
+
name: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface UpdateExampleData {
|
|
48
|
+
name?: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Specific API hooks - examples with proper typing
|
|
53
|
+
export function useGetExample(id: string) {
|
|
54
|
+
return useApiQuery(
|
|
55
|
+
['example', id],
|
|
56
|
+
() => apiClient.get<ExampleData>(`/api/example/${id}`),
|
|
57
|
+
{
|
|
58
|
+
enabled: !!id,
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useCreateExample() {
|
|
64
|
+
return useApiMutation(
|
|
65
|
+
(data: CreateExampleData) => apiClient.post<ExampleData>('/api/example', data)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useUpdateExample() {
|
|
70
|
+
return useApiMutation(
|
|
71
|
+
({ id, data }: { id: string; data: UpdateExampleData }) =>
|
|
72
|
+
apiClient.put<ExampleData>(`/api/example/${id}`, data)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useDeleteExample() {
|
|
77
|
+
return useApiMutation(
|
|
78
|
+
(id: string) => apiClient.delete<void>(`/api/example/${id}`)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query';
|
|
2
|
+
import { healthApi } from '../services/health';
|
|
3
|
+
|
|
4
|
+
export const healthKeys = {
|
|
5
|
+
all: ['health'] as const,
|
|
6
|
+
status: () => [...healthKeys.all, 'status'] as const,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function useHealth() {
|
|
10
|
+
return useQuery({
|
|
11
|
+
queryKey: healthKeys.status(),
|
|
12
|
+
queryFn: healthApi.getHealth,
|
|
13
|
+
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
14
|
+
refetchInterval: 5 * 60 * 1000, // Auto-refresh every 5 minutes
|
|
15
|
+
retry: 2,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Foundation layer exports
|
|
2
|
+
export * from './shared';
|
|
3
|
+
export * from './utils/utils';
|
|
4
|
+
export * from './utils/animations';
|
|
5
|
+
export * from './services/api/client';
|
|
6
|
+
export * from './hooks/useApi';
|
|
7
|
+
export * from './types/generated';
|
|
8
|
+
|
|
9
|
+
// UI primitives
|
|
10
|
+
export * from './ui';
|
|
11
|
+
|
|
12
|
+
// Composed components
|
|
13
|
+
export * from './composed';
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
|
2
|
+
|
|
3
|
+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';
|
|
4
|
+
|
|
5
|
+
export interface ApiClientConfig {
|
|
6
|
+
baseURL?: string;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Global auth service reference - will be set by AuthProvider
|
|
12
|
+
let globalAuthService: any = null;
|
|
13
|
+
|
|
14
|
+
export function setGlobalAuthService(authService: any): void {
|
|
15
|
+
globalAuthService = authService;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class ApiClient {
|
|
19
|
+
private instance: AxiosInstance;
|
|
20
|
+
|
|
21
|
+
constructor(config: ApiClientConfig = {}) {
|
|
22
|
+
this.instance = axios.create({
|
|
23
|
+
baseURL: config.baseURL || API_BASE_URL,
|
|
24
|
+
timeout: config.timeout || 10000,
|
|
25
|
+
headers: {
|
|
26
|
+
'Content-Type': 'application/json',
|
|
27
|
+
...config.headers,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.setupInterceptors();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private setupInterceptors() {
|
|
35
|
+
// Request interceptor for auth tokens and auto-refresh
|
|
36
|
+
this.instance.interceptors.request.use(
|
|
37
|
+
async (config) => {
|
|
38
|
+
// Add auth token if available
|
|
39
|
+
if (globalAuthService) {
|
|
40
|
+
const tokenData = globalAuthService.getTokenData();
|
|
41
|
+
if (tokenData?.token) {
|
|
42
|
+
// Check if token should be refreshed
|
|
43
|
+
if (globalAuthService.shouldRefreshToken()) {
|
|
44
|
+
try {
|
|
45
|
+
await globalAuthService.refreshToken();
|
|
46
|
+
// Get the updated token
|
|
47
|
+
const newTokenData = globalAuthService.getTokenData();
|
|
48
|
+
config.headers.Authorization = `Bearer ${newTokenData?.token}`;
|
|
49
|
+
} catch {
|
|
50
|
+
// Refresh failed, use existing token (will likely fail and trigger logout)
|
|
51
|
+
config.headers.Authorization = `Bearer ${tokenData.token}`;
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
config.headers.Authorization = `Bearer ${tokenData.token}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
// Fallback to old method if no auth service
|
|
59
|
+
const token = localStorage.getItem('auth_token');
|
|
60
|
+
if (token) {
|
|
61
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return config;
|
|
65
|
+
},
|
|
66
|
+
(error) => Promise.reject(error)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// Response interceptor for error handling
|
|
70
|
+
this.instance.interceptors.response.use(
|
|
71
|
+
(response) => response,
|
|
72
|
+
async (error) => {
|
|
73
|
+
if (error.response?.status === 401) {
|
|
74
|
+
if (globalAuthService) {
|
|
75
|
+
// Try to refresh token once
|
|
76
|
+
const tokenData = globalAuthService.getTokenData();
|
|
77
|
+
if (tokenData?.refreshToken && !error.config._retry) {
|
|
78
|
+
error.config._retry = true;
|
|
79
|
+
try {
|
|
80
|
+
await globalAuthService.refreshToken();
|
|
81
|
+
// Retry the original request with new token
|
|
82
|
+
const newTokenData = globalAuthService.getTokenData();
|
|
83
|
+
error.config.headers.Authorization = `Bearer ${newTokenData?.token}`;
|
|
84
|
+
return this.instance.request(error.config);
|
|
85
|
+
} catch {
|
|
86
|
+
// Refresh failed, clear auth
|
|
87
|
+
globalAuthService.clearAuth();
|
|
88
|
+
window.location.reload();
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
// No refresh token or retry failed, clear auth
|
|
92
|
+
globalAuthService.clearAuth();
|
|
93
|
+
window.location.reload();
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
// Fallback to old method
|
|
97
|
+
localStorage.removeItem('auth_token');
|
|
98
|
+
localStorage.removeItem('auth_user');
|
|
99
|
+
window.location.reload();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return Promise.reject(error);
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
108
|
+
const response: AxiosResponse<T> = await this.instance.get(url, config);
|
|
109
|
+
return response.data;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
113
|
+
const response: AxiosResponse<T> = await this.instance.post(url, data, config);
|
|
114
|
+
return response.data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
118
|
+
const response: AxiosResponse<T> = await this.instance.put(url, data, config);
|
|
119
|
+
return response.data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
123
|
+
const response: AxiosResponse<T> = await this.instance.patch(url, data, config);
|
|
124
|
+
return response.data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
128
|
+
const response: AxiosResponse<T> = await this.instance.delete(url, config);
|
|
129
|
+
return response.data;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const apiClient = new ApiClient();
|
|
134
|
+
export default apiClient;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { apiClient } from './api/client';
|
|
2
|
+
import type {
|
|
3
|
+
AuthConfig,
|
|
4
|
+
BaseUser,
|
|
5
|
+
LoginCredentials,
|
|
6
|
+
LoginResponse,
|
|
7
|
+
RefreshResponse,
|
|
8
|
+
TokenData
|
|
9
|
+
} from '../types';
|
|
10
|
+
|
|
11
|
+
export class AuthService<T extends BaseUser = BaseUser> {
|
|
12
|
+
private config: AuthConfig;
|
|
13
|
+
private refreshPromise: Promise<void> | null = null;
|
|
14
|
+
|
|
15
|
+
constructor(config: AuthConfig) {
|
|
16
|
+
this.config = {
|
|
17
|
+
tokenStorage: 'localStorage',
|
|
18
|
+
tokenRefreshBuffer: 5, // 5 minutes before expiry
|
|
19
|
+
autoRefresh: true,
|
|
20
|
+
...config
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private getStorageKey(key: string): string {
|
|
25
|
+
return `auth_${key}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private setItem(key: string, value: string): void {
|
|
29
|
+
const storageKey = this.getStorageKey(key);
|
|
30
|
+
switch (this.config.tokenStorage) {
|
|
31
|
+
case 'sessionStorage':
|
|
32
|
+
sessionStorage.setItem(storageKey, value);
|
|
33
|
+
break;
|
|
34
|
+
case 'cookie':
|
|
35
|
+
// Simple cookie implementation - for production use a proper cookie library
|
|
36
|
+
document.cookie = `${storageKey}=${value}; path=/; secure; samesite=strict`;
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
localStorage.setItem(storageKey, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private getItem(key: string): string | null {
|
|
44
|
+
const storageKey = this.getStorageKey(key);
|
|
45
|
+
switch (this.config.tokenStorage) {
|
|
46
|
+
case 'sessionStorage':
|
|
47
|
+
return sessionStorage.getItem(storageKey);
|
|
48
|
+
case 'cookie':
|
|
49
|
+
// Simple cookie reading - for production use a proper cookie library
|
|
50
|
+
const match = document.cookie.match(`(?:^|;)\\s*${storageKey}=([^;]*)`);
|
|
51
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
52
|
+
default:
|
|
53
|
+
return localStorage.getItem(storageKey);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private removeItem(key: string): void {
|
|
58
|
+
const storageKey = this.getStorageKey(key);
|
|
59
|
+
switch (this.config.tokenStorage) {
|
|
60
|
+
case 'sessionStorage':
|
|
61
|
+
sessionStorage.removeItem(storageKey);
|
|
62
|
+
break;
|
|
63
|
+
case 'cookie':
|
|
64
|
+
document.cookie = `${storageKey}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
65
|
+
break;
|
|
66
|
+
default:
|
|
67
|
+
localStorage.removeItem(storageKey);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getTokenData(): TokenData | null {
|
|
72
|
+
const token = this.getItem('token');
|
|
73
|
+
const refreshToken = this.getItem('refresh_token');
|
|
74
|
+
const expiresAt = this.getItem('expires_at');
|
|
75
|
+
|
|
76
|
+
if (!token) return null;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
token,
|
|
80
|
+
refreshToken: refreshToken || undefined,
|
|
81
|
+
expiresAt: expiresAt ? parseInt(expiresAt, 10) : undefined
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private setTokenData(tokenData: TokenData): void {
|
|
86
|
+
this.setItem('token', tokenData.token);
|
|
87
|
+
if (tokenData.refreshToken) {
|
|
88
|
+
this.setItem('refresh_token', tokenData.refreshToken);
|
|
89
|
+
}
|
|
90
|
+
if (tokenData.expiresAt) {
|
|
91
|
+
this.setItem('expires_at', tokenData.expiresAt.toString());
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getStoredUser(): T | null {
|
|
96
|
+
const userData = this.getItem('user');
|
|
97
|
+
if (!userData) return null;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(userData) as T;
|
|
101
|
+
} catch {
|
|
102
|
+
this.clearAuth();
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private setStoredUser(user: T): void {
|
|
108
|
+
this.setItem('user', JSON.stringify(user));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
clearAuth(): void {
|
|
112
|
+
this.removeItem('token');
|
|
113
|
+
this.removeItem('refresh_token');
|
|
114
|
+
this.removeItem('expires_at');
|
|
115
|
+
this.removeItem('user');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async login(credentials: LoginCredentials): Promise<T> {
|
|
119
|
+
const response = await apiClient.post<LoginResponse<T>>(
|
|
120
|
+
`${this.config.apiUrl}${this.config.endpoints.login}`,
|
|
121
|
+
credentials
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const { token, refreshToken, user, expiresIn } = response;
|
|
125
|
+
|
|
126
|
+
// Calculate expiration time
|
|
127
|
+
const expiresAt = expiresIn
|
|
128
|
+
? Date.now() + (expiresIn * 1000)
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
131
|
+
// Store tokens and user
|
|
132
|
+
this.setTokenData({ token, refreshToken, expiresAt });
|
|
133
|
+
this.setStoredUser(user);
|
|
134
|
+
|
|
135
|
+
// Setup auto-refresh if enabled
|
|
136
|
+
if (this.config.autoRefresh && expiresAt) {
|
|
137
|
+
this.scheduleTokenRefresh(expiresAt);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return user;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async refreshToken(): Promise<void> {
|
|
144
|
+
// Prevent multiple simultaneous refresh attempts
|
|
145
|
+
if (this.refreshPromise) {
|
|
146
|
+
return this.refreshPromise;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const tokenData = this.getTokenData();
|
|
150
|
+
if (!tokenData?.refreshToken) {
|
|
151
|
+
throw new Error('No refresh token available');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.refreshPromise = this.performTokenRefresh(tokenData.refreshToken);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await this.refreshPromise;
|
|
158
|
+
} finally {
|
|
159
|
+
this.refreshPromise = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async performTokenRefresh(refreshToken: string): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
const response = await apiClient.post<RefreshResponse>(
|
|
166
|
+
`${this.config.apiUrl}${this.config.endpoints.refresh}`,
|
|
167
|
+
{ refreshToken }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const { token, refreshToken: newRefreshToken, expiresIn } = response;
|
|
171
|
+
|
|
172
|
+
const expiresAt = expiresIn
|
|
173
|
+
? Date.now() + (expiresIn * 1000)
|
|
174
|
+
: undefined;
|
|
175
|
+
|
|
176
|
+
// Update stored tokens
|
|
177
|
+
this.setTokenData({
|
|
178
|
+
token,
|
|
179
|
+
refreshToken: newRefreshToken || refreshToken,
|
|
180
|
+
expiresAt
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Schedule next refresh
|
|
184
|
+
if (this.config.autoRefresh && expiresAt) {
|
|
185
|
+
this.scheduleTokenRefresh(expiresAt);
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
// Refresh failed, clear auth
|
|
189
|
+
this.clearAuth();
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private scheduleTokenRefresh(expiresAt: number): void {
|
|
195
|
+
const bufferMs = (this.config.tokenRefreshBuffer || 5) * 60 * 1000;
|
|
196
|
+
const refreshAt = expiresAt - bufferMs;
|
|
197
|
+
const delay = Math.max(0, refreshAt - Date.now());
|
|
198
|
+
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
if (this.getTokenData()) {
|
|
201
|
+
this.refreshToken().catch(() => {
|
|
202
|
+
// Silent fail - user will need to re-login
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}, delay);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
isTokenExpired(): boolean {
|
|
209
|
+
const tokenData = this.getTokenData();
|
|
210
|
+
if (!tokenData?.expiresAt) return false;
|
|
211
|
+
|
|
212
|
+
return Date.now() >= tokenData.expiresAt;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
shouldRefreshToken(): boolean {
|
|
216
|
+
const tokenData = this.getTokenData();
|
|
217
|
+
if (!tokenData?.expiresAt || !tokenData.refreshToken) return false;
|
|
218
|
+
|
|
219
|
+
const bufferMs = (this.config.tokenRefreshBuffer || 5) * 60 * 1000;
|
|
220
|
+
return Date.now() >= (tokenData.expiresAt - bufferMs);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async getCurrentUser(): Promise<T | null> {
|
|
224
|
+
const tokenData = this.getTokenData();
|
|
225
|
+
if (!tokenData?.token) return null;
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
return await apiClient.get<T>(`${this.config.apiUrl}${this.config.endpoints.me}`);
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async logout(): Promise<void> {
|
|
235
|
+
const tokenData = this.getTokenData();
|
|
236
|
+
|
|
237
|
+
// Call logout endpoint if available
|
|
238
|
+
if (this.config.endpoints.logout && tokenData?.token) {
|
|
239
|
+
try {
|
|
240
|
+
await apiClient.post(`${this.config.apiUrl}${this.config.endpoints.logout}`);
|
|
241
|
+
} catch {
|
|
242
|
+
// Ignore logout endpoint errors
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
this.clearAuth();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { apiClient } from './api/client';
|
|
2
|
+
import type { components } from '../types/generated';
|
|
3
|
+
|
|
4
|
+
type HealthCheckResponse = components['schemas']['HealthResponse'];
|
|
5
|
+
|
|
6
|
+
export const healthApi = {
|
|
7
|
+
getHealth: async (): Promise<HealthCheckResponse> => {
|
|
8
|
+
try {
|
|
9
|
+
return await apiClient.get<HealthCheckResponse>('/api/v1/health');
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.warn('Health API call failed:', error);
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const APP_NAME = 'Design System Showcase';
|
|
2
|
+
|
|
3
|
+
export const API_ENDPOINTS = {
|
|
4
|
+
HEALTH: '/health',
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export const ROUTES = {
|
|
8
|
+
HOME: '/',
|
|
9
|
+
COMPONENTS: '/components',
|
|
10
|
+
STYLE_GUIDE: '/style-guide',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export const UI_CONFIG = {
|
|
14
|
+
DEFAULT_PAGE_SIZE: 20,
|
|
15
|
+
DEBOUNCE_DELAY: 300,
|
|
16
|
+
TOAST_DURATION: 5000,
|
|
17
|
+
} as const;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Dashboard component sizing constants
|
|
2
|
+
// These provide standardized heights for consistent layouts
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dashboard chart heights in pixels
|
|
6
|
+
* Use these constants instead of hardcoded values for consistent sizing
|
|
7
|
+
*/
|
|
8
|
+
export const DASHBOARD_CHART_HEIGHTS = {
|
|
9
|
+
// Compact sizes for dense layouts
|
|
10
|
+
compact: 180, // For small charts in grids
|
|
11
|
+
small: 240, // Standard small chart size
|
|
12
|
+
|
|
13
|
+
// Medium sizes for primary content
|
|
14
|
+
medium: 320, // Most common dashboard chart size
|
|
15
|
+
mediumLarge: 400, // For detailed primary charts
|
|
16
|
+
|
|
17
|
+
// Large sizes for feature charts
|
|
18
|
+
large: 480, // Full-featured charts with legends
|
|
19
|
+
extraLarge: 600, // Hero charts and detailed analytics
|
|
20
|
+
|
|
21
|
+
// Special purpose sizes
|
|
22
|
+
sidebar: 200, // For sidebar/secondary charts
|
|
23
|
+
mobile: 160, // Mobile-optimized height
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Dashboard container heights (includes padding)
|
|
28
|
+
* These account for padding, headers, and spacing
|
|
29
|
+
*/
|
|
30
|
+
export const DASHBOARD_CONTAINER_HEIGHTS = {
|
|
31
|
+
// Chart containers (chart height + padding/headers)
|
|
32
|
+
compactChart: DASHBOARD_CHART_HEIGHTS.compact + 80, // 260px
|
|
33
|
+
smallChart: DASHBOARD_CHART_HEIGHTS.small + 80, // 320px
|
|
34
|
+
mediumChart: DASHBOARD_CHART_HEIGHTS.medium + 80, // 400px
|
|
35
|
+
mediumLargeChart: DASHBOARD_CHART_HEIGHTS.mediumLarge + 80, // 480px
|
|
36
|
+
largeChart: DASHBOARD_CHART_HEIGHTS.large + 80, // 560px
|
|
37
|
+
extraLargeChart: DASHBOARD_CHART_HEIGHTS.extraLarge + 80, // 680px
|
|
38
|
+
|
|
39
|
+
// Sidebar and panel heights
|
|
40
|
+
sidebarPanel: 280, // For alert panels, activity feeds
|
|
41
|
+
sidebarChart: DASHBOARD_CHART_HEIGHTS.sidebar + 60, // 260px
|
|
42
|
+
|
|
43
|
+
// Grid item heights
|
|
44
|
+
statCard: 120, // For metric/stat cards
|
|
45
|
+
miniChart: 180, // For sparkline charts
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* CSS height classes mapping to our size constants
|
|
50
|
+
* Use these in className props for consistent styling
|
|
51
|
+
*/
|
|
52
|
+
export const DASHBOARD_HEIGHT_CLASSES = {
|
|
53
|
+
// Chart heights
|
|
54
|
+
'chart-compact': `h-[${DASHBOARD_CHART_HEIGHTS.compact}px]`,
|
|
55
|
+
'chart-small': `h-[${DASHBOARD_CHART_HEIGHTS.small}px]`,
|
|
56
|
+
'chart-medium': `h-[${DASHBOARD_CHART_HEIGHTS.medium}px]`,
|
|
57
|
+
'chart-medium-large': `h-[${DASHBOARD_CHART_HEIGHTS.mediumLarge}px]`,
|
|
58
|
+
'chart-large': `h-[${DASHBOARD_CHART_HEIGHTS.large}px]`,
|
|
59
|
+
'chart-extra-large': `h-[${DASHBOARD_CHART_HEIGHTS.extraLarge}px]`,
|
|
60
|
+
'chart-sidebar': `h-[${DASHBOARD_CHART_HEIGHTS.sidebar}px]`,
|
|
61
|
+
|
|
62
|
+
// Container heights
|
|
63
|
+
'container-compact-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.compactChart}px]`,
|
|
64
|
+
'container-small-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.smallChart}px]`,
|
|
65
|
+
'container-medium-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.mediumChart}px]`,
|
|
66
|
+
'container-medium-large-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.mediumLargeChart}px]`,
|
|
67
|
+
'container-large-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.largeChart}px]`,
|
|
68
|
+
'container-extra-large-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.extraLargeChart}px]`,
|
|
69
|
+
|
|
70
|
+
// Panel heights
|
|
71
|
+
'sidebar-panel': `h-[${DASHBOARD_CONTAINER_HEIGHTS.sidebarPanel}px]`,
|
|
72
|
+
'sidebar-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.sidebarChart}px]`,
|
|
73
|
+
'stat-card': `h-[${DASHBOARD_CONTAINER_HEIGHTS.statCard}px]`,
|
|
74
|
+
'mini-chart': `h-[${DASHBOARD_CONTAINER_HEIGHTS.miniChart}px]`,
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Helper function to get chart height for Chart component
|
|
79
|
+
*/
|
|
80
|
+
export const getChartHeight = (size: keyof typeof DASHBOARD_CHART_HEIGHTS): number => {
|
|
81
|
+
return DASHBOARD_CHART_HEIGHTS[size];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Helper function to get container height class
|
|
86
|
+
*/
|
|
87
|
+
export const getContainerHeightClass = (size: keyof typeof DASHBOARD_HEIGHT_CLASSES): string => {
|
|
88
|
+
return DASHBOARD_HEIGHT_CLASSES[size];
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Responsive height configurations for different screen sizes
|
|
93
|
+
*/
|
|
94
|
+
export const RESPONSIVE_CHART_HEIGHTS = {
|
|
95
|
+
mobile: {
|
|
96
|
+
primary: DASHBOARD_CHART_HEIGHTS.mobile,
|
|
97
|
+
secondary: DASHBOARD_CHART_HEIGHTS.compact,
|
|
98
|
+
},
|
|
99
|
+
tablet: {
|
|
100
|
+
primary: DASHBOARD_CHART_HEIGHTS.small,
|
|
101
|
+
secondary: DASHBOARD_CHART_HEIGHTS.sidebar,
|
|
102
|
+
},
|
|
103
|
+
desktop: {
|
|
104
|
+
primary: DASHBOARD_CHART_HEIGHTS.medium,
|
|
105
|
+
secondary: DASHBOARD_CHART_HEIGHTS.small,
|
|
106
|
+
},
|
|
107
|
+
large: {
|
|
108
|
+
primary: DASHBOARD_CHART_HEIGHTS.large,
|
|
109
|
+
secondary: DASHBOARD_CHART_HEIGHTS.medium,
|
|
110
|
+
},
|
|
111
|
+
} as const;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const env = {
|
|
2
|
+
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
|
|
3
|
+
OPENAPI_URL: import.meta.env.OPENAPI_URL || 'http://localhost:8080/openapi.json',
|
|
4
|
+
NODE_ENV: import.meta.env.NODE_ENV || 'development',
|
|
5
|
+
IS_DEVELOPMENT: import.meta.env.NODE_ENV === 'development',
|
|
6
|
+
IS_PRODUCTION: import.meta.env.NODE_ENV === 'production',
|
|
7
|
+
// Feature flags for development
|
|
8
|
+
USE_MOCK_DATA: import.meta.env.VITE_USE_MOCK_DATA === 'true' || false,
|
|
9
|
+
MOCK_API_DELAY: parseInt(import.meta.env.VITE_MOCK_API_DELAY || '500'),
|
|
10
|
+
} as const;
|