feat: add blog page
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/department" element={<Department />} />
|
||||
<Route path="/doctor" element={<Doctor />} />
|
||||
<Route path="/blog" element={<Blog />} />
|
||||
<Route path="/blog/create" element={<BlogEditorPage />} />
|
||||
<Route path="/blog/edit/:id" element={<BlogEditorPage />} />
|
||||
</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() {
|
||||
return <div>Blog</div>;
|
||||
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<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