Commit 55d771ae authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

fix

parent 1d0d929b
......@@ -192,6 +192,9 @@ importers:
axios:
specifier: ^1.13.1
version: 1.13.1
baseline-browser-mapping:
specifier: ^2.10.32
version: 2.10.32
eslint:
specifier: ^9
version: 9.38.0(jiti@2.6.1)
......@@ -2019,8 +2022,9 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
baseline-browser-mapping@2.8.20:
resolution: {integrity: sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==}
baseline-browser-mapping@2.10.32:
resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==}
engines: {node: '>=6.0.0'}
hasBin: true
brace-expansion@1.1.12:
......@@ -5839,7 +5843,7 @@ snapshots:
balanced-match@1.0.2: {}
baseline-browser-mapping@2.8.20: {}
baseline-browser-mapping@2.10.32: {}
brace-expansion@1.1.12:
dependencies:
......@@ -5856,7 +5860,7 @@ snapshots:
browserslist@4.27.0:
dependencies:
baseline-browser-mapping: 2.8.20
baseline-browser-mapping: 2.10.32
caniuse-lite: 1.0.30001751
electron-to-chromium: 1.5.243
node-releases: 2.0.26
......
......@@ -153,6 +153,16 @@ const normalizeLink = (value?: string | null, fallback = "/") => {
return `/${trimmed}`;
};
const buildPostLink = (path?: string | null, id?: string | null, fallback = "#") => {
const normalizedPath = normalizeLink(path, fallback);
const trimmedId = id?.trim() ?? "";
if (!trimmedId || normalizedPath === "#") return normalizedPath;
const params = new URLSearchParams({ id: trimmedId });
return `${normalizedPath}?${params.toString()}`;
};
const resolveAssetUrl = (value?: string | null) => {
const trimmed = value?.trim();
......@@ -224,7 +234,6 @@ const isVisibleNewsPost = (item: HomePostItem) => {
if (item.type && item.type !== "news") return false;
if (item.isHidden) return false;
if (!item.isActive) return false;
if (item.status && item.status !== "published") return false;
return true;
};
......@@ -246,7 +255,6 @@ function createCategoryPostsQuery(categoryId: string, pageSize: string) {
`category.id==${categoryId}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
].join(","),
});
......@@ -265,7 +273,6 @@ function createEventCalendarQuery(currentMonth: Date) {
`registration_deadline<=${dayjs(monthEnd).format("YYYY-MM-DD HH:mm:ss")}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
].join(","),
});
......@@ -281,7 +288,6 @@ async function fetchHomePosts() {
"is_featured==true",
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
].join(","),
});
......@@ -346,8 +352,9 @@ async function fetchHomePosts() {
const thumbnailPath = item.thumbnail?.path ?? item.thumbnail?.original ?? null;
const title = String(item.title ?? "").trim();
const externalLink = normalizeLink(
const externalLink = buildPostLink(
item.external_link || (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
);
......@@ -560,8 +567,9 @@ export function useEventCalendarPosts(currentMonth: Date) {
const thumbnailPath = item.thumbnail?.path ?? item.thumbnail?.original ?? null;
const title = String(item.title ?? "").trim();
const externalLink = normalizeLink(
const externalLink = buildPostLink(
item.external_link || (title ? `/${title}` : undefined),
item.id ? String(item.id) : "",
"#",
);
......
"use client";
import { useEffect, useMemo } from "react";
import { notFound, useParams, useRouter } from "next/navigation";
import { notFound, useParams, useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Spinner } from "@/components/ui";
import ArticlePage from "./templates/ArticlePage";
......@@ -10,6 +10,7 @@ import CatalogPage from "./templates/CatalogPage";
import InformationPage from "./templates/InformationPage";
import {
fetchDynamicCategories,
fetchDynamicPostById,
fetchDynamicPostByExternalLink,
fetchDynamicSinglePagePost,
findDynamicCategoryByPath,
......@@ -23,6 +24,9 @@ export default function DynamicPage() {
const path = slug.join("/");
const routePath = `/${path}`;
const router = useRouter();
const searchParams = useSearchParams();
const postId = searchParams.get("id")?.trim() ?? "";
const preferredCategoryId = searchParams.get("categoryId")?.trim() ?? "";
const categoryQuery = useQuery({
queryKey: ["dynamic-categories"],
......@@ -30,21 +34,32 @@ export default function DynamicPage() {
staleTime: 5 * 60 * 1000,
});
const detailQuery = useQuery({
queryKey: ["dynamic-post-detail", routePath],
queryFn: () => fetchDynamicPostByExternalLink(routePath),
enabled: Boolean(routePath),
staleTime: 60 * 1000,
});
const matchedCategory = useMemo(
() => findDynamicCategoryByPath(categoryQuery.data ?? [], routePath),
[categoryQuery.data, routePath],
);
const detailQuery = useQuery({
queryKey: ["dynamic-post-detail", postId || routePath],
queryFn: () =>
postId
? fetchDynamicPostById(postId)
: fetchDynamicPostByExternalLink(routePath),
enabled:
(Boolean(postId) || Boolean(routePath)) &&
!categoryQuery.isLoading &&
(Boolean(postId) || !matchedCategory),
staleTime: 60 * 1000,
});
const resolvedCategory = useMemo(
() => matchedCategory ?? findMenuCategoryForPost(detailQuery.data ?? null, categoryQuery.data ?? []),
[matchedCategory, detailQuery.data, categoryQuery.data],
() =>
(preferredCategoryId
? categoryQuery.data?.find((item) => item.id === preferredCategoryId) ?? null
: null) ??
matchedCategory ??
findMenuCategoryForPost(detailQuery.data ?? null, categoryQuery.data ?? []),
[preferredCategoryId, matchedCategory, detailQuery.data, categoryQuery.data],
);
const singlePageQuery = useQuery({
......
......@@ -2,9 +2,10 @@
import dayjs from "dayjs";
import ImageNext from "@/components/shared/image-next";
import AppEditorContent from "@/components/shared/editor-content";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import { buildDynamicCategoryMenu } from "./data";
import { buildDynamicCategoryMenu, findDisplayCategoryForPost } from "./data";
import StructuredPostContent from "./StructuredPostContent";
import type { DynamicCategoryRouteItem, DynamicPostItem } from "./types";
......@@ -22,7 +23,10 @@ export default function ArticleDetailPage({
const publishedDate = dayjs(
post.release_at ?? post.published_at ?? post.created_at,
).format("DD/MM/YYYY");
const primaryCategory = post.categories[0]?.name || category?.name || "Tin tức";
const primaryCategory =
findDisplayCategoryForPost(post, category, allCategories)?.name ||
category?.name ||
"Tin tức";
const categoryMenu = category ? buildDynamicCategoryMenu(category, allCategories) : [];
return (
......@@ -44,9 +48,9 @@ export default function ArticleDetailPage({
<div className="mt-3 h-[3px] w-16 rounded-full bg-[#f5a400]" />
{post.summary ? (
<p className="mt-5 max-w-4xl text-base font-semibold leading-7 text-[#374151] md:text-lg md:leading-8">
{post.summary}
</p>
<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}
<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">
......@@ -59,13 +63,16 @@ export default function ArticleDetailPage({
<style jsx global>{`
.article-detail-content {
color: #1f2937;
font-size: 16px;
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,
......@@ -80,17 +87,39 @@ export default function ArticleDetailPage({
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%;
max-width: 100%;
height: auto;
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,
......
......@@ -11,9 +11,11 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicPostHref,
buildDynamicCategoryMenu,
buildPostFilters,
buildVisibleNewsFilters,
fetchDynamicPostList,
findDisplayCategoryForPost,
resolveDynamicPostImage,
stripHtml,
} from "./data";
......@@ -84,12 +86,8 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
fetchDynamicPostList({
page,
pageSize,
filters: buildPostFilters([
filters: buildVisibleNewsFilters([
`category.id==${category.id}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
keyword ? `title@=${keyword}` : null,
]),
}),
......@@ -134,10 +132,14 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
?.map((section) => section.content)
.join(" ");
const description =
item.summary ||
stripHtml(item.summary) ||
stripHtml(item.content) ||
stripHtml(fallbackDescription);
const primaryCategory = item.categories[0];
const primaryCategory = findDisplayCategoryForPost(
item,
category,
allCategories,
);
const tagIndex = categoryIndexMap.get(primaryCategory?.id ?? "") ?? index;
const date = formatPostDate(
item.release_at ?? item.published_at ?? item.created_at,
......@@ -149,16 +151,16 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
className="border-b border-[#eceff3] pb-8 last:border-b-0"
>
<Link
href={item.external_link}
href={buildDynamicPostHref(item.external_link, item.id, category.id)}
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="overflow-hidden rounded-md bg-[#edf1f5]">
<div className="relative overflow-hidden rounded-md bg-[#edf1f5] aspect-[25/15] sm:aspect-[5/3]">
<ImageNext
src={resolveDynamicPostImage(item.thumbnail)}
alt={item.title}
width={520}
height={360}
className="h-[170px] w-full object-cover transition-transform duration-500 group-hover:scale-[1.03] sm:h-[150px]"
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
......
......@@ -11,8 +11,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import {
buildDynamicPostHref,
buildDynamicCategoryMenu,
buildPostFilters,
buildVisibleNewsFilters,
fetchDynamicPostList,
resolveDynamicPostImage,
} from "./data";
......@@ -23,15 +24,6 @@ type CatalogPageProps = {
allCategories: DynamicCategoryRouteItem[];
};
const getCatalogImageClassName = (index: number) =>
index % 4 === 0
? "object-cover"
: index % 4 === 1
? "object-contain bg-[#f0f3f8]"
: index % 4 === 2
? "object-cover object-center"
: "object-contain bg-[#eef4fb]";
export default function CatalogPage({ category, allCategories }: CatalogPageProps) {
const searchParams = useSearchParams();
const router = useRouter();
......@@ -68,12 +60,8 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
fetchDynamicPostList({
page,
pageSize,
filters: buildPostFilters([
filters: buildVisibleNewsFilters([
`category.id==${category.id}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
keyword ? `title@=${keyword}` : null,
]),
}),
......@@ -105,11 +93,11 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
<main className="order-2 min-w-0 xl:order-1 xl:flex-1">
{paginatedPosts.length ? (
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4 xl:gap-6">
{paginatedPosts.map((item, index) => {
{paginatedPosts.map((item) => {
return (
<Link
key={item.id}
href={item.external_link}
href={buildDynamicPostHref(item.external_link, item.id, category.id)}
className="group block"
>
<div className="overflow-hidden bg-white shadow-[0_10px_24px_rgba(17,24,39,0.08)]">
......@@ -119,7 +107,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
alt={item.title}
width={520}
height={693}
className={`h-full w-full transition-transform duration-500 group-hover:scale-[1.03] ${getCatalogImageClassName(index)}`}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
</div>
</div>
......
......@@ -142,13 +142,16 @@ export default function InformationPage({
<style jsx global>{`
.page-detail-content {
color: #1f2937;
font-size: 16px;
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,
......@@ -163,17 +166,39 @@ export default function InformationPage({
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%;
max-width: 100%;
height: auto;
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,
......
......@@ -66,8 +66,101 @@ function normalizeCaptionShortcodes(html: string) {
});
}
function normalizeImportedLayout(html: string) {
if (typeof window === "undefined" || !html.trim()) return html;
const parser = new DOMParser();
const document = parser.parseFromString(html, "text/html");
const mediaLayoutSelectors = [
".article-content",
".article-content_toc",
"figure",
"figcaption",
"img",
"table",
"iframe",
];
document.body.querySelectorAll<HTMLElement>(mediaLayoutSelectors.join(",")).forEach((element) => {
element.style.removeProperty("width");
element.style.removeProperty("max-width");
element.style.removeProperty("min-width");
if (element.tagName === "IMG") {
element.style.removeProperty("height");
element.style.removeProperty("max-height");
element.style.removeProperty("min-height");
element.style.removeProperty("aspect-ratio");
element.style.setProperty("display", "block");
element.style.setProperty("width", "100%");
element.style.setProperty("max-width", "100%");
element.style.setProperty("height", "auto");
element.removeAttribute("width");
element.removeAttribute("height");
}
if (element.tagName === "FIGURE") {
element.style.setProperty("display", "block");
element.style.setProperty("margin", "1.75rem 0");
element.style.setProperty("width", "100%");
element.style.setProperty("max-width", "100%");
element.style.setProperty("text-align", "center");
}
if (element.classList.contains("article-content") || element.classList.contains("article-content_toc")) {
element.style.setProperty("display", "block");
element.style.setProperty("width", "100%");
element.style.setProperty("max-width", "100%");
element.style.setProperty("overflow", "hidden");
}
});
document.body.querySelectorAll<HTMLImageElement>("img").forEach((image) => {
let current = image.parentElement;
while (
current &&
current !== document.body &&
!current.classList.contains("article-content") &&
!current.classList.contains("article-content_toc")
) {
current.style.removeProperty("width");
current.style.removeProperty("max-width");
current.style.removeProperty("min-width");
current.style.removeProperty("height");
current.style.removeProperty("max-height");
current.style.removeProperty("min-height");
current.style.removeProperty("float");
current.style.removeProperty("left");
current.style.removeProperty("right");
if (current.tagName !== "FIGCAPTION") {
current.style.setProperty("max-width", "100%");
current.style.setProperty("box-sizing", "border-box");
}
if (current.tagName === "DIV" || current.tagName === "P" || current.tagName === "FIGURE") {
current.style.setProperty("display", "block");
current.style.setProperty("width", "100%");
}
current = current.parentElement;
}
});
document.body
.querySelectorAll<HTMLElement>(".article-content, .article-content_toc, figure, img, table, iframe")
.forEach((element) => {
element.style.removeProperty("float");
element.style.removeProperty("left");
element.style.removeProperty("right");
});
return document.body.innerHTML;
}
function renderStructuredHtml(html: string) {
return parse(normalizeCaptionShortcodes(html));
return parse(normalizeImportedLayout(normalizeCaptionShortcodes(html)));
}
export default function StructuredPostContent({ post }: StructuredPostContentProps) {
......
......@@ -85,6 +85,14 @@ type PostListResponse = {
};
};
type PostDetailResponse = RawPostItem;
type PostDetailEnvelope = {
responseData?: RawPostItem | null;
data?: {
responseData?: RawPostItem | null;
} | null;
};
export type DynamicPostListResult = {
count: number;
page: number;
......@@ -99,6 +107,44 @@ const normalizePath = (value?: string | null) => {
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
};
export const buildDynamicPostHref = (
path?: string | null,
id?: string | null,
categoryId?: string | null,
) => {
const normalizedPath = normalizePath(path);
const trimmedId = id?.trim() ?? "";
const trimmedCategoryId = categoryId?.trim() ?? "";
if ((!trimmedId && !trimmedCategoryId) || normalizedPath === "/") {
return normalizedPath;
}
const params = new URLSearchParams();
if (trimmedId) {
params.set("id", trimmedId);
}
if (trimmedCategoryId) {
params.set("categoryId", trimmedCategoryId);
}
return `${normalizedPath}?${params.toString()}`;
};
const getSlugFromPath = (value?: string | null) => {
const normalizedPath = normalizePath(value);
const segments = normalizedPath.split("/").filter(Boolean);
const lastSegment = segments.at(-1);
if (!lastSegment) return "";
try {
return decodeURIComponent(lastSegment).trim();
} catch {
return lastSegment.trim();
}
};
const normalizeCategoryType = (value?: string | null): DynamicCategoryType | null => {
if (value === "category" || value === "page" || value === "news") return value;
return null;
......@@ -195,6 +241,16 @@ const buildPostFilters = (filters: Array<string | null | undefined>) =>
.filter(Boolean)
.join(",");
export const buildVisibleNewsFilters = (
filters: Array<string | null | undefined> = [],
) =>
buildPostFilters([
...filters,
"is_hidden==false",
"is_active==true",
"type==news",
]);
export async function fetchDynamicCategories(): Promise<DynamicCategoryRouteItem[]> {
const response = await useCustomClient<CategoryListResponse>(
"/category?page=1&pageSize=200&sortField=sort_order&sortOrder=ASC",
......@@ -255,16 +311,57 @@ export async function fetchDynamicPostList(params: {
};
}
export async function fetchDynamicPostById(id: string) {
const normalizedId = id.trim();
if (!normalizedId) return null;
const listResult = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildVisibleNewsFilters([`id==${normalizedId}`]),
}).catch(() => null);
if (listResult?.rows[0]) {
return listResult.rows[0];
}
const newsResponse = await useCustomClient<PostDetailEnvelope>(`/news/${normalizedId}`).catch(() => null);
const newsItem = newsResponse?.responseData ?? newsResponse?.data?.responseData ?? null;
if (newsItem) {
const post = mapPost(newsItem);
return post.id && post.title ? post : null;
}
const postResponse = await useCustomClient<PostDetailResponse>(`/post/${normalizedId}`).catch(() => null);
if (!postResponse) return null;
const post = mapPost(postResponse);
return post.id && post.title ? post : null;
}
export async function fetchDynamicPostByExternalLink(path: string) {
const normalizedPath = normalizePath(path);
const slug = getSlugFromPath(normalizedPath);
if (slug) {
const slugResult = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildVisibleNewsFilters([`slug==${slug}`]),
});
if (slugResult.rows[0]) {
return slugResult.rows[0];
}
}
const result = await fetchDynamicPostList({
page: 1,
pageSize: 1,
filters: buildPostFilters([
`external_link==${normalizePath(path)}`,
"is_hidden==false",
"is_active==true",
"status==published",
]),
filters: buildVisibleNewsFilters([`external_link==${normalizedPath}`]),
});
return result.rows[0] ?? null;
......@@ -307,6 +404,51 @@ export function findMenuCategoryForPost(
return null;
}
export function findDisplayCategoryForPost(
post: DynamicPostItem | null,
activeCategory: DynamicCategoryRouteItem | null,
categories: DynamicCategoryRouteItem[] = [],
) {
if (!post) return null;
if (activeCategory) {
const matchedPostCategory =
post.categories.find((item) => item.id === activeCategory.id) ??
post.categories.find((item) => normalizePath(item.url) === normalizePath(activeCategory.url));
if (matchedPostCategory) {
return {
id: matchedPostCategory.id,
name: matchedPostCategory.name,
url: normalizePath(matchedPostCategory.url),
type: matchedPostCategory.type,
};
}
const matchedTreeCategory = categories.find((item) => item.id === activeCategory.id);
if (matchedTreeCategory) {
return {
id: matchedTreeCategory.id,
name: matchedTreeCategory.name,
url: normalizePath(matchedTreeCategory.url),
type: matchedTreeCategory.type,
};
}
}
const firstCategory = post.categories[0];
if (firstCategory) {
return {
id: firstCategory.id,
name: firstCategory.name,
url: normalizePath(firstCategory.url),
type: firstCategory.type,
};
}
return null;
}
export function buildDynamicCategoryMenu(
activeCategory: DynamicCategoryRouteItem | null,
categories: DynamicCategoryRouteItem[],
......
......@@ -7,6 +7,7 @@ import Link from "next/link";
import { useCustomClient } from "@/api/mutator/custom-client";
import ImageNext from "@/components/shared/image-next";
import { fetchClientVideos } from "@/lib/api/videos";
import { buildDynamicPostHref, buildVisibleNewsFilters } from "../data";
import StructuredPostContent from "../StructuredPostContent";
import type { DynamicPostItem } from "../types";
......@@ -87,13 +88,7 @@ export default function AboutVcciHcmPage({
pageSize: "3",
sortField: "release_at",
sortOrder: "desc",
filters: [
`category.id==${TIN_VCCI_CATEGORY_ID}`,
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
].join(","),
filters: buildVisibleNewsFilters([`category.id==${TIN_VCCI_CATEGORY_ID}`]),
});
const response = await useCustomClient<TinVcciApiEnvelope>(`/post?${query.toString()}`);
......@@ -101,7 +96,7 @@ export default function AboutVcciHcmPage({
return (response.responseData?.rows ?? []).map((item) => ({
id: String(item.id ?? ""),
title: String(item.title ?? "").trim(),
externalLink: item.external_link?.trim() || "#",
externalLink: buildDynamicPostHref(item.external_link?.trim() || "#", item.id ? String(item.id) : ""),
publishedAt: String(item.published_at ?? item.release_at ?? item.created_at ?? ""),
thumbnailUrl:
item.thumbnail?.url?.trim() ||
......@@ -133,7 +128,7 @@ export default function AboutVcciHcmPage({
) : 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">
<div className="about-vcci-page-content page-detail-content prose tiptap max-w-none overflow-hidden">
<StructuredPostContent post={post} />
</div>
</div>
......@@ -154,6 +149,24 @@ export default function AboutVcciHcmPage({
</aside>
</section>
<style jsx global>{`
.about-vcci-page-content figure {
width: 100% !important;
max-width: 100% !important;
margin: 28px 0 !important;
text-align: center;
}
.about-vcci-page-content img {
width: 100% !important;
max-width: 100% !important;
height: auto !important;
margin-left: auto !important;
margin-right: auto !important;
object-fit: contain;
}
`}</style>
<section className="mt-10 space-y-10 md:mt-12 md:space-y-12">
<div>
<div className="text-center">
......@@ -303,7 +316,7 @@ export default function AboutVcciHcmPage({
</Link>
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<div className="grid pb-6 gap-5 md:grid-cols-2 xl:grid-cols-3">
{tinVcciItems.map((item) => (
<Link
key={item.id}
......
......@@ -10,7 +10,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@components/ui/spinner";
import {
buildPostFilters,
buildDynamicPostHref,
buildVisibleNewsFilters,
fetchDynamicPostList,
resolveDynamicPostImage,
stripHtml,
......@@ -46,14 +47,14 @@ function SearchResultItem({ item, index }: { item: DynamicPostItem; index: numbe
?.map((section) => section.content)
.join(" ");
const description =
item.summary || stripHtml(item.content) || stripHtml(fallbackDescription);
stripHtml(item.summary) || stripHtml(item.content) || stripHtml(fallbackDescription);
const date = formatPostDate(item.release_at || item.published_at || item.created_at);
const categoryName = item.categories[0]?.name || "Tin tức";
return (
<article className="border-b border-[#eceff3] pb-8 last:border-b-0">
<Link
href={item.external_link || "#"}
href={buildDynamicPostHref(item.external_link, item.id)}
className="group grid gap-5 sm:grid-cols-[250px_minmax(0,1fr)]"
>
<div className="overflow-hidden rounded-md bg-[#edf1f5]">
......@@ -104,11 +105,7 @@ function SearchContent() {
fetchDynamicPostList({
page,
pageSize,
filters: buildPostFilters([
"is_hidden==false",
"is_active==true",
"status==published",
"type==news",
filters: buildVisibleNewsFilters([
query ? `title@=${query}` : null,
]),
}),
......
......@@ -22,6 +22,79 @@ interface AdminRichTextEditorProps {
readOnly?: boolean;
}
const FONT_FAMILY_OPTIONS = {
"Arial, Helvetica, sans-serif": "Arial",
"Tahoma, Geneva, sans-serif": "Tahoma",
"Verdana, Geneva, sans-serif": "Verdana",
"'Times New Roman', Times, serif": "Times New Roman",
"Georgia, serif": "Georgia",
"'Courier New', Courier, monospace": "Courier New",
"'Trebuchet MS', Helvetica, sans-serif": "Trebuchet MS",
} as const;
const FONT_SIZE_OPTIONS = {
"12px": "12",
"14px": "14",
"16px": "16",
"18px": "18",
"20px": "20",
"24px": "24",
"28px": "28",
"32px": "32",
"36px": "36",
"42px": "42",
} as const;
const IMPORTED_LAYOUT_SELECTORS = [
".article-content",
".article-content_toc",
"figure",
"figcaption",
"img",
"table",
"iframe",
];
function normalizeImportedHtml(value: string) {
if (typeof window === "undefined" || !value.trim()) return value;
const parser = new DOMParser();
const document = parser.parseFromString(value, "text/html");
document.body.querySelectorAll<HTMLElement>(IMPORTED_LAYOUT_SELECTORS.join(",")).forEach((element) => {
element.style.removeProperty("width");
element.style.removeProperty("max-width");
element.style.removeProperty("min-width");
element.style.removeProperty("float");
element.style.removeProperty("left");
element.style.removeProperty("right");
if (element.tagName === "IMG") {
element.style.setProperty("display", "block");
element.style.setProperty("width", "100%");
element.style.setProperty("max-width", "100%");
element.style.setProperty("height", "auto");
element.removeAttribute("width");
element.removeAttribute("height");
}
if (element.tagName === "FIGURE") {
element.style.setProperty("display", "block");
element.style.setProperty("margin", "1.5rem 0");
element.style.setProperty("width", "100%");
element.style.setProperty("max-width", "100%");
}
if (element.classList.contains("article-content") || element.classList.contains("article-content_toc")) {
element.style.setProperty("width", "100%");
element.style.setProperty("max-width", "100%");
element.style.setProperty("overflow", "hidden");
}
});
return document.body.innerHTML;
}
export function AdminRichTextEditor({
value,
onChange,
......@@ -97,6 +170,17 @@ export function AdminRichTextEditor({
defaultActionOnPaste: "insert_as_html",
enter: "p",
showPlaceholder: false,
toolbarAdaptive: false,
toolbarInlineForSelection: true,
showXPathInStatusbar: false,
controls: {
font: {
list: FONT_FAMILY_OPTIONS,
},
fontsize: {
list: FONT_SIZE_OPTIONS,
},
},
}),
[minHeight, placeholder, readOnly],
);
......@@ -117,6 +201,17 @@ export function AdminRichTextEditor({
padding: 10px;
}
.admin-rich-text-editor .jodit-toolbar-editor-collection {
border-radius: 0.9rem;
border: 1px solid rgba(6, 62, 142, 0.15);
box-shadow: 0 18px 34px rgba(17, 24, 39, 0.12);
overflow: hidden;
}
.admin-rich-text-editor .jodit-toolbar-editor-collection .jodit-toolbar__box {
padding: 8px;
}
.admin-rich-text-editor .jodit-workplace {
min-height: ${minHeight}px;
}
......@@ -134,11 +229,28 @@ export function AdminRichTextEditor({
}
.admin-rich-text-editor .jodit-wysiwyg img {
max-width: 100%;
display: block;
width: 100% !important;
max-width: 100% !important;
height: auto;
border-radius: 0.75rem;
}
.admin-rich-text-editor .jodit-wysiwyg .article-content,
.admin-rich-text-editor .jodit-wysiwyg .article-content_toc,
.admin-rich-text-editor .jodit-wysiwyg figure,
.admin-rich-text-editor .jodit-wysiwyg table,
.admin-rich-text-editor .jodit-wysiwyg iframe {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
}
.admin-rich-text-editor .jodit-wysiwyg figure {
display: block !important;
margin: 1.5rem 0 !important;
}
.admin-rich-text-editor .jodit-placeholder {
color: #374151 !important;
}
......@@ -149,7 +261,7 @@ export function AdminRichTextEditor({
ref={editor}
value={value}
config={config}
onBlur={(nextContent) => onChange(nextContent)}
onBlur={(nextContent) => onChange(normalizeImportedHtml(nextContent))}
onChange={() => undefined}
/>
</div>
......
......@@ -115,7 +115,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
......
......@@ -27,7 +27,7 @@ const ContextMenuSubTrigger = React.forwardRef<
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
......@@ -80,7 +80,7 @@ const ContextMenuItem = React.forwardRef<
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
......@@ -96,7 +96,7 @@ const ContextMenuCheckboxItem = React.forwardRef<
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
......@@ -120,7 +120,7 @@ const ContextMenuRadioItem = React.forwardRef<
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
......
......@@ -27,7 +27,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
......@@ -84,7 +84,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
......@@ -100,7 +100,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
......@@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
......
......@@ -58,7 +58,7 @@ const MenubarTrigger = React.forwardRef<
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"flex cursor-pointer select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
......@@ -75,7 +75,7 @@ const MenubarSubTrigger = React.forwardRef<
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
......@@ -136,7 +136,7 @@ const MenubarItem = React.forwardRef<
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
......@@ -152,7 +152,7 @@ const MenubarCheckboxItem = React.forwardRef<
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
......@@ -175,7 +175,7 @@ const MenubarRadioItem = React.forwardRef<
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
......
......@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
......@@ -39,7 +39,7 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
"flex cursor-pointer items-center justify-center py-1",
className
)}
{...props}
......@@ -56,7 +56,7 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
"flex cursor-pointer items-center justify-center py-1",
className
)}
{...props}
......@@ -118,7 +118,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
......
......@@ -22,6 +22,25 @@
font-family: var(--default-font-family);
}
:where(
button,
[type='button'],
[type='reset'],
[type='submit'],
[role='button'],
a[href],
label[for],
summary,
select,
option,
input[type='checkbox'],
input[type='radio'],
input[type='file'],
input[type='image']
):not(:disabled, [aria-disabled='true']) {
cursor: pointer;
}
/* COLOR */
:root {
/* Default background color of <body />...etc */
......
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