category management, upload cloudinary
This commit is contained in:
5
.env
5
.env
@@ -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"
|
||||||
|
|||||||
@@ -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
44
db.json
@@ -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
31
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/apis/core/category.api.ts
Normal file
20
src/apis/core/category.api.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/apis/core/cloudinary.api.ts
Normal file
12
src/apis/core/cloudinary.api.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
163
src/pages/admin/Category/CategoryManagement.tsx
Normal file
163
src/pages/admin/Category/CategoryManagement.tsx
Normal 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 Lý 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
6
src/types/category.type.ts
Normal file
6
src/types/category.type.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
iconUrl: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user