react-setup
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact SPA Setup
React单页应用搭建
Ask the user for the project name if not provided. Use it as throughout.
{project}如果用户没有提供项目名称,先询问用户获取。后续所有步骤都使用作为占位符指代项目名。
{project}Steps
步骤
1. Scaffold and clean up
1. 脚手架生成与清理
bash
pnpm create vite {project} --template react-ts
cd {project}
pnpm install
rm src/App.tsx src/App.css src/index.css
rm -rf src/assetsbash
pnpm create vite {project} --template react-ts
cd {project}
pnpm install
rm src/App.tsx src/App.css src/index.css
rm -rf src/assets2. Create directory structure
2. 创建目录结构
bash
mkdir -p src/pages src/lib src/hooks src/store/auth src/components/auth src/components/ui src/components/layoutbash
mkdir -p src/pages src/lib src/hooks src/store/auth src/components/auth src/components/ui src/components/layout3. Install dependencies
3. 安装依赖
bash
pnpm add @reduxjs/toolkit react-redux react-router sonner clsx tailwind-merge tailwindcss @tailwindcss/vite class-variance-authority radix-ui
pnpm add -D @types/node tw-animate-css shadcnbash
pnpm add @reduxjs/toolkit react-redux react-router sonner clsx tailwind-merge tailwindcss @tailwindcss/vite class-variance-authority radix-ui
pnpm add -D @types/node tw-animate-css shadcn4. Add shadcn components
4. 添加shadcn组件
Run from inside the project directory:
bash
cd {project} && pnpm dlx shadcn@latest add button field input card sidebar breadcrumb collapsible dropdown-menu avatar在项目目录内执行以下命令:
bash
cd {project} && pnpm dlx shadcn@latest add button field input card sidebar breadcrumb collapsible dropdown-menu avatar5. Write all files below
5. 编写以下所有文件
index.html
index.htmlindex.html
index.htmlhtml
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{project}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{project}</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>vite.config.ts
vite.config.tsvite.config.ts
vite.config.tsts
import path from 'path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})ts
import path from 'path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})tsconfig.app.json
— add paths
to compilerOptions
tsconfig.app.jsonpathstsconfig.app.json
— 向compilerOptions中添加paths
配置
tsconfig.app.jsonpathsjson
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}Merge these into the existing compilerOptions — do not overwrite the whole file.
tsconfig.app.jsonjson
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}将以上内容合并到现有的compilerOptions配置中——不要覆盖整个文件。
tsconfig.app.jsoncomponents.json
components.jsoncomponents.json
components.jsonjson
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}src/main.css
src/main.csssrc/main.css
src/main.csscss
@import "tailwindcss";
@import "tw-animate-css";
@source "../**/*.{ts,tsx}";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.269 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}css
@import "tailwindcss";
@import "tw-animate-css";
@source "../**/*.{ts,tsx}";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.269 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}src/main.tsx
src/main.tsxsrc/main.tsx
src/main.tsxtsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { Provider } from 'react-redux'
import App from './app'
import { store } from './store'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { Provider } from 'react-redux'
import App from './app'
import { store } from './store'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</StrictMode>,
)src/app.tsx
src/app.tsxsrc/app.tsx
src/app.tsxtsx
import { useEffect } from 'react'
import './main.css'
import AppRoutes from '@/routes'
import { useAppDispatch } from '@/store/hooks'
import { restoreFromStorage } from '@/store/auth'
export default function App() {
const dispatch = useAppDispatch()
useEffect(() => {
dispatch(restoreFromStorage())
}, [dispatch])
return <AppRoutes />
}tsx
import { useEffect } from 'react'
import './main.css'
import AppRoutes from '@/routes'
import { useAppDispatch } from '@/store/hooks'
import { restoreFromStorage } from '@/store/auth'
export default function App() {
const dispatch = useAppDispatch()
useEffect(() => {
dispatch(restoreFromStorage())
}, [dispatch])
return <AppRoutes />
}src/routes.tsx
src/routes.tsxsrc/routes.tsx
src/routes.tsxtsx
import { Route, Routes, Navigate } from 'react-router'
import Home from './pages/home'
import Login from './pages/login'
import { PrivateRoute, PublicRoute } from './components/auth'
export default function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<PrivateRoute><Home /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}tsx
import { Route, Routes, Navigate } from 'react-router'
import Home from './pages/home'
import Login from './pages/login'
import { PrivateRoute, PublicRoute } from './components/auth'
export default function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<PrivateRoute><Home /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}src/types.ts
src/types.tssrc/types.ts
src/types.tsts
export interface User {
id: string
email: string
name?: string
createdAt?: string
}
export interface LoginData {
email: string
password: string
}
export interface SignupData {
email: string
password: string
name?: string
}ts
export interface User {
id: string
email: string
name?: string
createdAt?: string
}
export interface LoginData {
email: string
password: string
}
export interface SignupData {
email: string
password: string
name?: string
}src/config.ts
src/config.tssrc/config.ts
src/config.tsts
export default {
appName: (import.meta.env.VITE_APP_NAME as string) || '{project}',
apiUrl: (import.meta.env.VITE_API_URL as string) || '/api',
tokenKey: (import.meta.env.VITE_TOKEN_KEY as string) || 'app-token',
userKey: (import.meta.env.VITE_USER_KEY as string) || 'app-user',
isProd: import.meta.env.PROD,
}ts
export default {
appName: (import.meta.env.VITE_APP_NAME as string) || '{project}',
apiUrl: (import.meta.env.VITE_API_URL as string) || '/api',
tokenKey: (import.meta.env.VITE_TOKEN_KEY as string) || 'app-token',
userKey: (import.meta.env.VITE_USER_KEY as string) || 'app-user',
isProd: import.meta.env.PROD,
}src/lib/utils.ts
src/lib/utils.tssrc/lib/utils.ts
src/lib/utils.tsts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}src/lib/token.ts
src/lib/token.tssrc/lib/token.ts
src/lib/token.tsts
import config from '@/config'
const TOKEN_KEY = config.tokenKey
const USER_KEY = config.userKey
export const getToken = () => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
export const removeToken = () => localStorage.removeItem(TOKEN_KEY)
export const getUserData = (): unknown => {
try { return JSON.parse(localStorage.getItem(USER_KEY) || '') } catch { return null }
}
export const setUserData = (user: unknown) => localStorage.setItem(USER_KEY, JSON.stringify(user))
export const removeUserData = () => localStorage.removeItem(USER_KEY)
export const clearAuthData = () => { removeToken(); removeUserData() }
export const isTokenExpired = (token = getToken()): boolean => {
if (!token) return true
try {
const { exp } = JSON.parse(atob(token.split('.')[1])) as { exp?: number }
return exp ? exp < Math.floor(Date.now() / 1000) : false
} catch { return true }
}ts
import config from '@/config'
const TOKEN_KEY = config.tokenKey
const USER_KEY = config.userKey
export const getToken = () => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
export const removeToken = () => localStorage.removeItem(TOKEN_KEY)
export const getUserData = (): unknown => {
try { return JSON.parse(localStorage.getItem(USER_KEY) || '') } catch { return null }
}
export const setUserData = (user: unknown) => localStorage.setItem(USER_KEY, JSON.stringify(user))
export const removeUserData = () => localStorage.removeItem(USER_KEY)
export const clearAuthData = () => { removeToken(); removeUserData() }
export const isTokenExpired = (token = getToken()): boolean => {
if (!token) return true
try {
const { exp } = JSON.parse(atob(token.split('.')[1])) as { exp?: number }
return exp ? exp < Math.floor(Date.now() / 1000) : false
} catch { return true }
}src/lib/api.ts
src/lib/api.tssrc/lib/api.ts
src/lib/api.tsts
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { clearStoredAuth, clearCurrentUser } from '@/store/auth/slice'
import { getToken } from '@/lib/token'
export const createBaseQueryWithReauth = (
baseUrl: string,
): BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =>
async (args, api, extraOptions) => {
const result = await fetchBaseQuery({
baseUrl,
prepareHeaders: (headers) => {
const token = getToken()
if (token) headers.set('authorization', `Bearer ${token}`)
return headers
},
})(args, api, extraOptions)
if (result.error?.status === 401) {
clearStoredAuth()
api.dispatch(clearCurrentUser())
}
return result
}ts
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { BaseQueryFn, FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query'
import { clearStoredAuth, clearCurrentUser } from '@/store/auth/slice'
import { getToken } from '@/lib/token'
export const createBaseQueryWithReauth = (
baseUrl: string,
): BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> =>
async (args, api, extraOptions) => {
const result = await fetchBaseQuery({
baseUrl,
prepareHeaders: (headers) => {
const token = getToken()
if (token) headers.set('authorization', `Bearer ${token}`)
return headers
},
})(args, api, extraOptions)
if (result.error?.status === 401) {
clearStoredAuth()
api.dispatch(clearCurrentUser())
}
return result
}src/store/hooks.ts
src/store/hooks.tssrc/store/hooks.ts
src/store/hooks.tsts
import { useDispatch, useSelector } from 'react-redux'
import type { AppRootState, AppDispatch } from './index'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<AppRootState>()ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppRootState, AppDispatch } from './index'
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<AppRootState>()src/store/index.ts
src/store/index.tssrc/store/index.ts
src/store/index.tsts
import { configureStore } from '@reduxjs/toolkit'
import { authReducer, authApi } from './auth'
export const store = configureStore({
reducer: {
auth: authReducer,
[authApi.reducerPath]: authApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(authApi.middleware),
})
export type AppRootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatchts
import { configureStore } from '@reduxjs/toolkit'
import { authReducer, authApi } from './auth'
export const store = configureStore({
reducer: {
auth: authReducer,
[authApi.reducerPath]: authApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(authApi.middleware),
})
export type AppRootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatchsrc/store/auth/slice.ts
src/store/auth/slice.tssrc/store/auth/slice.ts
src/store/auth/slice.tsts
import { createSlice } from '@reduxjs/toolkit'
import type { User } from '@/types'
import { getToken, getUserData, clearAuthData, isTokenExpired } from '@/lib/token'
export const clearStoredAuth = clearAuthData
interface AuthState {
current: User | null
isAuthenticated: boolean
isInitialized: boolean
}
export const authSlice = createSlice({
name: 'auth',
initialState: { current: null, isAuthenticated: false, isInitialized: false } as AuthState,
reducers: {
setCurrentUser: (state, action) => {
state.current = action.payload
state.isAuthenticated = true
state.isInitialized = true
},
clearCurrentUser: (state) => {
state.current = null
state.isAuthenticated = false
state.isInitialized = true
},
restoreFromStorage: (state) => {
const token = getToken()
const user = getUserData()
if (token && user && !isTokenExpired(token)) {
state.current = user as User
state.isAuthenticated = true
} else {
clearAuthData()
}
state.isInitialized = true
},
},
})
export const { setCurrentUser, clearCurrentUser, restoreFromStorage } = authSlice.actions
export const selectCurrentUser = (state: { auth: AuthState }) => state.auth.current
export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated
export const selectIsInitialized = (state: { auth: AuthState }) => state.auth.isInitialized
export default authSlice.reducerts
import { createSlice } from '@reduxjs/toolkit'
import type { User } from '@/types'
import { getToken, getUserData, clearAuthData, isTokenExpired } from '@/lib/token'
export const clearStoredAuth = clearAuthData
interface AuthState {
current: User | null
isAuthenticated: boolean
isInitialized: boolean
}
export const authSlice = createSlice({
name: 'auth',
initialState: { current: null, isAuthenticated: false, isInitialized: false } as AuthState,
reducers: {
setCurrentUser: (state, action) => {
state.current = action.payload
state.isAuthenticated = true
state.isInitialized = true
},
clearCurrentUser: (state) => {
state.current = null
state.isAuthenticated = false
state.isInitialized = true
},
restoreFromStorage: (state) => {
const token = getToken()
const user = getUserData()
if (token && user && !isTokenExpired(token)) {
state.current = user as User
state.isAuthenticated = true
} else {
clearAuthData()
}
state.isInitialized = true
},
},
})
export const { setCurrentUser, clearCurrentUser, restoreFromStorage } = authSlice.actions
export const selectCurrentUser = (state: { auth: AuthState }) => state.auth.current
export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated
export const selectIsInitialized = (state: { auth: AuthState }) => state.auth.isInitialized
export default authSlice.reducersrc/store/auth/api.ts
src/store/auth/api.tssrc/store/auth/api.ts
src/store/auth/api.tsts
import { createApi } from '@reduxjs/toolkit/query/react'
import type { User, LoginData, SignupData } from '@/types'
import { setCurrentUser, clearCurrentUser } from './slice'
import { createBaseQueryWithReauth } from '@/lib/api'
import { setToken, setUserData, clearAuthData } from '@/lib/token'
import config from '@/config'
interface AuthResponse { access_token: string; user: User }
export const authApi = createApi({
reducerPath: 'authApi',
baseQuery: createBaseQueryWithReauth(config.apiUrl),
endpoints: (builder) => ({
login: builder.mutation<AuthResponse, LoginData>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled
setToken(data.access_token)
setUserData(data.user)
dispatch(setCurrentUser(data.user))
} catch {}
},
}),
signup: builder.mutation<AuthResponse, SignupData>({
query: (body) => ({ url: '/auth/signup', method: 'POST', body }),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled
setToken(data.access_token)
setUserData(data.user)
dispatch(setCurrentUser(data.user))
} catch {}
},
}),
logout: builder.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
clearAuthData()
dispatch(clearCurrentUser())
try { await queryFulfilled } catch {}
},
}),
}),
})
export const { useLoginMutation, useSignupMutation, useLogoutMutation } = authApits
import { createApi } from '@reduxjs/toolkit/query/react'
import type { User, LoginData, SignupData } from '@/types'
import { setCurrentUser, clearCurrentUser } from './slice'
import { createBaseQueryWithReauth } from '@/lib/api'
import { setToken, setUserData, clearAuthData } from '@/lib/token'
import config from '@/config'
interface AuthResponse { access_token: string; user: User }
export const authApi = createApi({
reducerPath: 'authApi',
baseQuery: createBaseQueryWithReauth(config.apiUrl),
endpoints: (builder) => ({
login: builder.mutation<AuthResponse, LoginData>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled
setToken(data.access_token)
setUserData(data.user)
dispatch(setCurrentUser(data.user))
} catch {}
},
}),
signup: builder.mutation<AuthResponse, SignupData>({
query: (body) => ({ url: '/auth/signup', method: 'POST', body }),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled
setToken(data.access_token)
setUserData(data.user)
dispatch(setCurrentUser(data.user))
} catch {}
},
}),
logout: builder.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
async onQueryStarted(_, { dispatch, queryFulfilled }) {
clearAuthData()
dispatch(clearCurrentUser())
try { await queryFulfilled } catch {}
},
}),
}),
})
export const { useLoginMutation, useSignupMutation, useLogoutMutation } = authApisrc/store/auth/index.ts
src/store/auth/index.tssrc/store/auth/index.ts
src/store/auth/index.tsts
export { default as authReducer, setCurrentUser, clearCurrentUser, restoreFromStorage, selectCurrentUser, selectIsAuthenticated, selectIsInitialized } from './slice'
export { authApi, useLoginMutation, useSignupMutation, useLogoutMutation } from './api'ts
export { default as authReducer, setCurrentUser, clearCurrentUser, restoreFromStorage, selectCurrentUser, selectIsAuthenticated, selectIsInitialized } from './slice'
export { authApi, useLoginMutation, useSignupMutation, useLogoutMutation } from './api'src/components/auth/private-route.tsx
src/components/auth/private-route.tsxsrc/components/auth/private-route.tsx
src/components/auth/private-route.tsxtsx
import { Navigate, useLocation } from 'react-router'
import { useAppSelector } from '@/store/hooks'
import { selectIsAuthenticated, selectIsInitialized } from '@/store/auth'
export default function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAppSelector(selectIsAuthenticated)
const isInitialized = useAppSelector(selectIsInitialized)
const location = useLocation()
if (!isInitialized) {
return <div className="flex min-h-screen items-center justify-center">Loading...</div>
}
return isAuthenticated
? <>{children}</>
: <Navigate to="/login" state={{ from: location }} replace />
}tsx
import { Navigate, useLocation } from 'react-router'
import { useAppSelector } from '@/store/hooks'
import { selectIsAuthenticated, selectIsInitialized } from '@/store/auth'
export default function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAppSelector(selectIsAuthenticated)
const isInitialized = useAppSelector(selectIsInitialized)
const location = useLocation()
if (!isInitialized) {
return <div className="flex min-h-screen items-center justify-center">Loading...</div>
}
return isAuthenticated
? <>{children}</>
: <Navigate to="/login" state={{ from: location }} replace />
}src/components/auth/public-route.tsx
src/components/auth/public-route.tsxsrc/components/auth/public-route.tsx
src/components/auth/public-route.tsxtsx
import { Navigate, useLocation } from 'react-router'
import { useAppSelector } from '@/store/hooks'
import { selectIsAuthenticated } from '@/store/auth'
export default function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAppSelector(selectIsAuthenticated)
const location = useLocation()
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
return isAuthenticated ? <Navigate to={from} replace /> : <>{children}</>
}tsx
import { Navigate, useLocation } from 'react-router'
import { useAppSelector } from '@/store/hooks'
import { selectIsAuthenticated } from '@/store/auth'
export default function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAppSelector(selectIsAuthenticated)
const location = useLocation()
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/'
return isAuthenticated ? <Navigate to={from} replace /> : <>{children}</>
}src/components/auth/login-form.tsx
src/components/auth/login-form.tsxsrc/components/auth/login-form.tsx
src/components/auth/login-form.tsxtsx
import { useState } from "react"
import { cn } from "@/lib/utils"
import { useLoginMutation } from "@/store/auth"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [login, { isLoading, isError }] = useLoginMutation()
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>
Login with your Apple or Google account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={(e) => { e.preventDefault(); login({ email, password }) }}>
<FieldGroup>
<Field>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Login with Apple
</Button>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Login with Google
</Button>
</Field>
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
href="#"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
<Field>
{isError && <p className="text-sm text-destructive">Invalid credentials.</p>}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Signing in..." : "Login"}
</Button>
<FieldDescription className="text-center">
Don't have an account? <a href="#">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</FieldDescription>
</div>
)
}tsx
import { useState } from "react"
import { cn } from "@/lib/utils"
import { useLoginMutation } from "@/store/auth"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
FieldSeparator,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [login, { isLoading, isError }] = useLoginMutation()
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>
Login with your Apple or Google account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={(e) => { e.preventDefault(); login({ email, password }) }}>
<FieldGroup>
<Field>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Login with Apple
</Button>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Login with Google
</Button>
</Field>
<FieldSeparator className="*:data-[slot=field-separator-content]:bg-card">
Or continue with
</FieldSeparator>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
href="#"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
<Field>
{isError && <p className="text-sm text-destructive">Invalid credentials.</p>}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Signing in..." : "Login"}
</Button>
<FieldDescription className="text-center">
Don't have an account? <a href="#">Sign up</a>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "}
and <a href="#">Privacy Policy</a>.
</FieldDescription>
</div>
)
}src/components/auth/index.ts
src/components/auth/index.tssrc/components/auth/index.ts
src/components/auth/index.tsts
export { default as PrivateRoute } from './private-route'
export { default as PublicRoute } from './public-route'
export { LoginForm } from './login-form'ts
export { default as PrivateRoute } from './private-route'
export { default as PublicRoute } from './public-route'
export { LoginForm } from './login-form'src/hooks/use-mobile.ts
src/hooks/use-mobile.tssrc/hooks/use-mobile.ts
src/hooks/use-mobile.tsts
import { useEffect, useState } from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined)
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}ts
import { useEffect, useState } from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined)
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}src/components/layout/nav-main.tsx
src/components/layout/nav-main.tsxsrc/components/layout/nav-main.tsx
src/components/layout/nav-main.tsxtsx
import { ChevronRight, type LucideIcon } from 'lucide-react'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/sidebar'
interface NavItem {
title: string
url: string
icon: LucideIcon
isActive?: boolean
items?: { title: string; url: string }[]
}
export function NavMain({ items }: { items: NavItem[] }) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRight />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}><span>{subItem.title}</span></a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
}tsx
import { ChevronRight, type LucideIcon } from 'lucide-react'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@/components/ui/sidebar'
interface NavItem {
title: string
url: string
icon: LucideIcon
isActive?: boolean
items?: { title: string; url: string }[]
}
export function NavMain({ items }: { items: NavItem[] }) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRight />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<a href={subItem.url}><span>{subItem.title}</span></a>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
)
}src/components/layout/nav-user.tsx
src/components/layout/nav-user.tsxsrc/components/layout/nav-user.tsx
src/components/layout/nav-user.tsxtsx
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
import { useAppSelector } from '@/store/hooks'
import { selectCurrentUser, useLogoutMutation } from '@/store/auth'
export function NavUser() {
const { isMobile } = useSidebar()
const user = useAppSelector(selectCurrentUser)
const [logout] = useLogoutMutation()
const initials = user?.name
? user.name.split(' ').map((n) => n[0]).join('').toUpperCase()
: user?.email?.[0].toUpperCase() ?? '?'
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src="" alt={user?.name ?? ''} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name ?? user?.email}</span>
<span className="truncate text-xs">{user?.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src="" alt={user?.name ?? ''} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name ?? user?.email}</span>
<span className="truncate text-xs">{user?.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}tsx
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogOut, Sparkles } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
import { useAppSelector } from '@/store/hooks'
import { selectCurrentUser, useLogoutMutation } from '@/store/auth'
export function NavUser() {
const { isMobile } = useSidebar()
const user = useAppSelector(selectCurrentUser)
const [logout] = useLogoutMutation()
const initials = user?.name
? user.name.split(' ').map((n) => n[0]).join('').toUpperCase()
: user?.email?.[0].toUpperCase() ?? '?'
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src="" alt={user?.name ?? ''} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name ?? user?.email}</span>
<span className="truncate text-xs">{user?.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src="" alt={user?.name ?? ''} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name ?? user?.email}</span>
<span className="truncate text-xs">{user?.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}src/components/layout/app-sidebar.tsx
src/components/layout/app-sidebar.tsxsrc/components/layout/app-sidebar.tsx
src/components/layout/app-sidebar.tsxtsx
import { BookOpen, Bot, Command, Settings2, SquareTerminal } from 'lucide-react'
import { NavMain } from './nav-main'
import { NavUser } from './nav-user'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
import config from '@/config'
const navItems = [
{
title: 'Playground',
url: '#',
icon: SquareTerminal,
isActive: true,
items: [
{ title: 'History', url: '#' },
{ title: 'Starred', url: '#' },
{ title: 'Settings', url: '#' },
],
},
{
title: 'Models',
url: '#',
icon: Bot,
items: [
{ title: 'Genesis', url: '#' },
{ title: 'Explorer', url: '#' },
{ title: 'Quantum', url: '#' },
],
},
{
title: 'Documentation',
url: '#',
icon: BookOpen,
items: [
{ title: 'Introduction', url: '#' },
{ title: 'Get Started', url: '#' },
{ title: 'Tutorials', url: '#' },
{ title: 'Changelog', url: '#' },
],
},
{
title: 'Settings',
url: '#',
icon: Settings2,
items: [
{ title: 'General', url: '#' },
{ title: 'Team', url: '#' },
{ title: 'Billing', url: '#' },
{ title: 'Limits', url: '#' },
],
},
]
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{config.appName}</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}tsx
import { BookOpen, Bot, Command, Settings2, SquareTerminal } from 'lucide-react'
import { NavMain } from './nav-main'
import { NavUser } from './nav-user'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
import config from '@/config'
const navItems = [
{
title: 'Playground',
url: '#',
icon: SquareTerminal,
isActive: true,
items: [
{ title: 'History', url: '#' },
{ title: 'Starred', url: '#' },
{ title: 'Settings', url: '#' },
],
},
{
title: 'Models',
url: '#',
icon: Bot,
items: [
{ title: 'Genesis', url: '#' },
{ title: 'Explorer', url: '#' },
{ title: 'Quantum', url: '#' },
],
},
{
title: 'Documentation',
url: '#',
icon: BookOpen,
items: [
{ title: 'Introduction', url: '#' },
{ title: 'Get Started', url: '#' },
{ title: 'Tutorials', url: '#' },
{ title: 'Changelog', url: '#' },
],
},
{
title: 'Settings',
url: '#',
icon: Settings2,
items: [
{ title: 'General', url: '#' },
{ title: 'Team', url: '#' },
{ title: 'Billing', url: '#' },
{ title: 'Limits', url: '#' },
],
},
]
export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="#">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<Command className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{config.appName}</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
)
}src/components/layout/index.ts
src/components/layout/index.tssrc/components/layout/index.ts
src/components/layout/index.tsts
export { AppSidebar } from './app-sidebar'
export { NavMain } from './nav-main'
export { NavUser } from './nav-user'ts
export { AppSidebar } from './app-sidebar'
export { NavMain } from './nav-main'
export { NavUser } from './nav-user'src/pages/home.tsx
src/pages/home.tsxsrc/pages/home.tsx
src/pages/home.tsxtsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { AppSidebar } from '@/components/layout'
export default function Home() {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Dashboard</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</SidebarInset>
</SidebarProvider>
)
}tsx
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Separator } from '@/components/ui/separator'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
import { AppSidebar } from '@/components/layout'
export default function Home() {
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">Dashboard</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Overview</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</SidebarInset>
</SidebarProvider>
)
}src/pages/login.tsx
src/pages/login.tsxsrc/pages/login.tsx
src/pages/login.tsxtsx
import { LoginForm } from '@/components/auth'
export default function Login() {
return (
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
<LoginForm className="w-full max-w-sm" />
</div>
)
}tsx
import { LoginForm } from '@/components/auth'
export default function Login() {
return (
<div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
<LoginForm className="w-full max-w-sm" />
</div>
)
}.env.example
.env.example.env.example
.env.exampleVITE_APP_NAME={project}
VITE_API_URL=http://localhost:8000
VITE_TOKEN_KEY=app-token
VITE_USER_KEY=app-userVITE_APP_NAME={project}
VITE_API_URL=http://localhost:8000
VITE_TOKEN_KEY=app-token
VITE_USER_KEY=app-userCLAUDE.md
CLAUDE.mdCLAUDE.md
CLAUDE.mdmarkdown
undefinedmarkdown
undefinedCLAUDE.md
CLAUDE.md
Stack
技术栈
React 19 + Vite + TypeScript + Tailwind CSS v4 + shadcn/ui + Redux Toolkit + React Router
React 19 + Vite + TypeScript + Tailwind CSS v4 + shadcn/ui + Redux Toolkit + React Router
Structure
目录结构
- — one file per route
src/pages/ - —
src/store/{domain}/+slice.ts+api.tsper domainindex.ts - — shared utilities (api, token, utils)
src/lib/ - — PrivateRoute / PublicRoute guards
src/components/auth/ - — shadcn components (do not edit manually)
src/components/ui/
- — 每个路由对应一个文件
src/pages/ - — 每个业务域对应
src/store/{domain}/+slice.ts+api.ts三个文件index.ts - — 公共工具函数(api、token、通用工具)
src/lib/ - — 私有路由/公共路由守卫
src/components/auth/ - — shadcn组件(请勿手动编辑)
src/components/ui/
Component placement rules
组件放置规则
- Feature-specific component → (e.g.
src/components/{feature}/,logs/,alerts/)sources/ - Cross-cutting utility component →
src/components/common/ - Missing shadcn primitive → (installs into
cd {project} && pnpm dlx shadcn@latest add <component>)ui/ - All filenames use kebab-case — no exceptions: ,
my-component.tsx,private-route.tsxlogin-form.tsx - Each feature folder has an barrel — always export from it
index.ts
- 业务专属组件 → (例如
src/components/{feature}/、logs/、alerts/)sources/ - 通用工具组件 →
src/components/common/ - 缺少shadcn基础组件 → (会自动安装到
cd {project} && pnpm dlx shadcn@latest add <组件名>目录)ui/ - 所有文件名使用短横线命名法 — 无例外:、
my-component.tsx、private-route.tsxlogin-form.tsx - 每个功能文件夹都要有导出文件 — 始终从该文件导出内容
index.ts
Rules
开发规范
- Use /
useAppDispatch— never plain hooksuseAppSelector - Server data → RTK Query (), client state → Redux slice (
api.ts)slice.ts - All protected routes use , public routes use
<PrivateRoute><PublicRoute> - Path alias →
@src/ - Forms: always use ,
FieldGroup,FieldLabelfromFieldDescription— never use@/components/ui/fielddirectly in formsLabel
- 使用/
useAppDispatch— 禁止使用原生的Redux hooksuseAppSelector - 服务端数据 → RTK Query(),客户端状态 → Redux slice(
api.ts)slice.ts - 所有受保护路由使用,公开路由使用
<PrivateRoute><PublicRoute> - 路径别名→ 指向
@目录src/ - 表单: 始终使用提供的
@/components/ui/field、FieldGroup、FieldLabel— 禁止在表单中直接使用FieldDescription组件Label
Implementation Checklist
实现检查清单
- All imports use path aliases
@/ - Use utility for className merging (from
cn())@/lib/utils.ts - CRITICAL: Use Field components for forms, NOT Label
- Proper TypeScript types for all props
- Use CSS variables: ,
hsl(var(--primary)), etc.hsl(var(--background)) - Follow Tailwind spacing conventions (,
p-4, etc.)gap-2 - Implement loading states for async operations
- Handle error states gracefully
- Make responsive using Tailwind breakpoints (,
sm:,md:)lg: - Extract complex logic to custom hooks
- Add utility functions to , not inline
src/lib/utils.ts
- 所有导入都使用路径别名
@/ - 使用提供的
@/lib/utils.ts工具合并classNamecn() - 关键: 表单使用Field系列组件,不要使用Label
- 所有props都有正确的TypeScript类型定义
- 使用CSS变量:、
hsl(var(--primary))等hsl(var(--background)) - 遵循Tailwind间距规范(、
p-4等)gap-2 - 异步操作实现加载状态
- 优雅处理错误状态
- 使用Tailwind断点(、
sm:、md:)实现响应式lg: - 复杂逻辑提取到自定义hooks中
- 工具函数添加到,不要写在内联代码中
src/lib/utils.ts
Adding a domain
新增业务域步骤
- Types →
src/types.ts - →
src/store/{domain}/slice.ts→api.tsindex.ts - Register in
src/store/index.ts - Page →
src/pages/{domain}.tsx - Route →
src/routes.tsx
---- 类型定义 →
src/types.ts - 新建→
src/store/{domain}/slice.ts→api.tsindex.ts - 在中注册
src/store/index.ts - 页面文件 →
src/pages/{domain}.tsx - 路由配置 →
src/routes.tsx
---After Scaffolding — Tell the User
搭建完成后告知用户
bash
cp .env.example .env
pnpm devbash
cp .env.example .env
pnpm dev
Add more shadcn components as needed (run from inside the project directory):
```bash
cd {project} && pnpm dlx shadcn@latest add <component>Auth expects from your backend:
- →
POST /auth/login{ access_token, user } - →
POST /auth/signup{ access_token, user } POST /auth/logout
如需添加更多shadcn组件,在项目目录内执行以下命令:
```bash
cd {project} && pnpm dlx shadcn@latest add <组件名>身份验证模块要求后端提供以下接口:
- → 返回
POST /auth/login{ access_token, user } - → 返回
POST /auth/signup{ access_token, user } POST /auth/logout