[feature/frontend] admin panel (wip)
This commit is contained in:
parent
34ebb05808
commit
1526c27b49
18 changed files with 1130 additions and 22 deletions
|
@ -20,5 +20,42 @@
|
|||
},
|
||||
"footer": {
|
||||
"copyright": "TSS Rocks. All rights reserved."
|
||||
},
|
||||
"admin": {
|
||||
"common": {
|
||||
"search": "Search",
|
||||
"create": "Create",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"status": "Status",
|
||||
"actions": "Actions",
|
||||
"published": "Published",
|
||||
"draft": "Draft",
|
||||
"author": "Author",
|
||||
"date": "Date",
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"role": "Role",
|
||||
"bio": "Bio",
|
||||
"articles": "Articles",
|
||||
"lastLogin": "Last Login",
|
||||
"joinDate": "Join Date",
|
||||
"username": "Username",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"posts": "Posts",
|
||||
"categories": "Categories",
|
||||
"users": "Users",
|
||||
"contributors": "Contributors"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Administrator",
|
||||
"user": "User",
|
||||
"contributor": "Contributor"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,43 @@
|
|||
"system": "跟随系统"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "TSS.Rocks. 版权所有。"
|
||||
"copyright": "TSS Rocks. 保留所有权利。"
|
||||
},
|
||||
"admin": {
|
||||
"common": {
|
||||
"search": "搜索",
|
||||
"create": "新建",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
"published": "已发布",
|
||||
"draft": "草稿",
|
||||
"author": "作者",
|
||||
"date": "日期",
|
||||
"title": "标题",
|
||||
"description": "描述",
|
||||
"name": "名称",
|
||||
"email": "邮箱",
|
||||
"role": "角色",
|
||||
"bio": "简介",
|
||||
"articles": "文章数",
|
||||
"lastLogin": "最后登录",
|
||||
"joinDate": "加入时间",
|
||||
"username": "用户名",
|
||||
"logout": "退出"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
"posts": "文章",
|
||||
"categories": "分类",
|
||||
"users": "用户",
|
||||
"contributors": "作者"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "管理员",
|
||||
"user": "普通用户",
|
||||
"contributor": "作者"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,43 @@
|
|||
"system": "跟隨系統"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "TSS.Rocks. 版權所有。"
|
||||
"copyright": "TSS Rocks. 保留所有權利。"
|
||||
},
|
||||
"admin": {
|
||||
"common": {
|
||||
"search": "搜尋",
|
||||
"create": "新建",
|
||||
"edit": "編輯",
|
||||
"delete": "刪除",
|
||||
"status": "狀態",
|
||||
"actions": "操作",
|
||||
"published": "已發布",
|
||||
"draft": "草稿",
|
||||
"author": "作者",
|
||||
"date": "日期",
|
||||
"title": "標題",
|
||||
"description": "描述",
|
||||
"name": "名稱",
|
||||
"email": "郵箱",
|
||||
"role": "角色",
|
||||
"bio": "簡介",
|
||||
"articles": "文章數",
|
||||
"lastLogin": "最後登入",
|
||||
"joinDate": "加入時間",
|
||||
"username": "用戶名",
|
||||
"logout": "退出"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "儀表板",
|
||||
"posts": "文章",
|
||||
"categories": "分類",
|
||||
"users": "用戶",
|
||||
"contributors": "作者"
|
||||
},
|
||||
"roles": {
|
||||
"admin": "管理員",
|
||||
"user": "普通用戶",
|
||||
"contributor": "作者"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,10 +10,11 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tss-rocks/api": "workspace:*",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@tss-rocks/api": "workspace:*",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lucide-react": "^0.474.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"react": "^19.0.0",
|
||||
|
@ -27,6 +28,7 @@
|
|||
"@tailwindcss/postcss": "^4.0.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.3",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
|
|
@ -3,35 +3,61 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|||
import { Header } from './components/layout/Header';
|
||||
import { Suspense, lazy } from 'react';
|
||||
import Footer from './components/Footer';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
|
||||
// Lazy load pages
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Daily = lazy(() => import('./pages/Daily'));
|
||||
const Article = lazy(() => import('./pages/Article'));
|
||||
const AdminLayout = lazy(() => import('./pages/admin/layout/AdminLayout'));
|
||||
|
||||
// 管理页面懒加载
|
||||
const PostsManagement = lazy(() => import('./pages/admin/posts/PostsManagement'));
|
||||
const CategoriesManagement = lazy(() => import('./pages/admin/categories/CategoriesManagement'));
|
||||
const UsersManagement = lazy(() => import('./pages/admin/users/UsersManagement'));
|
||||
const ContributorsManagement = lazy(() => import('./pages/admin/contributors/ContributorsManagement'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
|
||||
<Header />
|
||||
{/* 页眉分隔线 */}
|
||||
<div className="w-[95%] mx-auto">
|
||||
<div className="border-t-2 border-gray-900 dark:border-gray-100 w-full mb-2" />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 w-[95%] mx-auto py-8">
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col min-h-screen bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/daily" element={<Daily />} />
|
||||
<Route path="/posts/:articleId" element={<Article />} />
|
||||
{/* Admin routes */}
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route path="posts" element={<PostsManagement />} />
|
||||
<Route path="categories" element={<CategoriesManagement />} />
|
||||
<Route path="users" element={<UsersManagement />} />
|
||||
<Route path="contributors" element={<ContributorsManagement />} />
|
||||
</Route>
|
||||
|
||||
{/* Public routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<>
|
||||
<Header />
|
||||
{/* 页眉分隔线 */}
|
||||
<div className="w-[95%] mx-auto">
|
||||
<div className="border-t-2 border-gray-900 dark:border-gray-100 w-full mb-2" />
|
||||
</div>
|
||||
<main className="flex-1 w-[95%] mx-auto py-8">
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/daily" element={<Daily />} />
|
||||
<Route path="/posts/:articleId" element={<Article />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
132
frontend/src/components/admin/Table.tsx
Normal file
132
frontend/src/components/admin/Table.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Column<T> {
|
||||
key: keyof T;
|
||||
title: string;
|
||||
render?: (value: T[keyof T], item: T) => ReactNode;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
onEdit?: (item: T) => void;
|
||||
onDelete?: (item: T) => void;
|
||||
}
|
||||
|
||||
const Table = <T extends Record<string, any>>({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TableProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Loading header */}
|
||||
<div className="flex border-b border-slate-200 dark:border-slate-700 pb-4">
|
||||
{columns.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1 h-6 bg-slate-200 dark:bg-slate-700 rounded animate-pulse"
|
||||
style={{ marginRight: index !== columns.length - 1 ? '2rem' : 0 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Loading rows */}
|
||||
<div className="space-y-4 pt-4">
|
||||
{[1, 2, 3].map((row) => (
|
||||
<div key={row} className="flex items-center">
|
||||
{columns.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex-1"
|
||||
style={{ marginRight: index !== columns.length - 1 ? '2rem' : 0 }}
|
||||
>
|
||||
<div className="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse"
|
||||
style={{ width: index === columns.length - 1 ? '30%' : '60%' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={String(column.key)}
|
||||
scope="col"
|
||||
className="px-6 py-4 text-left text-sm font-bold text-slate-900 dark:text-white uppercase tracking-wider"
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
{(onEdit || onDelete) && (
|
||||
<th scope="col" className="relative px-6 py-4">
|
||||
<span className="sr-only">{t('admin.common.actions')}</span>
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{data.map((item, itemIdx) => (
|
||||
<tr
|
||||
key={itemIdx}
|
||||
className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={String(column.key)}
|
||||
className="px-6 py-4 text-sm text-slate-800 dark:text-white"
|
||||
>
|
||||
{column.render
|
||||
? column.render(item[column.key], item)
|
||||
: String(item[column.key])}
|
||||
</td>
|
||||
))}
|
||||
{(onEdit || onDelete) && (
|
||||
<td className="px-6 py-4 text-right text-sm space-x-2">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(item)}
|
||||
className="text-slate-600 dark:text-white hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"
|
||||
>
|
||||
{t('admin.common.edit')}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(item)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||
>
|
||||
{t('admin.common.delete')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.length === 0 && !loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-slate-500 dark:text-white">{t('admin.common.noData')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { FiSun, FiMoon, FiSearch, FiGlobe, FiMonitor } from 'react-icons/fi';
|
||||
import { Menu } from '@headlessui/react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import type { Theme } from '../../hooks/useTheme';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'en', nativeName: 'English' },
|
||||
|
|
82
frontend/src/contexts/AuthContext.tsx
Normal file
82
frontend/src/contexts/AuthContext.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { AuthContextType, AuthState, User } from '../types/auth';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [state, setState] = useState<AuthState>(initialState);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('登录失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
user: data.user,
|
||||
token: data.token,
|
||||
loading: false,
|
||||
}));
|
||||
|
||||
localStorage.setItem('token', data.token);
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : '登录失败',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
clearError,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
|
|
|
@ -1,11 +1,27 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import en from '../data/i18n/en.json';
|
||||
import zhHans from '../data/i18n/zh-Hans.json';
|
||||
import zhHant from '../data/i18n/zh-Hant.json';
|
||||
|
||||
// 获取浏览器默认语言
|
||||
const getBrowserLanguage = () => {
|
||||
const lang = navigator.language;
|
||||
if (lang.startsWith('zh')) {
|
||||
return lang === 'zh-TW' || lang === 'zh-HK' ? 'zh-Hant' : 'zh-Hans';
|
||||
}
|
||||
return 'en';
|
||||
};
|
||||
|
||||
// 获取存储的语言设置
|
||||
const getStoredLanguage = () => {
|
||||
return localStorage.getItem('language') || getBrowserLanguage();
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
|
@ -13,11 +29,21 @@ i18n
|
|||
'zh-Hans': { translation: zhHans },
|
||||
'zh-Hant': { translation: zhHant },
|
||||
},
|
||||
lng: 'zh-Hans',
|
||||
lng: getStoredLanguage(),
|
||||
fallbackLng: 'en',
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
lookupLocalStorage: 'language',
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 监听语言变化并保存到 localStorage
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
localStorage.setItem('language', lng);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
|
129
frontend/src/pages/admin/categories/CategoriesManagement.tsx
Normal file
129
frontend/src/pages/admin/categories/CategoriesManagement.tsx
Normal file
|
@ -0,0 +1,129 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||
import Table from '../../../components/admin/Table';
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CategoriesManagement: FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 这里后续会通过 API 获取数据
|
||||
const categories: Category[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '新闻',
|
||||
description: '新闻分类',
|
||||
},
|
||||
];
|
||||
|
||||
const handleEdit = (category: Category) => {
|
||||
console.log('Edit category:', category);
|
||||
};
|
||||
|
||||
const handleDelete = (category: Category) => {
|
||||
console.log('Delete category:', category);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: t('admin.common.name'),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
title: t('admin.common.description'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-stone-200 dark:divide-stone-700">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('admin.common.search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border-b-2 border-slate-200 dark:border-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-transparent outline-none w-64 text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
||||
<RiAddLine className="text-xl" />
|
||||
<span>{t('admin.common.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={categories}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
>
|
||||
{({ data, onEdit, onDelete }) => (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 dark:border-stone-700">
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium ${
|
||||
index === 0 ? 'rounded-tl-md' : ''
|
||||
} ${index === columns.length - 1 ? 'rounded-tr-md' : ''}`}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((category) => (
|
||||
<tr key={category.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0">
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`py-4 px-6 ${
|
||||
column.key === 'description' ? 'text-left' : 'text-left'
|
||||
} text-stone-800 dark:text-stone-200`}
|
||||
>
|
||||
{category[column.key]}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-4 px-6 text-right">
|
||||
<button
|
||||
className="text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 px-2 py-1 rounded-md transition-colors"
|
||||
onClick={() => onEdit(category)}
|
||||
>
|
||||
{t('admin.common.edit')}
|
||||
</button>
|
||||
<button
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||
onClick={() => onDelete(category)}
|
||||
>
|
||||
{t('admin.common.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesManagement;
|
138
frontend/src/pages/admin/contributors/ContributorsManagement.tsx
Normal file
138
frontend/src/pages/admin/contributors/ContributorsManagement.tsx
Normal file
|
@ -0,0 +1,138 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||
import Table from '../../../components/admin/Table';
|
||||
|
||||
interface Contributor {
|
||||
id: string;
|
||||
name: string;
|
||||
bio: string;
|
||||
articles: number;
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
const ContributorsManagement: FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 这里后续会通过 API 获取数据
|
||||
const contributors: Contributor[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '李四',
|
||||
bio: '这是李四的简介',
|
||||
articles: 5,
|
||||
joinDate: '2024-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const handleEdit = (contributor: Contributor) => {
|
||||
console.log('Edit contributor:', contributor);
|
||||
};
|
||||
|
||||
const handleDelete = (contributor: Contributor) => {
|
||||
console.log('Delete contributor:', contributor);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
title: t('admin.common.name'),
|
||||
},
|
||||
{
|
||||
key: 'bio',
|
||||
title: t('admin.common.bio'),
|
||||
},
|
||||
{
|
||||
key: 'articles',
|
||||
title: t('admin.common.articles'),
|
||||
render: (value: number) => (
|
||||
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm">
|
||||
{value}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'joinDate',
|
||||
title: t('admin.common.joinDate'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-stone-200 dark:divide-stone-700">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('admin.common.search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border-b-2 border-slate-200 dark:border-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-transparent outline-none w-64 text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
||||
<RiAddLine className="text-xl" />
|
||||
<span>{t('admin.common.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={contributors}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
>
|
||||
{({ data, onEdit, onDelete }) => (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-200 dark:border-stone-700">
|
||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tl-md">{t('admin.common.name')}</th>
|
||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.bio')}</th>
|
||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.articles')}</th>
|
||||
<th className="text-left py-4 px-6 text-stone-600 dark:text-stone-400 font-medium">{t('admin.common.joinDate')}</th>
|
||||
<th className="text-right py-4 px-6 text-stone-600 dark:text-stone-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((contributor) => (
|
||||
<tr key={contributor.id} className="border-b border-stone-200 dark:border-stone-700 last:border-0">
|
||||
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.name}</td>
|
||||
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.bio}</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className="px-3 py-1 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-md text-sm">
|
||||
{contributor.articles}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-stone-800 dark:text-stone-200">{contributor.joinDate}</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<button
|
||||
className="text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-200 px-2 py-1 rounded-md transition-colors"
|
||||
onClick={() => onEdit(contributor)}
|
||||
>
|
||||
{t('admin.common.edit')}
|
||||
</button>
|
||||
<button
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||
onClick={() => onDelete(contributor)}
|
||||
>
|
||||
{t('admin.common.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContributorsManagement;
|
168
frontend/src/pages/admin/layout/AdminLayout.tsx
Normal file
168
frontend/src/pages/admin/layout/AdminLayout.tsx
Normal file
|
@ -0,0 +1,168 @@
|
|||
import { FC } from 'react';
|
||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RiFileTextLine,
|
||||
RiFolderLine,
|
||||
RiUserLine,
|
||||
RiTeamLine,
|
||||
RiLogoutBoxRLine,
|
||||
RiSunLine,
|
||||
RiMoonLine,
|
||||
RiComputerLine,
|
||||
RiGlobalLine
|
||||
} from 'react-icons/ri';
|
||||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import type { Theme } from '../../../hooks/useTheme';
|
||||
|
||||
interface AdminLayoutProps {}
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/admin/posts', icon: RiFileTextLine, label: 'admin.nav.posts' },
|
||||
{ path: '/admin/categories', icon: RiFolderLine, label: 'admin.nav.categories' },
|
||||
{ path: '/admin/users', icon: RiUserLine, label: 'admin.nav.users' },
|
||||
{ path: '/admin/contributors', icon: RiTeamLine, label: 'admin.nav.contributors' },
|
||||
];
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, icon: RiSunLine, label: 'theme.light' },
|
||||
{ value: 'dark' as const, icon: RiMoonLine, label: 'theme.dark' },
|
||||
{ value: 'system' as const, icon: RiComputerLine, label: 'theme.system' }
|
||||
];
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh-Hans', label: '简体中文' },
|
||||
{ value: 'zh-Hant', label: '繁體中文' }
|
||||
];
|
||||
|
||||
type LanguageMap = {
|
||||
'en': 'zh-Hans';
|
||||
'zh-Hans': 'zh-Hant';
|
||||
'zh-Hant': 'en';
|
||||
};
|
||||
|
||||
const languageMap: LanguageMap = {
|
||||
'en': 'zh-Hans',
|
||||
'zh-Hans': 'zh-Hant',
|
||||
'zh-Hant': 'en'
|
||||
};
|
||||
|
||||
const AdminLayout: FC<AdminLayoutProps> = () => {
|
||||
const location = useLocation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 dark:bg-slate-900 py-6 flex">
|
||||
{/* Background Overlay */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-slate-200 to-slate-300 dark:from-slate-800 dark:to-slate-900 backdrop-blur-xl -z-10" />
|
||||
|
||||
<div className="w-full max-w-[98%] mx-auto flex gap-4">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg rounded-lg shadow-lg flex flex-col">
|
||||
<div className="h-16 px-6 border-b border-slate-200/80 dark:border-slate-700/80 flex items-center justify-center">
|
||||
<h1 className="text-2xl font-bold text-slate-800 dark:text-white tracking-tight">
|
||||
TSS Rocks
|
||||
</h1>
|
||||
</div>
|
||||
<nav className="flex-1 p-4">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-4 py-3 mb-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" />
|
||||
<span className="tracking-wide">{t(item.label)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 space-y-2">
|
||||
{/* Language and Logout Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const currentLang = i18n.language as keyof LanguageMap;
|
||||
const nextLang = languageMap[currentLang] || 'en';
|
||||
i18n.changeLanguage(nextLang);
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors whitespace-nowrap min-w-[100px]"
|
||||
title={t('admin.common.switchLanguage')}
|
||||
>
|
||||
<RiGlobalLine className="text-xl flex-shrink-0" />
|
||||
<span className="text-sm truncate">{languageOptions.find(lang => lang.value === i18n.language)?.label || 'English'}</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50 transition-colors whitespace-nowrap"
|
||||
onClick={() => {/* TODO: Implement logout */}}
|
||||
>
|
||||
<RiLogoutBoxRLine className="text-xl flex-shrink-0" />
|
||||
<span className="text-sm">{t('admin.common.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Theme Buttons */}
|
||||
<div className="border-t border-slate-200/80 dark:border-slate-700/80 pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{themeOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => setTheme(option.value)}
|
||||
className={`flex-1 p-2 rounded-md transition-colors ${
|
||||
theme === option.value
|
||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700/50'
|
||||
}`}
|
||||
title={t(option.label)}
|
||||
>
|
||||
<Icon className="text-xl mx-auto" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col rounded-lg overflow-hidden bg-white/80 dark:bg-slate-800/80 backdrop-blur-lg shadow-lg">
|
||||
<header className="border-b border-slate-200/80 dark:border-slate-700/80">
|
||||
<div className="h-16 px-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">
|
||||
{t(menuItems.find(item => item.path === location.pathname)?.label || 'admin.nav.dashboard')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-full flex items-center justify-center text-slate-600 dark:text-slate-300">
|
||||
<span className="text-lg">A</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-800 dark:text-white">管理员</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">Administrator</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 p-6">
|
||||
<div className="h-full bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200/60 dark:border-slate-700/60">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
148
frontend/src/pages/admin/posts/PostsManagement.tsx
Normal file
148
frontend/src/pages/admin/posts/PostsManagement.tsx
Normal file
|
@ -0,0 +1,148 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Table from '../../../components/admin/Table';
|
||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||
|
||||
interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
status: 'draft' | 'published';
|
||||
publishDate: string;
|
||||
}
|
||||
|
||||
const PostsManagement: FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 这里后续会通过 API 获取数据
|
||||
const posts: Post[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '示例文章标题',
|
||||
author: '张三',
|
||||
status: 'published',
|
||||
publishDate: '2024-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const handleEdit = (post: Post) => {
|
||||
console.log('Edit post:', post);
|
||||
};
|
||||
|
||||
const handleDelete = (post: Post) => {
|
||||
console.log('Delete post:', post);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title',
|
||||
title: t('admin.common.title'),
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
title: t('admin.common.author'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: t('admin.common.status'),
|
||||
render: (value: Post['status']) => (
|
||||
<span
|
||||
className={`inline-block px-3 py-1 text-xs rounded-md ${
|
||||
value === 'published'
|
||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
||||
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.common.${value}`)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'publishDate',
|
||||
title: t('admin.common.date'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('admin.common.search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border-b-2 border-slate-200 dark:border-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-transparent outline-none w-64 text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
||||
<RiAddLine className="text-xl" />
|
||||
<span>{t('admin.common.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={posts}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
>
|
||||
{({ data, onEdit, onDelete }) => (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tl-md">{t('admin.common.title')}</th>
|
||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.author')}</th>
|
||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.status')}</th>
|
||||
<th className="text-left py-4 px-6 text-slate-600 dark:text-slate-400 font-medium">{t('admin.common.date')}</th>
|
||||
<th className="text-right py-4 px-6 text-slate-600 dark:text-slate-400 font-medium rounded-tr-md">{t('admin.common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((post) => (
|
||||
<tr key={post.id} className="border-b border-slate-200 dark:border-slate-700 last:border-0">
|
||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.title}</td>
|
||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.author}</td>
|
||||
<td className="py-4 px-6">
|
||||
<span className={`px-3 py-1 text-sm rounded-md ${
|
||||
post.status === 'published'
|
||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
||||
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
|
||||
}`}>
|
||||
{t(`admin.common.${post.status}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-slate-800 dark:text-slate-200">{post.publishDate}</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<button
|
||||
className="text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 px-2 py-1 rounded-md transition-colors"
|
||||
onClick={() => onEdit(post)}
|
||||
>
|
||||
{t('admin.common.edit')}
|
||||
</button>
|
||||
<button
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 px-2 py-1 rounded-md transition-colors"
|
||||
onClick={() => onDelete(post)}
|
||||
>
|
||||
{t('admin.common.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostsManagement;
|
100
frontend/src/pages/admin/users/UsersManagement.tsx
Normal file
100
frontend/src/pages/admin/users/UsersManagement.tsx
Normal file
|
@ -0,0 +1,100 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RiAddLine, RiSearchLine } from 'react-icons/ri';
|
||||
import Table from '../../../components/admin/Table';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'user';
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
const UsersManagement: FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 这里后续会通过 API 获取数据
|
||||
const users: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
username: '张三',
|
||||
email: 'zhangsan@example.com',
|
||||
role: 'admin',
|
||||
lastLogin: '2024-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
console.log('Edit user:', user);
|
||||
};
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
console.log('Delete user:', user);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'username',
|
||||
title: t('admin.common.username'),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
title: t('admin.common.role'),
|
||||
render: (value: User['role']) => (
|
||||
<span
|
||||
className={`inline-block px-3 py-1 text-xs rounded-md ${
|
||||
value === 'admin'
|
||||
? 'bg-slate-900 dark:bg-slate-700 text-white'
|
||||
: 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.roles.${value}`)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'joinDate',
|
||||
title: t('admin.common.joinDate'),
|
||||
},
|
||||
{
|
||||
key: 'lastLogin',
|
||||
title: t('admin.common.lastLogin'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('admin.common.search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border-b-2 border-slate-200 dark:border-slate-700 focus:border-slate-900 dark:focus:border-slate-500 bg-transparent outline-none w-64 text-slate-800 dark:text-white placeholder-slate-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
<RiSearchLine className="absolute left-0 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 text-xl" />
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white transition-colors rounded-md">
|
||||
<RiAddLine className="text-xl" />
|
||||
<span>{t('admin.common.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
data={users}
|
||||
loading={loading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersManagement;
|
13
frontend/src/router.tsx
Normal file
13
frontend/src/router.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import AdminLayout from './pages/admin/layout/AdminLayout';
|
||||
import Dashboard from './pages/admin/Dashboard';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/admin',
|
||||
element: <AdminLayout><Dashboard /></AdminLayout>,
|
||||
},
|
||||
// Add more routes here as we develop them
|
||||
]);
|
||||
|
||||
export default router;
|
19
frontend/src/types/auth.ts
Normal file
19
frontend/src/types/auth.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'admin' | 'contributor' | 'user';
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
clearError: () => void;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue