feat: add blog page

This commit is contained in:
ARJUN S THAMPI
2026-03-18 14:25:08 +05:30
parent 2584539fb0
commit 1bbf7f9c1c
12 changed files with 638 additions and 25 deletions

View File

@@ -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;
}

View File

@@ -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
View 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;
};

View File

@@ -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>
);
}

View 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>
);
}