diff --git a/package.json b/package.json index 4b0b043..66b26e6 100644 --- a/package.json +++ b/package.json @@ -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" @@ -39,4 +41,4 @@ "typescript": "^5" }, "packageManager": "yarn@4.9.1" -} \ No newline at end of file +} diff --git a/public/js/script.js b/public/js/script.js index a24670f..54c4c26 100644 --- a/public/js/script.js +++ b/public/js/script.js @@ -1084,91 +1084,91 @@ $(function () { } // RD Search - if (plugins.search.length || plugins.searchResults) { - var handler = "bat/rd-search.php"; - var defaultTemplate = '
#{title}
' + - '

...#{token}...

' + - '

Terms matched: #{count} - URL: #{href}

'; - var defaultFilter = '*.html'; + // if (plugins.search.length || plugins.searchResults) { + // var handler = "bat/rd-search.php"; + // var defaultTemplate = '
#{title}
' + + // '

...#{token}...

' + + // '

Terms matched: #{count} - URL: #{href}

'; + // 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 () { - $('').attr('type', 'hidden') - .attr('name', "filter") - .attr('value', this.filter) - .appendTo(this.element); - return true; - }, options, this)) - } - } + // searchItem.submit($.proxy(function () { + // $('').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) { diff --git a/src/app/(main)/blog/page.tsx b/src/app/(main)/blog/page.tsx index 74e2d91..f1d6f75 100644 --- a/src/app/(main)/blog/page.tsx +++ b/src/app/(main)/blog/page.tsx @@ -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 { 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} /> -
- {data.map((blog) => ( - - ))} -
-
- -
+ + diff --git a/src/collections/Blogs.ts b/src/collections/Blogs.ts index 55437d6..6a7eea8 100644 --- a/src/collections/Blogs.ts +++ b/src/collections/Blogs.ts @@ -99,4 +99,9 @@ export const Blogs: CollectionConfig = { group: "Blogs", useAsTitle: "title", }, + access: { + read: ({ req: { user } }) => { + return true; + }, + }, }; diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 0000000..0807c0b --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,17 @@ +export default function Loader() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/components/CardBlog.tsx b/src/components/blogs/CardBlog.tsx similarity index 73% rename from src/components/CardBlog.tsx rename to src/components/blogs/CardBlog.tsx index 9c0d163..6d8e299 100644 --- a/src/components/CardBlog.tsx +++ b/src/components/blogs/CardBlog.tsx @@ -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 (
- - {data.title} - +
+ + {data.img?.alt + +

diff --git a/src/components/blogs/ListOfBlog.tsx b/src/components/blogs/ListOfBlog.tsx new file mode 100644 index 0000000..f979e1d --- /dev/null +++ b/src/components/blogs/ListOfBlog.tsx @@ -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 ( + <> +
+ {blogQuery.data.map((blog) => ( + + ))} +
+
+ {blogQuery.isFetching && } + {blogQuery.hasNext && ( + + )} +
+ + ); +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 83618ec..c53d1fc 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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: { diff --git a/src/schema/blog.ts b/src/schema/blog.ts index 30f066d..6349fc2 100644 --- a/src/schema/blog.ts +++ b/src/schema/blog.ts @@ -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; }; diff --git a/src/schema/services/blog.ts b/src/schema/services/blog.ts new file mode 100644 index 0000000..ab91539 --- /dev/null +++ b/src/schema/services/blog.ts @@ -0,0 +1,6 @@ +export type FetchBlogParams = { + page?: number; + search?: string; + categoryId?: number; + tagId?: number; +}; diff --git a/src/services/hooks/blog.ts b/src/services/hooks/blog.ts new file mode 100644 index 0000000..ba9f31e --- /dev/null +++ b/src/services/hooks/blog.ts @@ -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([]); + 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, + }; +} diff --git a/src/services/payload/blog.ts b/src/services/payload/blog.ts new file mode 100644 index 0000000..bd4696d --- /dev/null +++ b/src/services/payload/blog.ts @@ -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], + }; +} diff --git a/src/services/rest/blog.ts b/src/services/rest/blog.ts new file mode 100644 index 0000000..1ea4527 --- /dev/null +++ b/src/services/rest/blog.ts @@ -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; + 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; + } +} diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts new file mode 100644 index 0000000..f48879e --- /dev/null +++ b/src/utils/datetime.ts @@ -0,0 +1,5 @@ +import dayjs from "dayjs"; + +export function formatDate(iso: string, format: string = "MMM, D YYYY") { + return dayjs(iso).format(format); +} diff --git a/src/utils/general.ts b/src/utils/general.ts new file mode 100644 index 0000000..294ef90 --- /dev/null +++ b/src/utils/general.ts @@ -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; +} diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts new file mode 100644 index 0000000..34432f6 --- /dev/null +++ b/src/utils/sanitize.ts @@ -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}...`; +} diff --git a/yarn.lock b/yarn.lock index 492728a..7579afd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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