category management, upload cloudinary

This commit is contained in:
2025-10-08 08:54:30 +07:00
parent 21e77456c9
commit c17f0d3d9c
13 changed files with 303 additions and 8 deletions

5
.env
View File

@@ -1,2 +1,7 @@
VITE_SV_HOST="http://localhost:3000" VITE_SV_HOST="http://localhost:3000"
VITE_JWT_TOKEN="phuocntbasdasasdasd" VITE_JWT_TOKEN="phuocntbasdasasdasd"
VITE_CLOUDINARY_UPLOAD_PRESET="testpreset"
VITE_CLOUDINARY_CLOUD_NAME="dusw32tsq"
VITE_CLOUDINARY_API_SECRET="NeUuUfbjlZSSyq0Zf390LECRdcI"
VITE_CLOUDINARY_API_KEY="114544946391646"

View File

@@ -14,3 +14,7 @@
- Đăng ký / đăng nhập X - Đăng ký / đăng nhập X
- Layout người dùng X - Layout người dùng X
## Cloudinary
npm i cloudinary

44
db.json
View File

@@ -33,5 +33,49 @@
"status": "ACTIVE", "status": "ACTIVE",
"banReason": "" "banReason": ""
} }
],
"categories": [
{
"id": "aa",
"title": "danh mục 1",
"iconUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTLx0GLJkUHO42P1w4qBVzajal81_-N7oP-6w&s",
"isActive": true
},
{
"id": "bb",
"title": "danh mục 2",
"iconUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTLx0GLJkUHO42P1w4qBVzajal81_-N7oP-6w&s",
"isActive": false
},
{
"id": "cafa",
"title": "Danh mục 3",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759887991/gtsjxafutf5nu140skar.png",
"isActive": true
},
{
"id": "20d1",
"title": "Điện Thoại",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888235/mehgtcrul6kmfp9oklko.png",
"isActive": true
},
{
"id": "545a",
"title": "Mèo",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888284/ewzw5idmjosvqzk4c2cn.png",
"isActive": true
},
{
"id": "37f1",
"title": "Mèo 12121",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888337/whofwib0nrquknf4v5b6.png",
"isActive": true
},
{
"id": "2d29",
"title": "Mèo 12121",
"iconUrl": "https://res.cloudinary.com/dusw32tsq/image/upload/v1759888364/oululvqiwuwpn8se3dww.png",
"isActive": true
}
] ]
} }

