npx shadcn-ui@latest add button
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add table
npx shadcn-ui@latest add input
npx shadcn-ui@latest add label
npx shadcn-ui@latest add card
npx shadcn-ui@latest add navigation-menu
npx shadcn-ui@latest add sheet
npx shadcn-ui@latest add badge
npx shadcn-ui@latest add avatar
src/components/layout/Header.tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
export const Header = () => {
return (
<header className="flex h-16 items-center justify-between border-b px-4">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">Admin Dashboard</h1>
</div>
<div className="flex items-center gap-4">
<Input
type="search"
placeholder="Search..."
className="w-[200px] md:w-[300px]"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src="/avatars/default.png" alt="User" />
<AvatarFallback>AD</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
)
}
src/components/layout/Sidebar.tsx
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import {
LayoutDashboard,
Users,
Package,
Settings,
} from "lucide-react"
export const Sidebar = () => {
const navItems = [
{ href: "/", icon: LayoutDashboard, label: "Dashboard" },
{ href: "/users", icon: Users, label: "Users" },
{ href: "/products", icon: Package, label: "Products" },
{ href: "/settings", icon: Settings, label: "Settings" },
]
return (
<aside className="hidden w-64 border-r bg-gray-50/40 md:block">
<div className="flex h-full flex-col gap-2 p-4">
<div className="flex h-[60px] items-center">
<h2 className="text-xl font-semibold">Admin Panel</h2>
</div>
<nav className="flex-1 space-y-2">
{navItems.map((item) => (
<Button
key={item.href}
asChild
variant="ghost"
className="w-full justify-start"
>
<Link to={item.href}>
<item.icon className="mr-2 h-4 w-4" />
{item.label}
</Link>
</Button>
))}
</nav>
<div className="mt-auto p-4">
<p className="text-sm text-gray-500">v1.0.0</p>
</div>
</div>
</aside>
)
}
src/App.tsx
import { Outlet } from "react-router-dom"
import { Header } from "@/components/layout/Header"
import { Sidebar } from "@/components/layout/Sidebar"
function App() {
return (
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 p-4">
<Outlet />
</main>
</div>
</div>
)
}
export default App
src/main.tsx
import React from "react"
import ReactDOM from "react-dom/client"
import { createBrowserRouter, RouterProvider } from "react-router-dom"
import App from "./App"
import "./index.css"
import Dashboard from "./pages/Dashboard"
import Users from "./pages/Users"
import Products from "./pages/Products"
import Settings from "./pages/Settings"
const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{ path: "/", element: <Dashboard /> },
{ path: "/users", element: <Users /> },
{ path: "/products", element: <Products /> },
{ path: "/settings", element: <Settings /> },
],
},
])
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)
src/pages/Dashboard.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Activity, Users2, Package, DollarSign } from "lucide-react"
export const Dashboard = () => {
const stats = [
{ title: "Total Revenue", value: "$45,231.89", icon: DollarSign, desc: "+20.1% from last month" },
{ title: "Users", value: "1,234", icon: Users2, desc: "+180.1% from last month" },
{ title: "Products", value: "573", icon: Package, desc: "+19% from last month" },
{ title: "Active Now", value: "573", icon: Activity, desc: "+201 since last hour" },
]
return (
<div className="space-y-4">
<h2 className="text-2xl font-bold">Dashboard</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{stat.title}
</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">{stat.desc}</p>
</CardContent>
</Card>
))}
</div>
</div>
)
}
src/types/auth.ts
export interface User {
id: string;
name: string;
email: string;
role: string;
avatar?: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface LoginFormData {
email: string;
password: string;
}
src/lib/constants.ts
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
export const AUTH_TOKEN_KEY = 'admin_dashboard_token';
src/contexts/AuthContext.tsx
import { createContext, useContext, ReactNode, useState, useEffect } from 'react';
import { User, AuthResponse } from '@/types/auth';
import { AUTH_TOKEN_KEY } from '@/lib/constants';
import { useNavigate } from 'react-router-dom';
interface AuthContextType {
user: User | null;
token: string | null;
login: (data: AuthResponse) => void;
logout: () => void;
isAuthenticated: boolean;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
useEffect(() => {
const initializeAuth = async () => {
const storedToken = localStorage.getItem(AUTH_TOKEN_KEY);
if (storedToken) {
// ここでトークンの有効性をAPIで確認するのが理想
// 簡略化のため、ローカルストレージのトークンをそのまま使用
setToken(storedToken);
// ユーザー情報を取得するAPIコールをここで行う
}
setLoading(false);
};
initializeAuth();
}, []);
const login = (data: AuthResponse) => {
localStorage.setItem(AUTH_TOKEN_KEY, data.token);
setToken(data.token);
setUser(data.user);
navigate('/');
};
const logout = () => {
localStorage.removeItem(AUTH_TOKEN_KEY);
setToken(null);
setUser(null);
navigate('/login');
};
const value = {
user,
token,
login,
logout,
isAuthenticated: !!token,
loading,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
src/pages/Login.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useAuth } from '@/contexts/AuthContext';
import { Link, useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
const formSchema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(6, 'パスワードは6文字以上必要です'),
});
export const Login = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setError('');
setLoading(true);
try {
// 実際にはAPI呼び出しを行う
// const response = await axios.post(`${API_BASE_URL}/auth/login`, values);
// login(response.data);
// ダミーデータでログイン(開発用)
setTimeout(() => {
login({
token: 'dummy_jwt_token',
user: {
id: '1',
name: 'Admin User',
email: values.email,
role: 'admin',
},
});
navigate('/');
}, 1000);
} catch (err) {
setError('ログインに失敗しました。メールアドレスとパスワードを確認してください');
console.error(err);
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center">管理者ログイン</CardTitle>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>エラー</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>メールアドレス</FormLabel>
<FormControl>
<Input placeholder="admin@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>パスワード</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? '処理中...' : 'ログイン'}
</Button>
</form>
</Form>
<div className="mt-4 text-center text-sm text-muted-foreground">
パスワードをお忘れの方は{' '}
<Link to="/forgot-password" className="underline">
こちら
</Link>
</div>
</CardContent>
</Card>
</div>
);
};
src/components/auth/ProtectedRoute.tsx
import { useAuth } from '@/contexts/AuthContext';
import { Navigate, Outlet } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
export const ProtectedRoute = () => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin" />
</div>
);
}
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
};
src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from './App';
import './index.css';
import Dashboard from './pages/Dashboard';
import Users from './pages/Users';
import Products from './pages/Products';
import Settings from './pages/Settings';
import { Login } from './pages/Login';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
const router = createBrowserRouter([
{
path: '/login',
element: <Login />,
},
{
path: '/',
element: (
<ProtectedRoute>
<App />
</ProtectedRoute>
),
children: [
{ path: '/', element: <Dashboard /> },
{ path: '/users', element: <Users /> },
{ path: '/products', element: <Products /> },
{ path: '/settings', element: <Settings /> },
],
},
]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</React.StrictMode>
);
src/components/layout/Header.tsx
import { useAuth } from '@/contexts/AuthContext';
// ... 他のインポート
export const Header = () => {
const { user, logout } = useAuth();
return (
<header className="flex h-16 items-center justify-between border-b px-4">
{/* ... 他のコード */}
<div className="flex items-center gap-4">
{/* ... 他のコード */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatar || '/avatars/default.png'} alt="User" />
<AvatarFallback>
{user?.name
?.split(' ')
.map((n) => n[0])
.join('')}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => logout()}
>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
};
src/lib/api.ts
import axios from 'axios';
import { AUTH_TOKEN_KEY } from '@/lib/constants';
const api = axios.create({
baseURL: API_BASE_URL,
});
// リクエストインターセプターでトークンを追加
api.interceptors.request.use((config) => {
const token = localStorage.getItem(AUTH_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// レスポンスインターセプターでエラー処理
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// トークンが無効な場合、ログアウト処理
localStorage.removeItem(AUTH_TOKEN_KEY);
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
.env
VITE_API_BASE_URL=http://localhost:3000/api
<NavigationMenu className="flex-1 flex flex-col">
<NavigationMenuList className="flex-col items-start space-y-2">
{navItems.map((item) => (
<NavigationMenuItem key={item.href} className="w-full">
<Link to={item.href} className="w-full">
<NavigationMenuLink
active={location.pathname === item.href}
className={cn(
navigationMenuTriggerStyle(),
"w-full justify-start gap-2",
location.pathname === item.href && "bg-accent"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
コメント