feat: blog list fetching and related

This commit is contained in:
RizqiSyahrendra 2025-04-21 08:14:53 +07:00
parent 14e5b30281
commit 455a9785bd
17 changed files with 460 additions and 146 deletions

View File

@ -17,9 +17,11 @@
"@payloadcms/payload-cloud": "^3.35.1",
"@payloadcms/richtext-lexical": "^3.35.1",
"@payloadcms/storage-s3": "^3.35.1",
"dayjs": "^1.11.13",
"graphql": "^16.8.1",
"next": "15.3.0",
"payload": "^3.35.1",
"qs-esm": "^7.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"swiper": "^11.2.6"

View File

@ -1084,91 +1084,91 @@ $(function () {
}
// RD Search
if (plugins.search.length || plugins.searchResults) {
var handler = "bat/rd-search.php";
var defaultTemplate = '<h5 class="search-title"><a target="_top" href="#{href}" class="search-link">#{title}</a></h5>' +
'<p>...#{token}...</p>' +
'<p class="match"><em>Terms matched: #{count} - URL: #{href}</em></p>';
var defaultFilter = '*.html';
// if (plugins.search.length || plugins.searchResults) {
// var handler = "bat/rd-search.php";
// var defaultTemplate = '<h5 class="search-title"><a target="_top" href="#{href}" class="search-link">#{title}</a></h5>' +
// '<p>...#{token}...</p>' +
// '<p class="match"><em>Terms matched: #{count} - URL: #{href}</em></p>';
// var defaultFilter = '*.html';
if (plugins.search.length) {
for (var i = 0; i < plugins.search.length; i++) {
var searchItem = $(plugins.search[i]),
options = {
element: searchItem,
filter: (searchItem.attr('data-search-filter')) ? searchItem.attr('data-search-filter') : defaultFilter,
template: (searchItem.attr('data-search-template')) ? searchItem.attr('data-search-template') : defaultTemplate,
live: (searchItem.attr('data-search-live')) ? searchItem.attr('data-search-live') : false,
liveCount: (searchItem.attr('data-search-live-count')) ? parseInt(searchItem.attr('data-search-live'), 10) : 4,
current: 0, processed: 0, timer: {}
};
// if (plugins.search.length) {
// for (var i = 0; i < plugins.search.length; i++) {
// var searchItem = $(plugins.search[i]),
// options = {
// element: searchItem,
// filter: (searchItem.attr('data-search-filter')) ? searchItem.attr('data-search-filter') : defaultFilter,
// template: (searchItem.attr('data-search-template')) ? searchItem.attr('data-search-template') : defaultTemplate,
// live: (searchItem.attr('data-search-live')) ? searchItem.attr('data-search-live') : false,
// liveCount: (searchItem.attr('data-search-live-count')) ? parseInt(searchItem.attr('data-search-live'), 10) : 4,
// current: 0, processed: 0, timer: {}
// };
var $toggle = $('.rd-navbar-search-toggle');
if ($toggle.length) {
$toggle.on('click', (function (searchItem) {
return function () {
if (!($(this).hasClass('active'))) {
searchItem.find('input').val('').trigger('propertychange');
}
}
})(searchItem));
}
// var $toggle = $('.rd-navbar-search-toggle');
// if ($toggle.length) {
// $toggle.on('click', (function (searchItem) {
// return function () {
// if (!($(this).hasClass('active'))) {
// searchItem.find('input').val('').trigger('propertychange');
// }
// }
// })(searchItem));
// }
if (options.live) {
var clearHandler = false;
// if (options.live) {
// var clearHandler = false;
searchItem.find('input').on("input propertychange", $.proxy(function () {
this.term = this.element.find('input').val().trim();
this.spin = this.element.find('.input-group-addon');
// searchItem.find('input').on("input propertychange", $.proxy(function () {
// this.term = this.element.find('input').val().trim();
// this.spin = this.element.find('.input-group-addon');
clearTimeout(this.timer);
// clearTimeout(this.timer);
if (this.term.length > 2) {
this.timer = setTimeout(liveSearch(this), 200);
// if (this.term.length > 2) {
// this.timer = setTimeout(liveSearch(this), 200);
if (clearHandler === false) {
clearHandler = true;
// if (clearHandler === false) {
// clearHandler = true;
$body.on("click", function (e) {
if ($(e.toElement).parents('.rd-search').length === 0) {
$('#rd-search-results-live').addClass('cleared').html('');
}
})
}
// $body.on("click", function (e) {
// if ($(e.toElement).parents('.rd-search').length === 0) {
// $('#rd-search-results-live').addClass('cleared').html('');
// }
// })
// }
} else if (this.term.length === 0) {
$('#' + this.live).addClass('cleared').html('');
}
}, options, this));
}
// } else if (this.term.length === 0) {
// $('#' + this.live).addClass('cleared').html('');
// }
// }, options, this));
// }
searchItem.submit($.proxy(function () {
$('<input />').attr('type', 'hidden')
.attr('name', "filter")
.attr('value', this.filter)
.appendTo(this.element);
return true;
}, options, this))
}
}
// searchItem.submit($.proxy(function () {
// $('<input />').attr('type', 'hidden')
// .attr('name', "filter")
// .attr('value', this.filter)
// .appendTo(this.element);
// return true;
// }, options, this))
// }
// }
if (plugins.searchResults.length) {
var regExp = /\?.*s=([^&]+)\&filter=([^&]+)/g;
var match = regExp.exec(location.search);
// if (plugins.searchResults.length) {
// var regExp = /\?.*s=([^&]+)\&filter=([^&]+)/g;
// var match = regExp.exec(location.search);
if (match !== null) {
$.get(handler, {
s: decodeURI(match[1]),
dataType: "html",
filter: match[2],
template: defaultTemplate,
live: ''
}, function (data) {
plugins.searchResults.html(data);
})
}
}
}
// if (match !== null) {
// $.get(handler, {
// s: decodeURI(match[1]),
// dataType: "html",
// filter: match[2],
// template: defaultTemplate,
// live: ''
// }, function (data) {
// plugins.searchResults.html(data);
// })
// }
// }
// }
// Swiper
function makeInterLeaveEffectOptions(interleaveOffset) {

View File

@ -1,6 +1,5 @@
import CardBlog from "@/components/CardBlog";
import ListOfBlog from "@/components/blogs/ListOfBlog";
import HeroImage from "@/components/HeroImage";
import { CardBlogData } from "@/schema/blog";
import { getDefaultMetadata } from "@/utils/metadata";
import { Metadata } from "next";
@ -15,57 +14,8 @@ export async function generateMetadata(): Promise<Metadata> {
return metadata;
}
export default function Blog() {
const data: CardBlogData[] = [
{
slug: "introducing-a-ray-kappe",
title: "Introducing A Ray Kappe-Designed Masterpiece",
description:
"The famed Cipriani family has unveiled Mr. C Residences, its first-ever hotel branded residences, situated adjacent to the flagship",
img: "/images/blog-04-736x540.jpg",
posted_at: "March 15, 2021",
},
{
slug: "24234",
title: "Introducing A Ray Kappe-Designed Masterpiece",
description:
"The famed Cipriani family has unveiled Mr. C Residences, its first-ever hotel branded residences, situated adjacent to the flagship",
img: "/images/blog-04-736x540.jpg",
posted_at: "March 15, 2021",
},
{
slug: "wkejrh2k3jr",
title: "Introducing A Ray Kappe-Designed Masterpiece",
description:
"The famed Cipriani family has unveiled Mr. C Residences, its first-ever hotel branded residences, situated adjacent to the flagship",
img: "/images/blog-04-736x540.jpg",
posted_at: "March 15, 2021",
},
{
slug: "1l2kj4lw34",
title: "Introducing A Ray Kappe-Designed Masterpiece",
description:
"The famed Cipriani family has unveiled Mr. C Residences, its first-ever hotel branded residences, situated adjacent to the flagship",
img: "/images/blog-04-736x540.jpg",
posted_at: "March 15, 2021",
},
{
slug: "asflj2lj53545",
title: "Introducing A Ray Kappe-Designed Masterpiece",
description:
"The famed Cipriani family has unveiled Mr. C Residences, its first-ever hotel branded residences, situated adjacent to the flagship",
img: "/images/blog-04-736x540.jpg",
posted_at: "March 15, 2021",
},
{
slug: "adflkj2oj545",
title: "Introducing A Ray Kappe-Designed Masterpiece",
description:
"The famed Cipriani family has unveiled Mr. C Residences, its first-ever hotel branded residences, situated adjacent to the flagship",
img: "/images/blog-04-736x540.jpg",
posted_at: "",
},
];
export default async function Blog({ searchParams }: { searchParams?: Promise<{ s?: string }> }) {
const params = await searchParams;
return (
<>
@ -82,21 +32,14 @@ export default function Blog() {
type="text"
name="s"
autoComplete="off"
defaultValue={params?.s}
/>
</div>
<button className="rd-search-submit" type="submit"></button>
</form>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-5">
{data.map((blog) => (
<CardBlog key={blog.slug} data={blog} />
))}
</div>
<div className="mt-5">
<button type="submit" className="button button-primary">
LOAD MORE...
</button>
</div>
<ListOfBlog searchKeyword={params?.s} />
</div>
</section>
</>

View File

@ -99,4 +99,9 @@ export const Blogs: CollectionConfig = {
group: "Blogs",
useAsTitle: "title",
},
access: {
read: ({ req: { user } }) => {
return true;
},
},
};

17
src/components/Loader.tsx Normal file
View File

@ -0,0 +1,17 @@
export default function Loader() {
return (
<div className="flex flex-row justify-center">
<div className="w-[72px] h-[72px]">
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
<div className="banter-loader__box"></div>
</div>
</div>
);
}

View File

@ -1,17 +1,20 @@
import { CardBlogData } from "@/schema/blog";
import { BlogData } from "@/schema/blog";
import Image from "next/image";
import Link from "next/link";
type CardBlogProps = {
data: CardBlogData;
data: BlogData;
};
export default function CardBlog({ data }: CardBlogProps) {
return (
<div>
<article className="post-default">
<a className="post-default-image" href="blog-post.html">
<Image src={data.img} alt={data.title} width="736" height="540" />
</a>
<div className="h-64 relative">
<Link href="#">
<Image src={data.img?.url ?? ""} alt={data.img?.alt ?? ""} fill />
</Link>
</div>
<div className="post-default-body">
<div className="post-default-title">
<h4>

View File

@ -0,0 +1,47 @@
"use client";
import Loader from "@/components/Loader";
import { useBlogQuery } from "@/services/hooks/blog";
import { useEffect, useRef } from "react";
import CardBlog from "./CardBlog";
type ListOfBlogProps = {
searchKeyword?: string;
};
export default function ListOfBlog({ searchKeyword }: ListOfBlogProps) {
const pageRef = useRef(1);
const blogQuery = useBlogQuery();
useEffect(() => {
blogQuery._fetch({
search: searchKeyword,
page: pageRef.current,
});
}, []);
function fetchMore() {
blogQuery._fetch({
search: searchKeyword,
page: ++pageRef.current,
});
}
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-5">
{blogQuery.data.map((blog) => (
<CardBlog key={blog.slug} data={blog} />
))}
</div>
<div className="mt-5">
{blogQuery.isFetching && <Loader />}
{blogQuery.hasNext && (
<button onClick={fetchMore} className="button button-primary">
LOAD MORE...
</button>
)}
</div>
</>
);
}

View File

@ -18,6 +18,8 @@ const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
export default buildConfig({
cors: [process.env.SITE_URL || ""],
csrf: [process.env.SITE_URL || ""],
admin: {
user: Users.slug,
importMap: {

View File

@ -1,7 +1,7 @@
export type CardBlogData = {
slug: string;
export type BlogData = {
slug?: string | null;
title: string;
description: string;
img: string;
img?: { url: string; alt?: string };
posted_at: string;
};

View File

@ -0,0 +1,6 @@
export type FetchBlogParams = {
page?: number;
search?: string;
categoryId?: number;
tagId?: number;
};

View File

@ -0,0 +1,28 @@
import { BlogData } from "@/schema/blog";
import { FetchBlogParams } from "@/schema/services/blog";
import { useState } from "react";
import { fetchBlogREST } from "../rest/blog";
export function useBlogQuery() {
const [data, setData] = useState<BlogData[]>([]);
const [isFetching, setFetching] = useState(false);
const [hasNext, setHasNext] = useState(false);
async function _fetch(params: FetchBlogParams = {}) {
setFetching(true);
const res = await fetchBlogREST(params);
setFetching(false);
if (Array.isArray(res?.formattedData)) {
setData(res.formattedData);
}
setHasNext(res?.hasNextPage ?? false);
}
return {
_fetch,
data,
isFetching,
hasNext,
};
}

View File

@ -0,0 +1,147 @@
import payloadConfig from "@/payload.config";
import { FetchBlogParams } from "@/schema/services/blog";
import { formatDate } from "@/utils/datetime";
import { getRandomNumber } from "@/utils/general";
import { getPayload, Where } from "payload";
export async function fetchBlog({ page, search = "", categoryId, tagId }: FetchBlogParams = {}) {
const payload = await getPayload({ config: payloadConfig });
const queryCondition: Where = {
_status: { equals: "published" },
};
if (!!search) {
queryCondition["title"] = {
contains: search,
};
}
if (!!categoryId) {
queryCondition["categories"] = {
equals: categoryId,
};
}
if (!!tagId) {
queryCondition["tags"] = {
equals: tagId,
};
}
const blogDataQuery = await payload.find({
collection: "blogs",
page,
pagination: true,
limit: 9,
where: queryCondition,
});
const formattedData = blogDataQuery.docs.map((item) => {
return {
...item,
imgFormatted: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined,
createdAtFormatted: formatDate(item.createdAt),
};
});
return {
...blogDataQuery,
formattedData,
};
}
export async function fetchBlogSuggestion() {
const payload = await getPayload({ config: payloadConfig });
const limitPerPage = 2;
const blogCountQuery = await payload.count({
collection: "blogs",
where: { _status: { equals: "published" } },
});
// randomize page
let page = 1;
const totalDocs = blogCountQuery.totalDocs;
if (totalDocs > limitPerPage) {
const totalPage = Math.ceil(totalDocs / limitPerPage);
page = getRandomNumber(totalPage);
}
const blogDataQuery = await payload.find({
collection: "blogs",
page,
limit: limitPerPage,
});
const formattedData = blogDataQuery.docs.map((item) => {
return {
...item,
imgFormatted: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined,
createdAtFormatted: formatDate(item.createdAt),
};
});
return {
...blogDataQuery,
formattedData,
};
}
export async function fetchBlogDetail(slug: string | undefined) {
const payload = await getPayload({ config: payloadConfig });
const blogDataQuery = await payload.find({
collection: "blogs",
where: {
_status: { equals: "published" },
slug: { equals: slug },
},
limit: 1,
pagination: false,
});
if (!blogDataQuery?.docs?.[0]) return null;
const data = blogDataQuery?.docs?.[0];
const createdAt = formatDate(data.createdAt);
const updatedAt = formatDate(data.updatedAt);
const imgUrl = typeof data.img !== "number" ? (data?.img?.url ?? "") : "";
return {
data,
createdAt,
updatedAt,
imgUrl,
};
}
export async function fetchBlogCategoryBySlug(slug: string) {
const payload = await getPayload({ config: payloadConfig });
const category = await payload.find({
collection: "blogCategories",
where: {
_status: { equals: "published" },
slug: { equals: slug },
},
});
if (!category?.docs?.[0]) return null;
return {
data: category.docs[0],
};
}
export async function fetchBlogTagBySlug(slug: string) {
const payload = await getPayload({ config: payloadConfig });
const tag = await payload.find({
collection: "blogTags",
where: {
_status: { equals: "published" },
slug: { equals: slug },
},
});
if (!tag?.docs?.[0]) return null;
return {
data: tag.docs[0],
};
}

61
src/services/rest/blog.ts Normal file
View File

@ -0,0 +1,61 @@
import { Blog } from "@/payload-types";
import { BlogData } from "@/schema/blog";
import { FetchBlogParams } from "@/schema/services/blog";
import { formatDate } from "@/utils/datetime";
import { sanitizeBlogContentIntoStringPreview } from "@/utils/sanitize";
import { PaginatedDocs, Where } from "payload";
import { stringify } from "qs-esm";
export async function fetchBlogREST({ page, search = "", categoryId, tagId }: FetchBlogParams = {}) {
const queryCondition: Where = {
_status: { equals: "published" },
};
if (!!search) {
queryCondition["title"] = {
contains: search,
};
}
if (!!categoryId) {
queryCondition["categories"] = {
equals: categoryId,
};
}
if (!!tagId) {
queryCondition["tags"] = {
equals: tagId,
};
}
const queryParams = stringify(
{
page,
pagination: true,
limit: 9,
where: queryCondition,
},
{ addQueryPrefix: true }
);
const blogRequest = await fetch(`/api/blogs${queryParams}`);
if (blogRequest.ok) {
const resData = (await blogRequest.json()) as PaginatedDocs<Blog>;
const formattedData: BlogData[] = resData.docs.map((item) => {
return {
slug: item.slug,
title: item.title,
description: sanitizeBlogContentIntoStringPreview(item.content),
img: typeof item.img !== "number" ? { url: item?.img?.url ?? "", alt: item.img.alt } : undefined,
posted_at: formatDate(item.createdAt),
};
});
return {
...resData,
formattedData,
};
} else {
return null;
}
}

5
src/utils/datetime.ts Normal file
View File

@ -0,0 +1,5 @@
import dayjs from "dayjs";
export function formatDate(iso: string, format: string = "MMM, D YYYY") {
return dayjs(iso).format(format);
}

7
src/utils/general.ts Normal file
View File

@ -0,0 +1,7 @@
export function limitString(text: string) {
return `${text.length > 100 ? `${text.slice(0, 100)}...` : text}`;
}
export function getRandomNumber(range: number): number {
return Math.floor(Math.random() * range) + 1;
}

32
src/utils/sanitize.ts Normal file
View File

@ -0,0 +1,32 @@
import { Blog } from "@/payload-types";
export function sanitizePageNumber(page: any, defaultPage = 1): number {
const parsedPage = Number(page);
if (isNaN(parsedPage) || parsedPage < 1 || !Number.isInteger(parsedPage)) {
return defaultPage;
}
return parsedPage;
}
export function sanitizeBlogContentIntoStringPreview(data: Blog["content"]) {
// Find the first paragraph that has children with text
const firstParagraph = data.root.children.find(
(node) =>
node.type === "paragraph" &&
Array.isArray(node.children) &&
node.children.length > 0 &&
!!node.children?.[0]?.text
);
if (!firstParagraph) {
return "...";
}
// @ts-ignore
const text = firstParagraph.children?.[0]?.text ?? "";
// Limit to 100 characters
return `${text.length > 100 ? text.slice(0, 100) : text}...`;
}

View File

@ -4759,6 +4759,13 @@ __metadata:
languageName: node
linkType: hard
"dayjs@npm:^1.11.13":
version: 1.11.13
resolution: "dayjs@npm:1.11.13"
checksum: 10c0/a3caf6ac8363c7dade9d1ee797848ddcf25c1ace68d9fe8678ecf8ba0675825430de5d793672ec87b24a69bf04a1544b176547b2539982275d5542a7955f35b7
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.0":
version: 4.4.0
resolution: "debug@npm:4.4.0"
@ -5015,6 +5022,7 @@ __metadata:
"@types/node": "npm:^20"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
dayjs: "npm:^1.11.13"
eslint: "npm:^9"
eslint-config-next: "npm:15.3.0"
eslint-config-prettier: "npm:^10.1.2"
@ -5023,6 +5031,7 @@ __metadata:
next: "npm:15.3.0"
payload: "npm:^3.35.1"
prettier: "npm:^3.5.3"
qs-esm: "npm:^7.0.2"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
swiper: "npm:^11.2.6"
@ -8673,7 +8682,7 @@ __metadata:
languageName: node
linkType: hard
"qs-esm@npm:7.0.2":
"qs-esm@npm:7.0.2, qs-esm@npm:^7.0.2":
version: 7.0.2
resolution: "qs-esm@npm:7.0.2"
checksum: 10c0/b46e15883b91818fd6b0862cac97439dfe67a1401c00729756b16463fa97e094239017dd4f17369dd0cf586e262305b165ee485c0b1088ca4d2eb7ad11c0c8fe