diff --git a/backend/uploads/blog/1773814232254.png b/backend/uploads/blog/1773814232254.png new file mode 100644 index 0000000..e0bbc79 Binary files /dev/null and b/backend/uploads/blog/1773814232254.png differ diff --git a/backend/uploads/blog/1773814239753.png b/backend/uploads/blog/1773814239753.png new file mode 100644 index 0000000..5d333f1 Binary files /dev/null and b/backend/uploads/blog/1773814239753.png differ diff --git a/backend/uploads/blog/1773814266558.png b/backend/uploads/blog/1773814266558.png new file mode 100644 index 0000000..5d333f1 Binary files /dev/null and b/backend/uploads/blog/1773814266558.png differ diff --git a/backend/uploads/blog/1773814356620.png b/backend/uploads/blog/1773814356620.png new file mode 100644 index 0000000..06e56ca Binary files /dev/null and b/backend/uploads/blog/1773814356620.png differ diff --git a/backend/uploads/blog/1773814805822.png b/backend/uploads/blog/1773814805822.png new file mode 100644 index 0000000..5d333f1 Binary files /dev/null and b/backend/uploads/blog/1773814805822.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 62dc828..f1a50aa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,15 @@ "name": "frontend", "version": "0.0.0", "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", "@tailwindcss/postcss": "^4.2.1", "axios": "^1.13.6", @@ -27,6 +36,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@types/node": "^24.12.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -512,6 +523,12 @@ "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": { "version": "1.54.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz", @@ -680,6 +697,145 @@ "@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": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -4826,6 +4982,18 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5230bf4..3c87900 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,15 @@ "preview": "vite preview" }, "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", "@tailwindcss/postcss": "^4.2.1", "axios": "^1.13.6", @@ -29,6 +38,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@types/node": "^24.12.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..df674c0 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1,42 @@ #root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; } .logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; } .logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + filter: drop-shadow(0 0 2em #646cffaa); } .logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + filter: drop-shadow(0 0 2em #61dafbaa); } @keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } @media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } } .card { - padding: 2em; + padding: 2em; } .read-the-docs { - color: #888; + color: #888; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de0ab13..dbdd97b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import {AuthProvider} from "./context/AuthContext"; import Department from "./pages/Department"; import Doctor from "./pages/Doctor"; import Blog from "./pages/Blog"; +import BlogEditorPage from "./pages/BlogEditor"; export default function App() { return ( @@ -27,6 +28,8 @@ export default function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/api/blog.ts b/frontend/src/api/blog.ts new file mode 100644 index 0000000..7e46c5c --- /dev/null +++ b/frontend/src/api/blog.ts @@ -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; +}; diff --git a/frontend/src/pages/Blog.tsx b/frontend/src/pages/Blog.tsx index 14b3994..d1fc088 100644 --- a/frontend/src/pages/Blog.tsx +++ b/frontend/src/pages/Blog.tsx @@ -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() { - return
Blog
; +import {getAllBlogsApi, deleteBlogApi} from "@/api/blog"; + +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([]); + 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 ( +
+
+

Blogs

+ +
+ setSearchText(e.target.value)} + className="w-[220px]" + /> + + + + +
+
+ + {error && ( +
+ {error} +
+ )} + + + + Blog List + + + +
+ + + + ID + Title + Writer + Actions + + + + + {loading ? ( + + + + + + ) : filteredBlogs.length === 0 ? ( + + + No blogs found + + + ) : ( + filteredBlogs.map((blog) => ( + + {blog.id} + + {blog.title} + + {blog.writer} + + + + + + + + )) + )} + +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/BlogEditor.tsx b/frontend/src/pages/BlogEditor.tsx new file mode 100644 index 0000000..b0e11c4 --- /dev/null +++ b/frontend/src/pages/BlogEditor.tsx @@ -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(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) => { + 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 ( +
+ + + {isEdit ? "Edit Blog" : "Create Blog"} + + + + setTitle(e.target.value)} + /> + + setWriter(e.target.value)} + /> + +
+ + + + + {coverImage && ( + cover + )} +
+ +
+ + + + +
+ ); +}