31
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"antd": "^5.27.4", "antd": "^5.27.4",
"axios": "^1.12.2", "axios": "^1.12.2",
"cloudinary": "^2.7.0",
"jose": "^6.1.0", "jose": "^6.1.0",
"json-server": "^1.0.0-beta.3", "json-server": "^1.0.0-beta.3",
"react": "^19.1.1", "react": "^19.1.1",
@@ -3177,6 +3178,19 @@
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cloudinary": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz",
"integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"q": "^1.5.1"
},
"engines": {
"node": ">=9"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4549,6 +4563,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4922,6 +4942,17 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
"license": "MIT",
"engines": {
"node": ">=0.6.0",
"teleport": ">=0.2.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -16,6 +16,7 @@
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"antd": "^5.27.4", "antd": "^5.27.4",
"axios": "^1.12.2", "axios": "^1.12.2",
"cloudinary": "^2.7.0",
"jose": "^6.1.0", "jose": "^6.1.0",
"json-server": "^1.0.0-beta.3", "json-server": "^1.0.0-beta.3",
"react": "^19.1.1", "react": "^19.1.1",

View File

@@ -5,6 +5,7 @@ import Admin from './pages/admin/Admin'
import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin' import ProtectedAdmin from './pages/admin/auth/ProtectedAdmin'
import Auth from './pages/home/auth/Auth' import Auth from './pages/home/auth/Auth'
import UserManagement from './pages/admin/User/UserManagement' import UserManagement from './pages/admin/User/UserManagement'
import CategoryManagement from './pages/admin/Category/CategoryManagement'
export default function RouterSetup() { export default function RouterSetup() {
return <Routes> return <Routes>
@@ -16,7 +17,7 @@ export default function RouterSetup() {
</ProtectedAdmin> </ProtectedAdmin>
}> }>
<Route path='user' element={<UserManagement />}></Route> <Route path='user' element={<UserManagement />}></Route>
<Route path='category' element={<CategoryManagement />}></Route>
</Route> </Route>
</Routes> </Routes>
} }

View File

@@ -0,0 +1,20 @@
import axios from "axios"
export const CategoryApi = {
getAll: async () => {
let resultRes = await axios.get(`${import.meta.env.VITE_SV_HOST}/categories`)
return resultRes.data
},
create: async (data: {
title: string;
iconUrl: string
}) => {
let result = await axios.post(`${import.meta.env.VITE_SV_HOST}/categories`, {
...data,
isActive: true
})
return result.data
}
}

View File

@@ -0,0 +1,12 @@
import axios from "axios"
export const CloudianryApi = {
upload: async (file: File) => {
let formData = new FormData()
formData.append("file", file)
formData.append("upload_preset", import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET)
formData.append("cloud_name", import.meta.env.VITE_CLOUDINARY_CLOUD_NAME)
let result = await axios.post(`https://api.cloudinary.com/v1_1/${import.meta.env.VITE_CLOUDINARY_CLOUD_NAME}/image/upload`, formData)
return result.data.secure_url
}
}

View File

@@ -1,5 +1,9 @@
import { CategoryApi } from "./core/category.api";
import { CloudianryApi } from "./core/cloudinary.api";
import { UserApi } from "./core/user.api"; import { UserApi } from "./core/user.api";
export const Apis = { export const Apis = {
user: UserApi user: UserApi,
category: CategoryApi,
cloundInary: CloudianryApi
} }

View File

@@ -0,0 +1,163 @@
import React, { useEffect, useState, type FormEvent } from 'react'
import { Apis } from '../../../apis'
import type { Category } from '../../../types/category.type'
import { Button, Form, Input, Modal, Space, Table, Upload } from 'antd'
import axios from 'axios'
export default function CategoryManagement() {
/* State Modal */
const [createNewModalState, setCreateNewModalState] = useState(false)
const [categories, setCategories] = useState<Category[]>([])
const [loadingState, setLoadingState] = useState(false)
async function getCategories() {
try {
let result = await Apis.category.getAll()
setCategories(result)
} catch (err) {
/* err */
alert("tạch!")
}
}
useEffect(() => {
getCategories()
}, [])
const columns = [
{
title: 'Số Thứ Tự',
key: 'index',
render: (_: any, __: any, index: number) => index + 1
},
{
title: 'Tiêu Đề',
dataIndex: 'title',
key: 'title',
},
{
title: 'Icon',
dataIndex: 'iconUrl',
key: 'iconUrl',
render: (_: any, record: Category) => (
<Space size="middle">
<img src={record.iconUrl} style={{
width: "50px",
height: "50px",
borderRadius: "50"
}} />
</Space>
)
},
{
title: 'Trạng Thái',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Category) => (
<Space size="middle">
{
record.isActive ?
<Button color="danger" variant="solid">Ngừng Hoạt Đng</Button>
: <Button type='primary'>Kích Hoạt</Button>
}
</Space>
)
},
{
title: 'Công Cụ',
dataIndex: 'isActive',
key: 'isActive',
render: (_: any, record: Category) => (
<Space size="middle">
<Button color="danger" variant="solid">Xóa</Button>
<Button type='primary'>Sửa</Button>
</Space>
)
}
];
/* form */
const [form] = Form.useForm()
return (
<div style={{
position: "relative"
}}>
<h1>Quản Danh Mục</h1>
<Button onClick={() => {
setCreateNewModalState(true)
}} type='primary'>Thêm Mới</Button>
<Table
dataSource={categories}
columns={columns}
/>
{/* Modal Create new */}
<Modal
title="Thêm mới danh mục"
closable={{ 'aria-label': 'Custom Close Button' }}
open={createNewModalState}
onOk={() => {
if(loadingState) {
return
}
// setCreateNewModalState(false)
try {
form
.validateFields()
.then(async (values) => {
setLoadingState(true)
let result = await Apis.cloundInary.upload(values.file.file)
let newCategory = {
title: values.title,
iconUrl: result
}
let resultRes = await Apis.category.create(newCategory)
setCategories([
...categories,
resultRes
])
setLoadingState(false)
setCreateNewModalState(false)
})
} catch (er) {
setLoadingState(false)
setCreateNewModalState(true)
}
}}
onCancel={() => {
setCreateNewModalState(false)
}}
okButtonProps={{
htmlType: "submit"
}}
>
<Form form={form} layout="vertical">
<Form.Item
name="title"
label="Tên danh mục"
rules={[{ required: true, message: 'Nhập tên danh mục!' }]}
>
<Input placeholder="Nhập title" />
</Form.Item>
<Form.Item name="file" label="Ảnh">
<Upload beforeUpload={() => false}>
<Input type="file" />
</Upload>
</Form.Item>
</Form>
</Modal>
{/* Loading */}
{
loadingState && <div style={{
zIndex:"99999999"
}} className="absolute inset-0 flex items-center justify-center bg-white/60 backdrop-blur-sm z-50">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
}
</div>
)
}

View File

@@ -13,6 +13,11 @@ export default function Slider({ collapsed }: { collapsed: boolean }) {
key: 'user', key: 'user',
icon: <UserOutlined />, icon: <UserOutlined />,
label: "Quản lý người dùng", label: "Quản lý người dùng",
},
{
key: 'category',
icon: <UserOutlined />,
label: "Quản lý danh mục",
} }
] ]

View File

@@ -25,7 +25,6 @@ const userSlice = createSlice({
state.loading = true state.loading = true
}) })
bd.addCase(fetchUserData.fulfilled, (state, action) => { bd.addCase(fetchUserData.fulfilled, (state, action) => {
console.log("đã vào full", action.payload)
state.loading = false state.loading = false
state.data = action.payload state.data = action.payload
}) })

View File

@@ -0,0 +1,6 @@
export interface Category {
id: string;
title: string;
iconUrl: string;
isActive: boolean;
}