feat(blog): enhance blog data handling with normalization and improved image management
This commit is contained in:
+27
-19
@@ -2,6 +2,9 @@ import BlogPage from "@/components/blog";
|
|||||||
import Layout from "@/components/layout/StLayout";
|
import Layout from "@/components/layout/StLayout";
|
||||||
import { fetchReq } from "@/lib/req";
|
import { fetchReq } from "@/lib/req";
|
||||||
import { getStateInfo } from "@/lib/getStateInfo";
|
import { getStateInfo } from "@/lib/getStateInfo";
|
||||||
|
import { normalizeBlog, imageUrl } from "@/helper";
|
||||||
|
|
||||||
|
const FALLBACK_IMG = "https://www.nobat724.com/assets/images/logo.png";
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
@@ -10,12 +13,16 @@ export async function generateMetadata({ params }) {
|
|||||||
const siteName = matchedCity?.site_name || "نوبت 724";
|
const siteName = matchedCity?.site_name || "نوبت 724";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blog = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
const res = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
||||||
|
const blog = res?.data?.data;
|
||||||
if (!blog) return {};
|
if (!blog) return {};
|
||||||
|
|
||||||
const title = `${blog.title} | ${siteName}`;
|
const title = `${blog.title} | ${siteName}`;
|
||||||
const rawText = blog.body ? blog.body.replace(/<[^>]*>/g, "").trim() : "";
|
const rawText = (blog.summary || blog.body || "")
|
||||||
|
.replace(/<[^>]*>/g, "")
|
||||||
|
.trim();
|
||||||
const description = rawText.slice(0, 160) || blog.title;
|
const description = rawText.slice(0, 160) || blog.title;
|
||||||
|
const image = blog.image_url ? imageUrl(blog.image_url) : FALLBACK_IMG;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@@ -24,18 +31,16 @@ export async function generateMetadata({ params }) {
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
type: "article",
|
type: "article",
|
||||||
...(blog.created && { publishedTime: blog.created }),
|
...(blog.created_at && {
|
||||||
images: blog.images?.[0]
|
publishedTime: new Date(blog.created_at * 1000).toISOString(),
|
||||||
? [blog.images[0]]
|
}),
|
||||||
: ["https://www.nobat724.com/assets/images/logo.png"],
|
images: [image],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
images: blog.images?.[0]
|
images: [image],
|
||||||
? [blog.images[0]]
|
|
||||||
: ["https://www.nobat724.com/assets/images/logo.png"],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
@@ -47,24 +52,27 @@ async function Blog({ params }) {
|
|||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
const blog = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
const res = await fetchReq(`${API_URL}/api/v1/blog/${slug}`);
|
||||||
|
const blog = normalizeBlog(res?.data?.data);
|
||||||
|
|
||||||
let relatedBlogs = [];
|
let relatedBlogs = [];
|
||||||
if (blog?.tag?.[0]?.id) {
|
const relatedResponse = await fetchReq(
|
||||||
const tagId = blog.tag[0].id;
|
`${API_URL}/api/v1/blogs?page=1&limit=6`
|
||||||
const relatedResponse = await fetchReq(
|
);
|
||||||
`${API_URL}/api/v1/blogs?page=1&limit=12&tag=${tagId}`
|
relatedBlogs = (relatedResponse?.data || [])
|
||||||
);
|
.filter((b) => b.uuid !== blog?.uuid)
|
||||||
relatedBlogs = relatedResponse?.blogs || [];
|
.slice(0, 4)
|
||||||
}
|
.map(normalizeBlog);
|
||||||
|
|
||||||
const jsonLd = blog
|
const jsonLd = blog
|
||||||
? {
|
? {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Article",
|
"@type": "Article",
|
||||||
headline: blog.title,
|
headline: blog.title,
|
||||||
...(blog.images?.[0] && { image: blog.images[0] }),
|
...(blog.images?.[0]?.url && { image: imageUrl(blog.images[0].url) }),
|
||||||
...(blog.created && { datePublished: blog.created }),
|
...(blog.created && {
|
||||||
|
datePublished: new Date(blog.created * 1000).toISOString(),
|
||||||
|
}),
|
||||||
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
|
...(blog.author && { author: { "@type": "Person", name: blog.author } }),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { sanitizeHtml } from "@/lib/sanitize";
|
import { sanitizeHtml } from "@/lib/sanitize";
|
||||||
|
import { imageUrl } from "@/helper";
|
||||||
|
|
||||||
function Caption({ data }) {
|
function Caption({ data }) {
|
||||||
const imageUrl = data?.images?.[0]?.url;
|
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{imageUrl && imageUrl.trim() !== "" && (
|
{cover && cover.trim() !== "" && (
|
||||||
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
|
<div className="relative w-full aspect-video rounded-[8px] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={cover}
|
||||||
alt={data?.title || "blog cover"}
|
alt={data?.title || "blog cover"}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
|
|||||||
@@ -2,24 +2,24 @@ import CircularLoading from "@/app/component/loading/Circular";
|
|||||||
import CustomLoading from "@/app/component/loading/Custom";
|
import CustomLoading from "@/app/component/loading/Custom";
|
||||||
import TextLoading from "@/app/component/loading/Text";
|
import TextLoading from "@/app/component/loading/Text";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { convertTimestampToJalali } from "@/helper";
|
import { convertTimestampToJalali, imageUrl } from "@/helper";
|
||||||
|
|
||||||
function Item({ data, idx, loading }) {
|
function Item({ data, idx, loading }) {
|
||||||
const imageUrl = data?.images?.[0]?.url;
|
const cover = data?.images?.[0]?.url ? imageUrl(data.images[0].url) : "";
|
||||||
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
|
const createdDate = data?.created ? convertTimestampToJalali(data.created) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex relative items-center justify-start gap-[8px] py-[12px] sm:py-[15px] md:py-[18px] lg:py-[20px]">
|
<li className="flex relative items-center justify-start gap-[8px] py-[12px] sm:py-[15px] md:py-[18px] lg:py-[20px]">
|
||||||
<CustomLoading width={80} height={72} loading={loading}>
|
<CustomLoading width={80} height={72} loading={loading}>
|
||||||
<Link href={`/blog/${data.uuid}`}>
|
<Link href={`/blog/${data.uuid}`}>
|
||||||
{imageUrl && imageUrl.trim() !== "" ? (
|
{cover && cover.trim() !== "" ? (
|
||||||
<img
|
<img
|
||||||
className="
|
className="
|
||||||
w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px]
|
w-[64px] sm:w-[73px] md:w-[85px] lg:w-[97px]
|
||||||
h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px]
|
h-[56px] sm:h-[64px] md:h-[75px] lg:h-[88px]
|
||||||
rounded-[8px] overflow-hidden object-cover
|
rounded-[8px] overflow-hidden object-cover
|
||||||
"
|
"
|
||||||
src={imageUrl}
|
src={cover}
|
||||||
alt={data.title || "article"}
|
alt={data.title || "article"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ function RelatedContent({ data, loading }) {
|
|||||||
</AnimationTextHead>
|
</AnimationTextHead>
|
||||||
<ul className="flex w-full lg:w-fit flex-col justify-start items-start">
|
<ul className="flex w-full lg:w-fit flex-col justify-start items-start">
|
||||||
{data &&
|
{data &&
|
||||||
data.length &&
|
data.length > 0 &&
|
||||||
data?.map((item, idx) => (
|
data?.map((item, idx) => (
|
||||||
<Item idx={idx} data={item} key={item.id} loading={loading} />
|
<Item idx={idx} data={item} key={item.uuid} loading={loading} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import CustomLoading from "@/app/component/loading/Custom";
|
|
||||||
import TextLoading from "@/app/component/loading/Text";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { request } from "@/services/response";
|
|
||||||
|
|
||||||
|
// نمایش «بلاگهای برتر» فعلاً غیرفعال است (endpoint /api/v1/blogs/top در بکاند وجود ندارد).
|
||||||
|
// markup زیر برای فعالسازی بعدی نگه داشته شده؛ تا آن زمان کامپوننت چیزی رندر نمیکند.
|
||||||
function Head() {
|
function Head() {
|
||||||
const [topBlogs, setTopBlogs] = useState([]);
|
return null;
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
/* eslint-disable */
|
||||||
const fetchTopBlogs = async () => {
|
/*
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const response = await request.getTopBlogs();
|
|
||||||
setTopBlogs(response || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching top blogs:", error);
|
|
||||||
setTopBlogs([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTopBlogs();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isLoading || topBlogs.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [firstBlog, secondBlog, thirdBlog, fourthBlog] = topBlogs;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>{/*
|
|
||||||
<div className="flex flex-col lg:flex-row mt-[40px] items-center justify-center gap-[16px] sm:gap-[19px] md:gap-[22px] lg:gap-[24px]">
|
<div className="flex flex-col lg:flex-row mt-[40px] items-center justify-center gap-[16px] sm:gap-[19px] md:gap-[22px] lg:gap-[24px]">
|
||||||
{firstBlog && (
|
{firstBlog && (
|
||||||
<Link href={`/blog/${firstBlog.uuid}`} className="w-full lg:w-1/2 cover-head-blogs">
|
<Link href={`/blog/${firstBlog.uuid}`} className="w-full lg:w-1/2 cover-head-blogs">
|
||||||
@@ -104,8 +78,6 @@ function Head() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
*/}</>
|
*/
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Head;
|
export default Head;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Head from "./Head";
|
|||||||
import Title from "./title";
|
import Title from "./title";
|
||||||
import LatestArticles from "./latestArticles";
|
import LatestArticles from "./latestArticles";
|
||||||
import { request } from "@/services/response";
|
import { request } from "@/services/response";
|
||||||
|
import { normalizeBlog } from "@/helper";
|
||||||
|
|
||||||
function BlogsPage() {
|
function BlogsPage() {
|
||||||
const [blogs, setBlogs] = useState([]);
|
const [blogs, setBlogs] = useState([]);
|
||||||
@@ -25,8 +26,8 @@ function BlogsPage() {
|
|||||||
params.tag = selectedTag;
|
params.tag = selectedTag;
|
||||||
}
|
}
|
||||||
const response = await request.getBlogs(params);
|
const response = await request.getBlogs(params);
|
||||||
setBlogs(response.blogs || []);
|
setBlogs((response?.data || []).map(normalizeBlog));
|
||||||
setTotalPages(response.page?.totalPages || 1);
|
setTotalPages(response?.meta?.totalPages || 1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching blogs:", error);
|
console.error("Error fetching blogs:", error);
|
||||||
setBlogs([]);
|
setBlogs([]);
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import CustomLoading from "@/app/component/loading/Custom";
|
|||||||
import TextLoading from "@/app/component/loading/Text";
|
import TextLoading from "@/app/component/loading/Text";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { convertTimestampToJalali } from "@/helper";
|
import { convertTimestampToJalali, imageUrl } from "@/helper";
|
||||||
|
|
||||||
function Article({ data }) {
|
function Article({ data }) {
|
||||||
const imageSrc = data.images?.[0]?.url || "/assets/images/default-blog.jpg";
|
const imageSrc = data.images?.[0]?.url
|
||||||
|
? imageUrl(data.images[0].url, "/assets/images/cover-blog-1.png")
|
||||||
|
: "/assets/images/cover-blog-1.png";
|
||||||
const createdDate = data.created ? convertTimestampToJalali(data.created) : "";
|
const createdDate = data.created ? convertTimestampToJalali(data.created) : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function Title({ onTagChange }) {
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
};
|
};
|
||||||
const response = await request.getBlogTags(params);
|
const response = await request.getBlogTags(params);
|
||||||
setTags(response.data || []);
|
setTags(response?.data?.data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching tags:", error);
|
console.error("Error fetching tags:", error);
|
||||||
setTags([]);
|
setTags([]);
|
||||||
|
|||||||
@@ -12,6 +12,28 @@ export const numberToArStyle = (num) => num?.toLocaleString("ar-AE") || "";
|
|||||||
|
|
||||||
export const isActiveURL = (link, name) => link === name;
|
export const isActiveURL = (link, name) => link === name;
|
||||||
|
|
||||||
|
// نگاشت پاسخ API بلاگ (image_url/tags/created_at/body/author-object) به شکلی که
|
||||||
|
// کامپوننتهای بلاگ انتظار دارند (images[{url}]/tag[{name}]/created/body.value/author-string).
|
||||||
|
export const normalizeBlog = (blog) => {
|
||||||
|
if (!blog) return null;
|
||||||
|
return {
|
||||||
|
...blog,
|
||||||
|
images: blog.image_url ? [{ url: blog.image_url }] : [],
|
||||||
|
tag: Array.isArray(blog.tags)
|
||||||
|
? blog.tags.map((t) => (typeof t === "string" ? { name: t } : t))
|
||||||
|
: [],
|
||||||
|
created: blog.created_at ?? blog.created ?? null,
|
||||||
|
body:
|
||||||
|
blog.body && typeof blog.body === "object"
|
||||||
|
? blog.body
|
||||||
|
: { value: blog.body ?? "" },
|
||||||
|
author:
|
||||||
|
blog.author && typeof blog.author === "object"
|
||||||
|
? blog.author.name
|
||||||
|
: blog.author ?? null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// تبدیل مسیر نسبی تصویر (/uploads/...) به URL کامل backend.
|
// تبدیل مسیر نسبی تصویر (/uploads/...) به URL کامل backend.
|
||||||
// مسیرهای absolute (http...) و asset های محلی (/assets، /default) دستنخورده میمانند.
|
// مسیرهای absolute (http...) و asset های محلی (/assets، /default) دستنخورده میمانند.
|
||||||
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
|
export const imageUrl = (url, fallback = "/assets/images/user.png") => {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export const request = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
getBlogTags: (params) =>
|
getBlogTags: (params) =>
|
||||||
api.get("api/v1/categorys/tag", {
|
api.get("api/v1/tags", {
|
||||||
params,
|
params,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "",
|
Authorization: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user