first commit
This commit is contained in:
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
37
src/App.tsx
Normal file
37
src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto p-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">
|
||||
SAM React Frontend
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
React 프로젝트가 성공적으로 설정되었습니다.
|
||||
</p>
|
||||
<div className="mt-8 p-6 bg-card rounded-lg border">
|
||||
<h2 className="text-2xl font-semibold mb-2">설정 완료</h2>
|
||||
<ul className="list-disc list-inside space-y-1 text-card-foreground">
|
||||
<li>Vite + React + TypeScript</li>
|
||||
<li>Tailwind CSS with shadcn/ui theming</li>
|
||||
<li>React Query for server state</li>
|
||||
<li>Zustand for global state</li>
|
||||
<li>Axios with interceptors</li>
|
||||
<li>React Router for routing</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
52
src/index.css
Normal file
52
src/index.css
Normal file
@@ -0,0 +1,52 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0a0a0a;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #0a0a0a;
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #0a0a0a;
|
||||
--color-primary: #18181b;
|
||||
--color-primary-foreground: #fafafa;
|
||||
--color-secondary: #f4f4f5;
|
||||
--color-secondary-foreground: #18181b;
|
||||
--color-muted: #f4f4f5;
|
||||
--color-muted-foreground: #71717a;
|
||||
--color-accent: #f4f4f5;
|
||||
--color-accent-foreground: #18181b;
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-border: #e4e4e7;
|
||||
--color-input: #e4e4e7;
|
||||
--color-ring: #18181b;
|
||||
|
||||
/* Border radius */
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dark mode colors (optional) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@theme {
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-card: #0a0a0a;
|
||||
--color-card-foreground: #fafafa;
|
||||
--color-popover: #0a0a0a;
|
||||
--color-popover-foreground: #fafafa;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #18181b;
|
||||
--color-secondary: #27272a;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
--color-muted: #27272a;
|
||||
--color-muted-foreground: #a1a1aa;
|
||||
--color-accent: #27272a;
|
||||
--color-accent-foreground: #fafafa;
|
||||
--color-destructive: #7f1d1d;
|
||||
--color-destructive-foreground: #fafafa;
|
||||
--color-border: #27272a;
|
||||
--color-input: #27272a;
|
||||
--color-ring: #d4d4d8;
|
||||
}
|
||||
}
|
||||
73
src/lib/axios.ts
Normal file
73
src/lib/axios.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { API_BASE_URL, API_KEY } from './utils'
|
||||
|
||||
/**
|
||||
* Axios instance with default configuration
|
||||
*/
|
||||
export const axiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Request interceptor - Add API Key and Bearer token
|
||||
*/
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Add API Key header
|
||||
if (API_KEY) {
|
||||
config.headers['X-API-Key'] = API_KEY
|
||||
}
|
||||
|
||||
// Add Bearer token if available
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Response interceptor - Handle common errors
|
||||
*/
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
return response
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// Handle 401 Unauthorized - redirect to login
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
// Handle 403 Forbidden
|
||||
if (error.response?.status === 403) {
|
||||
console.error('Access forbidden:', error.response.data)
|
||||
}
|
||||
|
||||
// Handle 404 Not Found
|
||||
if (error.response?.status === 404) {
|
||||
console.error('Resource not found:', error.config?.url)
|
||||
}
|
||||
|
||||
// Handle 500 Internal Server Error
|
||||
if (error.response?.status === 500) {
|
||||
console.error('Server error:', error.response.data)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default axiosInstance
|
||||
18
src/lib/query-client.ts
Normal file
18
src/lib/query-client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
/**
|
||||
* React Query client configuration
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes (formerly cacheTime)
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
43
src/lib/utils.ts
Normal file
43
src/lib/utils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/**
|
||||
* Merge Tailwind CSS classes with conflict resolution
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date using date-fns
|
||||
*/
|
||||
export function formatDate(date: Date | string, _format: string = 'yyyy-MM-dd'): string {
|
||||
// TODO: Implement using date-fns when needed
|
||||
return new Date(date).toLocaleDateString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay execution (useful for testing loading states)
|
||||
*/
|
||||
export const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Get environment variable with type safety
|
||||
*/
|
||||
export function getEnv(key: string, defaultValue?: string): string {
|
||||
const value = import.meta.env[key]
|
||||
if (value === undefined && defaultValue === undefined) {
|
||||
throw new Error(`Environment variable ${key} is not defined`)
|
||||
}
|
||||
return value ?? defaultValue ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* API base URL
|
||||
*/
|
||||
export const API_BASE_URL = getEnv('VITE_API_BASE_URL', 'http://api.sam.kr')
|
||||
|
||||
/**
|
||||
* API Key
|
||||
*/
|
||||
export const API_KEY = getEnv('VITE_API_KEY', '')
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
85
src/services/api.ts
Normal file
85
src/services/api.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import axiosInstance from '@/lib/axios'
|
||||
import type { ApiResponse, LoginResponse } from '@/types/api'
|
||||
|
||||
/**
|
||||
* Auth API Service
|
||||
*/
|
||||
export const authApi = {
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
login: async (email: string, password: string): Promise<ApiResponse<LoginResponse>> => {
|
||||
const response = await axiosInstance.post<ApiResponse<LoginResponse>>('/v1/login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout current user
|
||||
*/
|
||||
logout: async (): Promise<ApiResponse<null>> => {
|
||||
const response = await axiosInstance.post<ApiResponse<null>>('/v1/logout')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
*/
|
||||
me: async (): Promise<ApiResponse<any>> => {
|
||||
const response = await axiosInstance.get<ApiResponse<any>>('/v1/users/me')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant API Service
|
||||
*/
|
||||
export const tenantApi = {
|
||||
/**
|
||||
* Switch to different tenant
|
||||
*/
|
||||
switch: async (tenantId: number): Promise<ApiResponse<any>> => {
|
||||
const response = await axiosInstance.post<ApiResponse<any>>(`/v1/tenants/${tenantId}/switch`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of tenants for current user
|
||||
*/
|
||||
list: async (): Promise<ApiResponse<any>> => {
|
||||
const response = await axiosInstance.get<ApiResponse<any>>('/v1/users/me/tenants')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API helper for CRUD operations
|
||||
*/
|
||||
export const createCrudApi = <T>(basePath: string) => ({
|
||||
list: async (params?: Record<string, any>): Promise<ApiResponse<T[]>> => {
|
||||
const response = await axiosInstance.get<ApiResponse<T[]>>(basePath, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
get: async (id: number | string): Promise<ApiResponse<T>> => {
|
||||
const response = await axiosInstance.get<ApiResponse<T>>(`${basePath}/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
create: async (data: Partial<T>): Promise<ApiResponse<T>> => {
|
||||
const response = await axiosInstance.post<ApiResponse<T>>(basePath, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
update: async (id: number | string, data: Partial<T>): Promise<ApiResponse<T>> => {
|
||||
const response = await axiosInstance.put<ApiResponse<T>>(`${basePath}/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
delete: async (id: number | string): Promise<ApiResponse<null>> => {
|
||||
const response = await axiosInstance.delete<ApiResponse<null>>(`${basePath}/${id}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
83
src/stores/auth.ts
Normal file
83
src/stores/auth.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { AuthUser, Tenant } from '@/types/api'
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
currentTenant: Tenant | null
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
setAuth: (user: AuthUser, token: string) => void
|
||||
setCurrentTenant: (tenant: Tenant) => void
|
||||
logout: () => void
|
||||
clearAuth: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication store using Zustand with persistence
|
||||
*/
|
||||
export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
currentTenant: null,
|
||||
|
||||
// Actions
|
||||
setAuth: (user, token) => {
|
||||
localStorage.setItem('auth_token', token)
|
||||
set({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
currentTenant: user.current_tenant,
|
||||
})
|
||||
},
|
||||
|
||||
setCurrentTenant: (tenant) => {
|
||||
set((state) => ({
|
||||
currentTenant: tenant,
|
||||
user: state.user
|
||||
? { ...state.user, current_tenant: tenant }
|
||||
: null,
|
||||
}))
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
currentTenant: null,
|
||||
})
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_user')
|
||||
set({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
currentTenant: null,
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
token: state.token,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
currentTenant: state.currentTenant,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
85
src/types/api.ts
Normal file
85
src/types/api.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Standard API response structure (from SAM API)
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
/**
|
||||
* API error response
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
success: false
|
||||
message: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated API response
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
meta: PaginationMeta
|
||||
links: {
|
||||
first: string
|
||||
last: string
|
||||
prev: string | null
|
||||
next: string | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User type
|
||||
*/
|
||||
export interface User {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant type
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth login response
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: User
|
||||
tenants: Tenant[]
|
||||
current_tenant: Tenant | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth user with tenant info
|
||||
*/
|
||||
export interface AuthUser extends User {
|
||||
current_tenant: Tenant | null
|
||||
tenants: Tenant[]
|
||||
}
|
||||
Reference in New Issue
Block a user