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 {
event_dates?: string[];
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 @@
import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { Spinner } from "@/components/ui";
import NewsPage from "./templates/NewsPage";
import NewsDetailPage from "./templates/NewsDetailPage";
import InformationPage from "./templates/InformationPage";
import NewsPage from "./templates/news-page";
import NewsDetailPage from "./templates/news-detail-page";
import InformationPage from "./templates/information-page";
import AnPham from "./static-pages/AnPham";
import ThuVienTaiLieu from "./static-pages/ThuVienTaiLieu";
import AboutVcciHcm from "./static-pages/AboutVcciHcm";
......@@ -26,7 +26,8 @@ import SiteMap from "./static-pages/SiteMap";
import Video from "./static-pages/Video";
import { useGetApiV10Post } from "@/api/vcci-news/endpoints/post";
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 trimmed = value?.trim() ?? "";
......
......@@ -8,7 +8,7 @@ import {
import { getApiV10Post, getApiV10PostId } from "@/api/vcci-news/endpoints/post";
import { getApiV10Category } from "@/api/vcci-news/endpoints/category";
import { categoryFallbackRows } from "@/mockdata/categories";
import type { DynamicPostItem } from "./templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
import DynamicPageClient from "./DynamicPageClient";
const STATIC_PAGE_SLUGS = new Set([
......
......@@ -13,8 +13,9 @@ import {
buildDynamicCategoryMenu,
stripHtml,
} from "../templates/data";
import StructuredPostContent from "../templates/StructuredPostContent";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "../templates/types";
import PostContent from "@/components/shared/post-content";
import type { DynamicCategoryRouteItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const ABOUT_HIGHLIGHTS = [
{
......@@ -165,8 +166,8 @@ export default function AboutVcciHcm({ post, category, allCategories }: AboutVcc
) : 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="about-vcci-page-content page-detail-content prose tiptap max-w-none overflow-hidden">
<StructuredPostContent post={post} />
<div className="about-vcci-page-content">
<PostContent post={post} />
</div>
</div>
</div>
......
......@@ -18,7 +18,8 @@ import {
resolveDynamicPostImage,
mapPost,
} 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 = {
category: DynamicCategoryRouteItem | null;
......
......@@ -5,7 +5,8 @@ import { FileText, Globe2, Newspaper, TrendingUp } from "lucide-react";
import { SafeImage } from "@/components/shared/safe-image";
import ListCategory from "@/components/base/list-category";
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 = {
name: string;
......
......@@ -3,7 +3,8 @@
import links from "@/links";
import ListCategory from "@/components/base/list-category";
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 = [
"Đơ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 {
resolveDynamicPostImage,
mapPost,
} from "../templates/data";
import type { DynamicPostItem } from "../templates/types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const formatPostDate = (value?: string | null) => {
if (!value) return "";
......
......@@ -16,7 +16,8 @@ import ListCategory from "@/components/base/list-category";
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 SERVICE_SUPPORT_ITEMS = [
{
......
......@@ -18,7 +18,8 @@ import {
resolveDynamicPostImage,
mapPost,
} 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 = {
category: DynamicCategoryRouteItem | null;
......
......@@ -4,11 +4,13 @@ import type {
DynamicCategoryMenuItem,
DynamicCategoryRouteItem,
DynamicCategoryType,
} from "./types";
import type {
DynamicPostContentSection,
DynamicPostItem,
DynamicPostThumbnail,
DynamicPostUser,
} from "./types";
} from "@/api/vcci-news/types/post";
type RawPostCategory = {
id?: string | null;
......
......@@ -2,9 +2,10 @@
import ListCategory from "@/components/base/list-category";
import parse from "html-react-parser";
import { buildDynamicCategoryMenu } from "./data";
import StructuredPostContent from "./StructuredPostContent";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
import { buildDynamicCategoryMenu } from "../data";
import PostContent from "@/components/shared/post-content";
import type { DynamicCategoryRouteItem } from "../types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type InformationPageProps = {
post: DynamicPostItem;
......@@ -38,101 +39,10 @@ export default function InformationPage({
) : 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="page-detail-content prose tiptap max-w-none overflow-hidden">
<StructuredPostContent post={post} />
</div>
<PostContent post={post} />
</div>
</div>
</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>
</div>
</div>
......
'use client';
import dayjs from "dayjs";
import AppEditorContent from "@/components/shared/editor-content";
import ListCategory from "@/components/base/list-category";
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";
import { Calendar, MapPin, Clock, Users, CreditCard } from "lucide-react";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
const formatDate = (value: string | null) =>
value ? dayjs(value).format("DD/MM/YYYY") : "";
......@@ -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;
const startedAt = post.started_at;
......@@ -108,157 +100,4 @@ const EventInfoCard = ({ post }: { post: DynamicPostItem }) => {
</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 {
findDisplayCategoryForPost,
getDynamicPostExcerpt,
resolveDynamicPostImage,
} from "./data";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
} from "../data";
import type { DynamicCategoryRouteItem } from "../types";
import type { DynamicPostItem } from "@/api/vcci-news/types/post";
type NewsPageProps = {
category: DynamicCategoryRouteItem;
......@@ -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">
{"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>
)}
......
......@@ -15,77 +15,3 @@ export type DynamicCategoryMenuItem = {
name: 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 { ImageLightbox } from "@/components/shared/image-lightbox";
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);
}}
/>
</>
);
}
import type { DynamicPostContentSection } from "@/api/vcci-news/types/post";
function normalizeCaptionShortcodes(html: string) {
return html.replace(/\[caption[^\]]*]([\s\S]*?)\[\/caption]/gi, (_match, innerContent: string) => {
......@@ -195,35 +118,100 @@ function renderStructuredHtml(html: string) {
return parse(normalizeImportedLayout(normalizeCaptionShortcodes(html)));
}
export default function StructuredPostContent({ post }: StructuredPostContentProps) {
const sections = (post.content_structure?.post_content ?? [])
.slice()
.sort((left, right) => left.position - right.position);
export function TextSection({ section }: { section: DynamicPostContentSection }) {
const content = section.content.trim();
if (!content) return null;
if (!sections.length) {
return <>{renderStructuredHtml(getDynamicPostBodyHtml(post))}</>;
}
return (
<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(
(section) => section.content.trim() || section.images.length,
);
.post-text-section p,
.post-text-section div {
margin: 0 0 18px;
max-width: 100% !important;
box-sizing: border-box;
}
if (!hasRenderableSection) {
return <>{renderStructuredHtml(getDynamicPostBodyHtml(post))}</>;
}
.post-text-section h1,
.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 (
<>
{sections.map((section) => {
if (section.type === "image") {
return <StructuredImageSection key={section.id} section={section} />;
.post-text-section :is(p, div, span, li, a, strong, em, u, s) {
font-family: inherit;
}
const content = section.content.trim();
if (!content) return null;
.post-text-section img {
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;
}
return <div key={section.id}>{renderStructuredHtml(content)}</div>;
})}
</>
.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;
}
.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';
import { SafeImage } from "@/components/shared/safe-image";
import Link from "next/link";
import { useMemo } from "react";
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 { 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 }) {
const ads = useAdvertisements("square");
const visibleAds = ads.slice(startIndex, startIndex + count);
......
"use client";
import * as React from "react";
import { Clock } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
......@@ -19,11 +19,11 @@ export function TimePicker({
value: string;
onChange: (time: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const [hour, setHour] = React.useState<number | null>(null);
const [minute, setMinute] = React.useState<number | null>(null);
const [open, setOpen] = useState(false);
const [hour, setHour] = useState<number | null>(null);
const [minute, setMinute] = useState<number | null>(null);
React.useEffect(() => {
useEffect(() => {
if (!open) return;
if (value) {
// 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