feat: add blog page
This commit is contained in:
BIN
backend/uploads/blog/1773814232254.png
Normal file
BIN
backend/uploads/blog/1773814232254.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
BIN
backend/uploads/blog/1773814239753.png
Normal file
BIN
backend/uploads/blog/1773814239753.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
backend/uploads/blog/1773814266558.png
Normal file
BIN
backend/uploads/blog/1773814266558.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
backend/uploads/blog/1773814356620.png
Normal file
BIN
backend/uploads/blog/1773814356620.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
BIN
backend/uploads/blog/1773814805822.png
Normal file
BIN
backend/uploads/blog/1773814805822.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
168
frontend/package-lock.json
generated
168
frontend/package-lock.json
generated
@@ -8,6 +8,15 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@editorjs/code": "^2.9.4",
|
||||||
|
"@editorjs/delimiter": "^1.4.2",
|
||||||
|
"@editorjs/editorjs": "^2.31.5",
|
||||||
|
"@editorjs/embed": "^2.8.0",
|
||||||
|
"@editorjs/header": "^2.8.8",
|
||||||
|
"@editorjs/image": "^2.10.3",
|
||||||
|
"@editorjs/list": "^2.0.9",
|
||||||
|
"@editorjs/quote": "^2.7.6",
|
||||||
|
"@editorjs/table": "^2.4.5",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
@@ -27,6 +36,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/estree": "^1.0.8",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -512,6 +523,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codexteam/icons": {
|
||||||
|
"version": "0.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.3.3.tgz",
|
||||||
|
"integrity": "sha512-cp7mkZPgmBuSxigTm3Vb+DtVHYeX7qXfQd7o05vcLD8Ag5WvRlol2QSn5P10k0CDAJwmkH9nQGQLBycErS9lsQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.54.1",
|
"version": "1.54.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz",
|
||||||
@@ -680,6 +697,145 @@
|
|||||||
"@noble/ciphers": "^1.0.0"
|
"@noble/ciphers": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@editorjs/caret": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/caret/-/caret-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-VmgwQJZgL/LQjk049JunzRV1YCa0vDi+BNEpbDmr5cp3lGZllq9QQFO1eI71ZPzvFVn3vvhb+eOif4sAEyGgbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/dom": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/code": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/code/-/code-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-c0zyWodNqjL/0WI67sZvACIOFU9IAHG0UeeIpjss8pZGGNBum+UWkh7nKULK0SYvaOrdPdlWWqjuFU1TFA5jUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/delimiter": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/delimiter/-/delimiter-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-S8q2LpeYdYkVShLp7K8c4HLthDHBevLw+sT+iO0+SH0oMvFmld9SUon3DFzMQ2gG07EOdZGRZ958+sVxyvFjZw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/dom": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-yLO+86MYOIUr1Jl7SQw23SYT84ggv6aJW0EIRsI3NTHYgnQzmK7Bt2n5ZFupQlB0GJqmKqA5tCue3NKQb+o7Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/helpers": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/editorjs": {
|
||||||
|
"version": "2.31.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.31.5.tgz",
|
||||||
|
"integrity": "sha512-pEwYE4HzE63DlSSCErV2foTak7Wp9fd7SGkG+WcwiYD0cPmuCowhEsqL+9MF4/ZIjc/KJzDEvhB3NC1B8gQkpQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/caret": "^1.0.1",
|
||||||
|
"codex-notifier": "^1.1.2",
|
||||||
|
"codex-tooltip": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/embed": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/embed/-/embed-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-GkgL07M1GmRXq+vtYPkP9RLoij19mIMeyr5GrNo/0Km2XHmvDz2h6KHsDbiHXbq/5hZ5UgWi86kr+/aK165OBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/editorjs": "^2.31.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/header": {
|
||||||
|
"version": "2.8.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.8.tgz",
|
||||||
|
"integrity": "sha512-bsMSs34u2hoi0UBuRoc5EGWXIFzJiwYgkFUYQGVm63y5FU+s8zPBmVx5Ip2sw1xgs0fqfDROqmteMvvmbCy62w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.0.5",
|
||||||
|
"@editorjs/editorjs": "^2.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/header/node_modules/@codexteam/icons": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/helpers": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-Lmr8ImoQvoROXtzhsIJsA1ZtXzH46DmE6O8hMjn9/AvQq62UfjREjn+Ewi6KxjIZMay2PsgDEbLlsVyNJGEaxw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/image": {
|
||||||
|
"version": "2.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/image/-/image-2.10.3.tgz",
|
||||||
|
"integrity": "sha512-ekCsGICZOIdghF/U2T34H7CItqaWAoJDXbkRD+x8l/LIo/7Ozf7KovYm21qz+CluArgV4RurVFHqwlz+O0vfJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/list": {
|
||||||
|
"version": "2.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/list/-/list-2.0.9.tgz",
|
||||||
|
"integrity": "sha512-rUTgDSt5wygD3Dp24bNyp6vvye/Xf4UWju0ZuvWeP13Z4cu2z1Jb5JFSTEhCou72XUGuf4xVhtsd8cm/bwUS1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/quote": {
|
||||||
|
"version": "2.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/quote/-/quote-2.7.6.tgz",
|
||||||
|
"integrity": "sha512-D01KUMSDj2r+6Z+xjDkQqI+y6URpeHCvj0+P4pah+GtkG040lWjFb2H4pgHFXuol2cbfyAoraYSw85fuPheCvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.3.2",
|
||||||
|
"@editorjs/dom": "^0.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/quote/node_modules/@editorjs/dom": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/dom/-/dom-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-SZ78Gwpkp3EUhjBIp0lSojeQ35V9acF8SubJsMeOH/vlOUE40GOnvvwWZnF05lO7bIB0dOHhhJy4N7IIAWxP2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/helpers": "^0.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/quote/node_modules/@editorjs/helpers": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/helpers/-/helpers-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-ieg3dzo2m1/ELze/RMNADiAiC5amXxIlVXoJ5vvXITOu/p/dPsrF+Oi3h5gBYvtGk9vg5LJUSG5YWU0tBUO1tw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/table": {
|
||||||
|
"version": "2.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/table/-/table-2.4.5.tgz",
|
||||||
|
"integrity": "sha512-pF48R2wc5m0c+N+RjtCLXBGZd23Rl7EjfSFpmcSViwNsu5RwMgYGrEiQ8mzVh98mbvYQwXm/NYBi9DEUUs970A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/table/node_modules/@codexteam/icons": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-L7Q5PET8PjKcBT5wp7VR+FCjwCi5PUp7rd/XjsgQ0CI5FJz0DphyHGRILMuDUdCW2MQT9NHbVr4QP31vwAkS/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -4826,6 +4982,18 @@
|
|||||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/codex-notifier": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/codex-tooltip": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@editorjs/code": "^2.9.4",
|
||||||
|
"@editorjs/delimiter": "^1.4.2",
|
||||||
|
"@editorjs/editorjs": "^2.31.5",
|
||||||
|
"@editorjs/embed": "^2.8.0",
|
||||||
|
"@editorjs/header": "^2.8.8",
|
||||||
|
"@editorjs/image": "^2.10.3",
|
||||||
|
"@editorjs/list": "^2.0.9",
|
||||||
|
"@editorjs/quote": "^2.7.6",
|
||||||
|
"@editorjs/table": "^2.4.5",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
@@ -29,6 +38,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/estree": "^1.0.8",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
#root {
|
#root {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: 6em;
|
height: 6em;
|
||||||
padding: 1.5em;
|
padding: 1.5em;
|
||||||
will-change: filter;
|
will-change: filter;
|
||||||
transition: filter 300ms;
|
transition: filter 300ms;
|
||||||
}
|
}
|
||||||
.logo:hover {
|
.logo:hover {
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
}
|
}
|
||||||
.logo.react:hover {
|
.logo.react:hover {
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes logo-spin {
|
@keyframes logo-spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
a:nth-of-type(2) .logo {
|
a:nth-of-type(2) .logo {
|
||||||
animation: logo-spin infinite 20s linear;
|
animation: logo-spin infinite 20s linear;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
padding: 2em;
|
padding: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.read-the-docs {
|
.read-the-docs {
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {AuthProvider} from "./context/AuthContext";
|
|||||||
import Department from "./pages/Department";
|
import Department from "./pages/Department";
|
||||||
import Doctor from "./pages/Doctor";
|
import Doctor from "./pages/Doctor";
|
||||||
import Blog from "./pages/Blog";
|
import Blog from "./pages/Blog";
|
||||||
|
import BlogEditorPage from "./pages/BlogEditor";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -27,6 +28,8 @@ export default function App() {
|
|||||||
<Route path="/department" element={<Department />} />
|
<Route path="/department" element={<Department />} />
|
||||||
<Route path="/doctor" element={<Doctor />} />
|
<Route path="/doctor" element={<Doctor />} />
|
||||||
<Route path="/blog" element={<Blog />} />
|
<Route path="/blog" element={<Blog />} />
|
||||||
|
<Route path="/blog/create" element={<BlogEditorPage />} />
|
||||||
|
<Route path="/blog/edit/:id" element={<BlogEditorPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
48
frontend/src/api/blog.ts
Normal file
48
frontend/src/api/blog.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import apiClient from "@/api/client";
|
||||||
|
|
||||||
|
export interface Blog {
|
||||||
|
id?: number;
|
||||||
|
title: string;
|
||||||
|
writer: string;
|
||||||
|
image?: string;
|
||||||
|
content: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllBlogsApi = async () => {
|
||||||
|
const res = await apiClient.get("/blogs");
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBlogByIdApi = async (id: number) => {
|
||||||
|
const res = await apiClient.get(`/blogs/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBlogApi = async (data: Blog) => {
|
||||||
|
const res = await apiClient.post("/blogs", data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBlogApi = async (id: number, data: Blog) => {
|
||||||
|
const res = await apiClient.put(`/blogs/${id}`, data);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBlogApi = async (id: number) => {
|
||||||
|
const res = await apiClient.delete(`/blogs/${id}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* IMAGE UPLOAD */
|
||||||
|
export const uploadImageApi = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", file);
|
||||||
|
|
||||||
|
const res = await apiClient.post("/upload/image", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
@@ -1,7 +1,182 @@
|
|||||||
import React from "react";
|
import {useState, useEffect, useCallback} from "react";
|
||||||
|
import {AxiosError} from "axios";
|
||||||
|
import {useNavigate} from "react-router-dom";
|
||||||
|
|
||||||
function Blog() {
|
import {getAllBlogsApi, deleteBlogApi} from "@/api/blog";
|
||||||
return <div>Blog</div>;
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
|
||||||
|
import {Loader2, RefreshCw, Plus, Pencil, Trash} from "lucide-react";
|
||||||
|
|
||||||
|
interface Blog {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
writer: string;
|
||||||
|
image: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Blog;
|
export default function BlogPage() {
|
||||||
|
const [blogs, setBlogs] = useState<Blog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const fetchBlogs = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getAllBlogsApi();
|
||||||
|
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
setBlogs(res);
|
||||||
|
} else {
|
||||||
|
setBlogs([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AxiosError) {
|
||||||
|
setError(err.response?.data?.message || "Failed to load blogs");
|
||||||
|
} else {
|
||||||
|
setError("Something went wrong");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlogs();
|
||||||
|
}, [fetchBlogs]);
|
||||||
|
|
||||||
|
const filteredBlogs = blogs.filter((b) => {
|
||||||
|
const text = searchText.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
b.title?.toLowerCase().includes(text) ||
|
||||||
|
b.writer?.toLowerCase().includes(text)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
const confirmDelete = confirm("Delete this blog?");
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteBlogApi(id);
|
||||||
|
fetchBlogs();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold">Blogs</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Search blog..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
className="w-[220px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={fetchBlogs} disabled={loading}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={() => navigate("/blog/create")}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Blog
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-red-600 bg-red-50 border rounded-md">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Blog List</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<div className="border rounded-md overflow-x-auto max-w-full">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Writer</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : filteredBlogs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center">
|
||||||
|
No blogs found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filteredBlogs.map((blog) => (
|
||||||
|
<TableRow key={blog.id}>
|
||||||
|
<TableCell>{blog.id}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{blog.title}</TableCell>
|
||||||
|
|
||||||
|
<TableCell>{blog.writer}</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(`/blog/edit/${blog.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(blog.id)}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
208
frontend/src/pages/BlogEditor.tsx
Normal file
208
frontend/src/pages/BlogEditor.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {useEffect, useRef, useState} from "react";
|
||||||
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
|
|
||||||
|
import EditorJS, {OutputData} from "@editorjs/editorjs";
|
||||||
|
import Header from "@editorjs/header";
|
||||||
|
import List from "@editorjs/list";
|
||||||
|
import ImageTool from "@editorjs/image";
|
||||||
|
import Quote from "@editorjs/quote";
|
||||||
|
import Table from "@editorjs/table";
|
||||||
|
import CodeTool from "@editorjs/code";
|
||||||
|
import Embed from "@editorjs/embed";
|
||||||
|
import Delimiter from "@editorjs/delimiter";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBlogApi,
|
||||||
|
updateBlogApi,
|
||||||
|
getBlogByIdApi,
|
||||||
|
uploadImageApi,
|
||||||
|
} from "@/api/blog";
|
||||||
|
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
|
||||||
|
export default function BlogEditorPage() {
|
||||||
|
const {id} = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const editorRef = useRef<EditorJS | null>(null);
|
||||||
|
const hasInitialized = useRef(false);
|
||||||
|
const hasRenderedContent = useRef(false);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [writer, setWriter] = useState("");
|
||||||
|
const [coverImage, setCoverImage] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasInitialized.current) return;
|
||||||
|
hasInitialized.current = true;
|
||||||
|
|
||||||
|
let editor: EditorJS;
|
||||||
|
|
||||||
|
const initEditor = async () => {
|
||||||
|
editor = new EditorJS({
|
||||||
|
holder: "editorjs",
|
||||||
|
|
||||||
|
placeholder: "Write blog content...",
|
||||||
|
|
||||||
|
tools: {
|
||||||
|
header: {
|
||||||
|
class: Header,
|
||||||
|
inlineToolbar: true,
|
||||||
|
config: {
|
||||||
|
placeholder: "Enter heading",
|
||||||
|
levels: [1, 2, 3, 4],
|
||||||
|
defaultLevel: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
list: {
|
||||||
|
class: List,
|
||||||
|
inlineToolbar: true,
|
||||||
|
config: {
|
||||||
|
defaultStyle: "unordered",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
quote: Quote,
|
||||||
|
table: Table,
|
||||||
|
code: CodeTool,
|
||||||
|
embed: Embed,
|
||||||
|
delimiter: Delimiter,
|
||||||
|
|
||||||
|
image: {
|
||||||
|
class: ImageTool,
|
||||||
|
config: {
|
||||||
|
uploader: {
|
||||||
|
uploadByFile: async (file: File) => {
|
||||||
|
const res = await uploadImageApi(file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: 1,
|
||||||
|
file: {url: res.file.url},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await editor.isReady;
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
if (isEdit && id && !hasRenderedContent.current) {
|
||||||
|
try {
|
||||||
|
const res = await getBlogByIdApi(Number(id));
|
||||||
|
|
||||||
|
setTitle(res.title);
|
||||||
|
setWriter(res.writer);
|
||||||
|
setCoverImage(res.image || "");
|
||||||
|
|
||||||
|
if (res.content) {
|
||||||
|
await editor.blocks.clear();
|
||||||
|
await editor.render(res.content);
|
||||||
|
hasRenderedContent.current = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initEditor();
|
||||||
|
}, [id, isEdit]);
|
||||||
|
|
||||||
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await uploadImageApi(file);
|
||||||
|
setCoverImage(res.file.url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content: OutputData = await editorRef.current.save();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
writer,
|
||||||
|
image: coverImage,
|
||||||
|
content,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
await updateBlogApi(Number(id), payload);
|
||||||
|
} else {
|
||||||
|
await createBlogApi(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate("/blog");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{isEdit ? "Edit Blog" : "Create Blog"}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Blog Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Writer Name"
|
||||||
|
value={writer}
|
||||||
|
onChange={(e) => setWriter(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Cover Image</label>
|
||||||
|
|
||||||
|
<Input type="file" onChange={handleCoverUpload} />
|
||||||
|
|
||||||
|
{coverImage && (
|
||||||
|
<img
|
||||||
|
src={coverImage}
|
||||||
|
alt="cover"
|
||||||
|
className="w-full max-h-[250px] object-cover rounded-md"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="editorjs"
|
||||||
|
className="border rounded-md p-4 bg-white min-h-[300px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Save Blog"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user