[1.0.0] #19
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 48 KiB |
@@ -6,7 +6,7 @@ import axios from "axios";
|
|||||||
interface BytescaleUploaderProps {
|
interface BytescaleUploaderProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (url: string) => void;
|
onChange: (url: string) => void;
|
||||||
folderPath: "/doctors" | "/departments" | "/news";
|
folderPath: "/doctors" | "/departments" | "/news" | "/blog";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BytescaleUploader({
|
export function BytescaleUploader({
|
||||||
|
|||||||
@@ -1,72 +1,202 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {useParams, useNavigate} from "react-router-dom";
|
import {useParams, useNavigate} from "react-router-dom";
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import {Card, CardContent} from "@/components/ui/card";
|
|
||||||
import {getBlogByIdApi} from "@/api/blog";
|
import {getBlogByIdApi} from "@/api/blog";
|
||||||
|
|
||||||
|
/* ---------------- LIST RENDERER ---------------- */
|
||||||
|
const renderList = (items, style) => {
|
||||||
|
// ✅ Checklist
|
||||||
|
if (style === "checklist") {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.meta?.checked || false}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={item.meta?.checked ? "line-through opacity-60" : ""}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: item.content || "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Ordered / Unordered
|
||||||
|
const ListTag = style === "ordered" ? "ol" : "ul";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListTag
|
||||||
|
className={`pl-6 mb-4 ${
|
||||||
|
style === "ordered" ? "list-decimal" : "list-disc"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{items
|
||||||
|
.filter((item) => item.content)
|
||||||
|
.map((item, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: item.content,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{item.items?.length > 0 && renderList(item.items, style)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- BLOCK RENDERER ---------------- */
|
||||||
|
const renderBlock = (block, index) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case "paragraph":
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={index}
|
||||||
|
className="mb-4 leading-7 text-gray-800"
|
||||||
|
dangerouslySetInnerHTML={{__html: block.data.text}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "header":
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
key={index}
|
||||||
|
className="text-2xl font-semibold mb-4"
|
||||||
|
dangerouslySetInnerHTML={{__html: block.data.text}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={block.data.file?.url}
|
||||||
|
alt={block.data.caption || "blog-image"}
|
||||||
|
className="w-full rounded-lg mb-4"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
return (
|
||||||
|
<div key={index}>{renderList(block.data.items, block.data.style)}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "quote":
|
||||||
|
return (
|
||||||
|
<blockquote
|
||||||
|
key={index}
|
||||||
|
className="border-l-4 border-gray-300 pl-4 italic my-4 text-gray-600"
|
||||||
|
>
|
||||||
|
{block.data.text}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "code":
|
||||||
|
return (
|
||||||
|
<pre
|
||||||
|
key={index}
|
||||||
|
className="bg-gray-100 p-4 rounded-md overflow-x-auto text-sm mb-4"
|
||||||
|
>
|
||||||
|
<code>{block.data.code}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
return (
|
||||||
|
<div key={index} className="overflow-x-auto mb-6">
|
||||||
|
<table className="w-full border border-gray-200">
|
||||||
|
<tbody>
|
||||||
|
{block.data.content.map((row, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
{row.map((cell, j) => (
|
||||||
|
<td
|
||||||
|
key={j}
|
||||||
|
className="border px-3 py-2"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: cell,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "delimiter":
|
||||||
|
return <hr key={index} className="my-6 border-gray-300" />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- MAIN COMPONENT ---------------- */
|
||||||
const BlogDetail = () => {
|
const BlogDetail = () => {
|
||||||
const {id} = useParams();
|
const {id} = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [blog, setBlog] = useState<any>(null);
|
const [blog, setBlog] = useState(null);
|
||||||
|
|
||||||
const fetchBlogById = async () => {
|
const fetchBlog = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getBlogByIdApi(Number(id));
|
const res = await getBlogByIdApi(Number(id));
|
||||||
|
console.log({res});
|
||||||
setBlog(response);
|
setBlog(res);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error("Error fetching blog", error);
|
console.error("Error fetching blog", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBlogById();
|
fetchBlog();
|
||||||
}, [id]); // ✅ FIXED dependency
|
}, [id]);
|
||||||
|
|
||||||
if (!blog) {
|
if (!blog) {
|
||||||
return <div className="mt-40 text-center text-gray-500">Loading...</div>;
|
return <p className="mt-40 text-center">Loading...</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className=" mx-auto flex flex-col ">
|
<div className="mx-auto flex flex-col ">
|
||||||
<Card>
|
{/* Back Button */}
|
||||||
<CardContent className="p-6 space-y-4">
|
<Button
|
||||||
{/* Back */}
|
variant="ghost"
|
||||||
<Button
|
className="mb-4 w-fit text-black px-0"
|
||||||
variant="ghost"
|
onClick={() => navigate(-1)}
|
||||||
className="text-black-600 p-0"
|
>
|
||||||
onClick={() => navigate(-1)}
|
← Back
|
||||||
>
|
</Button>
|
||||||
← Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h1 className="text-2xl md:text-4xl lg:text-5xl font-bold">
|
<h1 className="text-3xl md:text-5xl font-bold mb-2">{blog.title}</h1>
|
||||||
{blog.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
<p className="text-gray-500 text-sm">
|
<p className="text-gray-500 mb-4">
|
||||||
{blog.writer} • {new Date(blog.createdAt).toLocaleDateString()}
|
{blog.writer} • {new Date(blog.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image (only if exists) */}
|
||||||
<img
|
{blog.image?.trim() && (
|
||||||
src={blog.image}
|
<img
|
||||||
alt={blog.title}
|
src={blog.image}
|
||||||
className="w-full h-[220px] md:h-[400px] object-cover rounded-md"
|
alt="blog"
|
||||||
/>
|
className="w-full h-[220px] md:h-[400px] object-cover rounded-lg mb-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-3 text-gray-800 leading-relaxed">
|
<div>
|
||||||
{blog.content?.blocks?.map((block: any, index: number) => (
|
{blog.content?.blocks?.map((block, index) => renderBlock(block, index))}
|
||||||
<p key={index}>{block.data?.text}</p>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useEffect, useRef, useState} from "react";
|
import {useEffect, useRef, useState} from "react";
|
||||||
import {useNavigate, useParams} from "react-router-dom";
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
|
import {BytescaleUploader} from "@/components/BytescaleUploader/BytescaleUploader";
|
||||||
import EditorJS, {OutputData} from "@editorjs/editorjs";
|
import EditorJS, {OutputData} from "@editorjs/editorjs";
|
||||||
import Header from "@editorjs/header";
|
import Header from "@editorjs/header";
|
||||||
import List from "@editorjs/list";
|
import List from "@editorjs/list";
|
||||||
@@ -117,18 +117,6 @@ export default function BlogEditorPage() {
|
|||||||
initEditor();
|
initEditor();
|
||||||
}, [id, isEdit]);
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
@@ -182,7 +170,11 @@ export default function BlogEditorPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Cover Image</label>
|
<label className="text-sm font-medium">Cover Image</label>
|
||||||
|
|
||||||
<Input type="file" onChange={handleCoverUpload} />
|
<BytescaleUploader
|
||||||
|
value={coverImage}
|
||||||
|
folderPath="/blog"
|
||||||
|
onChange={(url) => setCoverImage(url)}
|
||||||
|
/>
|
||||||
|
|
||||||
{coverImage && (
|
{coverImage && (
|
||||||
<img
|
<img
|
||||||
|
|||||||