Loading...
Loading...
This skill should be used when the user asks to "create a new React project", "setup a react app", "new react frontend", "scaffold a react spa", "initialize a react frontend", or "start a new react app". Scaffolds a React 19 + Vite + TypeScript + Tailwind CSS v4 + shadcn/ui + Redux Toolkit SPA with auth and routing.
npx skill4agent add ghalex/skills react-setup{project}pnpm create vite {project} --template react-ts
cd {project}
pnpm install
rm src/App.tsx src/App.css src/index.css
rm -rf src/assetsmkdir -p src/pages src/lib src/hooks src/store/auth src/components/auth src/components/ui src/components/layoutpnpm 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 shadcncd {project} && pnpm dlx shadcn@latest add button field input card sidebar breadcrumb collapsible dropdown-menu avatarindex.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.tsimport 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.jsonpaths{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}tsconfig.app.jsoncomponents.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@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.tsximport { 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.tsximport { 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.tsximport { 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.tsexport 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.tsexport 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.tsimport { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}src/lib/token.tsimport 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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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.tsexport { default as authReducer, setCurrentUser, clearCurrentUser, restoreFromStorage, selectCurrentUser, selectIsAuthenticated, selectIsInitialized } from './slice'
export { authApi, useLoginMutation, useSignupMutation, useLogoutMutation } from './api'src/components/auth/private-route.tsximport { 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.tsximport { 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.tsximport { 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.tsexport { default as PrivateRoute } from './private-route'
export { default as PublicRoute } from './public-route'
export { LoginForm } from './login-form'src/hooks/use-mobile.tsimport { 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.tsximport { 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.tsximport { 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.tsximport { 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.tsexport { AppSidebar } from './app-sidebar'
export { NavMain } from './nav-main'
export { NavUser } from './nav-user'src/pages/home.tsximport {
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.tsximport { 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.exampleVITE_APP_NAME={project}
VITE_API_URL=http://localhost:8000
VITE_TOKEN_KEY=app-token
VITE_USER_KEY=app-userCLAUDE.md# CLAUDE.md
## Stack
React 19 + Vite + TypeScript + Tailwind CSS v4 + shadcn/ui + Redux Toolkit + React Router
## Structure
- `src/pages/` — one file per route
- `src/store/{domain}/` — `slice.ts` + `api.ts` + `index.ts` per domain
- `src/lib/` — shared utilities (api, token, utils)
- `src/components/auth/` — PrivateRoute / PublicRoute guards
- `src/components/ui/` — shadcn components (do not edit manually)
## Component placement rules
- Feature-specific component → `src/components/{feature}/` (e.g. `logs/`, `alerts/`, `sources/`)
- Cross-cutting utility component → `src/components/common/`
- Missing shadcn primitive → `cd {project} && pnpm dlx shadcn@latest add <component>` (installs into `ui/`)
- **All filenames use kebab-case** — no exceptions: `my-component.tsx`, `private-route.tsx`, `login-form.tsx`
- Each feature folder has an `index.ts` barrel — always export from it
## Rules
- Use `useAppDispatch` / `useAppSelector` — never plain hooks
- Server data → RTK Query (`api.ts`), client state → Redux slice (`slice.ts`)
- All protected routes use `<PrivateRoute>`, public routes use `<PublicRoute>`
- Path alias `@` → `src/`
- **Forms:** always use `FieldGroup`, `FieldLabel`, `FieldDescription` from `@/components/ui/field` — never use `Label` directly in forms
## Implementation Checklist
- All imports use `@/` path aliases
- Use `cn()` utility for className merging (from `@/lib/utils.ts`)
- **CRITICAL:** Use Field components for forms, NOT Label
- Proper TypeScript types for all props
- Use CSS variables: `hsl(var(--primary))`, `hsl(var(--background))`, etc.
- Follow Tailwind spacing conventions (`p-4`, `gap-2`, etc.)
- 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 `src/lib/utils.ts`, not inline
## Adding a domain
1. Types → `src/types.ts`
2. `src/store/{domain}/slice.ts` → `api.ts` → `index.ts`
3. Register in `src/store/index.ts`
4. Page → `src/pages/{domain}.tsx`
5. Route → `src/routes.tsx`cp .env.example .env
pnpm dev
# → http://localhost:5173cd {project} && pnpm dlx shadcn@latest add <component>POST /auth/login{ access_token, user }POST /auth/signup{ access_token, user }POST /auth/logout