Commit 4d80d722 authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

[tag]0.1-vcci

parents 2da97d7d 17ad281c
Pipeline #53249 failed with stages
in 3 minutes and 27 seconds
...@@ -101,3 +101,77 @@ export interface AdminNewsFormValues { ...@@ -101,3 +101,77 @@ export interface AdminNewsFormValues {
event_dates?: string[]; event_dates?: string[];
post_content: AdminNewsContentSection[]; post_content: AdminNewsContentSection[];
} }
export type DynamicPostCategoryItem = {
id: string;
name: string;
slug: string;
url: string;
type: string;
};
export type DynamicPostThumbnail = {
path?: string | null;
original?: string | null;
url?: string | null;
} | null;
export type DynamicPostUser = {
id: string;
email: string;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
avatar_url: string | null;
} | null;
export type DynamicPostContentSection = {
id: string;
type: string;
content: string;
position: number;
image_rows: number;
image_columns: number;
images: Array<{
position: number;
caption: string | null;
image: {
id: string;
name: string;
alt: string;
url: string;
path?: string | null;
original?: string | null;
} | null;
}>;
};
export type DynamicPostItem = {
id: string;
title: string;
slug: string;
content: string;
summary: string;
release_at: string | null;
published_at: string | null;
created_at: string | null;
started_at: string | null;
ended_at: string | null;
expired_at: string | null;
registration_deadline: string | null;
location: string | null;
participation_fee: string | null;
is_featured: boolean;
is_hidden: boolean;
is_active: boolean;
status: string;
type: string;
thumbnail: DynamicPostThumbnail;
categories: DynamicPostCategoryItem[];
content_structure: {
post_content: DynamicPostContentSection[];
} | null;
creator: DynamicPostUser;
editor: DynamicPostUser;
};
...@@ -3,9 +3,9 @@ ...@@ -3,9 +3,9 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation"; import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import NewsPage from "./templates/NewsPage"; import NewsPage from "./templates/news-page";
import NewsDetailPage from "./templates/NewsDetailPage"; import NewsDetailPage from "./templates/news-detail-page";
import InformationPage from "./templates/InformationPage"; import InformationPage from "./templates/information-page";
import AnPham from "./static-pages/AnPham"; import AnPham from "./static-pages/AnPham";
import ThuVienTaiLieu from "./static-pages/ThuVienTaiLieu"; import ThuVienTaiLieu from "./static-pages/ThuVienTaiLieu";
import AboutVcciHcm from "./static-pages/AboutVcciHcm"; import AboutVcciHcm from "./static-pages/AboutVcciHcm";
...@@ -26,7 +26,8 @@ import SiteMap from "./static-pages/SiteMap"; ...@@ -26,7 +26,8 @@ import SiteMap from "./static-pages/SiteMap";
import Video from "./static-pages/Video"; import Video from "./static-pages/Video";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post"; import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
import { useGetApiV10Category } from "@/api/vcci-news/endpoints/category"; import { useGetApiV10Category } from "@/api/vcci-news/endpoints/category";
import type { DynamicPostItem, DynamicCategoryRouteItem } from "./templates/types"; import type { DynamicCategoryRouteItem } from "./templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const normalizePath = (value?: string | null) => { const normalizePath = (value?: string | null) => {
const trimmed = value?.trim() ?? ""; const trimmed = value?.trim() ?? "";
......
...@@ -8,7 +8,7 @@ import { ...@@ -8,7 +8,7 @@ import {
import { getApiV10Post, getApiV10PostId } from "@/api/vcci-news/endpoints/post"; import { getApiV10Post, getApiV10PostId } from "@/api/vcci-news/endpoints/post";
import { getApiV10Category } from "@/api/vcci-news/endpoints/category"; import { getApiV10Category } from "@/api/vcci-news/endpoints/category";
import { categoryFallbackRows } from "@/mockdata/categories"; import { categoryFallbackRows } from "@/mockdata/categories";
import type { DynamicPostItem } from "./templates/types"; import type { DynamicPostItem } from "@/api/vcci-news/types/post";
import DynamicPageClient from "./DynamicPageClient"; import DynamicPageClient from "./DynamicPageClient";
const STATIC_PAGE_SLUGS = new Set([ const STATIC_PAGE_SLUGS = new Set([
......
...@@ -13,8 +13,9 @@ import { ...@@ -13,8 +13,9 @@ import {
buildDynamicCategoryMenu, buildDynamicCategoryMenu,
stripHtml, stripHtml,
} from "../templates/data"; } from "../templates/data";
import StructuredPostContent from "../templates/StructuredPostContent"; import PostContent from "@/components/shared/post-content";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const ABOUT_HIGHLIGHTS = [ const ABOUT_HIGHLIGHTS = [
{ {
...@@ -165,8 +166,8 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc ...@@ -165,8 +166,8 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc
) : null} ) : null}
<div className="mt-7 rounded-3xl bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10"> <div className="mt-7 rounded-3xl bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10">
<div className="about-vcci-page-content page-detail-content prose tiptap max-w-none overflow-hidden"> <div className="about-vcci-page-content">
<StructuredPostContent post={post} /> <PostContent post={post} />
</div> </div>
</div> </div>
</div> </div>
......
...@@ -18,7 +18,8 @@ import { ...@@ -18,7 +18,8 @@ import {
resolveDynamicPostImage, resolveDynamicPostImage,
mapPost, mapPost,
} from "../templates/data"; } from "../templates/data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type AnPhamProps = { type AnPhamProps = {
category: DynamicCategoryRouteItem | null; category: DynamicCategoryRouteItem | null;
......
...@@ -5,7 +5,8 @@ import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-react"; ...@@ -5,7 +5,8 @@ import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image"; import { SafeImage } from "@/components/shared/safe-image";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data"; import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type RegionMarketItem = { type RegionMarketItem = {
name: string; name: string;
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
import links from "@/links"; import links from "@/links";
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import { buildDynamicCategoryMenu } from "../templates/data"; import { buildDynamicCategoryMenu } from "../templates/data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const MEMBERSHIP_REQUIREMENTS = [ const MEMBERSHIP_REQUIREMENTS = [
"Đơn xin gia nhập làm hội viên chính thức VCCI (2 bản theo mẫu của VCCI)", "Đơn xin gia nhập làm hội viên chính thức VCCI (2 bản theo mẫu của VCCI)",
......
...@@ -19,7 +19,7 @@ import { ...@@ -19,7 +19,7 @@ import {
resolveDynamicPostImage, resolveDynamicPostImage,
mapPost, mapPost,
} from "../templates/data"; } from "../templates/data";
import type { DynamicPostItem } from "../templates/types"; import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const formatPostDate = (value?: string | null) => { const formatPostDate = (value?: string | null) => {
if (!value) return ""; if (!value) return "";
......
...@@ -16,7 +16,8 @@ import ListCategory from "@/components/base/list-category"; ...@@ -16,7 +16,8 @@ import ListCategory from "@/components/base/list-category";
import { import {
buildDynamicCategoryMenu, buildDynamicCategoryMenu,
} from "../templates/data"; } from "../templates/data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const SERVICE_SUPPORT_ITEMS = [ const SERVICE_SUPPORT_ITEMS = [
{ {
......
...@@ -18,7 +18,8 @@ import { ...@@ -18,7 +18,8 @@ import {
resolveDynamicPostImage, resolveDynamicPostImage,
mapPost, mapPost,
} from "../templates/data"; } from "../templates/data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types"; import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type ThuVienTaiLieuProps = { type ThuVienTaiLieuProps = {
category: DynamicCategoryRouteItem | null; category: DynamicCategoryRouteItem | null;
......
...@@ -4,11 +4,13 @@ import type { ...@@ -4,11 +4,13 @@ import type {
DynamicCategoryMenuItem, DynamicCategoryMenuItem,
DynamicCategoryRouteItem, DynamicCategoryRouteItem,
DynamicCategoryType, DynamicCategoryType,
} from "./types";
import type {
DynamicPostContentSection, DynamicPostContentSection,
DynamicPostItem, DynamicPostItem,
DynamicPostThumbnail, DynamicPostThumbnail,
DynamicPostUser, DynamicPostUser,
} from "./types"; } from "@/api/vcci-news/types/post";
type RawPostCategory = { type RawPostCategory = {
id?: string | null; id?: string | null;
......
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
import ListCategory from "@/components/base/list-category"; import ListCategory from "@/components/base/list-category";
import parse from "html-react-parser"; import parse from "html-react-parser";
import { buildDynamicCategoryMenu } from "./data"; import { buildDynamicCategoryMenu } from "../data";
import StructuredPostContent from "./StructuredPostContent"; import PostContent from "@/components/shared/post-content";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types"; import type { DynamicCategoryRouteItem } from "../types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type InformationPageProps = { type InformationPageProps = {
post: DynamicPostItem; post: DynamicPostItem;
...@@ -38,101 +39,10 @@ export default function InformationPage({ ...@@ -38,101 +39,10 @@ export default function InformationPage({
) : null} ) : null}
<div className="mt-7 rounded-3xl bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10"> <div className="mt-7 rounded-3xl bg-white px-5 py-6 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 lg:px-10">
<div className="page-detail-content prose tiptap max-w-none overflow-hidden"> <PostContent post={post} />
<StructuredPostContent post={post} />
</div>
</div> </div>
</div> </div>
</section> </section>
<div className="page-detail-styles">
<style jsx global>{`
.page-detail-content {
color: #1f2937;
line-height: 1.85;
width: 100%;
max-width: 100%;
}
.page-detail-content p,
.page-detail-content div {
margin: 0 0 18px;
max-width: 100% !important;
box-sizing: border-box;
}
.page-detail-content h1,
.page-detail-content h2,
.page-detail-content h3,
.page-detail-content h4,
.page-detail-content h5,
.page-detail-content h6 {
margin: 0 0 18px;
color: #111827;
font-weight: 700;
line-height: 1.45;
}
.page-detail-content :is(p, div, span, li, a, strong, em, u, s) {
font-family: inherit;
}
.page-detail-content img {
display: block;
width: 100% !important;
max-width: 100% !important;
height: auto !important;
margin: 24px auto 10px;
border-radius: 14px;
}
.page-detail-content figure {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 28px 0;
text-align: center;
}
.page-detail-content .article-content,
.page-detail-content .article-content_toc,
.page-detail-content table,
.page-detail-content iframe {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.page-detail-content table {
display: table;
table-layout: fixed;
}
.page-detail-content figcaption,
.page-detail-content .wp-caption-text {
margin-top: 10px;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
text-align: center;
}
.page-detail-content a {
color: #14519f;
font-weight: 600;
}
.page-detail-content ul,
.page-detail-content ol {
margin: 18px 0;
padding-left: 24px;
}
.page-detail-content li {
margin: 8px 0;
}
`}</style>
</div>
</main> </main>
</div> </div>
</div> </div>
......
'use client';
import dayjs from "dayjs"; import dayjs from "dayjs";
import AppEditorContent from "@/components/shared/editor-content"; import { Calendar, MapPin, Clock, Users, CreditCard } from "lucide-react";
import ListCategory from "@/components/base/list-category"; import type { DynamicPostItem } from "@/api/vcci-news/types/post";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Calendar, MapPin, Clock, DollarSign, Users, CreditCard } from "lucide-react";
import { buildDynamicCategoryMenu, findDisplayCategoryForPost } from "./data";
import StructuredPostContent from "./StructuredPostContent";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
const formatDate = (value: string | null) => const formatDate = (value: string | null) =>
value ? dayjs(value).format("DD/MM/YYYY") : ""; value ? dayjs(value).format("DD/MM/YYYY") : "";
...@@ -36,7 +28,7 @@ const hasEventInfo = (post: DynamicPostItem) => { ...@@ -36,7 +28,7 @@ const hasEventInfo = (post: DynamicPostItem) => {
); );
}; };
const EventInfoCard = ({ post }: { post: DynamicPostItem }) => { export default function EventInfoCard({ post }: { post: DynamicPostItem }) {
if (!isEventOrTraining(post) || !hasEventInfo(post)) return null; if (!isEventOrTraining(post) || !hasEventInfo(post)) return null;
const startedAt = post.started_at; const startedAt = post.started_at;
...@@ -108,157 +100,4 @@ const EventInfoCard = ({ post }: { post: DynamicPostItem }) => { ...@@ -108,157 +100,4 @@ const EventInfoCard = ({ post }: { post: DynamicPostItem }) => {
</div> </div>
</div> </div>
); );
};
type NewsDetailPageProps = {
post: DynamicPostItem;
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
export default function NewsDetailPage({
post,
category,
allCategories,
}: NewsDetailPageProps) {
const publishedDate = dayjs(
post.release_at ?? post.published_at ?? post.created_at,
).format("DD/MM/YYYY");
const primaryCategory =
findDisplayCategoryForPost(post, category, allCategories)?.name ||
category?.name ||
"Tin tức";
const categoryMenu = category ? buildDynamicCategoryMenu(category, allCategories) : [];
return (
<div className="min-h-screen bg-white">
{/* {categoryMenu.length ? <ListCategory categories={categoryMenu} /> : null} */}
<div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<div className="grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_340px] xl:gap-12">
<main className="min-w-0">
<div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
<span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{primaryCategory}
</span>
<span className="text-[#9aa3ad]">{publishedDate}</span>
</div>
<h1 className="max-w-4xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]">
{post.title}
</h1>
<div className="mt-3 h-[3px] w-16 rounded-full bg-[#f5a400]" />
{post.summary ? (
<div className="mt-5 max-w-4xl text-base font-semibold leading-7 text-[#374151] md:text-lg md:leading-8">
<AppEditorContent value={post.summary} />
</div>
) : null}
<EventInfoCard post={post} />
<div className="mt-7 rounded-3xl bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10">
<div className="article-detail-content prose tiptap max-w-none overflow-hidden">
<StructuredPostContent post={post} />
</div>
</div>
<div className="article-detail-styles">
<style jsx global>{`
.article-detail-content {
color: #1f2937;
line-height: 1.85;
width: 100%;
max-width: 100%;
}
.article-detail-content p,
.article-detail-content div {
margin: 0 0 18px;
max-width: 100% !important;
box-sizing: border-box;
}
.article-detail-content h1,
.article-detail-content h2,
.article-detail-content h3,
.article-detail-content h4,
.article-detail-content h5,
.article-detail-content h6 {
margin: 0 0 18px;
color: #111827;
font-weight: 700;
line-height: 1.45;
}
.article-detail-content :is(p, div, span, li, a, strong, em, u, s) {
font-family: inherit;
}
.article-detail-content img {
display: block;
width: 100% !important;
max-width: 100% !important;
height: auto !important;
margin: 24px auto 10px;
border-radius: 14px;
}
.article-detail-content figure {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 28px 0;
text-align: center;
}
.article-detail-content .article-content,
.article-detail-content .article-content_toc,
.article-detail-content table,
.article-detail-content iframe {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.article-detail-content table {
display: table;
table-layout: fixed;
}
.article-detail-content figcaption,
.article-detail-content .wp-caption-text {
margin-top: 10px;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
text-align: center;
}
.article-detail-content a {
color: #14519f;
font-weight: 600;
}
.article-detail-content ul,
.article-detail-content ol {
margin: 18px 0;
padding-left: 24px;
}
.article-detail-content li {
margin: 8px 0;
}
`}</style>
</div>
</main>
<aside className="space-y-5 xl:pt-0">
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<SidebarAdvertisements count={3} startIndex={0} />
</aside>
</div>
</div>
</div>
);
} }
import parse, {
domToReact,
DOMNode,
Element,
type HTMLReactParserOptions,
} from "html-react-parser";
const summaryParserOptions: HTMLReactParserOptions = {
replace: (node) => {
if (!(node instanceof Element)) return;
const tagName = node.tagName.toLowerCase();
if (tagName === "img") {
return <></>;
}
if (tagName === "a") {
return <>{domToReact(node.children as DOMNode[], summaryParserOptions)}</>;
}
},
};
export function renderPostSummary(html: string) {
return parse(html, summaryParserOptions);
}
'use client';
import dayjs from "dayjs";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { findDisplayCategoryForPost } from "../data";
import { renderPostSummary } from "./components/post-summary-content";
import PostContent from "@/components/shared/post-content";
import EventInfoCard from "./components/event-info-card";
import type { DynamicCategoryRouteItem } from "../types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type NewsDetailPageProps = {
post: DynamicPostItem;
category: DynamicCategoryRouteItem | null;
allCategories: DynamicCategoryRouteItem[];
};
export default function NewsDetailPage({
post,
category,
allCategories,
}: NewsDetailPageProps) {
const publishedDate = dayjs(post.release_at ?? post.published_at ?? post.created_at).format("DD/MM/YYYY");
const primaryCategory = findDisplayCategoryForPost(post, category, allCategories)?.name || category?.name || "Tin tức";
return (
<div className="min-h-screen bg-white">
<div className="container mx-auto px-4 py-4 lg:pb-6 sm:px-6 lg:px-10">
<div className="grid grid-cols-1 gap-8 xl:grid-cols-[minmax(0,1fr)_340px] xl:gap-12">
<main className="min-w-0">
{/* badge */}
<div className="mb-5 flex flex-wrap items-center gap-3 text-xs">
<span className="rounded-full bg-[#eaf0ff] px-2.5 py-1 font-semibold text-[#1f4fa3]">
{primaryCategory}
</span>
<span className="text-[#9aa3ad]">{publishedDate}</span>
</div>
{/* header */}
<h1 className="max-w-4xl text-3xl font-bold leading-tight text-[#111827] md:text-[38px] md:leading-[1.15]">
{post.title}
</h1>
<div className="mt-3 h-[3px] w-16 rounded-full bg-[#f5a400]" />
{/* summary */}
{post.summary ? (
<div className="mt-5 max-w-4xl text-base font-semibold leading-7 text-[#374151] md:text-lg md:leading-8">
{renderPostSummary(post.summary)}
</div>
) : null}
{/* event info */}
<EventInfoCard post={post} />
{/* content */}
<div className="mt-7 rounded-3xl bg-white px-4 py-5 shadow-[0_18px_42px_rgba(17,24,39,0.06)] sm:px-8 sm:py-6 lg:px-10">
<PostContent post={post} />
</div>
</main>
{/* sidebar */}
<aside className="space-y-5 xl:pt-0">
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<SidebarAdvertisements count={3} startIndex={0} />
</aside>
</div>
</div>
</div>
);
}
...@@ -18,8 +18,9 @@ import { ...@@ -18,8 +18,9 @@ import {
findDisplayCategoryForPost, findDisplayCategoryForPost,
getDynamicPostExcerpt, getDynamicPostExcerpt,
resolveDynamicPostImage, resolveDynamicPostImage,
} from "./data"; } from "../data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types"; import type { DynamicCategoryRouteItem } from "../types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type NewsPageProps = { type NewsPageProps = {
category: DynamicCategoryRouteItem; category: DynamicCategoryRouteItem;
...@@ -192,7 +193,7 @@ export default function NewsPage({ category, allCategories }: NewsPageProps) { ...@@ -192,7 +193,7 @@ export default function NewsPage({ category, allCategories }: NewsPageProps) {
}) })
) : ( ) : (
<div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600"> <div className="rounded-2xl border border-[#edf1f5] bg-white px-6 py-12 text-center text-gray-600">
{"Ch\u01b0a c\u00f3 b\u00e0i vi\u1ebft ph\u00f9 h\u1ee3p trong danh m\u1ee5c n\u00e0y."} <p>Chưa có bài viết phù hợp trong danh mục này.</p>
</div> </div>
)} )}
......
...@@ -15,77 +15,3 @@ export type DynamicCategoryMenuItem = { ...@@ -15,77 +15,3 @@ export type DynamicCategoryMenuItem = {
name: string; name: string;
static_link: string; static_link: string;
}; };
export type DynamicPostCategoryItem = {
id: string;
name: string;
slug: string;
url: string;
type: string;
};
export type DynamicPostThumbnail = {
path?: string | null;
original?: string | null;
url?: string | null;
} | null;
export type DynamicPostUser = {
id: string;
email: string;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
avatar_url: string | null;
} | null;
export type DynamicPostContentSection = {
id: string;
type: string;
content: string;
position: number;
image_rows: number;
image_columns: number;
images: Array<{
position: number;
caption: string | null;
image: {
id: string;
name: string;
alt: string;
url: string;
path?: string | null;
original?: string | null;
} | null;
}>;
};
export type DynamicPostItem = {
id: string;
title: string;
slug: string;
content: string;
summary: string;
release_at: string | null;
published_at: string | null;
created_at: string | null;
started_at: string | null;
ended_at: string | null;
expired_at: string | null;
registration_deadline: string | null;
location: string | null;
participation_fee: string | null;
is_featured: boolean;
is_hidden: boolean;
is_active: boolean;
status: string;
type: string;
thumbnail: DynamicPostThumbnail;
categories: DynamicPostCategoryItem[];
content_structure: {
post_content: DynamicPostContentSection[];
} | null;
creator: DynamicPostUser;
editor: DynamicPostUser;
};
.app-editor-container {
min-height: unset !important;
height: auto !important;
border: none !important;
border-radius: 0px !important;
background-color: transparent !important;
line-height: 1.5;
.jodit-wysiwyg {
padding: 0 !important;
overflow-x: visible !important;
}
}
\ No newline at end of file
import { FC, JSX } from "react";
import htmlParse, { DOMNode, Element, Text } from "html-react-parser";
import { AppEditorContentProps } from "./AppEditorContent.type";
import "./AppEditorContent.css";
const AppEditorContent: FC<AppEditorContentProps> = ({ value = '', className = '' }) => {
const transform = (node: DOMNode): JSX.Element | string | undefined | null => {
// 1. Xử lý Text Node
if (node instanceof Text) {
return node.data;
}
if (!(node instanceof Element)) return undefined;
const tagName = node.tagName.toLowerCase();
// ✅ FIX LỖI: Ép kiểu (as DOMNode) để Type 'ChildNode' khớp với 'DOMNode'
const children = node.children
? node.children.map(child => transform(child as DOMNode))
: [];
// --- LOGIC XỬ LÝ THEO YÊU CẦU ---
// 2. ✅ Xóa thẻ <img>
if (tagName === 'img') {
return <></>;
}
// 3. ✅ Xóa thẻ <a> nhưng giữ lại nội dung
if (tagName === 'a') {
// Tr? v? children d? du?c x? l? (l?m ph?ng th? <a>)
return <>{children}</>;
}
// 4. ✅ Xử lý thẻ <strong>
if (tagName === 'strong') {
return (
<strong className="custom-strong">
{children}
</strong>
);
}
// 5. ✅ Render các thẻ HTML khác (Fallback)
// Trả về undefined để thư viện tự động render các thẻ còn lại (p, div, br,...)
return undefined;
};
return (
<div className="jodit-container app-editor-container">
<div className={`jodit-wysiwyg ${className}`}>
{htmlParse(value, { replace: transform })}
</div>
</div>
);
};
export default AppEditorContent;
\ No newline at end of file
// Props
interface AppEditorContentProps {
value: string
className?: string
}
export type { AppEditorContentProps }
import AppEditorContent from './AppEditorContent'
export default AppEditorContent
import type { DynamicPostContentSection } from "@/api/vcci-news/types/post";
import { ImageLightbox } from "../../image-lightbox";
import { SafeImage } from "../../safe-image";
import { useState } from "react";
function getGridClassName(columns: number) {
if (columns >= 4) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4";
if (columns === 3) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
if (columns === 2) return "grid-cols-1 sm:grid-cols-2";
return "grid-cols-1";
}
export function ImageSection({ section }: { section: DynamicPostContentSection }) {
const images = section.images.filter((item) => item.image?.url);
const [activeImage, setActiveImage] = useState<{
src: string;
alt: string;
caption?: string;
} | null>(null);
if (!images.length) return null;
return (
<>
<div className={`post-image-section my-6 grid gap-4 ${getGridClassName(section.image_columns)}`}>
{images.map((item) => {
const image = item.image;
if (!image?.url) return null;
return (
<figure
key={`${section.id}-${image.id || image.url}-${item.position}`}
className="group cursor-zoom-in overflow-hidden rounded-[18px] bg-white"
onClick={() =>
setActiveImage({
src: image.url,
alt: image.alt || image.name || "Hình ảnh bài viết",
caption: item.caption ?? undefined,
})
}
>
<SafeImage
src={image.url}
alt={image.alt || image.name || "Hình ảnh bài viết"}
width={1200}
height={800}
className="h-auto w-full object-contain transition-transform duration-300 group-hover:scale-[1.02]"
/>
{item.caption ? (
<figcaption className="mt-2 text-center text-sm text-gray-600">
{item.caption}
</figcaption>
) : null}
</figure>
);
})}
</div>
<ImageLightbox
src={activeImage?.src ?? ""}
alt={activeImage?.alt}
caption={activeImage?.caption}
open={Boolean(activeImage)}
onOpenChange={(open) => {
if (!open) setActiveImage(null);
}}
/>
</>
);
}
\ No newline at end of file
"use client";
import { useState } from "react";
import parse from "html-react-parser"; import parse from "html-react-parser";
import { ImageLightbox } from "@/components/shared/image-lightbox"; import type { DynamicPostContentSection } from "@/api/vcci-news/types/post";
import { SafeImage } from "@/components/shared/safe-image";
import { getDynamicPostBodyHtml } from "./data";
import type { DynamicPostContentSection, DynamicPostItem } from "./types";
type StructuredPostContentProps = {
post: DynamicPostItem;
};
function getGridClassName(columns: number) {
if (columns >= 4) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4";
if (columns === 3) return "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3";
if (columns === 2) return "grid-cols-1 sm:grid-cols-2";
return "grid-cols-1";
}
function StructuredImageSection({ section }: { section: DynamicPostContentSection }) {
const images = section.images.filter((item) => item.image?.url);
const [activeImage, setActiveImage] = useState<{
src: string;
alt: string;
caption?: string;
} | null>(null);
if (!images.length) return null;
return (
<>
<div className={`not-prose my-6 grid gap-4 ${getGridClassName(section.image_columns)}`}>
{images.map((item) => {
const image = item.image;
if (!image?.url) return null;
return (
<figure
key={`${section.id}-${image.id || image.url}-${item.position}`}
className="group cursor-zoom-in overflow-hidden rounded-[18px] bg-white"
onClick={() =>
setActiveImage({
src: image.url,
alt: image.alt || image.name || "Hình ảnh bài viết",
caption: item.caption ?? undefined,
})
}
>
<SafeImage
src={image.url}
alt={image.alt || image.name || "Hình ảnh bài viết"}
width={1200}
height={800}
className="h-auto w-full object-contain transition-transform duration-300 group-hover:scale-[1.02]"
/>
{item.caption ? (
<figcaption className="mt-2 text-center text-sm text-gray-600">
{item.caption}
</figcaption>
) : null}
</figure>
);
})}
</div>
<ImageLightbox
src={activeImage?.src ?? ""}
alt={activeImage?.alt}
caption={activeImage?.caption}
open={Boolean(activeImage)}
onOpenChange={(open) => {
if (!open) setActiveImage(null);
}}
/>
</>
);
}
function normalizeCaptionShortcodes(html: string) { function normalizeCaptionShortcodes(html: string) {
return html.replace(/\[caption[^\]]*]([\s\S]*?)\[\/caption]/gi, (_match, innerContent: string) => { return html.replace(/\[caption[^\]]*]([\s\S]*?)\[\/caption]/gi, (_match, innerContent: string) => {
...@@ -195,35 +118,100 @@ function renderStructuredHtml(html: string) { ...@@ -195,35 +118,100 @@ function renderStructuredHtml(html: string) {
return parse(normalizeImportedLayout(normalizeCaptionShortcodes(html))); return parse(normalizeImportedLayout(normalizeCaptionShortcodes(html)));
} }
export default function StructuredPostContent({ post }: StructuredPostContentProps) { export function TextSection({ section }: { section: DynamicPostContentSection }) {
const sections = (post.content_structure?.post_content ?? []) const content = section.content.trim();
.slice() if (!content) return null;
.sort((left, right) => left.position - right.position);
if (!sections.length) { return (
return <>{renderStructuredHtml(getDynamicPostBodyHtml(post))}</>; <div className="post-text-section">
{renderStructuredHtml(content)}
<style jsx global>{`
.post-text-section {
color: #1f2937;
line-height: 1.85;
width: 100%;
max-width: 100%;
} }
const hasRenderableSection = sections.some( .post-text-section p,
(section) => section.content.trim() || section.images.length, .post-text-section div {
); margin: 0 0 18px;
max-width: 100% !important;
box-sizing: border-box;
}
if (!hasRenderableSection) { .post-text-section h1,
return <>{renderStructuredHtml(getDynamicPostBodyHtml(post))}</>; .post-text-section h2,
.post-text-section h3,
.post-text-section h4,
.post-text-section h5,
.post-text-section h6 {
margin: 0 0 18px;
color: #111827;
font-weight: 700;
line-height: 1.45;
} }
return ( .post-text-section :is(p, div, span, li, a, strong, em, u, s) {
<> font-family: inherit;
{sections.map((section) => {
if (section.type === "image") {
return <StructuredImageSection key={section.id} section={section} />;
} }
const content = section.content.trim(); .post-text-section img {
if (!content) return null; display: block;
width: 100% !important;
max-width: 100% !important;
height: auto !important;
margin: 24px auto 10px;
border-radius: 14px;
}
.post-text-section figure {
display: block !important;
width: 100% !important;
max-width: 100% !important;
margin: 28px 0;
text-align: center;
}
.post-text-section .article-content,
.post-text-section .article-content_toc,
.post-text-section table,
.post-text-section iframe {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.post-text-section table {
display: table;
table-layout: fixed;
}
.post-text-section figcaption,
.post-text-section .wp-caption-text {
margin-top: 10px;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
text-align: center;
}
.post-text-section a {
color: #14519f;
font-weight: 600;
}
return <div key={section.id}>{renderStructuredHtml(content)}</div>; .post-text-section ul,
})} .post-text-section ol {
</> margin: 18px 0;
padding-left: 24px;
}
.post-text-section li {
margin: 8px 0;
}
`}</style>
</div>
); );
} }
"use client";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
import { ImageSection } from "./components/image-section";
import { TextSection } from "./components/text-section";
type PostContentProps = {
post: DynamicPostItem;
};
export default function PostContent({ post }: PostContentProps) {
const sections = (post.content_structure?.post_content ?? [])
.slice()
.sort((left, right) => left.position - right.position);
return (
<div className="overflow-hidden">
{sections.map((section) => {
if (section.type === "image") {
return <ImageSection key={section.id} section={section} />;
}
return <TextSection key={section.id} section={section} />;
})}
</div>
);
}
import { SafeImage } from "@/components/shared/safe-image";
import Link from "next/link";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
export function FallbackSidebarAdItem({ src }: { src: string }) {
return (
<Link
href={FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[16px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
title="Quảng cáo VCCI HCM"
>
<div className="relative aspect-[16/10]">
<SafeImage
src={src}
alt="Quảng cáo VCCI HCM"
fill
sizes="(max-width: 768px) 100vw, 300px"
className="h-full w-full object-cover"
/>
</div>
</Link>
);
}
import { SafeImage } from "@/components/shared/safe-image";
import Link from "next/link";
import links from "@/links";
import type { Advertisement } from "@/api/vcci-news/models/advertisement";
export function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: string }) {
const src = item.file?.path ? links.resolveImageUrl(item.file.path) : fallbackSrc;
return (
<Link
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[16px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
title={item.name}
>
<div className="relative aspect-[16/10]">
<SafeImage
src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name}
fill
sizes="(max-width: 768px) 100vw, 300px"
className="h-full w-full object-cover"
/>
</div>
</Link>
);
}
'use client'; 'use client';
import { SafeImage } from "@/components/shared/safe-image";
import Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements"; import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import links from "@/links";
import type { Advertisement } from "@/api/vcci-news/models/advertisement";
import { getFallbackImage } from "@/lib/utils/fallback-image"; import { getFallbackImage } from "@/lib/utils/fallback-image";
import { SidebarAdItem } from "./components/sidebar-ad-item";
import { FallbackSidebarAdItem } from "./components/fallback-sidebar-ad-item";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
function SidebarAdItem({ item, fallbackSrc }: { item: Advertisement; fallbackSrc: string }) {
const src = item.file?.path ? links.resolveImageUrl(item.file.path) : fallbackSrc;
return (
<Link
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[16px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
title={item.name}
>
<div className="relative aspect-[16/10]">
<SafeImage
src={src}
fallbackSrc={fallbackSrc}
alt={item.alt || item.name}
fill
sizes="(max-width: 768px) 100vw, 300px"
className="h-full w-full object-cover"
/>
</div>
</Link>
);
}
function FallbackSidebarAdItem({ src }: { src: string }) {
return (
<Link
href={FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[16px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
title="Quảng cáo VCCI HCM"
>
<div className="relative aspect-[16/10]">
<SafeImage
src={src}
alt="Quảng cáo VCCI HCM"
fill
sizes="(max-width: 768px) 100vw, 300px"
className="h-full w-full object-cover"
/>
</div>
</Link>
);
}
/** Sidebar quảng cáo vuông (stack dọc) cho các trang detail/catalog/search */
function SidebarAdvertisements({ count = 5, startIndex = 0 }: { count?: number; startIndex?: number }) { function SidebarAdvertisements({ count = 5, startIndex = 0 }: { count?: number; startIndex?: number }) {
const ads = useAdvertisements("square"); const ads = useAdvertisements("square");
const visibleAds = ads.slice(startIndex, startIndex + count); const visibleAds = ads.slice(startIndex, startIndex + count);
......
"use client"; "use client";
import * as React from "react";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
const HOURS = Array.from({ length: 24 }, (_, i) => i); const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5); const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
...@@ -19,11 +19,11 @@ export function TimePicker({ ...@@ -19,11 +19,11 @@ export function TimePicker({
value: string; value: string;
onChange: (time: string) => void; onChange: (time: string) => void;
}) { }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = useState(false);
const [hour, setHour] = React.useState<number | null>(null); const [hour, setHour] = useState<number | null>(null);
const [minute, setMinute] = React.useState<number | null>(null); const [minute, setMinute] = useState<number | null>(null);
React.useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (value) { if (value) {
// Trim seconds & timezone (VD: 19:39:00.000Z -> 19:39) // Trim seconds & timezone (VD: 19:39:00.000Z -> 19:39)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment